mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	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:
		| @@ -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> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user