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 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| @@ -49,6 +50,22 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { | ||||
|     } | ||||
|  | ||||
|     // 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 { | ||||
|       var loginResponse = await _apiService.authenticationApi.login( | ||||
|         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/AssetResponseDto.md | ||||
| doc/AssetTypeEnum.md | ||||
| doc/AuthDeviceResponseDto.md | ||||
| doc/AuthenticationApi.md | ||||
| doc/ChangePasswordDto.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_response_dto.dart | ||||
| lib/model/asset_type_enum.dart | ||||
| lib/model/auth_device_response_dto.dart | ||||
| lib/model/change_password_dto.dart | ||||
| lib/model/check_duplicate_asset_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_response_dto_test.dart | ||||
| test/asset_type_enum_test.dart | ||||
| test/auth_device_response_dto_test.dart | ||||
| test/authentication_api_test.dart | ||||
| test/change_password_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 |  | ||||
| *AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up |  | ||||
| *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* | [**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 |  | ||||
| *DeviceInfoApi* | [**upsertDeviceInfo**](doc//DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info |  | ||||
| *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |  | ||||
| @@ -174,6 +176,7 @@ Class | Method | HTTP request | Description | ||||
|  - [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md) | ||||
|  - [AssetResponseDto](doc//AssetResponseDto.md) | ||||
|  - [AssetTypeEnum](doc//AssetTypeEnum.md) | ||||
|  - [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md) | ||||
|  - [ChangePasswordDto](doc//ChangePasswordDto.md) | ||||
|  - [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.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 |  | ||||
| [**changePassword**](AuthenticationApi.md#changepassword) | **POST** /auth/change-password |  | ||||
| [**getAuthDevices**](AuthenticationApi.md#getauthdevices) | **GET** /auth/devices |  | ||||
| [**login**](AuthenticationApi.md#login) | **POST** /auth/login |  | ||||
| [**logout**](AuthenticationApi.md#logout) | **POST** /auth/logout |  | ||||
| [**logoutAuthDevice**](AuthenticationApi.md#logoutauthdevice) | **DELETE** /auth/devices/{id} |  | ||||
| [**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) | ||||
| 
 | ||||
| # **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** | ||||
| > 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) | ||||
| 
 | ||||
| # **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** | ||||
| > 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 | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **successful** | **bool** |  | [readonly]  | ||||
| **redirectUri** | **String** |  | [readonly]  | ||||
| **successful** | **bool** |  |  | ||||
| **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) | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										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_response_dto.dart'; | ||||
| part 'model/asset_type_enum.dart'; | ||||
| part 'model/auth_device_response_dto.dart'; | ||||
| part 'model/change_password_dto.dart'; | ||||
| part 'model/check_duplicate_asset_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; | ||||
|   } | ||||
| 
 | ||||
|   /// 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]. | ||||
|   /// Parameters: | ||||
|   /// | ||||
| @@ -198,6 +242,46 @@ class AuthenticationApi { | ||||
|     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]. | ||||
|   Future<Response> validateAccessTokenWithHttpInfo() async { | ||||
|     // 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); | ||||
|         case 'AssetTypeEnum': | ||||
|           return AssetTypeEnumTypeTransformer().decode(value); | ||||
|         case 'AuthDeviceResponseDto': | ||||
|           return AuthDeviceResponseDto.fromJson(value); | ||||
|         case 'ChangePasswordDto': | ||||
|           return ChangePasswordDto.fromJson(value); | ||||
|         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 | ||||
|     }); | ||||
| 
 | ||||
|     //Future<List<AuthDeviceResponseDto>> getAuthDevices() async | ||||
|     test('test getAuthDevices', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<LoginResponseDto> login(LoginCredentialDto loginCredentialDto) async | ||||
|     test('test login', () async { | ||||
|       // TODO | ||||
| @@ -37,6 +42,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future logoutAuthDevice(String id) async | ||||
|     test('test logoutAuthDevice', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<ValidateAccessTokenResponseDto> validateAccessToken() async | ||||
|     test('test validateAccessToken', () async { | ||||
|       // TODO | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { | ||||
|   AdminSignupResponseDto, | ||||
|   AuthDeviceResponseDto, | ||||
|   AuthService, | ||||
|   AuthType, | ||||
|   AuthUserDto, | ||||
| @@ -7,18 +8,20 @@ import { | ||||
|   IMMICH_ACCESS_COOKIE, | ||||
|   IMMICH_AUTH_TYPE_COOKIE, | ||||
|   LoginCredentialDto, | ||||
|   LoginDetails, | ||||
|   LoginResponseDto, | ||||
|   LogoutResponseDto, | ||||
|   SignUpDto, | ||||
|   UserResponseDto, | ||||
|   ValidateAccessTokenResponseDto, | ||||
| } 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 { 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 { UseValidation } from '../decorators/use-validation.decorator'; | ||||
| import { UUIDParamDto } from './dto/uuid-param.dto'; | ||||
|  | ||||
| @ApiTags('Authentication') | ||||
| @Controller('auth') | ||||
| @@ -29,11 +32,10 @@ export class AuthController { | ||||
|   @Post('login') | ||||
|   async login( | ||||
|     @Body() loginCredential: LoginCredentialDto, | ||||
|     @Ip() clientIp: string, | ||||
|     @Req() req: Request, | ||||
|     @Res({ passthrough: true }) res: Response, | ||||
|     @GetLoginDetails() loginDetails: LoginDetails, | ||||
|   ): 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); | ||||
|     return response; | ||||
|   } | ||||
| @@ -44,6 +46,18 @@ export class AuthController { | ||||
|     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() | ||||
|   @Post('validateToken') | ||||
|   validateAccessToken(): ValidateAccessTokenResponseDto { | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { | ||||
|   AuthUserDto, | ||||
|   LoginDetails, | ||||
|   LoginResponseDto, | ||||
|   OAuthCallbackDto, | ||||
|   OAuthConfigDto, | ||||
| @@ -10,7 +11,7 @@ import { | ||||
| import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; | ||||
| import { ApiTags } from '@nestjs/swagger'; | ||||
| 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 { UseValidation } from '../decorators/use-validation.decorator'; | ||||
|  | ||||
| @@ -38,9 +39,9 @@ export class OAuthController { | ||||
|   async callback( | ||||
|     @Res({ passthrough: true }) res: Response, | ||||
|     @Body() dto: OAuthCallbackDto, | ||||
|     @Req() req: Request, | ||||
|     @GetLoginDetails() loginDetails: LoginDetails, | ||||
|   ): 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); | ||||
|     return response; | ||||
|   } | ||||
|   | ||||
| @@ -1,7 +1,20 @@ | ||||
| export { AuthUserDto } from '@app/domain'; | ||||
| import { AuthUserDto } from '@app/domain'; | ||||
| import { AuthUserDto, LoginDetails } from '@app/domain'; | ||||
| import { createParamDecorator, ExecutionContext } from '@nestjs/common'; | ||||
| import { UAParser } from 'ua-parser-js'; | ||||
|  | ||||
| export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => { | ||||
|   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 === '') { | ||||
|         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": { | ||||
|       "post": { | ||||
|         "operationId": "validateAccessToken", | ||||
| @@ -3986,6 +4050,37 @@ | ||||
|           "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": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
| @@ -4018,12 +4113,10 @@ | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "successful": { | ||||
|             "type": "boolean", | ||||
|             "readOnly": true | ||||
|             "type": "boolean" | ||||
|           }, | ||||
|           "redirectUri": { | ||||
|             "type": "string", | ||||
|             "readOnly": true | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|   | ||||
| @@ -1,10 +1,17 @@ | ||||
| import { SystemConfig, UserEntity } from '@app/infra/entities'; | ||||
| import { ICryptoRepository } from '../crypto/crypto.repository'; | ||||
| import { ISystemConfigRepository } from '../system-config'; | ||||
| 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 { 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 { | ||||
|   private userTokenCore: UserTokenCore; | ||||
| @@ -23,7 +30,7 @@ export class AuthCore { | ||||
|     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 | ||||
|  | ||||
|     let authTypeCookie = ''; | ||||
| @@ -39,10 +46,10 @@ export class AuthCore { | ||||
|     return [accessTokenCookie, authTypeCookie]; | ||||
|   } | ||||
|  | ||||
|   public async createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) { | ||||
|     const accessToken = await this.userTokenCore.createToken(user); | ||||
|   async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) { | ||||
|     const accessToken = await this.userTokenCore.create(user, loginDetails); | ||||
|     const response = mapLoginResponse(user, accessToken); | ||||
|     const cookie = this.getCookies(response, authType, isSecure); | ||||
|     const cookie = this.getCookies(response, authType, loginDetails); | ||||
|     return { response, cookie }; | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -32,6 +32,12 @@ import { AuthUserDto, SignUpDto } from './dto'; | ||||
|  | ||||
| const email = 'test@immich.com'; | ||||
| const sub = 'my-auth-user-sub'; | ||||
| const loginDetails = { | ||||
|   isSecure: true, | ||||
|   clientIp: '127.0.0.1', | ||||
|   deviceOS: '', | ||||
|   deviceType: '', | ||||
| }; | ||||
|  | ||||
| const fixtures = { | ||||
|   login: { | ||||
| @@ -40,8 +46,6 @@ const fixtures = { | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| const CLIENT_IP = '127.0.0.1'; | ||||
|  | ||||
| describe('AuthService', () => { | ||||
|   let sut: AuthService; | ||||
|   let cryptoMock: jest.Mocked<ICryptoRepository>; | ||||
| @@ -96,32 +100,39 @@ describe('AuthService', () => { | ||||
|     it('should throw an error if password login is disabled', async () => { | ||||
|       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 () => { | ||||
|       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); | ||||
|     }); | ||||
|  | ||||
|     it('should check the user has a password', async () => { | ||||
|       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); | ||||
|     }); | ||||
|  | ||||
|     it('should successfully log the user in', async () => { | ||||
|       userMock.getByEmail.mockResolvedValue(userEntityStub.user1); | ||||
|       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); | ||||
|     }); | ||||
|  | ||||
|     it('should generate the cookie headers (insecure)', async () => { | ||||
|       userMock.getByEmail.mockResolvedValue(userEntityStub.user1); | ||||
|       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); | ||||
|     }); | ||||
|   }); | ||||
| @@ -205,7 +216,7 @@ describe('AuthService', () => { | ||||
|         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 () => { | ||||
|       userMock.get.mockResolvedValue(userEntityStub.user1); | ||||
|       userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken); | ||||
|       userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken); | ||||
|       const client = { request: { headers: { authorization: 'Bearer auth_token' } } }; | ||||
|       await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userEntityStub.user1); | ||||
|     }); | ||||
| @@ -276,16 +287,32 @@ describe('AuthService', () => { | ||||
|  | ||||
|   describe('validate - user token', () => { | ||||
|     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' }; | ||||
|       await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); | ||||
|     }); | ||||
|  | ||||
|     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' }; | ||||
|       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', () => { | ||||
| @@ -303,4 +330,38 @@ describe('AuthService', () => { | ||||
|       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 { IUserRepository, UserCore } from '../user'; | ||||
| 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 { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto'; | ||||
| import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto'; | ||||
| @@ -21,6 +21,7 @@ import cookieParser from 'cookie'; | ||||
| import { ISharedLinkRepository, ShareCore } from '../share'; | ||||
| import { APIKeyCore } from '../api-key/api-key.core'; | ||||
| import { IKeyRepository } from '../api-key'; | ||||
| import { AuthDeviceResponseDto, mapUserToken } from './response-dto'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AuthService { | ||||
| @@ -53,8 +54,7 @@ export class AuthService { | ||||
|  | ||||
|   public async login( | ||||
|     loginCredential: LoginCredentialDto, | ||||
|     clientIp: string, | ||||
|     isSecure: boolean, | ||||
|     loginDetails: LoginDetails, | ||||
|   ): Promise<{ response: LoginResponseDto; cookie: string[] }> { | ||||
|     if (!this.authCore.isPasswordLoginEnabled()) { | ||||
|       throw new UnauthorizedException('Password login has been disabled'); | ||||
| @@ -69,16 +69,18 @@ export class AuthService { | ||||
|     } | ||||
|  | ||||
|     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'); | ||||
|     } | ||||
|  | ||||
|     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> { | ||||
|     if (authUser.accessTokenId) { | ||||
|       await this.userTokenCore.deleteToken(authUser.accessTokenId); | ||||
|       await this.userTokenCore.delete(authUser.id, authUser.accessTokenId); | ||||
|     } | ||||
|  | ||||
|     if (authType === AuthType.OAUTH) { | ||||
| @@ -152,6 +154,15 @@ export class AuthService { | ||||
|     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 { | ||||
|     const [type, token] = (headers.authorization || '').split(' '); | ||||
|     if (type.toLowerCase() === 'bearer') { | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| export * from './auth.constant'; | ||||
| export * from './auth.core'; | ||||
| export * from './auth.service'; | ||||
| export * from './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 './auth-device-response.dto'; | ||||
| export * from './login-response.dto'; | ||||
| export * from './logout-response.dto'; | ||||
| export * from './validate-asset-token-response.dto'; | ||||
|   | ||||
| @@ -1,13 +1,4 @@ | ||||
| import { ApiResponseProperty } from '@nestjs/swagger'; | ||||
|  | ||||
| export class LogoutResponseDto { | ||||
|   constructor(successful: boolean) { | ||||
|     this.successful = successful; | ||||
|   } | ||||
|  | ||||
|   @ApiResponseProperty() | ||||
|   successful!: boolean; | ||||
|  | ||||
|   @ApiResponseProperty() | ||||
|   redirectUri!: string; | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,3 @@ | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
|  | ||||
| export class ValidateAccessTokenResponseDto { | ||||
|   constructor(authStatus: boolean) { | ||||
|     this.authStatus = authStatus; | ||||
|   } | ||||
|  | ||||
|   @ApiProperty({ type: 'boolean' }) | ||||
|   authStatus!: boolean; | ||||
| } | ||||
|   | ||||
| @@ -17,9 +17,16 @@ import { ISystemConfigRepository } from '../system-config'; | ||||
| import { IUserRepository } from '../user'; | ||||
| import { IUserTokenRepository } from '../user-token'; | ||||
| import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock'; | ||||
| import { LoginDetails } from '../auth'; | ||||
|  | ||||
| const email = 'user@immich.com'; | ||||
| const sub = 'my-auth-user-sub'; | ||||
| const loginDetails: LoginDetails = { | ||||
|   isSecure: true, | ||||
|   clientIp: '127.0.0.1', | ||||
|   deviceOS: '', | ||||
|   deviceType: '', | ||||
| }; | ||||
|  | ||||
| describe('OAuthService', () => { | ||||
|   let sut: OAuthService; | ||||
| @@ -95,13 +102,13 @@ describe('OAuthService', () => { | ||||
|  | ||||
|   describe('login', () => { | ||||
|     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 () => { | ||||
|       sut = create(systemConfigStub.noAutoRegister); | ||||
|       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, | ||||
|       ); | ||||
|       expect(userMock.getByEmail).toHaveBeenCalledTimes(1); | ||||
| @@ -113,7 +120,7 @@ describe('OAuthService', () => { | ||||
|       userMock.update.mockResolvedValue(userEntityStub.user1); | ||||
|       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, | ||||
|       ); | ||||
|  | ||||
| @@ -129,7 +136,7 @@ describe('OAuthService', () => { | ||||
|       userMock.create.mockResolvedValue(userEntityStub.user1); | ||||
|       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, | ||||
|       ); | ||||
|  | ||||
| @@ -143,7 +150,7 @@ describe('OAuthService', () => { | ||||
|       userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1); | ||||
|       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' }); | ||||
|     }); | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { SystemConfig } from '@app/infra/entities'; | ||||
| import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; | ||||
| import { AuthType, AuthUserDto, LoginResponseDto } from '../auth'; | ||||
| import { AuthCore } from '../auth/auth.core'; | ||||
| import { AuthCore, LoginDetails } from '../auth/auth.core'; | ||||
| import { ICryptoRepository } from '../crypto'; | ||||
| import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; | ||||
| import { IUserRepository, UserCore, UserResponseDto } from '../user'; | ||||
| @@ -39,7 +39,10 @@ export class OAuthService { | ||||
|     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); | ||||
|  | ||||
|     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)); | ||||
|     } | ||||
|  | ||||
|     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> { | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import { UserEntity } from '@app/infra/entities'; | ||||
| import { UserEntity, UserTokenEntity } from '@app/infra/entities'; | ||||
| import { Injectable, UnauthorizedException } from '@nestjs/common'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { LoginDetails } from '../auth'; | ||||
| import { ICryptoRepository } from '../crypto'; | ||||
| import { IUserTokenRepository } from './user-token.repository'; | ||||
|  | ||||
| @@ -9,9 +11,16 @@ export class UserTokenCore { | ||||
|  | ||||
|   async validate(tokenValue: string) { | ||||
|     const hashedToken = this.crypto.hashSha256(tokenValue); | ||||
|     const token = await this.repository.get(hashedToken); | ||||
|     let token = await this.repository.getByToken(hashedToken); | ||||
|  | ||||
|     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 { | ||||
|         ...token.user, | ||||
|         isPublicUser: false, | ||||
| @@ -25,18 +34,24 @@ export class UserTokenCore { | ||||
|     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 token = this.crypto.hashSha256(key); | ||||
|     await this.repository.create({ | ||||
|       token, | ||||
|       user, | ||||
|       deviceOS: loginDetails.deviceOS, | ||||
|       deviceType: loginDetails.deviceType, | ||||
|     }); | ||||
|  | ||||
|     return key; | ||||
|   } | ||||
|  | ||||
|   public async deleteToken(id: string): Promise<void> { | ||||
|     await this.repository.delete(id); | ||||
|   async delete(userId: string, id: string): Promise<void> { | ||||
|     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 { | ||||
|   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>; | ||||
|   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>({ | ||||
|     id: 'token-id', | ||||
|     token: 'auth_token', | ||||
|     userId: userEntityStub.user1.id, | ||||
|     user: userEntityStub.user1, | ||||
|     createdAt: '2021-01-01', | ||||
|     updatedAt: '2021-01-01', | ||||
|     createdAt: new Date('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> => { | ||||
|   return { | ||||
|     create: jest.fn(), | ||||
|     save: jest.fn(), | ||||
|     delete: jest.fn(), | ||||
|     deleteAll: jest.fn(), | ||||
|     get: jest.fn(), | ||||
|     getByToken: jest.fn(), | ||||
|     getAll: jest.fn(), | ||||
|   }; | ||||
| }; | ||||
|   | ||||
| @@ -9,12 +9,21 @@ export class UserTokenEntity { | ||||
|   @Column({ select: false }) | ||||
|   token!: string; | ||||
|  | ||||
|   @Column() | ||||
|   userId!: string; | ||||
|  | ||||
|   @ManyToOne(() => UserEntity) | ||||
|   user!: UserEntity; | ||||
|  | ||||
|   @CreateDateColumn({ type: 'timestamptz' }) | ||||
|   createdAt!: string; | ||||
|   createdAt!: Date; | ||||
|  | ||||
|   @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() | ||||
| export class UserTokenRepository implements IUserTokenRepository { | ||||
|   constructor( | ||||
|     @InjectRepository(UserTokenEntity) | ||||
|     private userTokenRepository: Repository<UserTokenEntity>, | ||||
|   ) {} | ||||
|   constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {} | ||||
|  | ||||
|   async get(userToken: string): Promise<UserTokenEntity | null> { | ||||
|     return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } }); | ||||
|   getByToken(token: string): Promise<UserTokenEntity | null> { | ||||
|     return this.repository.findOne({ where: { token }, relations: { user: true } }); | ||||
|   } | ||||
|  | ||||
|   async create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> { | ||||
|     return this.userTokenRepository.save(userToken); | ||||
|   getAll(userId: string): Promise<UserTokenEntity[]> { | ||||
|     return this.repository.find({ | ||||
|       where: { | ||||
|         userId, | ||||
|       }, | ||||
|       relations: { | ||||
|         user: true, | ||||
|       }, | ||||
|       order: { | ||||
|         updatedAt: 'desc', | ||||
|         createdAt: 'desc', | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   async delete(id: string): Promise<void> { | ||||
|     await this.userTokenRepository.delete(id); | ||||
|   create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> { | ||||
|     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> { | ||||
|     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": { | ||||
|     "": { | ||||
|       "name": "immich", | ||||
|       "version": "1.53.0", | ||||
|       "version": "1.54.1", | ||||
|       "license": "UNLICENSED", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.20.13", | ||||
| @@ -48,7 +48,8 @@ | ||||
|         "sanitize-filename": "^1.6.3", | ||||
|         "sharp": "^0.28.0", | ||||
|         "typeorm": "^0.3.11", | ||||
|         "typesense": "^1.5.3" | ||||
|         "typesense": "^1.5.3", | ||||
|         "ua-parser-js": "^1.0.35" | ||||
|       }, | ||||
|       "bin": { | ||||
|         "immich": "bin/cli.sh" | ||||
| @@ -73,6 +74,7 @@ | ||||
|         "@types/node": "^16.0.0", | ||||
|         "@types/sharp": "^0.30.2", | ||||
|         "@types/supertest": "^2.0.11", | ||||
|         "@types/ua-parser-js": "^0.7.36", | ||||
|         "@typescript-eslint/eslint-plugin": "^5.48.1", | ||||
|         "@typescript-eslint/parser": "^5.48.1", | ||||
|         "dotenv": "^14.2.0", | ||||
| @@ -2852,6 +2854,12 @@ | ||||
|         "@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": { | ||||
|       "version": "13.7.14", | ||||
|       "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz", | ||||
| @@ -11207,6 +11215,24 @@ | ||||
|         "@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": { | ||||
|       "version": "3.17.4", | ||||
|       "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", | ||||
| @@ -13872,6 +13898,12 @@ | ||||
|         "@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": { | ||||
|       "version": "13.7.14", | ||||
|       "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz", | ||||
| @@ -20132,6 +20164,11 @@ | ||||
|         "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": { | ||||
|       "version": "3.17.4", | ||||
|       "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", | ||||
|   | ||||
| @@ -79,7 +79,8 @@ | ||||
|     "sanitize-filename": "^1.6.3", | ||||
|     "sharp": "^0.28.0", | ||||
|     "typeorm": "^0.3.11", | ||||
|     "typesense": "^1.5.3" | ||||
|     "typesense": "^1.5.3", | ||||
|     "ua-parser-js": "^1.0.35" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@nestjs/cli": "^9.1.8", | ||||
| @@ -101,6 +102,7 @@ | ||||
|     "@types/node": "^16.0.0", | ||||
|     "@types/sharp": "^0.30.2", | ||||
|     "@types/supertest": "^2.0.11", | ||||
|     "@types/ua-parser-js": "^0.7.36", | ||||
|     "@typescript-eslint/eslint-plugin": "^5.48.1", | ||||
|     "@typescript-eslint/parser": "^5.48.1", | ||||
|     "dotenv": "^14.2.0", | ||||
| @@ -139,9 +141,9 @@ | ||||
|     "coverageThreshold": { | ||||
|       "./libs/domain/": { | ||||
|         "branches": 80, | ||||
|         "functions": 85, | ||||
|         "lines": 90, | ||||
|         "statements": 90 | ||||
|         "functions": 88, | ||||
|         "lines": 94, | ||||
|         "statements": 94 | ||||
|       } | ||||
|     }, | ||||
|     "setupFilesAfterEnv": [ | ||||
|   | ||||
							
								
								
									
										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 | ||||
|  * @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 | ||||
| @@ -5951,6 +5994,41 @@ export const AuthenticationApiAxiosParamCreator = function (configuration?: Conf | ||||
|                 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  | ||||
| @@ -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); | ||||
|             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; | ||||
|             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; | ||||
| @@ -6086,6 +6203,15 @@ export const AuthenticationApiFp = function(configuration?: Configuration) { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.changePassword(changePasswordDto, options); | ||||
|             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  | ||||
| @@ -6105,6 +6231,16 @@ export const AuthenticationApiFp = function(configuration?: Configuration) { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.logout(options); | ||||
|             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. | ||||
| @@ -6142,6 +6278,14 @@ export const AuthenticationApiFactory = function (configuration?: Configuration, | ||||
|         changePassword(changePasswordDto: ChangePasswordDto, options?: any): AxiosPromise<UserResponseDto> { | ||||
|             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  | ||||
| @@ -6159,6 +6303,15 @@ export const AuthenticationApiFactory = function (configuration?: Configuration, | ||||
|         logout(options?: any): AxiosPromise<LogoutResponseDto> { | ||||
|             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. | ||||
| @@ -6199,6 +6352,16 @@ export class AuthenticationApi extends BaseAPI { | ||||
|         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  | ||||
| @@ -6220,6 +6383,17 @@ export class AuthenticationApi extends BaseAPI { | ||||
|         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. | ||||
|   | ||||
							
								
								
									
										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 OAuthSettings from './oauth-settings.svelte'; | ||||
| 	import UserAPIKeyList from './user-api-key-list.svelte'; | ||||
| 	import DeviceList from './device-list.svelte'; | ||||
| 	import UserProfileSettings from './user-profile-settings.svelte'; | ||||
|  | ||||
| 	export let user: UserResponseDto; | ||||
| @@ -46,3 +47,7 @@ | ||||
| 		<OAuthSettings {user} /> | ||||
| 	</SettingAccordion> | ||||
| {/if} | ||||
|  | ||||
| <SettingAccordion title="Authorized Devices" subtitle="View and manage your logged-in devices"> | ||||
| 	<DeviceList /> | ||||
| </SettingAccordion> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user