forked from Cutlery/immich
feat(web,server): manage authorized devices (#2329)
* feat: manage authorized devices * chore: open api * get header from mobile app * write header from mobile app * styling * fix unit test * feat: use relative time * feat: update access time * fix: tests * chore: confirm wording * chore: bump test coverage thresholds * feat: add some icons * chore: icon tweaks --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
aa91b946fa
commit
b8313abfa8
@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@ -49,6 +50,22 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make sign-in request
|
// Make sign-in request
|
||||||
|
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||||
|
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
var iosInfo = await deviceInfoPlugin.iosInfo;
|
||||||
|
_apiService.authenticationApi.apiClient
|
||||||
|
.addDefaultHeader('deviceModel', iosInfo.utsname.machine ?? '');
|
||||||
|
_apiService.authenticationApi.apiClient
|
||||||
|
.addDefaultHeader('deviceType', 'iOS');
|
||||||
|
} else {
|
||||||
|
var androidInfo = await deviceInfoPlugin.androidInfo;
|
||||||
|
_apiService.authenticationApi.apiClient
|
||||||
|
.addDefaultHeader('deviceModel', androidInfo.model);
|
||||||
|
_apiService.authenticationApi.apiClient
|
||||||
|
.addDefaultHeader('deviceType', 'Android');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var loginResponse = await _apiService.authenticationApi.login(
|
var loginResponse = await _apiService.authenticationApi.login(
|
||||||
LoginCredentialDto(
|
LoginCredentialDto(
|
||||||
|
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
@ -23,6 +23,7 @@ doc/AssetCountByUserIdResponseDto.md
|
|||||||
doc/AssetFileUploadResponseDto.md
|
doc/AssetFileUploadResponseDto.md
|
||||||
doc/AssetResponseDto.md
|
doc/AssetResponseDto.md
|
||||||
doc/AssetTypeEnum.md
|
doc/AssetTypeEnum.md
|
||||||
|
doc/AuthDeviceResponseDto.md
|
||||||
doc/AuthenticationApi.md
|
doc/AuthenticationApi.md
|
||||||
doc/ChangePasswordDto.md
|
doc/ChangePasswordDto.md
|
||||||
doc/CheckDuplicateAssetDto.md
|
doc/CheckDuplicateAssetDto.md
|
||||||
@ -145,6 +146,7 @@ lib/model/asset_count_by_user_id_response_dto.dart
|
|||||||
lib/model/asset_file_upload_response_dto.dart
|
lib/model/asset_file_upload_response_dto.dart
|
||||||
lib/model/asset_response_dto.dart
|
lib/model/asset_response_dto.dart
|
||||||
lib/model/asset_type_enum.dart
|
lib/model/asset_type_enum.dart
|
||||||
|
lib/model/auth_device_response_dto.dart
|
||||||
lib/model/change_password_dto.dart
|
lib/model/change_password_dto.dart
|
||||||
lib/model/check_duplicate_asset_dto.dart
|
lib/model/check_duplicate_asset_dto.dart
|
||||||
lib/model/check_duplicate_asset_response_dto.dart
|
lib/model/check_duplicate_asset_response_dto.dart
|
||||||
@ -238,6 +240,7 @@ test/asset_count_by_user_id_response_dto_test.dart
|
|||||||
test/asset_file_upload_response_dto_test.dart
|
test/asset_file_upload_response_dto_test.dart
|
||||||
test/asset_response_dto_test.dart
|
test/asset_response_dto_test.dart
|
||||||
test/asset_type_enum_test.dart
|
test/asset_type_enum_test.dart
|
||||||
|
test/auth_device_response_dto_test.dart
|
||||||
test/authentication_api_test.dart
|
test/authentication_api_test.dart
|
||||||
test/change_password_dto_test.dart
|
test/change_password_dto_test.dart
|
||||||
test/check_duplicate_asset_dto_test.dart
|
test/check_duplicate_asset_dto_test.dart
|
||||||
|
3
mobile/openapi/README.md
generated
3
mobile/openapi/README.md
generated
@ -111,8 +111,10 @@ Class | Method | HTTP request | Description
|
|||||||
*AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload |
|
*AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload |
|
||||||
*AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up |
|
*AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up |
|
||||||
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
|
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
|
||||||
|
*AuthenticationApi* | [**getAuthDevices**](doc//AuthenticationApi.md#getauthdevices) | **GET** /auth/devices |
|
||||||
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
|
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
|
||||||
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
|
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
|
||||||
|
*AuthenticationApi* | [**logoutAuthDevice**](doc//AuthenticationApi.md#logoutauthdevice) | **DELETE** /auth/devices/{id} |
|
||||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
|
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
|
||||||
*DeviceInfoApi* | [**upsertDeviceInfo**](doc//DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info |
|
*DeviceInfoApi* | [**upsertDeviceInfo**](doc//DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info |
|
||||||
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
|
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
|
||||||
@ -174,6 +176,7 @@ Class | Method | HTTP request | Description
|
|||||||
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
|
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
|
||||||
- [AssetResponseDto](doc//AssetResponseDto.md)
|
- [AssetResponseDto](doc//AssetResponseDto.md)
|
||||||
- [AssetTypeEnum](doc//AssetTypeEnum.md)
|
- [AssetTypeEnum](doc//AssetTypeEnum.md)
|
||||||
|
- [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
|
||||||
- [ChangePasswordDto](doc//ChangePasswordDto.md)
|
- [ChangePasswordDto](doc//ChangePasswordDto.md)
|
||||||
- [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md)
|
- [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md)
|
||||||
- [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md)
|
- [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md)
|
||||||
|
20
mobile/openapi/doc/AuthDeviceResponseDto.md
generated
Normal file
20
mobile/openapi/doc/AuthDeviceResponseDto.md
generated
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# openapi.model.AuthDeviceResponseDto
|
||||||
|
|
||||||
|
## Load the model package
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------ | ------------- | ------------- | -------------
|
||||||
|
**id** | **String** | |
|
||||||
|
**createdAt** | **String** | |
|
||||||
|
**updatedAt** | **String** | |
|
||||||
|
**current** | **bool** | |
|
||||||
|
**deviceType** | **String** | |
|
||||||
|
**deviceOS** | **String** | |
|
||||||
|
|
||||||
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
|
99
mobile/openapi/doc/AuthenticationApi.md
generated
99
mobile/openapi/doc/AuthenticationApi.md
generated
@ -11,8 +11,10 @@ Method | HTTP request | Description
|
|||||||
------------- | ------------- | -------------
|
------------- | ------------- | -------------
|
||||||
[**adminSignUp**](AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up |
|
[**adminSignUp**](AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up |
|
||||||
[**changePassword**](AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
|
[**changePassword**](AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
|
||||||
|
[**getAuthDevices**](AuthenticationApi.md#getauthdevices) | **GET** /auth/devices |
|
||||||
[**login**](AuthenticationApi.md#login) | **POST** /auth/login |
|
[**login**](AuthenticationApi.md#login) | **POST** /auth/login |
|
||||||
[**logout**](AuthenticationApi.md#logout) | **POST** /auth/logout |
|
[**logout**](AuthenticationApi.md#logout) | **POST** /auth/logout |
|
||||||
|
[**logoutAuthDevice**](AuthenticationApi.md#logoutauthdevice) | **DELETE** /auth/devices/{id} |
|
||||||
[**validateAccessToken**](AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
|
[**validateAccessToken**](AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
|
||||||
|
|
||||||
|
|
||||||
@ -108,6 +110,53 @@ Name | Type | Description | Notes
|
|||||||
|
|
||||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
# **getAuthDevices**
|
||||||
|
> List<AuthDeviceResponseDto> getAuthDevices()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// TODO Configure API key authorization: cookie
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure HTTP Bearer authorization: bearer
|
||||||
|
// Case 1. Use String Token
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||||
|
// Case 2. Use Function which generate token.
|
||||||
|
// String yourTokenGeneratorFunction() { ... }
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
|
final api_instance = AuthenticationApi();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = api_instance.getAuthDevices();
|
||||||
|
print(result);
|
||||||
|
} catch (e) {
|
||||||
|
print('Exception when calling AuthenticationApi->getAuthDevices: $e\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
This endpoint does not need any parameter.
|
||||||
|
|
||||||
|
### Return type
|
||||||
|
|
||||||
|
[**List<AuthDeviceResponseDto>**](AuthDeviceResponseDto.md)
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
[cookie](../README.md#cookie), [bearer](../README.md#bearer)
|
||||||
|
|
||||||
|
### HTTP request headers
|
||||||
|
|
||||||
|
- **Content-Type**: Not defined
|
||||||
|
- **Accept**: application/json
|
||||||
|
|
||||||
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
# **login**
|
# **login**
|
||||||
> LoginResponseDto login(loginCredentialDto)
|
> LoginResponseDto login(loginCredentialDto)
|
||||||
|
|
||||||
@ -196,6 +245,56 @@ This endpoint does not need any parameter.
|
|||||||
|
|
||||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
# **logoutAuthDevice**
|
||||||
|
> logoutAuthDevice(id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// TODO Configure API key authorization: cookie
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure HTTP Bearer authorization: bearer
|
||||||
|
// Case 1. Use String Token
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||||
|
// Case 2. Use Function which generate token.
|
||||||
|
// String yourTokenGeneratorFunction() { ... }
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
|
final api_instance = AuthenticationApi();
|
||||||
|
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||||
|
|
||||||
|
try {
|
||||||
|
api_instance.logoutAuthDevice(id);
|
||||||
|
} catch (e) {
|
||||||
|
print('Exception when calling AuthenticationApi->logoutAuthDevice: $e\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------- | ------------- | ------------- | -------------
|
||||||
|
**id** | **String**| |
|
||||||
|
|
||||||
|
### Return type
|
||||||
|
|
||||||
|
void (empty response body)
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
[cookie](../README.md#cookie), [bearer](../README.md#bearer)
|
||||||
|
|
||||||
|
### HTTP request headers
|
||||||
|
|
||||||
|
- **Content-Type**: Not defined
|
||||||
|
- **Accept**: Not defined
|
||||||
|
|
||||||
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
# **validateAccessToken**
|
# **validateAccessToken**
|
||||||
> ValidateAccessTokenResponseDto validateAccessToken()
|
> ValidateAccessTokenResponseDto validateAccessToken()
|
||||||
|
|
||||||
|
4
mobile/openapi/doc/LogoutResponseDto.md
generated
4
mobile/openapi/doc/LogoutResponseDto.md
generated
@ -8,8 +8,8 @@ import 'package:openapi/api.dart';
|
|||||||
## Properties
|
## Properties
|
||||||
Name | Type | Description | Notes
|
Name | Type | Description | Notes
|
||||||
------------ | ------------- | ------------- | -------------
|
------------ | ------------- | ------------- | -------------
|
||||||
**successful** | **bool** | | [readonly]
|
**successful** | **bool** | |
|
||||||
**redirectUri** | **String** | | [readonly]
|
**redirectUri** | **String** | |
|
||||||
|
|
||||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
@ -59,6 +59,7 @@ part 'model/asset_count_by_user_id_response_dto.dart';
|
|||||||
part 'model/asset_file_upload_response_dto.dart';
|
part 'model/asset_file_upload_response_dto.dart';
|
||||||
part 'model/asset_response_dto.dart';
|
part 'model/asset_response_dto.dart';
|
||||||
part 'model/asset_type_enum.dart';
|
part 'model/asset_type_enum.dart';
|
||||||
|
part 'model/auth_device_response_dto.dart';
|
||||||
part 'model/change_password_dto.dart';
|
part 'model/change_password_dto.dart';
|
||||||
part 'model/check_duplicate_asset_dto.dart';
|
part 'model/check_duplicate_asset_dto.dart';
|
||||||
part 'model/check_duplicate_asset_response_dto.dart';
|
part 'model/check_duplicate_asset_response_dto.dart';
|
||||||
|
84
mobile/openapi/lib/api/authentication_api.dart
generated
84
mobile/openapi/lib/api/authentication_api.dart
generated
@ -110,6 +110,50 @@ class AuthenticationApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /auth/devices' operation and returns the [Response].
|
||||||
|
Future<Response> getAuthDevicesWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/auth/devices';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AuthDeviceResponseDto>?> getAuthDevices() async {
|
||||||
|
final response = await getAuthDevicesWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
final responseBody = await _decodeBodyBytes(response);
|
||||||
|
return (await apiClient.deserializeAsync(responseBody, 'List<AuthDeviceResponseDto>') as List)
|
||||||
|
.cast<AuthDeviceResponseDto>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'POST /auth/login' operation and returns the [Response].
|
/// Performs an HTTP 'POST /auth/login' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
@ -198,6 +242,46 @@ class AuthenticationApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'DELETE /auth/devices/{id}' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
Future<Response> logoutAuthDeviceWithHttpInfo(String id,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/auth/devices/{id}'
|
||||||
|
.replaceAll('{id}', id);
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'DELETE',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
Future<void> logoutAuthDevice(String id,) async {
|
||||||
|
final response = await logoutAuthDeviceWithHttpInfo(id,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'POST /auth/validateToken' operation and returns the [Response].
|
/// Performs an HTTP 'POST /auth/validateToken' operation and returns the [Response].
|
||||||
Future<Response> validateAccessTokenWithHttpInfo() async {
|
Future<Response> validateAccessTokenWithHttpInfo() async {
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
|
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
@ -215,6 +215,8 @@ class ApiClient {
|
|||||||
return AssetResponseDto.fromJson(value);
|
return AssetResponseDto.fromJson(value);
|
||||||
case 'AssetTypeEnum':
|
case 'AssetTypeEnum':
|
||||||
return AssetTypeEnumTypeTransformer().decode(value);
|
return AssetTypeEnumTypeTransformer().decode(value);
|
||||||
|
case 'AuthDeviceResponseDto':
|
||||||
|
return AuthDeviceResponseDto.fromJson(value);
|
||||||
case 'ChangePasswordDto':
|
case 'ChangePasswordDto':
|
||||||
return ChangePasswordDto.fromJson(value);
|
return ChangePasswordDto.fromJson(value);
|
||||||
case 'CheckDuplicateAssetDto':
|
case 'CheckDuplicateAssetDto':
|
||||||
|
151
mobile/openapi/lib/model/auth_device_response_dto.dart
generated
Normal file
151
mobile/openapi/lib/model/auth_device_response_dto.dart
generated
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AuthDeviceResponseDto {
|
||||||
|
/// Returns a new [AuthDeviceResponseDto] instance.
|
||||||
|
AuthDeviceResponseDto({
|
||||||
|
required this.id,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.current,
|
||||||
|
required this.deviceType,
|
||||||
|
required this.deviceOS,
|
||||||
|
});
|
||||||
|
|
||||||
|
String id;
|
||||||
|
|
||||||
|
String createdAt;
|
||||||
|
|
||||||
|
String updatedAt;
|
||||||
|
|
||||||
|
bool current;
|
||||||
|
|
||||||
|
String deviceType;
|
||||||
|
|
||||||
|
String deviceOS;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AuthDeviceResponseDto &&
|
||||||
|
other.id == id &&
|
||||||
|
other.createdAt == createdAt &&
|
||||||
|
other.updatedAt == updatedAt &&
|
||||||
|
other.current == current &&
|
||||||
|
other.deviceType == deviceType &&
|
||||||
|
other.deviceOS == deviceOS;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(id.hashCode) +
|
||||||
|
(createdAt.hashCode) +
|
||||||
|
(updatedAt.hashCode) +
|
||||||
|
(current.hashCode) +
|
||||||
|
(deviceType.hashCode) +
|
||||||
|
(deviceOS.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AuthDeviceResponseDto[id=$id, createdAt=$createdAt, updatedAt=$updatedAt, current=$current, deviceType=$deviceType, deviceOS=$deviceOS]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'id'] = this.id;
|
||||||
|
json[r'createdAt'] = this.createdAt;
|
||||||
|
json[r'updatedAt'] = this.updatedAt;
|
||||||
|
json[r'current'] = this.current;
|
||||||
|
json[r'deviceType'] = this.deviceType;
|
||||||
|
json[r'deviceOS'] = this.deviceOS;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AuthDeviceResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AuthDeviceResponseDto? fromJson(dynamic value) {
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
// Ensure that the map contains the required keys.
|
||||||
|
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||||
|
// Note 2: this code is stripped in release mode!
|
||||||
|
assert(() {
|
||||||
|
requiredKeys.forEach((key) {
|
||||||
|
assert(json.containsKey(key), 'Required key "AuthDeviceResponseDto[$key]" is missing from JSON.');
|
||||||
|
assert(json[key] != null, 'Required key "AuthDeviceResponseDto[$key]" has a null value in JSON.');
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}());
|
||||||
|
|
||||||
|
return AuthDeviceResponseDto(
|
||||||
|
id: mapValueOfType<String>(json, r'id')!,
|
||||||
|
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||||
|
updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
|
||||||
|
current: mapValueOfType<bool>(json, r'current')!,
|
||||||
|
deviceType: mapValueOfType<String>(json, r'deviceType')!,
|
||||||
|
deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AuthDeviceResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AuthDeviceResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AuthDeviceResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AuthDeviceResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AuthDeviceResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AuthDeviceResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AuthDeviceResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AuthDeviceResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AuthDeviceResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AuthDeviceResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'id',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'current',
|
||||||
|
'deviceType',
|
||||||
|
'deviceOS',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
52
mobile/openapi/test/auth_device_response_dto_test.dart
generated
Normal file
52
mobile/openapi/test/auth_device_response_dto_test.dart
generated
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
// tests for AuthDeviceResponseDto
|
||||||
|
void main() {
|
||||||
|
// final instance = AuthDeviceResponseDto();
|
||||||
|
|
||||||
|
group('test AuthDeviceResponseDto', () {
|
||||||
|
// String id
|
||||||
|
test('to test the property `id`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// String createdAt
|
||||||
|
test('to test the property `createdAt`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// String updatedAt
|
||||||
|
test('to test the property `updatedAt`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// bool current
|
||||||
|
test('to test the property `current`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// String deviceType
|
||||||
|
test('to test the property `deviceType`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// String deviceOS
|
||||||
|
test('to test the property `deviceOS`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
10
mobile/openapi/test/authentication_api_test.dart
generated
10
mobile/openapi/test/authentication_api_test.dart
generated
@ -27,6 +27,11 @@ void main() {
|
|||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//Future<List<AuthDeviceResponseDto>> getAuthDevices() async
|
||||||
|
test('test getAuthDevices', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
//Future<LoginResponseDto> login(LoginCredentialDto loginCredentialDto) async
|
//Future<LoginResponseDto> login(LoginCredentialDto loginCredentialDto) async
|
||||||
test('test login', () async {
|
test('test login', () async {
|
||||||
// TODO
|
// TODO
|
||||||
@ -37,6 +42,11 @@ void main() {
|
|||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//Future logoutAuthDevice(String id) async
|
||||||
|
test('test logoutAuthDevice', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
//Future<ValidateAccessTokenResponseDto> validateAccessToken() async
|
//Future<ValidateAccessTokenResponseDto> validateAccessToken() async
|
||||||
test('test validateAccessToken', () async {
|
test('test validateAccessToken', () async {
|
||||||
// TODO
|
// TODO
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AdminSignupResponseDto,
|
AdminSignupResponseDto,
|
||||||
|
AuthDeviceResponseDto,
|
||||||
AuthService,
|
AuthService,
|
||||||
AuthType,
|
AuthType,
|
||||||
AuthUserDto,
|
AuthUserDto,
|
||||||
@ -7,18 +8,20 @@ import {
|
|||||||
IMMICH_ACCESS_COOKIE,
|
IMMICH_ACCESS_COOKIE,
|
||||||
IMMICH_AUTH_TYPE_COOKIE,
|
IMMICH_AUTH_TYPE_COOKIE,
|
||||||
LoginCredentialDto,
|
LoginCredentialDto,
|
||||||
|
LoginDetails,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
LogoutResponseDto,
|
LogoutResponseDto,
|
||||||
SignUpDto,
|
SignUpDto,
|
||||||
UserResponseDto,
|
UserResponseDto,
|
||||||
ValidateAccessTokenResponseDto,
|
ValidateAccessTokenResponseDto,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { Body, Controller, Ip, Post, Req, Res } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Req, Res } from '@nestjs/common';
|
||||||
import { ApiBadRequestResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBadRequestResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
import { GetAuthUser, GetLoginDetails } from '../decorators/auth-user.decorator';
|
||||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||||
import { UseValidation } from '../decorators/use-validation.decorator';
|
import { UseValidation } from '../decorators/use-validation.decorator';
|
||||||
|
import { UUIDParamDto } from './dto/uuid-param.dto';
|
||||||
|
|
||||||
@ApiTags('Authentication')
|
@ApiTags('Authentication')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@ -29,11 +32,10 @@ export class AuthController {
|
|||||||
@Post('login')
|
@Post('login')
|
||||||
async login(
|
async login(
|
||||||
@Body() loginCredential: LoginCredentialDto,
|
@Body() loginCredential: LoginCredentialDto,
|
||||||
@Ip() clientIp: string,
|
|
||||||
@Req() req: Request,
|
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
@GetLoginDetails() loginDetails: LoginDetails,
|
||||||
): Promise<LoginResponseDto> {
|
): Promise<LoginResponseDto> {
|
||||||
const { response, cookie } = await this.service.login(loginCredential, clientIp, req.secure);
|
const { response, cookie } = await this.service.login(loginCredential, loginDetails);
|
||||||
res.header('Set-Cookie', cookie);
|
res.header('Set-Cookie', cookie);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@ -44,6 +46,18 @@ export class AuthController {
|
|||||||
return this.service.adminSignUp(signUpCredential);
|
return this.service.adminSignUp(signUpCredential);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Authenticated()
|
||||||
|
@Get('devices')
|
||||||
|
getAuthDevices(@GetAuthUser() authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
|
||||||
|
return this.service.getDevices(authUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Authenticated()
|
||||||
|
@Delete('devices/:id')
|
||||||
|
logoutAuthDevice(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||||
|
return this.service.logoutDevice(authUser, id);
|
||||||
|
}
|
||||||
|
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
@Post('validateToken')
|
@Post('validateToken')
|
||||||
validateAccessToken(): ValidateAccessTokenResponseDto {
|
validateAccessToken(): ValidateAccessTokenResponseDto {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AuthUserDto,
|
AuthUserDto,
|
||||||
|
LoginDetails,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
OAuthCallbackDto,
|
OAuthCallbackDto,
|
||||||
OAuthConfigDto,
|
OAuthConfigDto,
|
||||||
@ -10,7 +11,7 @@ import {
|
|||||||
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
|
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
import { GetAuthUser, GetLoginDetails } from '../decorators/auth-user.decorator';
|
||||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||||
import { UseValidation } from '../decorators/use-validation.decorator';
|
import { UseValidation } from '../decorators/use-validation.decorator';
|
||||||
|
|
||||||
@ -38,9 +39,9 @@ export class OAuthController {
|
|||||||
async callback(
|
async callback(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Body() dto: OAuthCallbackDto,
|
@Body() dto: OAuthCallbackDto,
|
||||||
@Req() req: Request,
|
@GetLoginDetails() loginDetails: LoginDetails,
|
||||||
): Promise<LoginResponseDto> {
|
): Promise<LoginResponseDto> {
|
||||||
const { response, cookie } = await this.service.login(dto, req.secure);
|
const { response, cookie } = await this.service.login(dto, loginDetails);
|
||||||
res.header('Set-Cookie', cookie);
|
res.header('Set-Cookie', cookie);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,20 @@
|
|||||||
export { AuthUserDto } from '@app/domain';
|
export { AuthUserDto } from '@app/domain';
|
||||||
import { AuthUserDto } from '@app/domain';
|
import { AuthUserDto, LoginDetails } from '@app/domain';
|
||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
|
export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => {
|
||||||
return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
|
return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => {
|
||||||
|
const req = ctx.switchToHttp().getRequest();
|
||||||
|
const userAgent = UAParser(req.headers['user-agent']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientIp: req.clientIp,
|
||||||
|
isSecure: req.secure,
|
||||||
|
deviceType: userAgent.browser.name || userAgent.device.type || req.headers.devicemodel || '',
|
||||||
|
deviceOS: userAgent.os.name || req.headers.devicetype || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
@ -21,6 +21,10 @@ export function patchOpenAPI(document: OpenAPIObject) {
|
|||||||
if (operation.summary === '') {
|
if (operation.summary === '') {
|
||||||
delete operation.summary;
|
delete operation.summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (operation.description === '') {
|
||||||
|
delete operation.description;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -339,6 +339,70 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/auth/devices": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getAuthDevices",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AuthDeviceResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Authentication"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/auth/devices/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"operationId": "logoutAuthDevice",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Authentication"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/auth/validateToken": {
|
"/auth/validateToken": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "validateAccessToken",
|
"operationId": "validateAccessToken",
|
||||||
@ -3986,6 +4050,37 @@
|
|||||||
"createdAt"
|
"createdAt"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"AuthDeviceResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"deviceType": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"deviceOS": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
"current",
|
||||||
|
"deviceType",
|
||||||
|
"deviceOS"
|
||||||
|
]
|
||||||
|
},
|
||||||
"ValidateAccessTokenResponseDto": {
|
"ValidateAccessTokenResponseDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -4018,12 +4113,10 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"successful": {
|
"successful": {
|
||||||
"type": "boolean",
|
"type": "boolean"
|
||||||
"readOnly": true
|
|
||||||
},
|
},
|
||||||
"redirectUri": {
|
"redirectUri": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"readOnly": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
@ -1,10 +1,17 @@
|
|||||||
import { SystemConfig, UserEntity } from '@app/infra/entities';
|
import { SystemConfig, UserEntity } from '@app/infra/entities';
|
||||||
|
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||||
import { ISystemConfigRepository } from '../system-config';
|
import { ISystemConfigRepository } from '../system-config';
|
||||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
|
||||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
|
||||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
|
|
||||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
||||||
|
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||||
|
import { LoginResponseDto, mapLoginResponse } from './response-dto';
|
||||||
|
|
||||||
|
export interface LoginDetails {
|
||||||
|
isSecure: boolean;
|
||||||
|
clientIp: string;
|
||||||
|
deviceType: string;
|
||||||
|
deviceOS: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class AuthCore {
|
export class AuthCore {
|
||||||
private userTokenCore: UserTokenCore;
|
private userTokenCore: UserTokenCore;
|
||||||
@ -23,7 +30,7 @@ export class AuthCore {
|
|||||||
return this.config.passwordLogin.enabled;
|
return this.config.passwordLogin.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCookies(loginResponse: LoginResponseDto, authType: AuthType, isSecure: boolean) {
|
getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
|
||||||
const maxAge = 400 * 24 * 3600; // 400 days
|
const maxAge = 400 * 24 * 3600; // 400 days
|
||||||
|
|
||||||
let authTypeCookie = '';
|
let authTypeCookie = '';
|
||||||
@ -39,10 +46,10 @@ export class AuthCore {
|
|||||||
return [accessTokenCookie, authTypeCookie];
|
return [accessTokenCookie, authTypeCookie];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) {
|
async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
|
||||||
const accessToken = await this.userTokenCore.createToken(user);
|
const accessToken = await this.userTokenCore.create(user, loginDetails);
|
||||||
const response = mapLoginResponse(user, accessToken);
|
const response = mapLoginResponse(user, accessToken);
|
||||||
const cookie = this.getCookies(response, authType, isSecure);
|
const cookie = this.getCookies(response, authType, loginDetails);
|
||||||
return { response, cookie };
|
return { response, cookie };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,6 +32,12 @@ import { AuthUserDto, SignUpDto } from './dto';
|
|||||||
|
|
||||||
const email = 'test@immich.com';
|
const email = 'test@immich.com';
|
||||||
const sub = 'my-auth-user-sub';
|
const sub = 'my-auth-user-sub';
|
||||||
|
const loginDetails = {
|
||||||
|
isSecure: true,
|
||||||
|
clientIp: '127.0.0.1',
|
||||||
|
deviceOS: '',
|
||||||
|
deviceType: '',
|
||||||
|
};
|
||||||
|
|
||||||
const fixtures = {
|
const fixtures = {
|
||||||
login: {
|
login: {
|
||||||
@ -40,8 +46,6 @@ const fixtures = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const CLIENT_IP = '127.0.0.1';
|
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let sut: AuthService;
|
let sut: AuthService;
|
||||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||||
@ -96,32 +100,39 @@ describe('AuthService', () => {
|
|||||||
it('should throw an error if password login is disabled', async () => {
|
it('should throw an error if password login is disabled', async () => {
|
||||||
sut = create(systemConfigStub.disabled);
|
sut = create(systemConfigStub.disabled);
|
||||||
|
|
||||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(UnauthorizedException);
|
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should check the user exists', async () => {
|
it('should check the user exists', async () => {
|
||||||
userMock.getByEmail.mockResolvedValue(null);
|
userMock.getByEmail.mockResolvedValue(null);
|
||||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should check the user has a password', async () => {
|
it('should check the user has a password', async () => {
|
||||||
userMock.getByEmail.mockResolvedValue({} as UserEntity);
|
userMock.getByEmail.mockResolvedValue({} as UserEntity);
|
||||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should successfully log the user in', async () => {
|
it('should successfully log the user in', async () => {
|
||||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||||
await expect(sut.login(fixtures.login, CLIENT_IP, true)).resolves.toEqual(loginResponseStub.user1password);
|
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password);
|
||||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate the cookie headers (insecure)', async () => {
|
it('should generate the cookie headers (insecure)', async () => {
|
||||||
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
|
||||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||||
await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure);
|
await expect(
|
||||||
|
sut.login(fixtures.login, {
|
||||||
|
clientIp: '127.0.0.1',
|
||||||
|
isSecure: false,
|
||||||
|
deviceOS: '',
|
||||||
|
deviceType: '',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(loginResponseStub.user1insecure);
|
||||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -205,7 +216,7 @@ describe('AuthService', () => {
|
|||||||
redirectUri: '/auth/login?autoLaunch=0',
|
redirectUri: '/auth/login?autoLaunch=0',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(userTokenMock.delete).toHaveBeenCalledWith('token123');
|
expect(userTokenMock.delete).toHaveBeenCalledWith('123', 'token123');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -240,7 +251,7 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
it('should validate using authorization header', async () => {
|
it('should validate using authorization header', async () => {
|
||||||
userMock.get.mockResolvedValue(userEntityStub.user1);
|
userMock.get.mockResolvedValue(userEntityStub.user1);
|
||||||
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
|
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
|
||||||
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
|
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
|
||||||
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userEntityStub.user1);
|
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||||
});
|
});
|
||||||
@ -276,16 +287,32 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
describe('validate - user token', () => {
|
describe('validate - user token', () => {
|
||||||
it('should throw if no token is found', async () => {
|
it('should throw if no token is found', async () => {
|
||||||
userTokenMock.get.mockResolvedValue(null);
|
userTokenMock.getByToken.mockResolvedValue(null);
|
||||||
const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' };
|
const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' };
|
||||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an auth dto', async () => {
|
it('should return an auth dto', async () => {
|
||||||
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
|
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken);
|
||||||
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
|
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
|
||||||
await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
|
await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should update when access time exceeds an hour', async () => {
|
||||||
|
userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.inactiveToken);
|
||||||
|
userTokenMock.save.mockResolvedValue(userTokenEntityStub.userToken);
|
||||||
|
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
|
||||||
|
await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||||
|
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
|
||||||
|
id: 'not_active',
|
||||||
|
token: 'auth_token',
|
||||||
|
userId: 'immich_id',
|
||||||
|
createdAt: new Date('2021-01-01'),
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
deviceOS: 'Android',
|
||||||
|
deviceType: 'Mobile',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validate - api key', () => {
|
describe('validate - api key', () => {
|
||||||
@ -303,4 +330,38 @@ describe('AuthService', () => {
|
|||||||
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getDevices', () => {
|
||||||
|
it('should get the devices', async () => {
|
||||||
|
userTokenMock.getAll.mockResolvedValue([userTokenEntityStub.userToken, userTokenEntityStub.inactiveToken]);
|
||||||
|
await expect(sut.getDevices(authStub.user1)).resolves.toEqual([
|
||||||
|
{
|
||||||
|
createdAt: '2021-01-01T00:00:00.000Z',
|
||||||
|
current: true,
|
||||||
|
deviceOS: '',
|
||||||
|
deviceType: '',
|
||||||
|
id: 'token-id',
|
||||||
|
updatedAt: expect.any(String),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: '2021-01-01T00:00:00.000Z',
|
||||||
|
current: false,
|
||||||
|
deviceOS: 'Android',
|
||||||
|
deviceType: 'Mobile',
|
||||||
|
id: 'not_active',
|
||||||
|
updatedAt: expect.any(String),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logoutDevice', () => {
|
||||||
|
it('should logout the device', async () => {
|
||||||
|
await sut.logoutDevice(authStub.user1, 'token-1');
|
||||||
|
|
||||||
|
expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -12,7 +12,7 @@ import { OAuthCore } from '../oauth/oauth.core';
|
|||||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||||
import { IUserRepository, UserCore } from '../user';
|
import { IUserRepository, UserCore } from '../user';
|
||||||
import { AuthType, IMMICH_ACCESS_COOKIE } from './auth.constant';
|
import { AuthType, IMMICH_ACCESS_COOKIE } from './auth.constant';
|
||||||
import { AuthCore } from './auth.core';
|
import { AuthCore, LoginDetails } from './auth.core';
|
||||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||||
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
|
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
|
||||||
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
|
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
|
||||||
@ -21,6 +21,7 @@ import cookieParser from 'cookie';
|
|||||||
import { ISharedLinkRepository, ShareCore } from '../share';
|
import { ISharedLinkRepository, ShareCore } from '../share';
|
||||||
import { APIKeyCore } from '../api-key/api-key.core';
|
import { APIKeyCore } from '../api-key/api-key.core';
|
||||||
import { IKeyRepository } from '../api-key';
|
import { IKeyRepository } from '../api-key';
|
||||||
|
import { AuthDeviceResponseDto, mapUserToken } from './response-dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@ -53,8 +54,7 @@ export class AuthService {
|
|||||||
|
|
||||||
public async login(
|
public async login(
|
||||||
loginCredential: LoginCredentialDto,
|
loginCredential: LoginCredentialDto,
|
||||||
clientIp: string,
|
loginDetails: LoginDetails,
|
||||||
isSecure: boolean,
|
|
||||||
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
||||||
if (!this.authCore.isPasswordLoginEnabled()) {
|
if (!this.authCore.isPasswordLoginEnabled()) {
|
||||||
throw new UnauthorizedException('Password login has been disabled');
|
throw new UnauthorizedException('Password login has been disabled');
|
||||||
@ -69,16 +69,18 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
this.logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`);
|
this.logger.warn(
|
||||||
|
`Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`,
|
||||||
|
);
|
||||||
throw new BadRequestException('Incorrect email or password');
|
throw new BadRequestException('Incorrect email or password');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.authCore.createLoginResponse(user, AuthType.PASSWORD, isSecure);
|
return this.authCore.createLoginResponse(user, AuthType.PASSWORD, loginDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
|
public async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||||
if (authUser.accessTokenId) {
|
if (authUser.accessTokenId) {
|
||||||
await this.userTokenCore.deleteToken(authUser.accessTokenId);
|
await this.userTokenCore.delete(authUser.id, authUser.accessTokenId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authType === AuthType.OAUTH) {
|
if (authType === AuthType.OAUTH) {
|
||||||
@ -152,6 +154,15 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Authentication required');
|
throw new UnauthorizedException('Authentication required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
|
||||||
|
const userTokens = await this.userTokenCore.getAll(authUser.id);
|
||||||
|
return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
|
||||||
|
await this.userTokenCore.delete(authUser.id, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
private getBearerToken(headers: IncomingHttpHeaders): string | null {
|
private getBearerToken(headers: IncomingHttpHeaders): string | null {
|
||||||
const [type, token] = (headers.authorization || '').split(' ');
|
const [type, token] = (headers.authorization || '').split(' ');
|
||||||
if (type.toLowerCase() === 'bearer') {
|
if (type.toLowerCase() === 'bearer') {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
export * from './auth.constant';
|
export * from './auth.constant';
|
||||||
|
export * from './auth.core';
|
||||||
export * from './auth.service';
|
export * from './auth.service';
|
||||||
export * from './dto';
|
export * from './dto';
|
||||||
export * from './response-dto';
|
export * from './response-dto';
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
import { UserTokenEntity } from '@app/infra/entities';
|
||||||
|
|
||||||
|
export class AuthDeviceResponseDto {
|
||||||
|
id!: string;
|
||||||
|
createdAt!: string;
|
||||||
|
updatedAt!: string;
|
||||||
|
current!: boolean;
|
||||||
|
deviceType!: string;
|
||||||
|
deviceOS!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapUserToken = (entity: UserTokenEntity, currentId?: string): AuthDeviceResponseDto => ({
|
||||||
|
id: entity.id,
|
||||||
|
createdAt: entity.createdAt.toISOString(),
|
||||||
|
updatedAt: entity.updatedAt.toISOString(),
|
||||||
|
current: currentId === entity.id,
|
||||||
|
deviceOS: entity.deviceOS,
|
||||||
|
deviceType: entity.deviceType,
|
||||||
|
});
|
@ -1,4 +1,5 @@
|
|||||||
export * from './admin-signup-response.dto';
|
export * from './admin-signup-response.dto';
|
||||||
|
export * from './auth-device-response.dto';
|
||||||
export * from './login-response.dto';
|
export * from './login-response.dto';
|
||||||
export * from './logout-response.dto';
|
export * from './logout-response.dto';
|
||||||
export * from './validate-asset-token-response.dto';
|
export * from './validate-asset-token-response.dto';
|
||||||
|
@ -1,13 +1,4 @@
|
|||||||
import { ApiResponseProperty } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class LogoutResponseDto {
|
export class LogoutResponseDto {
|
||||||
constructor(successful: boolean) {
|
|
||||||
this.successful = successful;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiResponseProperty()
|
|
||||||
successful!: boolean;
|
successful!: boolean;
|
||||||
|
|
||||||
@ApiResponseProperty()
|
|
||||||
redirectUri!: string;
|
redirectUri!: string;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,3 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class ValidateAccessTokenResponseDto {
|
export class ValidateAccessTokenResponseDto {
|
||||||
constructor(authStatus: boolean) {
|
|
||||||
this.authStatus = authStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiProperty({ type: 'boolean' })
|
|
||||||
authStatus!: boolean;
|
authStatus!: boolean;
|
||||||
}
|
}
|
||||||
|
@ -17,9 +17,16 @@ import { ISystemConfigRepository } from '../system-config';
|
|||||||
import { IUserRepository } from '../user';
|
import { IUserRepository } from '../user';
|
||||||
import { IUserTokenRepository } from '../user-token';
|
import { IUserTokenRepository } from '../user-token';
|
||||||
import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock';
|
import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock';
|
||||||
|
import { LoginDetails } from '../auth';
|
||||||
|
|
||||||
const email = 'user@immich.com';
|
const email = 'user@immich.com';
|
||||||
const sub = 'my-auth-user-sub';
|
const sub = 'my-auth-user-sub';
|
||||||
|
const loginDetails: LoginDetails = {
|
||||||
|
isSecure: true,
|
||||||
|
clientIp: '127.0.0.1',
|
||||||
|
deviceOS: '',
|
||||||
|
deviceType: '',
|
||||||
|
};
|
||||||
|
|
||||||
describe('OAuthService', () => {
|
describe('OAuthService', () => {
|
||||||
let sut: OAuthService;
|
let sut: OAuthService;
|
||||||
@ -95,13 +102,13 @@ describe('OAuthService', () => {
|
|||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('should throw an error if OAuth is not enabled', async () => {
|
it('should throw an error if OAuth is not enabled', async () => {
|
||||||
await expect(sut.login({ url: '' }, true)).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.login({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow auto registering', async () => {
|
it('should not allow auto registering', async () => {
|
||||||
sut = create(systemConfigStub.noAutoRegister);
|
sut = create(systemConfigStub.noAutoRegister);
|
||||||
userMock.getByEmail.mockResolvedValue(null);
|
userMock.getByEmail.mockResolvedValue(null);
|
||||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).rejects.toBeInstanceOf(
|
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
|
||||||
@ -113,7 +120,7 @@ describe('OAuthService', () => {
|
|||||||
userMock.update.mockResolvedValue(userEntityStub.user1);
|
userMock.update.mockResolvedValue(userEntityStub.user1);
|
||||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||||
|
|
||||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
|
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||||
loginResponseStub.user1oauth,
|
loginResponseStub.user1oauth,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -129,7 +136,7 @@ describe('OAuthService', () => {
|
|||||||
userMock.create.mockResolvedValue(userEntityStub.user1);
|
userMock.create.mockResolvedValue(userEntityStub.user1);
|
||||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||||
|
|
||||||
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual(
|
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||||
loginResponseStub.user1oauth,
|
loginResponseStub.user1oauth,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -143,7 +150,7 @@ describe('OAuthService', () => {
|
|||||||
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
|
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
|
||||||
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
|
||||||
|
|
||||||
await sut.login({ url: `app.immich:/?code=abc123` }, true);
|
await sut.login({ url: `app.immich:/?code=abc123` }, loginDetails);
|
||||||
|
|
||||||
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
|
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { SystemConfig } from '@app/infra/entities';
|
import { SystemConfig } from '@app/infra/entities';
|
||||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { AuthType, AuthUserDto, LoginResponseDto } from '../auth';
|
import { AuthType, AuthUserDto, LoginResponseDto } from '../auth';
|
||||||
import { AuthCore } from '../auth/auth.core';
|
import { AuthCore, LoginDetails } from '../auth/auth.core';
|
||||||
import { ICryptoRepository } from '../crypto';
|
import { ICryptoRepository } from '../crypto';
|
||||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||||
import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
||||||
@ -39,7 +39,10 @@ export class OAuthService {
|
|||||||
return this.oauthCore.generateConfig(dto);
|
return this.oauthCore.generateConfig(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(dto: OAuthCallbackDto, isSecure: boolean): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
async login(
|
||||||
|
dto: OAuthCallbackDto,
|
||||||
|
loginDetails: LoginDetails,
|
||||||
|
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
|
||||||
const profile = await this.oauthCore.callback(dto.url);
|
const profile = await this.oauthCore.callback(dto.url);
|
||||||
|
|
||||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||||
@ -66,7 +69,7 @@ export class OAuthService {
|
|||||||
user = await this.userCore.createUser(this.oauthCore.asUser(profile));
|
user = await this.userCore.createUser(this.oauthCore.asUser(profile));
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.authCore.createLoginResponse(user, AuthType.OAUTH, isSecure);
|
return this.authCore.createLoginResponse(user, AuthType.OAUTH, loginDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
|
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { UserEntity } from '@app/infra/entities';
|
import { UserEntity, UserTokenEntity } from '@app/infra/entities';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { LoginDetails } from '../auth';
|
||||||
import { ICryptoRepository } from '../crypto';
|
import { ICryptoRepository } from '../crypto';
|
||||||
import { IUserTokenRepository } from './user-token.repository';
|
import { IUserTokenRepository } from './user-token.repository';
|
||||||
|
|
||||||
@ -9,9 +11,16 @@ export class UserTokenCore {
|
|||||||
|
|
||||||
async validate(tokenValue: string) {
|
async validate(tokenValue: string) {
|
||||||
const hashedToken = this.crypto.hashSha256(tokenValue);
|
const hashedToken = this.crypto.hashSha256(tokenValue);
|
||||||
const token = await this.repository.get(hashedToken);
|
let token = await this.repository.getByToken(hashedToken);
|
||||||
|
|
||||||
if (token?.user) {
|
if (token?.user) {
|
||||||
|
const now = DateTime.now();
|
||||||
|
const updatedAt = DateTime.fromJSDate(token.updatedAt);
|
||||||
|
const diff = now.diff(updatedAt, ['hours']);
|
||||||
|
if (diff.hours > 1) {
|
||||||
|
token = await this.repository.save({ ...token, updatedAt: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...token.user,
|
...token.user,
|
||||||
isPublicUser: false,
|
isPublicUser: false,
|
||||||
@ -25,18 +34,24 @@ export class UserTokenCore {
|
|||||||
throw new UnauthorizedException('Invalid user token');
|
throw new UnauthorizedException('Invalid user token');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createToken(user: UserEntity): Promise<string> {
|
async create(user: UserEntity, loginDetails: LoginDetails): Promise<string> {
|
||||||
const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
|
const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, '');
|
||||||
const token = this.crypto.hashSha256(key);
|
const token = this.crypto.hashSha256(key);
|
||||||
await this.repository.create({
|
await this.repository.create({
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
|
deviceOS: loginDetails.deviceOS,
|
||||||
|
deviceType: loginDetails.deviceType,
|
||||||
});
|
});
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteToken(id: string): Promise<void> {
|
async delete(userId: string, id: string): Promise<void> {
|
||||||
await this.repository.delete(id);
|
await this.repository.delete(userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(userId: string): Promise<UserTokenEntity[]> {
|
||||||
|
return this.repository.getAll(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,9 @@ export const IUserTokenRepository = 'IUserTokenRepository';
|
|||||||
|
|
||||||
export interface IUserTokenRepository {
|
export interface IUserTokenRepository {
|
||||||
create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
||||||
delete(userToken: string): Promise<void>;
|
save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
||||||
|
delete(userId: string, id: string): Promise<void>;
|
||||||
deleteAll(userId: string): Promise<void>;
|
deleteAll(userId: string): Promise<void>;
|
||||||
get(userToken: string): Promise<UserTokenEntity | null>;
|
getByToken(token: string): Promise<UserTokenEntity | null>;
|
||||||
|
getAll(userId: string): Promise<UserTokenEntity[]>;
|
||||||
}
|
}
|
||||||
|
@ -391,9 +391,22 @@ export const userTokenEntityStub = {
|
|||||||
userToken: Object.freeze<UserTokenEntity>({
|
userToken: Object.freeze<UserTokenEntity>({
|
||||||
id: 'token-id',
|
id: 'token-id',
|
||||||
token: 'auth_token',
|
token: 'auth_token',
|
||||||
|
userId: userEntityStub.user1.id,
|
||||||
user: userEntityStub.user1,
|
user: userEntityStub.user1,
|
||||||
createdAt: '2021-01-01',
|
createdAt: new Date('2021-01-01'),
|
||||||
updatedAt: '2021-01-01',
|
updatedAt: new Date(),
|
||||||
|
deviceType: '',
|
||||||
|
deviceOS: '',
|
||||||
|
}),
|
||||||
|
inactiveToken: Object.freeze<UserTokenEntity>({
|
||||||
|
id: 'not_active',
|
||||||
|
token: 'auth_token',
|
||||||
|
userId: userEntityStub.user1.id,
|
||||||
|
user: userEntityStub.user1,
|
||||||
|
createdAt: new Date('2021-01-01'),
|
||||||
|
updatedAt: new Date('2021-01-01'),
|
||||||
|
deviceType: 'Mobile',
|
||||||
|
deviceOS: 'Android',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,8 +3,10 @@ import { IUserTokenRepository } from '../src';
|
|||||||
export const newUserTokenRepositoryMock = (): jest.Mocked<IUserTokenRepository> => {
|
export const newUserTokenRepositoryMock = (): jest.Mocked<IUserTokenRepository> => {
|
||||||
return {
|
return {
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
deleteAll: jest.fn(),
|
deleteAll: jest.fn(),
|
||||||
get: jest.fn(),
|
getByToken: jest.fn(),
|
||||||
|
getAll: jest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -9,12 +9,21 @@ export class UserTokenEntity {
|
|||||||
@Column({ select: false })
|
@Column({ select: false })
|
||||||
token!: string;
|
token!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => UserEntity)
|
@ManyToOne(() => UserEntity)
|
||||||
user!: UserEntity;
|
user!: UserEntity;
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz' })
|
@CreateDateColumn({ type: 'timestamptz' })
|
||||||
createdAt!: string;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn({ type: 'timestamptz' })
|
@UpdateDateColumn({ type: 'timestamptz' })
|
||||||
updatedAt!: string;
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ default: '' })
|
||||||
|
deviceType!: string;
|
||||||
|
|
||||||
|
@Column({ default: '' })
|
||||||
|
deviceOS!: string;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class FixNullableRelations1682371561743 implements MigrationInterface {
|
||||||
|
name = 'FixNullableRelations1682371561743';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_token" ALTER COLUMN "userId" SET NOT NULL`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_token" ALTER COLUMN "userId" DROP NOT NULL`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddDeviceInfoToUserToken1682371791038 implements MigrationInterface {
|
||||||
|
name = 'AddDeviceInfoToUserToken1682371791038'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_token" ADD "deviceType" character varying NOT NULL DEFAULT ''`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_token" ADD "deviceOS" character varying NOT NULL DEFAULT ''`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceOS"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceType"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -6,24 +6,40 @@ import { IUserTokenRepository } from '@app/domain/user-token';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserTokenRepository implements IUserTokenRepository {
|
export class UserTokenRepository implements IUserTokenRepository {
|
||||||
constructor(
|
constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {}
|
||||||
@InjectRepository(UserTokenEntity)
|
|
||||||
private userTokenRepository: Repository<UserTokenEntity>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async get(userToken: string): Promise<UserTokenEntity | null> {
|
getByToken(token: string): Promise<UserTokenEntity | null> {
|
||||||
return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } });
|
return this.repository.findOne({ where: { token }, relations: { user: true } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
|
getAll(userId: string): Promise<UserTokenEntity[]> {
|
||||||
return this.userTokenRepository.save(userToken);
|
return this.repository.find({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
updatedAt: 'desc',
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
|
||||||
await this.userTokenRepository.delete(id);
|
return this.repository.save(userToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
|
||||||
|
return this.repository.save(userToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(userId: string, id: string): Promise<void> {
|
||||||
|
await this.repository.delete({ userId, id });
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAll(userId: string): Promise<void> {
|
async deleteAll(userId: string): Promise<void> {
|
||||||
await this.userTokenRepository.delete({ user: { id: userId } });
|
await this.repository.delete({ userId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
41
server/package-lock.json
generated
41
server/package-lock.json
generated
@ -6,7 +6,7 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.53.0",
|
"version": "1.54.1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.20.13",
|
"@babel/runtime": "^7.20.13",
|
||||||
@ -48,7 +48,8 @@
|
|||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"sharp": "^0.28.0",
|
"sharp": "^0.28.0",
|
||||||
"typeorm": "^0.3.11",
|
"typeorm": "^0.3.11",
|
||||||
"typesense": "^1.5.3"
|
"typesense": "^1.5.3",
|
||||||
|
"ua-parser-js": "^1.0.35"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"immich": "bin/cli.sh"
|
"immich": "bin/cli.sh"
|
||||||
@ -73,6 +74,7 @@
|
|||||||
"@types/node": "^16.0.0",
|
"@types/node": "^16.0.0",
|
||||||
"@types/sharp": "^0.30.2",
|
"@types/sharp": "^0.30.2",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.11",
|
||||||
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||||
"@typescript-eslint/parser": "^5.48.1",
|
"@typescript-eslint/parser": "^5.48.1",
|
||||||
"dotenv": "^14.2.0",
|
"dotenv": "^14.2.0",
|
||||||
@ -2852,6 +2854,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ua-parser-js": {
|
||||||
|
"version": "0.7.36",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
|
||||||
|
"integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/validator": {
|
"node_modules/@types/validator": {
|
||||||
"version": "13.7.14",
|
"version": "13.7.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz",
|
||||||
@ -11207,6 +11215,24 @@
|
|||||||
"@babel/runtime": "^7.17.2"
|
"@babel/runtime": "^7.17.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ua-parser-js": {
|
||||||
|
"version": "1.0.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz",
|
||||||
|
"integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ua-parser-js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/faisalman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uglify-js": {
|
"node_modules/uglify-js": {
|
||||||
"version": "3.17.4",
|
"version": "3.17.4",
|
||||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
|
||||||
@ -13872,6 +13898,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/ua-parser-js": {
|
||||||
|
"version": "0.7.36",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
|
||||||
|
"integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/validator": {
|
"@types/validator": {
|
||||||
"version": "13.7.14",
|
"version": "13.7.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz",
|
||||||
@ -20132,6 +20164,11 @@
|
|||||||
"loglevel": "^1.8.0"
|
"loglevel": "^1.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ua-parser-js": {
|
||||||
|
"version": "1.0.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz",
|
||||||
|
"integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA=="
|
||||||
|
},
|
||||||
"uglify-js": {
|
"uglify-js": {
|
||||||
"version": "3.17.4",
|
"version": "3.17.4",
|
||||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
|
||||||
|
@ -79,7 +79,8 @@
|
|||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"sharp": "^0.28.0",
|
"sharp": "^0.28.0",
|
||||||
"typeorm": "^0.3.11",
|
"typeorm": "^0.3.11",
|
||||||
"typesense": "^1.5.3"
|
"typesense": "^1.5.3",
|
||||||
|
"ua-parser-js": "^1.0.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^9.1.8",
|
"@nestjs/cli": "^9.1.8",
|
||||||
@ -101,6 +102,7 @@
|
|||||||
"@types/node": "^16.0.0",
|
"@types/node": "^16.0.0",
|
||||||
"@types/sharp": "^0.30.2",
|
"@types/sharp": "^0.30.2",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.11",
|
||||||
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||||
"@typescript-eslint/parser": "^5.48.1",
|
"@typescript-eslint/parser": "^5.48.1",
|
||||||
"dotenv": "^14.2.0",
|
"dotenv": "^14.2.0",
|
||||||
@ -139,9 +141,9 @@
|
|||||||
"coverageThreshold": {
|
"coverageThreshold": {
|
||||||
"./libs/domain/": {
|
"./libs/domain/": {
|
||||||
"branches": 80,
|
"branches": 80,
|
||||||
"functions": 85,
|
"functions": 88,
|
||||||
"lines": 90,
|
"lines": 94,
|
||||||
"statements": 90
|
"statements": 94
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"setupFilesAfterEnv": [
|
"setupFilesAfterEnv": [
|
||||||
@ -158,4 +160,4 @@
|
|||||||
},
|
},
|
||||||
"globalSetup": "<rootDir>/libs/domain/test/global-setup.js"
|
"globalSetup": "<rootDir>/libs/domain/test/global-setup.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
174
web/src/api/open-api/api.ts
generated
174
web/src/api/open-api/api.ts
generated
@ -585,6 +585,49 @@ export const AssetTypeEnum = {
|
|||||||
export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum];
|
export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum];
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface AuthDeviceResponseDto
|
||||||
|
*/
|
||||||
|
export interface AuthDeviceResponseDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof AuthDeviceResponseDto
|
||||||
|
*/
|
||||||
|
'id': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof AuthDeviceResponseDto
|
||||||
|
*/
|
||||||
|
'createdAt': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof AuthDeviceResponseDto
|
||||||
|
*/
|
||||||
|
'updatedAt': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof AuthDeviceResponseDto
|
||||||
|
*/
|
||||||
|
'current': boolean;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof AuthDeviceResponseDto
|
||||||
|
*/
|
||||||
|
'deviceType': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof AuthDeviceResponseDto
|
||||||
|
*/
|
||||||
|
'deviceOS': string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @export
|
* @export
|
||||||
@ -5951,6 +5994,41 @@ export const AuthenticationApiAxiosParamCreator = function (configuration?: Conf
|
|||||||
options: localVarRequestOptions,
|
options: localVarRequestOptions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getAuthDevices: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
const localVarPath = `/auth/devices`;
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication cookie required
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {LoginCredentialDto} loginCredentialDto
|
* @param {LoginCredentialDto} loginCredentialDto
|
||||||
@ -6012,6 +6090,45 @@ export const AuthenticationApiAxiosParamCreator = function (configuration?: Conf
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
logoutAuthDevice: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'id' is not null or undefined
|
||||||
|
assertParamExists('logoutAuthDevice', 'id', id)
|
||||||
|
const localVarPath = `/auth/devices/{id}`
|
||||||
|
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication cookie required
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
@ -6086,6 +6203,15 @@ export const AuthenticationApiFp = function(configuration?: Configuration) {
|
|||||||
const localVarAxiosArgs = await localVarAxiosParamCreator.changePassword(changePasswordDto, options);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.changePassword(changePasswordDto, options);
|
||||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async getAuthDevices(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AuthDeviceResponseDto>>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getAuthDevices(options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {LoginCredentialDto} loginCredentialDto
|
* @param {LoginCredentialDto} loginCredentialDto
|
||||||
@ -6105,6 +6231,16 @@ export const AuthenticationApiFp = function(configuration?: Configuration) {
|
|||||||
const localVarAxiosArgs = await localVarAxiosParamCreator.logout(options);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.logout(options);
|
||||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async logoutAuthDevice(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.logoutAuthDevice(id, options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
@ -6142,6 +6278,14 @@ export const AuthenticationApiFactory = function (configuration?: Configuration,
|
|||||||
changePassword(changePasswordDto: ChangePasswordDto, options?: any): AxiosPromise<UserResponseDto> {
|
changePassword(changePasswordDto: ChangePasswordDto, options?: any): AxiosPromise<UserResponseDto> {
|
||||||
return localVarFp.changePassword(changePasswordDto, options).then((request) => request(axios, basePath));
|
return localVarFp.changePassword(changePasswordDto, options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getAuthDevices(options?: any): AxiosPromise<Array<AuthDeviceResponseDto>> {
|
||||||
|
return localVarFp.getAuthDevices(options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {LoginCredentialDto} loginCredentialDto
|
* @param {LoginCredentialDto} loginCredentialDto
|
||||||
@ -6159,6 +6303,15 @@ export const AuthenticationApiFactory = function (configuration?: Configuration,
|
|||||||
logout(options?: any): AxiosPromise<LogoutResponseDto> {
|
logout(options?: any): AxiosPromise<LogoutResponseDto> {
|
||||||
return localVarFp.logout(options).then((request) => request(axios, basePath));
|
return localVarFp.logout(options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
logoutAuthDevice(id: string, options?: any): AxiosPromise<void> {
|
||||||
|
return localVarFp.logoutAuthDevice(id, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
@ -6199,6 +6352,16 @@ export class AuthenticationApi extends BaseAPI {
|
|||||||
return AuthenticationApiFp(this.configuration).changePassword(changePasswordDto, options).then((request) => request(this.axios, this.basePath));
|
return AuthenticationApiFp(this.configuration).changePassword(changePasswordDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof AuthenticationApi
|
||||||
|
*/
|
||||||
|
public getAuthDevices(options?: AxiosRequestConfig) {
|
||||||
|
return AuthenticationApiFp(this.configuration).getAuthDevices(options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {LoginCredentialDto} loginCredentialDto
|
* @param {LoginCredentialDto} loginCredentialDto
|
||||||
@ -6220,6 +6383,17 @@ export class AuthenticationApi extends BaseAPI {
|
|||||||
return AuthenticationApiFp(this.configuration).logout(options).then((request) => request(this.axios, this.basePath));
|
return AuthenticationApiFp(this.configuration).logout(options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof AuthenticationApi
|
||||||
|
*/
|
||||||
|
public logoutAuthDevice(id: string, options?: AxiosRequestConfig) {
|
||||||
|
return AuthenticationApiFp(this.configuration).logoutAuthDevice(id, options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
|
72
web/src/lib/components/user-settings-page/device-card.svelte
Normal file
72
web/src/lib/components/user-settings-page/device-card.svelte
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import { AuthDeviceResponseDto } from '@api';
|
||||||
|
import { DateTime, ToRelativeCalendarOptions } from 'luxon';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import Android from 'svelte-material-icons/Android.svelte';
|
||||||
|
import Apple from 'svelte-material-icons/Apple.svelte';
|
||||||
|
import AppleSafari from 'svelte-material-icons/AppleSafari.svelte';
|
||||||
|
import GoogleChrome from 'svelte-material-icons/GoogleChrome.svelte';
|
||||||
|
import Help from 'svelte-material-icons/Help.svelte';
|
||||||
|
import Linux from 'svelte-material-icons/Linux.svelte';
|
||||||
|
import MicrosoftWindows from 'svelte-material-icons/MicrosoftWindows.svelte';
|
||||||
|
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
|
||||||
|
|
||||||
|
export let device: AuthDeviceResponseDto;
|
||||||
|
|
||||||
|
const dispatcher = createEventDispatcher();
|
||||||
|
|
||||||
|
const options: ToRelativeCalendarOptions = {
|
||||||
|
unit: 'days',
|
||||||
|
locale: $locale
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-row w-full">
|
||||||
|
<!-- TODO: Device Image -->
|
||||||
|
<div
|
||||||
|
class="hidden sm:flex pr-2 justify-center items-center text-immich-primary dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
{#if device.deviceOS === 'Android'}
|
||||||
|
<Android size="40" />
|
||||||
|
{:else if device.deviceOS === 'iOS' || device.deviceOS === 'Mac OS'}
|
||||||
|
<Apple size="40" />
|
||||||
|
{:else if device.deviceOS.indexOf('Safari') !== -1}
|
||||||
|
<AppleSafari size="40" />
|
||||||
|
{:else if device.deviceOS.indexOf('Windows') !== -1}
|
||||||
|
<MicrosoftWindows size="40" />
|
||||||
|
{:else if device.deviceOS === 'Linux'}
|
||||||
|
<Linux size="40" />
|
||||||
|
{:else if device.deviceOS === 'Chromium OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium'}
|
||||||
|
<GoogleChrome size="40" />
|
||||||
|
{:else}
|
||||||
|
<Help size="40" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="pl-4 sm:pl-0 flex flex-row grow justify-between gap-1">
|
||||||
|
<div class="flex flex-col gap-1 justify-center dark:text-white">
|
||||||
|
<span class="text-sm">
|
||||||
|
{#if device.deviceType || device.deviceOS}
|
||||||
|
<span>{device.deviceOS || 'Unknown'} • {device.deviceType || 'Unknown'}</span>
|
||||||
|
{:else}
|
||||||
|
<span>Unknown</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="">Last seen</span>
|
||||||
|
<span>{DateTime.fromISO(device.updatedAt).toRelativeCalendar(options)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if !device.current}
|
||||||
|
<div class="text-sm flex flex-col justify-center">
|
||||||
|
<button
|
||||||
|
on:click={() => dispatcher('delete')}
|
||||||
|
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<TrashCanOutline size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
71
web/src/lib/components/user-settings-page/device-list.svelte
Normal file
71
web/src/lib/components/user-settings-page/device-list.svelte
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api, AuthDeviceResponseDto } from '@api';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { handleError } from '../../utils/handle-error';
|
||||||
|
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
|
||||||
|
import {
|
||||||
|
notificationController,
|
||||||
|
NotificationType
|
||||||
|
} from '../shared-components/notification/notification';
|
||||||
|
import DeviceCard from './device-card.svelte';
|
||||||
|
|
||||||
|
let devices: AuthDeviceResponseDto[] = [];
|
||||||
|
let deleteDevice: AuthDeviceResponseDto | null = null;
|
||||||
|
|
||||||
|
const refresh = () => api.authenticationApi.getAuthDevices().then(({ data }) => (devices = data));
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
$: currentDevice = devices.find((device) => device.current);
|
||||||
|
$: otherDevices = devices.filter((device) => !device.current);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteDevice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.authenticationApi.logoutAuthDevice(deleteDevice.id);
|
||||||
|
notificationController.show({ message: `Logged out device`, type: NotificationType.Info });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Unable to logout device');
|
||||||
|
} finally {
|
||||||
|
await refresh();
|
||||||
|
deleteDevice = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if deleteDevice}
|
||||||
|
<ConfirmDialogue
|
||||||
|
prompt="Are you sure you want to logout this device?"
|
||||||
|
on:confirm={() => handleDelete()}
|
||||||
|
on:cancel={() => (deleteDevice = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="my-4">
|
||||||
|
{#if currentDevice}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="font-medium text-xs mb-2 text-immich-primary dark:text-immich-dark-primary">
|
||||||
|
CURRENT DEVICE
|
||||||
|
</h3>
|
||||||
|
<DeviceCard device={currentDevice} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if otherDevices.length > 0}
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-xs mb-2 text-immich-primary dark:text-immich-dark-primary">
|
||||||
|
OTHER DEVICES
|
||||||
|
</h3>
|
||||||
|
{#each otherDevices as device, i}
|
||||||
|
<DeviceCard {device} on:delete={() => (deleteDevice = device)} />
|
||||||
|
{#if i !== otherDevices.length - 1}
|
||||||
|
<hr class="my-3" />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
@ -6,6 +6,7 @@
|
|||||||
import ChangePasswordSettings from './change-password-settings.svelte';
|
import ChangePasswordSettings from './change-password-settings.svelte';
|
||||||
import OAuthSettings from './oauth-settings.svelte';
|
import OAuthSettings from './oauth-settings.svelte';
|
||||||
import UserAPIKeyList from './user-api-key-list.svelte';
|
import UserAPIKeyList from './user-api-key-list.svelte';
|
||||||
|
import DeviceList from './device-list.svelte';
|
||||||
import UserProfileSettings from './user-profile-settings.svelte';
|
import UserProfileSettings from './user-profile-settings.svelte';
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
export let user: UserResponseDto;
|
||||||
@ -46,3 +47,7 @@
|
|||||||
<OAuthSettings {user} />
|
<OAuthSettings {user} />
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<SettingAccordion title="Authorized Devices" subtitle="View and manage your logged-in devices">
|
||||||
|
<DeviceList />
|
||||||
|
</SettingAccordion>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user