mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-01 02:17:43 +09:00 
			
		
		
		
	feat: user pin-code (#18138)
* feat: user pincode * pr feedback * chore: cleanup --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
		
							
								
								
									
										14
									
								
								i18n/en.json
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								i18n/en.json
									
									
									
									
									
								
							| @@ -1,4 +1,16 @@ | |||||||
| { | { | ||||||
|  |   "user_pin_code_settings": "PIN Code", | ||||||
|  |   "user_pin_code_settings_description": "Manage your PIN code", | ||||||
|  |   "current_pin_code": "Current PIN code", | ||||||
|  |   "new_pin_code": "New PIN code", | ||||||
|  |   "setup_pin_code": "Setup a PIN code", | ||||||
|  |   "confirm_new_pin_code": "Confirm new PIN code", | ||||||
|  |   "unable_to_change_pin_code": "Unable to change PIN code", | ||||||
|  |   "unable_to_setup_pin_code": "Unable to setup PIN code", | ||||||
|  |   "pin_code_changed_successfully": "Successfully changed PIN code", | ||||||
|  |   "pin_code_setup_successfully": "Successfully setup a PIN code", | ||||||
|  |   "pin_code_reset_successfully": "Successfully reset PIN code", | ||||||
|  |   "reset_pin_code": "Reset PIN code", | ||||||
|   "about": "About", |   "about": "About", | ||||||
|   "account": "Account", |   "account": "Account", | ||||||
|   "account_settings": "Account Settings", |   "account_settings": "Account Settings", | ||||||
| @@ -53,6 +65,7 @@ | |||||||
|     "confirm_email_below": "To confirm, type \"{email}\" below", |     "confirm_email_below": "To confirm, type \"{email}\" below", | ||||||
|     "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", |     "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", | ||||||
|     "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", |     "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", | ||||||
|  |     "confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?", | ||||||
|     "create_job": "Create job", |     "create_job": "Create job", | ||||||
|     "cron_expression": "Cron expression", |     "cron_expression": "Cron expression", | ||||||
|     "cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>", |     "cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>", | ||||||
| @@ -922,6 +935,7 @@ | |||||||
|     "unable_to_remove_reaction": "Unable to remove reaction", |     "unable_to_remove_reaction": "Unable to remove reaction", | ||||||
|     "unable_to_repair_items": "Unable to repair items", |     "unable_to_repair_items": "Unable to repair items", | ||||||
|     "unable_to_reset_password": "Unable to reset password", |     "unable_to_reset_password": "Unable to reset password", | ||||||
|  |     "unable_to_reset_pin_code": "Unable to reset PIN code", | ||||||
|     "unable_to_resolve_duplicate": "Unable to resolve duplicate", |     "unable_to_resolve_duplicate": "Unable to resolve duplicate", | ||||||
|     "unable_to_restore_assets": "Unable to restore assets", |     "unable_to_restore_assets": "Unable to restore assets", | ||||||
|     "unable_to_restore_trash": "Unable to restore trash", |     "unable_to_restore_trash": "Unable to restore trash", | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -109,8 +109,12 @@ Class | Method | HTTP request | Description | |||||||
| *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |  | *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |  | ||||||
| *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |  | *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |  | ||||||
| *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |  | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |  | ||||||
|  | *AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code |  | ||||||
|  | *AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status |  | ||||||
| *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |  | *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |  | ||||||
| *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |  | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |  | ||||||
|  | *AuthenticationApi* | [**resetPinCode**](doc//AuthenticationApi.md#resetpincode) | **DELETE** /auth/pin-code |  | ||||||
|  | *AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code |  | ||||||
| *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |  | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |  | ||||||
| *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |  | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |  | ||||||
| *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random |  | *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random |  | ||||||
| @@ -304,6 +308,7 @@ Class | Method | HTTP request | Description | |||||||
|  - [AssetTypeEnum](doc//AssetTypeEnum.md) |  - [AssetTypeEnum](doc//AssetTypeEnum.md) | ||||||
|  - [AssetVisibility](doc//AssetVisibility.md) |  - [AssetVisibility](doc//AssetVisibility.md) | ||||||
|  - [AudioCodec](doc//AudioCodec.md) |  - [AudioCodec](doc//AudioCodec.md) | ||||||
|  |  - [AuthStatusResponseDto](doc//AuthStatusResponseDto.md) | ||||||
|  - [AvatarUpdate](doc//AvatarUpdate.md) |  - [AvatarUpdate](doc//AvatarUpdate.md) | ||||||
|  - [BulkIdResponseDto](doc//BulkIdResponseDto.md) |  - [BulkIdResponseDto](doc//BulkIdResponseDto.md) | ||||||
|  - [BulkIdsDto](doc//BulkIdsDto.md) |  - [BulkIdsDto](doc//BulkIdsDto.md) | ||||||
| @@ -383,6 +388,8 @@ Class | Method | HTTP request | Description | |||||||
|  - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) |  - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) | ||||||
|  - [PersonUpdateDto](doc//PersonUpdateDto.md) |  - [PersonUpdateDto](doc//PersonUpdateDto.md) | ||||||
|  - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md) |  - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md) | ||||||
|  |  - [PinCodeChangeDto](doc//PinCodeChangeDto.md) | ||||||
|  |  - [PinCodeSetupDto](doc//PinCodeSetupDto.md) | ||||||
|  - [PlacesResponseDto](doc//PlacesResponseDto.md) |  - [PlacesResponseDto](doc//PlacesResponseDto.md) | ||||||
|  - [PurchaseResponse](doc//PurchaseResponse.md) |  - [PurchaseResponse](doc//PurchaseResponse.md) | ||||||
|  - [PurchaseUpdate](doc//PurchaseUpdate.md) |  - [PurchaseUpdate](doc//PurchaseUpdate.md) | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							| @@ -108,6 +108,7 @@ part 'model/asset_stats_response_dto.dart'; | |||||||
| part 'model/asset_type_enum.dart'; | part 'model/asset_type_enum.dart'; | ||||||
| part 'model/asset_visibility.dart'; | part 'model/asset_visibility.dart'; | ||||||
| part 'model/audio_codec.dart'; | part 'model/audio_codec.dart'; | ||||||
|  | part 'model/auth_status_response_dto.dart'; | ||||||
| part 'model/avatar_update.dart'; | part 'model/avatar_update.dart'; | ||||||
| part 'model/bulk_id_response_dto.dart'; | part 'model/bulk_id_response_dto.dart'; | ||||||
| part 'model/bulk_ids_dto.dart'; | part 'model/bulk_ids_dto.dart'; | ||||||
| @@ -187,6 +188,8 @@ part 'model/person_response_dto.dart'; | |||||||
| part 'model/person_statistics_response_dto.dart'; | part 'model/person_statistics_response_dto.dart'; | ||||||
| part 'model/person_update_dto.dart'; | part 'model/person_update_dto.dart'; | ||||||
| part 'model/person_with_faces_response_dto.dart'; | part 'model/person_with_faces_response_dto.dart'; | ||||||
|  | part 'model/pin_code_change_dto.dart'; | ||||||
|  | part 'model/pin_code_setup_dto.dart'; | ||||||
| part 'model/places_response_dto.dart'; | part 'model/places_response_dto.dart'; | ||||||
| part 'model/purchase_response.dart'; | part 'model/purchase_response.dart'; | ||||||
| part 'model/purchase_update.dart'; | part 'model/purchase_update.dart'; | ||||||
|   | |||||||
							
								
								
									
										158
									
								
								mobile/openapi/lib/api/authentication_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										158
									
								
								mobile/openapi/lib/api/authentication_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -63,6 +63,86 @@ class AuthenticationApi { | |||||||
|     return null; |     return null; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /// Performs an HTTP 'PUT /auth/pin-code' operation and returns the [Response]. | ||||||
|  |   /// Parameters: | ||||||
|  |   /// | ||||||
|  |   /// * [PinCodeChangeDto] pinCodeChangeDto (required): | ||||||
|  |   Future<Response> changePinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async { | ||||||
|  |     // ignore: prefer_const_declarations | ||||||
|  |     final apiPath = r'/auth/pin-code'; | ||||||
|  | 
 | ||||||
|  |     // ignore: prefer_final_locals | ||||||
|  |     Object? postBody = pinCodeChangeDto; | ||||||
|  | 
 | ||||||
|  |     final queryParams = <QueryParam>[]; | ||||||
|  |     final headerParams = <String, String>{}; | ||||||
|  |     final formParams = <String, String>{}; | ||||||
|  | 
 | ||||||
|  |     const contentTypes = <String>['application/json']; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     return apiClient.invokeAPI( | ||||||
|  |       apiPath, | ||||||
|  |       'PUT', | ||||||
|  |       queryParams, | ||||||
|  |       postBody, | ||||||
|  |       headerParams, | ||||||
|  |       formParams, | ||||||
|  |       contentTypes.isEmpty ? null : contentTypes.first, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Parameters: | ||||||
|  |   /// | ||||||
|  |   /// * [PinCodeChangeDto] pinCodeChangeDto (required): | ||||||
|  |   Future<void> changePinCode(PinCodeChangeDto pinCodeChangeDto,) async { | ||||||
|  |     final response = await changePinCodeWithHttpInfo(pinCodeChangeDto,); | ||||||
|  |     if (response.statusCode >= HttpStatus.badRequest) { | ||||||
|  |       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Performs an HTTP 'GET /auth/status' operation and returns the [Response]. | ||||||
|  |   Future<Response> getAuthStatusWithHttpInfo() async { | ||||||
|  |     // ignore: prefer_const_declarations | ||||||
|  |     final apiPath = r'/auth/status'; | ||||||
|  | 
 | ||||||
|  |     // ignore: prefer_final_locals | ||||||
|  |     Object? postBody; | ||||||
|  | 
 | ||||||
|  |     final queryParams = <QueryParam>[]; | ||||||
|  |     final headerParams = <String, String>{}; | ||||||
|  |     final formParams = <String, String>{}; | ||||||
|  | 
 | ||||||
|  |     const contentTypes = <String>[]; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     return apiClient.invokeAPI( | ||||||
|  |       apiPath, | ||||||
|  |       'GET', | ||||||
|  |       queryParams, | ||||||
|  |       postBody, | ||||||
|  |       headerParams, | ||||||
|  |       formParams, | ||||||
|  |       contentTypes.isEmpty ? null : contentTypes.first, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<AuthStatusResponseDto?> getAuthStatus() async { | ||||||
|  |     final response = await getAuthStatusWithHttpInfo(); | ||||||
|  |     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) { | ||||||
|  |       return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AuthStatusResponseDto',) as AuthStatusResponseDto; | ||||||
|  |      | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /// Performs an HTTP 'POST /auth/login' operation and returns the [Response]. |   /// Performs an HTTP 'POST /auth/login' operation and returns the [Response]. | ||||||
|   /// Parameters: |   /// Parameters: | ||||||
|   /// |   /// | ||||||
| @@ -151,6 +231,84 @@ class AuthenticationApi { | |||||||
|     return null; |     return null; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /// Performs an HTTP 'DELETE /auth/pin-code' operation and returns the [Response]. | ||||||
|  |   /// Parameters: | ||||||
|  |   /// | ||||||
|  |   /// * [PinCodeChangeDto] pinCodeChangeDto (required): | ||||||
|  |   Future<Response> resetPinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async { | ||||||
|  |     // ignore: prefer_const_declarations | ||||||
|  |     final apiPath = r'/auth/pin-code'; | ||||||
|  | 
 | ||||||
|  |     // ignore: prefer_final_locals | ||||||
|  |     Object? postBody = pinCodeChangeDto; | ||||||
|  | 
 | ||||||
|  |     final queryParams = <QueryParam>[]; | ||||||
|  |     final headerParams = <String, String>{}; | ||||||
|  |     final formParams = <String, String>{}; | ||||||
|  | 
 | ||||||
|  |     const contentTypes = <String>['application/json']; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     return apiClient.invokeAPI( | ||||||
|  |       apiPath, | ||||||
|  |       'DELETE', | ||||||
|  |       queryParams, | ||||||
|  |       postBody, | ||||||
|  |       headerParams, | ||||||
|  |       formParams, | ||||||
|  |       contentTypes.isEmpty ? null : contentTypes.first, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Parameters: | ||||||
|  |   /// | ||||||
|  |   /// * [PinCodeChangeDto] pinCodeChangeDto (required): | ||||||
|  |   Future<void> resetPinCode(PinCodeChangeDto pinCodeChangeDto,) async { | ||||||
|  |     final response = await resetPinCodeWithHttpInfo(pinCodeChangeDto,); | ||||||
|  |     if (response.statusCode >= HttpStatus.badRequest) { | ||||||
|  |       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Performs an HTTP 'POST /auth/pin-code' operation and returns the [Response]. | ||||||
|  |   /// Parameters: | ||||||
|  |   /// | ||||||
|  |   /// * [PinCodeSetupDto] pinCodeSetupDto (required): | ||||||
|  |   Future<Response> setupPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async { | ||||||
|  |     // ignore: prefer_const_declarations | ||||||
|  |     final apiPath = r'/auth/pin-code'; | ||||||
|  | 
 | ||||||
|  |     // ignore: prefer_final_locals | ||||||
|  |     Object? postBody = pinCodeSetupDto; | ||||||
|  | 
 | ||||||
|  |     final queryParams = <QueryParam>[]; | ||||||
|  |     final headerParams = <String, String>{}; | ||||||
|  |     final formParams = <String, String>{}; | ||||||
|  | 
 | ||||||
|  |     const contentTypes = <String>['application/json']; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     return apiClient.invokeAPI( | ||||||
|  |       apiPath, | ||||||
|  |       'POST', | ||||||
|  |       queryParams, | ||||||
|  |       postBody, | ||||||
|  |       headerParams, | ||||||
|  |       formParams, | ||||||
|  |       contentTypes.isEmpty ? null : contentTypes.first, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Parameters: | ||||||
|  |   /// | ||||||
|  |   /// * [PinCodeSetupDto] pinCodeSetupDto (required): | ||||||
|  |   Future<void> setupPinCode(PinCodeSetupDto pinCodeSetupDto,) async { | ||||||
|  |     final response = await setupPinCodeWithHttpInfo(pinCodeSetupDto,); | ||||||
|  |     if (response.statusCode >= HttpStatus.badRequest) { | ||||||
|  |       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /// Performs an HTTP 'POST /auth/admin-sign-up' operation and returns the [Response]. |   /// Performs an HTTP 'POST /auth/admin-sign-up' operation and returns the [Response]. | ||||||
|   /// Parameters: |   /// Parameters: | ||||||
|   /// |   /// | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							| @@ -272,6 +272,8 @@ class ApiClient { | |||||||
|           return AssetVisibilityTypeTransformer().decode(value); |           return AssetVisibilityTypeTransformer().decode(value); | ||||||
|         case 'AudioCodec': |         case 'AudioCodec': | ||||||
|           return AudioCodecTypeTransformer().decode(value); |           return AudioCodecTypeTransformer().decode(value); | ||||||
|  |         case 'AuthStatusResponseDto': | ||||||
|  |           return AuthStatusResponseDto.fromJson(value); | ||||||
|         case 'AvatarUpdate': |         case 'AvatarUpdate': | ||||||
|           return AvatarUpdate.fromJson(value); |           return AvatarUpdate.fromJson(value); | ||||||
|         case 'BulkIdResponseDto': |         case 'BulkIdResponseDto': | ||||||
| @@ -430,6 +432,10 @@ class ApiClient { | |||||||
|           return PersonUpdateDto.fromJson(value); |           return PersonUpdateDto.fromJson(value); | ||||||
|         case 'PersonWithFacesResponseDto': |         case 'PersonWithFacesResponseDto': | ||||||
|           return PersonWithFacesResponseDto.fromJson(value); |           return PersonWithFacesResponseDto.fromJson(value); | ||||||
|  |         case 'PinCodeChangeDto': | ||||||
|  |           return PinCodeChangeDto.fromJson(value); | ||||||
|  |         case 'PinCodeSetupDto': | ||||||
|  |           return PinCodeSetupDto.fromJson(value); | ||||||
|         case 'PlacesResponseDto': |         case 'PlacesResponseDto': | ||||||
|           return PlacesResponseDto.fromJson(value); |           return PlacesResponseDto.fromJson(value); | ||||||
|         case 'PurchaseResponse': |         case 'PurchaseResponse': | ||||||
|   | |||||||
							
								
								
									
										107
									
								
								mobile/openapi/lib/model/auth_status_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								mobile/openapi/lib/model/auth_status_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | |||||||
|  | // | ||||||
|  | // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||||
|  | // | ||||||
|  | // @dart=2.18 | ||||||
|  | 
 | ||||||
|  | // 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 AuthStatusResponseDto { | ||||||
|  |   /// Returns a new [AuthStatusResponseDto] instance. | ||||||
|  |   AuthStatusResponseDto({ | ||||||
|  |     required this.password, | ||||||
|  |     required this.pinCode, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   bool password; | ||||||
|  | 
 | ||||||
|  |   bool pinCode; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto && | ||||||
|  |     other.password == password && | ||||||
|  |     other.pinCode == pinCode; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   int get hashCode => | ||||||
|  |     // ignore: unnecessary_parenthesis | ||||||
|  |     (password.hashCode) + | ||||||
|  |     (pinCode.hashCode); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   String toString() => 'AuthStatusResponseDto[password=$password, pinCode=$pinCode]'; | ||||||
|  | 
 | ||||||
|  |   Map<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       json[r'password'] = this.password; | ||||||
|  |       json[r'pinCode'] = this.pinCode; | ||||||
|  |     return json; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Returns a new [AuthStatusResponseDto] instance and imports its values from | ||||||
|  |   /// [value] if it's a [Map], null otherwise. | ||||||
|  |   // ignore: prefer_constructors_over_static_methods | ||||||
|  |   static AuthStatusResponseDto? fromJson(dynamic value) { | ||||||
|  |     upgradeDto(value, "AuthStatusResponseDto"); | ||||||
|  |     if (value is Map) { | ||||||
|  |       final json = value.cast<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       return AuthStatusResponseDto( | ||||||
|  |         password: mapValueOfType<bool>(json, r'password')!, | ||||||
|  |         pinCode: mapValueOfType<bool>(json, r'pinCode')!, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<AuthStatusResponseDto> listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <AuthStatusResponseDto>[]; | ||||||
|  |     if (json is List && json.isNotEmpty) { | ||||||
|  |       for (final row in json) { | ||||||
|  |         final value = AuthStatusResponseDto.fromJson(row); | ||||||
|  |         if (value != null) { | ||||||
|  |           result.add(value); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return result.toList(growable: growable); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static Map<String, AuthStatusResponseDto> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, AuthStatusResponseDto>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         final value = AuthStatusResponseDto.fromJson(entry.value); | ||||||
|  |         if (value != null) { | ||||||
|  |           map[entry.key] = value; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // maps a json object with a list of AuthStatusResponseDto-objects as value to a dart map | ||||||
|  |   static Map<String, List<AuthStatusResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<AuthStatusResponseDto>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       // ignore: parameter_assignments | ||||||
|  |       json = json.cast<String, dynamic>(); | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         map[entry.key] = AuthStatusResponseDto.listFromJson(entry.value, growable: growable,); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// The list of required keys that must be present in a JSON. | ||||||
|  |   static const requiredKeys = <String>{ | ||||||
|  |     'password', | ||||||
|  |     'pinCode', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										133
									
								
								mobile/openapi/lib/model/pin_code_change_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								mobile/openapi/lib/model/pin_code_change_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | |||||||
|  | // | ||||||
|  | // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||||
|  | // | ||||||
|  | // @dart=2.18 | ||||||
|  | 
 | ||||||
|  | // 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 PinCodeChangeDto { | ||||||
|  |   /// Returns a new [PinCodeChangeDto] instance. | ||||||
|  |   PinCodeChangeDto({ | ||||||
|  |     required this.newPinCode, | ||||||
|  |     this.password, | ||||||
|  |     this.pinCode, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   String newPinCode; | ||||||
|  | 
 | ||||||
|  |   /// | ||||||
|  |   /// Please note: This property should have been non-nullable! Since the specification file | ||||||
|  |   /// does not include a default value (using the "default:" property), however, the generated | ||||||
|  |   /// source code must fall back to having a nullable type. | ||||||
|  |   /// Consider adding a "default:" property in the specification file to hide this note. | ||||||
|  |   /// | ||||||
|  |   String? password; | ||||||
|  | 
 | ||||||
|  |   /// | ||||||
|  |   /// Please note: This property should have been non-nullable! Since the specification file | ||||||
|  |   /// does not include a default value (using the "default:" property), however, the generated | ||||||
|  |   /// source code must fall back to having a nullable type. | ||||||
|  |   /// Consider adding a "default:" property in the specification file to hide this note. | ||||||
|  |   /// | ||||||
|  |   String? pinCode; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) => identical(this, other) || other is PinCodeChangeDto && | ||||||
|  |     other.newPinCode == newPinCode && | ||||||
|  |     other.password == password && | ||||||
|  |     other.pinCode == pinCode; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   int get hashCode => | ||||||
|  |     // ignore: unnecessary_parenthesis | ||||||
|  |     (newPinCode.hashCode) + | ||||||
|  |     (password == null ? 0 : password!.hashCode) + | ||||||
|  |     (pinCode == null ? 0 : pinCode!.hashCode); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   String toString() => 'PinCodeChangeDto[newPinCode=$newPinCode, password=$password, pinCode=$pinCode]'; | ||||||
|  | 
 | ||||||
|  |   Map<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       json[r'newPinCode'] = this.newPinCode; | ||||||
|  |     if (this.password != null) { | ||||||
|  |       json[r'password'] = this.password; | ||||||
|  |     } else { | ||||||
|  |     //  json[r'password'] = null; | ||||||
|  |     } | ||||||
|  |     if (this.pinCode != null) { | ||||||
|  |       json[r'pinCode'] = this.pinCode; | ||||||
|  |     } else { | ||||||
|  |     //  json[r'pinCode'] = null; | ||||||
|  |     } | ||||||
|  |     return json; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Returns a new [PinCodeChangeDto] instance and imports its values from | ||||||
|  |   /// [value] if it's a [Map], null otherwise. | ||||||
|  |   // ignore: prefer_constructors_over_static_methods | ||||||
|  |   static PinCodeChangeDto? fromJson(dynamic value) { | ||||||
|  |     upgradeDto(value, "PinCodeChangeDto"); | ||||||
|  |     if (value is Map) { | ||||||
|  |       final json = value.cast<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       return PinCodeChangeDto( | ||||||
|  |         newPinCode: mapValueOfType<String>(json, r'newPinCode')!, | ||||||
|  |         password: mapValueOfType<String>(json, r'password'), | ||||||
|  |         pinCode: mapValueOfType<String>(json, r'pinCode'), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<PinCodeChangeDto> listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <PinCodeChangeDto>[]; | ||||||
|  |     if (json is List && json.isNotEmpty) { | ||||||
|  |       for (final row in json) { | ||||||
|  |         final value = PinCodeChangeDto.fromJson(row); | ||||||
|  |         if (value != null) { | ||||||
|  |           result.add(value); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return result.toList(growable: growable); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static Map<String, PinCodeChangeDto> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, PinCodeChangeDto>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         final value = PinCodeChangeDto.fromJson(entry.value); | ||||||
|  |         if (value != null) { | ||||||
|  |           map[entry.key] = value; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // maps a json object with a list of PinCodeChangeDto-objects as value to a dart map | ||||||
|  |   static Map<String, List<PinCodeChangeDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<PinCodeChangeDto>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       // ignore: parameter_assignments | ||||||
|  |       json = json.cast<String, dynamic>(); | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         map[entry.key] = PinCodeChangeDto.listFromJson(entry.value, growable: growable,); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// The list of required keys that must be present in a JSON. | ||||||
|  |   static const requiredKeys = <String>{ | ||||||
|  |     'newPinCode', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										99
									
								
								mobile/openapi/lib/model/pin_code_setup_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								mobile/openapi/lib/model/pin_code_setup_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | |||||||
|  | // | ||||||
|  | // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||||
|  | // | ||||||
|  | // @dart=2.18 | ||||||
|  | 
 | ||||||
|  | // 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 PinCodeSetupDto { | ||||||
|  |   /// Returns a new [PinCodeSetupDto] instance. | ||||||
|  |   PinCodeSetupDto({ | ||||||
|  |     required this.pinCode, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   String pinCode; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) => identical(this, other) || other is PinCodeSetupDto && | ||||||
|  |     other.pinCode == pinCode; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   int get hashCode => | ||||||
|  |     // ignore: unnecessary_parenthesis | ||||||
|  |     (pinCode.hashCode); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   String toString() => 'PinCodeSetupDto[pinCode=$pinCode]'; | ||||||
|  | 
 | ||||||
|  |   Map<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       json[r'pinCode'] = this.pinCode; | ||||||
|  |     return json; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Returns a new [PinCodeSetupDto] instance and imports its values from | ||||||
|  |   /// [value] if it's a [Map], null otherwise. | ||||||
|  |   // ignore: prefer_constructors_over_static_methods | ||||||
|  |   static PinCodeSetupDto? fromJson(dynamic value) { | ||||||
|  |     upgradeDto(value, "PinCodeSetupDto"); | ||||||
|  |     if (value is Map) { | ||||||
|  |       final json = value.cast<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       return PinCodeSetupDto( | ||||||
|  |         pinCode: mapValueOfType<String>(json, r'pinCode')!, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<PinCodeSetupDto> listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <PinCodeSetupDto>[]; | ||||||
|  |     if (json is List && json.isNotEmpty) { | ||||||
|  |       for (final row in json) { | ||||||
|  |         final value = PinCodeSetupDto.fromJson(row); | ||||||
|  |         if (value != null) { | ||||||
|  |           result.add(value); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return result.toList(growable: growable); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static Map<String, PinCodeSetupDto> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, PinCodeSetupDto>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         final value = PinCodeSetupDto.fromJson(entry.value); | ||||||
|  |         if (value != null) { | ||||||
|  |           map[entry.key] = value; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // maps a json object with a list of PinCodeSetupDto-objects as value to a dart map | ||||||
|  |   static Map<String, List<PinCodeSetupDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<PinCodeSetupDto>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       // ignore: parameter_assignments | ||||||
|  |       json = json.cast<String, dynamic>(); | ||||||
|  |       for (final entry in json.entries) { | ||||||
|  |         map[entry.key] = PinCodeSetupDto.listFromJson(entry.value, growable: growable,); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return map; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// The list of required keys that must be present in a JSON. | ||||||
|  |   static const requiredKeys = <String>{ | ||||||
|  |     'pinCode', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										13
									
								
								mobile/openapi/lib/model/user_admin_update_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								mobile/openapi/lib/model/user_admin_update_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -17,6 +17,7 @@ class UserAdminUpdateDto { | |||||||
|     this.email, |     this.email, | ||||||
|     this.name, |     this.name, | ||||||
|     this.password, |     this.password, | ||||||
|  |     this.pinCode, | ||||||
|     this.quotaSizeInBytes, |     this.quotaSizeInBytes, | ||||||
|     this.shouldChangePassword, |     this.shouldChangePassword, | ||||||
|     this.storageLabel, |     this.storageLabel, | ||||||
| @@ -48,6 +49,8 @@ class UserAdminUpdateDto { | |||||||
|   /// |   /// | ||||||
|   String? password; |   String? password; | ||||||
| 
 | 
 | ||||||
|  |   String? pinCode; | ||||||
|  | 
 | ||||||
|   /// Minimum value: 0 |   /// Minimum value: 0 | ||||||
|   int? quotaSizeInBytes; |   int? quotaSizeInBytes; | ||||||
| 
 | 
 | ||||||
| @@ -67,6 +70,7 @@ class UserAdminUpdateDto { | |||||||
|     other.email == email && |     other.email == email && | ||||||
|     other.name == name && |     other.name == name && | ||||||
|     other.password == password && |     other.password == password && | ||||||
|  |     other.pinCode == pinCode && | ||||||
|     other.quotaSizeInBytes == quotaSizeInBytes && |     other.quotaSizeInBytes == quotaSizeInBytes && | ||||||
|     other.shouldChangePassword == shouldChangePassword && |     other.shouldChangePassword == shouldChangePassword && | ||||||
|     other.storageLabel == storageLabel; |     other.storageLabel == storageLabel; | ||||||
| @@ -78,12 +82,13 @@ class UserAdminUpdateDto { | |||||||
|     (email == null ? 0 : email!.hashCode) + |     (email == null ? 0 : email!.hashCode) + | ||||||
|     (name == null ? 0 : name!.hashCode) + |     (name == null ? 0 : name!.hashCode) + | ||||||
|     (password == null ? 0 : password!.hashCode) + |     (password == null ? 0 : password!.hashCode) + | ||||||
|  |     (pinCode == null ? 0 : pinCode!.hashCode) + | ||||||
|     (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + |     (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + | ||||||
|     (shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) + |     (shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) + | ||||||
|     (storageLabel == null ? 0 : storageLabel!.hashCode); |     (storageLabel == null ? 0 : storageLabel!.hashCode); | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; |   String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, pinCode=$pinCode, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; | ||||||
| 
 | 
 | ||||||
|   Map<String, dynamic> toJson() { |   Map<String, dynamic> toJson() { | ||||||
|     final json = <String, dynamic>{}; |     final json = <String, dynamic>{}; | ||||||
| @@ -107,6 +112,11 @@ class UserAdminUpdateDto { | |||||||
|     } else { |     } else { | ||||||
|     //  json[r'password'] = null; |     //  json[r'password'] = null; | ||||||
|     } |     } | ||||||
|  |     if (this.pinCode != null) { | ||||||
|  |       json[r'pinCode'] = this.pinCode; | ||||||
|  |     } else { | ||||||
|  |     //  json[r'pinCode'] = null; | ||||||
|  |     } | ||||||
|     if (this.quotaSizeInBytes != null) { |     if (this.quotaSizeInBytes != null) { | ||||||
|       json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; |       json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; | ||||||
|     } else { |     } else { | ||||||
| @@ -138,6 +148,7 @@ class UserAdminUpdateDto { | |||||||
|         email: mapValueOfType<String>(json, r'email'), |         email: mapValueOfType<String>(json, r'email'), | ||||||
|         name: mapValueOfType<String>(json, r'name'), |         name: mapValueOfType<String>(json, r'name'), | ||||||
|         password: mapValueOfType<String>(json, r'password'), |         password: mapValueOfType<String>(json, r'password'), | ||||||
|  |         pinCode: mapValueOfType<String>(json, r'pinCode'), | ||||||
|         quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'), |         quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'), | ||||||
|         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'), |         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'), | ||||||
|         storageLabel: mapValueOfType<String>(json, r'storageLabel'), |         storageLabel: mapValueOfType<String>(json, r'storageLabel'), | ||||||
|   | |||||||
| @@ -2294,6 +2294,139 @@ | |||||||
|         ] |         ] | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "/auth/pin-code": { | ||||||
|  |       "delete": { | ||||||
|  |         "operationId": "resetPinCode", | ||||||
|  |         "parameters": [], | ||||||
|  |         "requestBody": { | ||||||
|  |           "content": { | ||||||
|  |             "application/json": { | ||||||
|  |               "schema": { | ||||||
|  |                 "$ref": "#/components/schemas/PinCodeChangeDto" | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           "required": true | ||||||
|  |         }, | ||||||
|  |         "responses": { | ||||||
|  |           "200": { | ||||||
|  |             "description": "" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "security": [ | ||||||
|  |           { | ||||||
|  |             "bearer": [] | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "cookie": [] | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "api_key": [] | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "tags": [ | ||||||
|  |           "Authentication" | ||||||
|  |         ] | ||||||
|  |       }, | ||||||
|  |       "post": { | ||||||
|  |         "operationId": "setupPinCode", | ||||||
|  |         "parameters": [], | ||||||
|  |         "requestBody": { | ||||||
|  |           "content": { | ||||||
|  |             "application/json": { | ||||||
|  |               "schema": { | ||||||
|  |                 "$ref": "#/components/schemas/PinCodeSetupDto" | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           "required": true | ||||||
|  |         }, | ||||||
|  |         "responses": { | ||||||
|  |           "201": { | ||||||
|  |             "description": "" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "security": [ | ||||||
|  |           { | ||||||
|  |             "bearer": [] | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "cookie": [] | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "api_key": [] | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "tags": [ | ||||||
|  |           "Authentication" | ||||||
|  |         ] | ||||||
|  |       }, | ||||||
|  |       "put": { | ||||||
|  |         "operationId": "changePinCode", | ||||||
|  |         "parameters": [], | ||||||
|  |         "requestBody": { | ||||||
|  |           "content": { | ||||||
|  |             "application/json": { | ||||||
|  |               "schema": { | ||||||
|  |                 "$ref": "#/components/schemas/PinCodeChangeDto" | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           "required": true | ||||||
|  |         }, | ||||||
|  |         "responses": { | ||||||
|  |           "200": { | ||||||
|  |             "description": "" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "security": [ | ||||||
|  |           { | ||||||
|  |             "bearer": [] | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "cookie": [] | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "api_key": [] | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "tags": [ | ||||||
|  |           "Authentication" | ||||||
|  |         ] | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "/auth/status": { | ||||||
|  |       "get": { | ||||||
|  |         "operationId": "getAuthStatus", | ||||||
|  |         "parameters": [], | ||||||
|  |         "responses": { | ||||||
|  |           "200": { | ||||||
|  |             "content": { | ||||||
|  |               "application/json": { | ||||||
|  |                 "schema": { | ||||||
|  |                   "$ref": "#/components/schemas/AuthStatusResponseDto" | ||||||
|  |                 } | ||||||
|  |               } | ||||||
|  |             }, | ||||||
|  |             "description": "" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "security": [ | ||||||
|  |           { | ||||||
|  |             "bearer": [] | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "cookie": [] | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "api_key": [] | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         "tags": [ | ||||||
|  |           "Authentication" | ||||||
|  |         ] | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "/auth/validateToken": { |     "/auth/validateToken": { | ||||||
|       "post": { |       "post": { | ||||||
|         "operationId": "validateAccessToken", |         "operationId": "validateAccessToken", | ||||||
| @@ -9031,6 +9164,21 @@ | |||||||
|         ], |         ], | ||||||
|         "type": "string" |         "type": "string" | ||||||
|       }, |       }, | ||||||
|  |       "AuthStatusResponseDto": { | ||||||
|  |         "properties": { | ||||||
|  |           "password": { | ||||||
|  |             "type": "boolean" | ||||||
|  |           }, | ||||||
|  |           "pinCode": { | ||||||
|  |             "type": "boolean" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "required": [ | ||||||
|  |           "password", | ||||||
|  |           "pinCode" | ||||||
|  |         ], | ||||||
|  |         "type": "object" | ||||||
|  |       }, | ||||||
|       "AvatarUpdate": { |       "AvatarUpdate": { | ||||||
|         "properties": { |         "properties": { | ||||||
|           "color": { |           "color": { | ||||||
| @@ -10964,6 +11112,37 @@ | |||||||
|         ], |         ], | ||||||
|         "type": "object" |         "type": "object" | ||||||
|       }, |       }, | ||||||
|  |       "PinCodeChangeDto": { | ||||||
|  |         "properties": { | ||||||
|  |           "newPinCode": { | ||||||
|  |             "example": "123456", | ||||||
|  |             "type": "string" | ||||||
|  |           }, | ||||||
|  |           "password": { | ||||||
|  |             "type": "string" | ||||||
|  |           }, | ||||||
|  |           "pinCode": { | ||||||
|  |             "example": "123456", | ||||||
|  |             "type": "string" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "required": [ | ||||||
|  |           "newPinCode" | ||||||
|  |         ], | ||||||
|  |         "type": "object" | ||||||
|  |       }, | ||||||
|  |       "PinCodeSetupDto": { | ||||||
|  |         "properties": { | ||||||
|  |           "pinCode": { | ||||||
|  |             "example": "123456", | ||||||
|  |             "type": "string" | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "required": [ | ||||||
|  |           "pinCode" | ||||||
|  |         ], | ||||||
|  |         "type": "object" | ||||||
|  |       }, | ||||||
|       "PlacesResponseDto": { |       "PlacesResponseDto": { | ||||||
|         "properties": { |         "properties": { | ||||||
|           "admin1name": { |           "admin1name": { | ||||||
| @@ -13958,6 +14137,11 @@ | |||||||
|           "password": { |           "password": { | ||||||
|             "type": "string" |             "type": "string" | ||||||
|           }, |           }, | ||||||
|  |           "pinCode": { | ||||||
|  |             "example": "123456", | ||||||
|  |             "nullable": true, | ||||||
|  |             "type": "string" | ||||||
|  |           }, | ||||||
|           "quotaSizeInBytes": { |           "quotaSizeInBytes": { | ||||||
|             "format": "int64", |             "format": "int64", | ||||||
|             "minimum": 0, |             "minimum": 0, | ||||||
|   | |||||||
| @@ -123,6 +123,7 @@ export type UserAdminUpdateDto = { | |||||||
|     email?: string; |     email?: string; | ||||||
|     name?: string; |     name?: string; | ||||||
|     password?: string; |     password?: string; | ||||||
|  |     pinCode?: string | null; | ||||||
|     quotaSizeInBytes?: number | null; |     quotaSizeInBytes?: number | null; | ||||||
|     shouldChangePassword?: boolean; |     shouldChangePassword?: boolean; | ||||||
|     storageLabel?: string | null; |     storageLabel?: string | null; | ||||||
| @@ -510,6 +511,18 @@ export type LogoutResponseDto = { | |||||||
|     redirectUri: string; |     redirectUri: string; | ||||||
|     successful: boolean; |     successful: boolean; | ||||||
| }; | }; | ||||||
|  | export type PinCodeChangeDto = { | ||||||
|  |     newPinCode: string; | ||||||
|  |     password?: string; | ||||||
|  |     pinCode?: string; | ||||||
|  | }; | ||||||
|  | export type PinCodeSetupDto = { | ||||||
|  |     pinCode: string; | ||||||
|  | }; | ||||||
|  | export type AuthStatusResponseDto = { | ||||||
|  |     password: boolean; | ||||||
|  |     pinCode: boolean; | ||||||
|  | }; | ||||||
| export type ValidateAccessTokenResponseDto = { | export type ValidateAccessTokenResponseDto = { | ||||||
|     authStatus: boolean; |     authStatus: boolean; | ||||||
| }; | }; | ||||||
| @@ -2017,6 +2030,41 @@ export function logout(opts?: Oazapfts.RequestOpts) { | |||||||
|         method: "POST" |         method: "POST" | ||||||
|     })); |     })); | ||||||
| } | } | ||||||
|  | export function resetPinCode({ pinCodeChangeDto }: { | ||||||
|  |     pinCodeChangeDto: PinCodeChangeDto; | ||||||
|  | }, opts?: Oazapfts.RequestOpts) { | ||||||
|  |     return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({ | ||||||
|  |         ...opts, | ||||||
|  |         method: "DELETE", | ||||||
|  |         body: pinCodeChangeDto | ||||||
|  |     }))); | ||||||
|  | } | ||||||
|  | export function setupPinCode({ pinCodeSetupDto }: { | ||||||
|  |     pinCodeSetupDto: PinCodeSetupDto; | ||||||
|  | }, opts?: Oazapfts.RequestOpts) { | ||||||
|  |     return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({ | ||||||
|  |         ...opts, | ||||||
|  |         method: "POST", | ||||||
|  |         body: pinCodeSetupDto | ||||||
|  |     }))); | ||||||
|  | } | ||||||
|  | export function changePinCode({ pinCodeChangeDto }: { | ||||||
|  |     pinCodeChangeDto: PinCodeChangeDto; | ||||||
|  | }, opts?: Oazapfts.RequestOpts) { | ||||||
|  |     return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({ | ||||||
|  |         ...opts, | ||||||
|  |         method: "PUT", | ||||||
|  |         body: pinCodeChangeDto | ||||||
|  |     }))); | ||||||
|  | } | ||||||
|  | export function getAuthStatus(opts?: Oazapfts.RequestOpts) { | ||||||
|  |     return oazapfts.ok(oazapfts.fetchJson<{ | ||||||
|  |         status: 200; | ||||||
|  |         data: AuthStatusResponseDto; | ||||||
|  |     }>("/auth/status", { | ||||||
|  |         ...opts | ||||||
|  |     })); | ||||||
|  | } | ||||||
| export function validateAccessToken(opts?: Oazapfts.RequestOpts) { | export function validateAccessToken(opts?: Oazapfts.RequestOpts) { | ||||||
|     return oazapfts.ok(oazapfts.fetchJson<{ |     return oazapfts.ok(oazapfts.fetchJson<{ | ||||||
|         status: 200; |         status: 200; | ||||||
|   | |||||||
| @@ -142,4 +142,50 @@ describe(AuthController.name, () => { | |||||||
|       expect(ctx.authenticate).toHaveBeenCalled(); |       expect(ctx.authenticate).toHaveBeenCalled(); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   describe('POST /auth/pin-code', () => { | ||||||
|  |     it('should be an authenticated route', async () => { | ||||||
|  |       await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '123456' }); | ||||||
|  |       expect(ctx.authenticate).toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should reject 5 digits', async () => { | ||||||
|  |       const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' }); | ||||||
|  |       expect(status).toEqual(400); | ||||||
|  |       expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string'])); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should reject 7 digits', async () => { | ||||||
|  |       const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' }); | ||||||
|  |       expect(status).toEqual(400); | ||||||
|  |       expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string'])); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should reject non-numbers', async () => { | ||||||
|  |       const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' }); | ||||||
|  |       expect(status).toEqual(400); | ||||||
|  |       expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string'])); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('PUT /auth/pin-code', () => { | ||||||
|  |     it('should be an authenticated route', async () => { | ||||||
|  |       await request(ctx.getHttpServer()).put('/auth/pin-code').send({ pinCode: '123456', newPinCode: '654321' }); | ||||||
|  |       expect(ctx.authenticate).toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('DELETE /auth/pin-code', () => { | ||||||
|  |     it('should be an authenticated route', async () => { | ||||||
|  |       await request(ctx.getHttpServer()).delete('/auth/pin-code').send({ pinCode: '123456' }); | ||||||
|  |       expect(ctx.authenticate).toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('GET /auth/status', () => { | ||||||
|  |     it('should be an authenticated route', async () => { | ||||||
|  |       await request(ctx.getHttpServer()).get('/auth/status'); | ||||||
|  |       expect(ctx.authenticate).toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -1,12 +1,15 @@ | |||||||
| import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common'; | import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common'; | ||||||
| import { ApiTags } from '@nestjs/swagger'; | import { ApiTags } from '@nestjs/swagger'; | ||||||
| import { Request, Response } from 'express'; | import { Request, Response } from 'express'; | ||||||
| import { | import { | ||||||
|   AuthDto, |   AuthDto, | ||||||
|  |   AuthStatusResponseDto, | ||||||
|   ChangePasswordDto, |   ChangePasswordDto, | ||||||
|   LoginCredentialDto, |   LoginCredentialDto, | ||||||
|   LoginResponseDto, |   LoginResponseDto, | ||||||
|   LogoutResponseDto, |   LogoutResponseDto, | ||||||
|  |   PinCodeChangeDto, | ||||||
|  |   PinCodeSetupDto, | ||||||
|   SignUpDto, |   SignUpDto, | ||||||
|   ValidateAccessTokenResponseDto, |   ValidateAccessTokenResponseDto, | ||||||
| } from 'src/dtos/auth.dto'; | } from 'src/dtos/auth.dto'; | ||||||
| @@ -74,4 +77,28 @@ export class AuthController { | |||||||
|       ImmichCookie.IS_AUTHENTICATED, |       ImmichCookie.IS_AUTHENTICATED, | ||||||
|     ]); |     ]); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   @Get('status') | ||||||
|  |   @Authenticated() | ||||||
|  |   getAuthStatus(@Auth() auth: AuthDto): Promise<AuthStatusResponseDto> { | ||||||
|  |     return this.service.getAuthStatus(auth); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @Post('pin-code') | ||||||
|  |   @Authenticated() | ||||||
|  |   setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> { | ||||||
|  |     return this.service.setupPinCode(auth, dto); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @Put('pin-code') | ||||||
|  |   @Authenticated() | ||||||
|  |   async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> { | ||||||
|  |     return this.service.changePinCode(auth, dto); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @Delete('pin-code') | ||||||
|  |   @Authenticated() | ||||||
|  |   async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> { | ||||||
|  |     return this.service.resetPinCode(auth, dto); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ import { Transform } from 'class-transformer'; | |||||||
| import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; | import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; | ||||||
| import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; | import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; | ||||||
| import { ImmichCookie } from 'src/enum'; | import { ImmichCookie } from 'src/enum'; | ||||||
| import { Optional, toEmail } from 'src/validation'; | import { Optional, PinCode, toEmail } from 'src/validation'; | ||||||
|  |  | ||||||
| export type CookieResponse = { | export type CookieResponse = { | ||||||
|   isSecure: boolean; |   isSecure: boolean; | ||||||
| @@ -78,6 +78,26 @@ export class ChangePasswordDto { | |||||||
|   newPassword!: string; |   newPassword!: string; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export class PinCodeSetupDto { | ||||||
|  |   @PinCode() | ||||||
|  |   pinCode!: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export class PinCodeResetDto { | ||||||
|  |   @PinCode({ optional: true }) | ||||||
|  |   pinCode?: string; | ||||||
|  |  | ||||||
|  |   @Optional() | ||||||
|  |   @IsString() | ||||||
|  |   @IsNotEmpty() | ||||||
|  |   password?: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export class PinCodeChangeDto extends PinCodeResetDto { | ||||||
|  |   @PinCode() | ||||||
|  |   newPinCode!: string; | ||||||
|  | } | ||||||
|  |  | ||||||
| export class ValidateAccessTokenResponseDto { | export class ValidateAccessTokenResponseDto { | ||||||
|   authStatus!: boolean; |   authStatus!: boolean; | ||||||
| } | } | ||||||
| @@ -114,3 +134,8 @@ export class OAuthConfigDto { | |||||||
| export class OAuthAuthorizeResponseDto { | export class OAuthAuthorizeResponseDto { | ||||||
|   url!: string; |   url!: string; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export class AuthStatusResponseDto { | ||||||
|  |   pinCode!: boolean; | ||||||
|  |   password!: boolean; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from | |||||||
| import { User, UserAdmin } from 'src/database'; | import { User, UserAdmin } from 'src/database'; | ||||||
| import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; | import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; | ||||||
| import { UserMetadataItem } from 'src/types'; | import { UserMetadataItem } from 'src/types'; | ||||||
| import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; | import { Optional, PinCode, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; | ||||||
|  |  | ||||||
| export class UserUpdateMeDto { | export class UserUpdateMeDto { | ||||||
|   @Optional() |   @Optional() | ||||||
| @@ -116,6 +116,9 @@ export class UserAdminUpdateDto { | |||||||
|   @IsString() |   @IsString() | ||||||
|   password?: string; |   password?: string; | ||||||
|  |  | ||||||
|  |   @PinCode({ optional: true, nullable: true, emptyToNull: true }) | ||||||
|  |   pinCode?: string | null; | ||||||
|  |  | ||||||
|   @Optional() |   @Optional() | ||||||
|   @IsString() |   @IsString() | ||||||
|   @IsNotEmpty() |   @IsNotEmpty() | ||||||
|   | |||||||
| @@ -87,6 +87,16 @@ where | |||||||
|   "users"."isAdmin" = $1 |   "users"."isAdmin" = $1 | ||||||
|   and "users"."deletedAt" is null |   and "users"."deletedAt" is null | ||||||
|  |  | ||||||
|  | -- UserRepository.getForPinCode | ||||||
|  | select | ||||||
|  |   "users"."pinCode", | ||||||
|  |   "users"."password" | ||||||
|  | from | ||||||
|  |   "users" | ||||||
|  | where | ||||||
|  |   "users"."id" = $1 | ||||||
|  |   and "users"."deletedAt" is null | ||||||
|  |  | ||||||
| -- UserRepository.getByEmail | -- UserRepository.getByEmail | ||||||
| select | select | ||||||
|   "id", |   "id", | ||||||
|   | |||||||
| @@ -89,13 +89,23 @@ export class UserRepository { | |||||||
|     return !!admin; |     return !!admin; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   @GenerateSql({ params: [DummyValue.UUID] }) | ||||||
|  |   getForPinCode(id: string) { | ||||||
|  |     return this.db | ||||||
|  |       .selectFrom('users') | ||||||
|  |       .select(['users.pinCode', 'users.password']) | ||||||
|  |       .where('users.id', '=', id) | ||||||
|  |       .where('users.deletedAt', 'is', null) | ||||||
|  |       .executeTakeFirstOrThrow(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @GenerateSql({ params: [DummyValue.EMAIL] }) |   @GenerateSql({ params: [DummyValue.EMAIL] }) | ||||||
|   getByEmail(email: string, withPassword?: boolean) { |   getByEmail(email: string, options?: { withPassword?: boolean }) { | ||||||
|     return this.db |     return this.db | ||||||
|       .selectFrom('users') |       .selectFrom('users') | ||||||
|       .select(columns.userAdmin) |       .select(columns.userAdmin) | ||||||
|       .select(withMetadata) |       .select(withMetadata) | ||||||
|       .$if(!!withPassword, (eb) => eb.select('password')) |       .$if(!!options?.withPassword, (eb) => eb.select('password')) | ||||||
|       .where('email', '=', email) |       .where('email', '=', email) | ||||||
|       .where('users.deletedAt', 'is', null) |       .where('users.deletedAt', 'is', null) | ||||||
|       .executeTakeFirst(); |       .executeTakeFirst(); | ||||||
|   | |||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | import { Kysely, sql } from 'kysely'; | ||||||
|  |  | ||||||
|  | export async function up(db: Kysely<any>): Promise<void> { | ||||||
|  |   await sql`ALTER TABLE "users" ADD "pinCode" character varying;`.execute(db); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export async function down(db: Kysely<any>): Promise<void> { | ||||||
|  |   await sql`ALTER TABLE "users" DROP COLUMN "pinCode";`.execute(db); | ||||||
|  | } | ||||||
| @@ -37,6 +37,9 @@ export class UserTable { | |||||||
|   @Column({ default: '' }) |   @Column({ default: '' }) | ||||||
|   password!: Generated<string>; |   password!: Generated<string>; | ||||||
|  |  | ||||||
|  |   @Column({ nullable: true }) | ||||||
|  |   pinCode!: string | null; | ||||||
|  |  | ||||||
|   @CreateDateColumn() |   @CreateDateColumn() | ||||||
|   createdAt!: Generated<Timestamp>; |   createdAt!: Generated<Timestamp>; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; | import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; | ||||||
| import { DateTime } from 'luxon'; | import { DateTime } from 'luxon'; | ||||||
|  | import { SALT_ROUNDS } from 'src/constants'; | ||||||
| import { UserAdmin } from 'src/database'; | import { UserAdmin } from 'src/database'; | ||||||
| import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; | import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; | ||||||
| import { AuthType, Permission } from 'src/enum'; | import { AuthType, Permission } from 'src/enum'; | ||||||
| @@ -118,7 +119,7 @@ describe(AuthService.name, () => { | |||||||
|  |  | ||||||
|       await sut.changePassword(auth, dto); |       await sut.changePassword(auth, dto); | ||||||
|  |  | ||||||
|       expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true); |       expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, { withPassword: true }); | ||||||
|       expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); |       expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
| @@ -859,4 +860,77 @@ describe(AuthService.name, () => { | |||||||
|       expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' }); |       expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' }); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   describe('setupPinCode', () => { | ||||||
|  |     it('should setup a PIN code', async () => { | ||||||
|  |       const user = factory.userAdmin(); | ||||||
|  |       const auth = factory.auth({ user }); | ||||||
|  |       const dto = { pinCode: '123456' }; | ||||||
|  |  | ||||||
|  |       mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' }); | ||||||
|  |       mocks.user.update.mockResolvedValue(user); | ||||||
|  |  | ||||||
|  |       await sut.setupPinCode(auth, dto); | ||||||
|  |  | ||||||
|  |       expect(mocks.user.getForPinCode).toHaveBeenCalledWith(user.id); | ||||||
|  |       expect(mocks.crypto.hashBcrypt).toHaveBeenCalledWith('123456', SALT_ROUNDS); | ||||||
|  |       expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: expect.any(String) }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should fail if the user already has a PIN code', async () => { | ||||||
|  |       const user = factory.userAdmin(); | ||||||
|  |       const auth = factory.auth({ user }); | ||||||
|  |  | ||||||
|  |       mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); | ||||||
|  |  | ||||||
|  |       await expect(sut.setupPinCode(auth, { pinCode: '123456' })).rejects.toThrow('User already has a PIN code'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('changePinCode', () => { | ||||||
|  |     it('should change the PIN code', async () => { | ||||||
|  |       const user = factory.userAdmin(); | ||||||
|  |       const auth = factory.auth({ user }); | ||||||
|  |       const dto = { pinCode: '123456', newPinCode: '012345' }; | ||||||
|  |  | ||||||
|  |       mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); | ||||||
|  |       mocks.user.update.mockResolvedValue(user); | ||||||
|  |       mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); | ||||||
|  |  | ||||||
|  |       await sut.changePinCode(auth, dto); | ||||||
|  |  | ||||||
|  |       expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('123456', '123456 (hashed)'); | ||||||
|  |       expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: '012345 (hashed)' }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should fail if the PIN code does not match', async () => { | ||||||
|  |       const user = factory.userAdmin(); | ||||||
|  |       mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); | ||||||
|  |       mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); | ||||||
|  |  | ||||||
|  |       await expect( | ||||||
|  |         sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }), | ||||||
|  |       ).rejects.toThrow('Wrong PIN code'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   describe('resetPinCode', () => { | ||||||
|  |     it('should reset the PIN code', async () => { | ||||||
|  |       const user = factory.userAdmin(); | ||||||
|  |       mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); | ||||||
|  |       mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); | ||||||
|  |  | ||||||
|  |       await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); | ||||||
|  |  | ||||||
|  |       expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should throw if the PIN code does not match', async () => { | ||||||
|  |       const user = factory.userAdmin(); | ||||||
|  |       mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); | ||||||
|  |       mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); | ||||||
|  |  | ||||||
|  |       await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -9,11 +9,15 @@ import { StorageCore } from 'src/cores/storage.core'; | |||||||
| import { UserAdmin } from 'src/database'; | import { UserAdmin } from 'src/database'; | ||||||
| import { | import { | ||||||
|   AuthDto, |   AuthDto, | ||||||
|  |   AuthStatusResponseDto, | ||||||
|   ChangePasswordDto, |   ChangePasswordDto, | ||||||
|   LoginCredentialDto, |   LoginCredentialDto, | ||||||
|   LogoutResponseDto, |   LogoutResponseDto, | ||||||
|   OAuthCallbackDto, |   OAuthCallbackDto, | ||||||
|   OAuthConfigDto, |   OAuthConfigDto, | ||||||
|  |   PinCodeChangeDto, | ||||||
|  |   PinCodeResetDto, | ||||||
|  |   PinCodeSetupDto, | ||||||
|   SignUpDto, |   SignUpDto, | ||||||
|   mapLoginResponse, |   mapLoginResponse, | ||||||
| } from 'src/dtos/auth.dto'; | } from 'src/dtos/auth.dto'; | ||||||
| @@ -56,9 +60,9 @@ export class AuthService extends BaseService { | |||||||
|       throw new UnauthorizedException('Password login has been disabled'); |       throw new UnauthorizedException('Password login has been disabled'); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     let user = await this.userRepository.getByEmail(dto.email, true); |     let user = await this.userRepository.getByEmail(dto.email, { withPassword: true }); | ||||||
|     if (user) { |     if (user) { | ||||||
|       const isAuthenticated = this.validatePassword(dto.password, user); |       const isAuthenticated = this.validateSecret(dto.password, user.password); | ||||||
|       if (!isAuthenticated) { |       if (!isAuthenticated) { | ||||||
|         user = undefined; |         user = undefined; | ||||||
|       } |       } | ||||||
| @@ -86,12 +90,12 @@ export class AuthService extends BaseService { | |||||||
|  |  | ||||||
|   async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> { |   async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> { | ||||||
|     const { password, newPassword } = dto; |     const { password, newPassword } = dto; | ||||||
|     const user = await this.userRepository.getByEmail(auth.user.email, true); |     const user = await this.userRepository.getByEmail(auth.user.email, { withPassword: true }); | ||||||
|     if (!user) { |     if (!user) { | ||||||
|       throw new UnauthorizedException(); |       throw new UnauthorizedException(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const valid = this.validatePassword(password, user); |     const valid = this.validateSecret(password, user.password); | ||||||
|     if (!valid) { |     if (!valid) { | ||||||
|       throw new BadRequestException('Wrong password'); |       throw new BadRequestException('Wrong password'); | ||||||
|     } |     } | ||||||
| @@ -103,6 +107,56 @@ export class AuthService extends BaseService { | |||||||
|     return mapUserAdmin(updatedUser); |     return mapUserAdmin(updatedUser); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   async setupPinCode(auth: AuthDto, { pinCode }: PinCodeSetupDto) { | ||||||
|  |     const user = await this.userRepository.getForPinCode(auth.user.id); | ||||||
|  |     if (!user) { | ||||||
|  |       throw new UnauthorizedException(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (user.pinCode) { | ||||||
|  |       throw new BadRequestException('User already has a PIN code'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const hashed = await this.cryptoRepository.hashBcrypt(pinCode, SALT_ROUNDS); | ||||||
|  |     await this.userRepository.update(auth.user.id, { pinCode: hashed }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) { | ||||||
|  |     const user = await this.userRepository.getForPinCode(auth.user.id); | ||||||
|  |     this.resetPinChecks(user, dto); | ||||||
|  |  | ||||||
|  |     await this.userRepository.update(auth.user.id, { pinCode: null }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) { | ||||||
|  |     const user = await this.userRepository.getForPinCode(auth.user.id); | ||||||
|  |     this.resetPinChecks(user, dto); | ||||||
|  |  | ||||||
|  |     const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS); | ||||||
|  |     await this.userRepository.update(auth.user.id, { pinCode: hashed }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   private resetPinChecks( | ||||||
|  |     user: { pinCode: string | null; password: string | null }, | ||||||
|  |     dto: { pinCode?: string; password?: string }, | ||||||
|  |   ) { | ||||||
|  |     if (!user.pinCode) { | ||||||
|  |       throw new BadRequestException('User does not have a PIN code'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (dto.password) { | ||||||
|  |       if (!this.validateSecret(dto.password, user.password)) { | ||||||
|  |         throw new BadRequestException('Wrong password'); | ||||||
|  |       } | ||||||
|  |     } else if (dto.pinCode) { | ||||||
|  |       if (!this.validateSecret(dto.pinCode, user.pinCode)) { | ||||||
|  |         throw new BadRequestException('Wrong PIN code'); | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       throw new BadRequestException('Either password or pinCode is required'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> { |   async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> { | ||||||
|     const adminUser = await this.userRepository.getAdmin(); |     const adminUser = await this.userRepository.getAdmin(); | ||||||
|     if (adminUser) { |     if (adminUser) { | ||||||
| @@ -371,11 +425,12 @@ export class AuthService extends BaseService { | |||||||
|     throw new UnauthorizedException('Invalid API key'); |     throw new UnauthorizedException('Invalid API key'); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private validatePassword(inputPassword: string, user: { password?: string }): boolean { |   private validateSecret(inputSecret: string, existingHash?: string | null): boolean { | ||||||
|     if (!user || !user.password) { |     if (!existingHash) { | ||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
|     return this.cryptoRepository.compareBcrypt(inputPassword, user.password); |  | ||||||
|  |     return this.cryptoRepository.compareBcrypt(inputSecret, existingHash); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private async validateSession(tokenValue: string): Promise<AuthDto> { |   private async validateSession(tokenValue: string): Promise<AuthDto> { | ||||||
| @@ -428,4 +483,16 @@ export class AuthService extends BaseService { | |||||||
|     } |     } | ||||||
|     return url; |     return url; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   async getAuthStatus(auth: AuthDto): Promise<AuthStatusResponseDto> { | ||||||
|  |     const user = await this.userRepository.getForPinCode(auth.user.id); | ||||||
|  |     if (!user) { | ||||||
|  |       throw new UnauthorizedException(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       pinCode: !!user.pinCode, | ||||||
|  |       password: !!user.password, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -70,6 +70,10 @@ export class UserAdminService extends BaseService { | |||||||
|       dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS); |       dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     if (dto.pinCode) { | ||||||
|  |       dto.pinCode = await this.cryptoRepository.hashBcrypt(dto.pinCode, SALT_ROUNDS); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     if (dto.storageLabel === '') { |     if (dto.storageLabel === '') { | ||||||
|       dto.storageLabel = null; |       dto.storageLabel = null; | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -18,6 +18,7 @@ import { | |||||||
|   IsOptional, |   IsOptional, | ||||||
|   IsString, |   IsString, | ||||||
|   IsUUID, |   IsUUID, | ||||||
|  |   Matches, | ||||||
|   Validate, |   Validate, | ||||||
|   ValidateBy, |   ValidateBy, | ||||||
|   ValidateIf, |   ValidateIf, | ||||||
| @@ -70,6 +71,22 @@ export class UUIDParamDto { | |||||||
|   id!: string; |   id!: string; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | type PinCodeOptions = { optional?: boolean } & OptionalOptions; | ||||||
|  | export const PinCode = ({ optional, ...options }: PinCodeOptions = {}) => { | ||||||
|  |   const decorators = [ | ||||||
|  |     IsString(), | ||||||
|  |     IsNotEmpty(), | ||||||
|  |     Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }), | ||||||
|  |     ApiProperty({ example: '123456' }), | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   if (optional) { | ||||||
|  |     decorators.push(Optional(options)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return applyDecorators(...decorators); | ||||||
|  | }; | ||||||
|  |  | ||||||
| export interface OptionalOptions extends ValidationOptions { | export interface OptionalOptions extends ValidationOptions { | ||||||
|   nullable?: boolean; |   nullable?: boolean; | ||||||
|   /** convert empty strings to null */ |   /** convert empty strings to null */ | ||||||
|   | |||||||
							
								
								
									
										114
									
								
								web/src/lib/components/user-settings-page/PinCodeInput.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								web/src/lib/components/user-settings-page/PinCodeInput.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | |||||||
|  | <script lang="ts"> | ||||||
|  |   interface Props { | ||||||
|  |     label: string; | ||||||
|  |     value?: string; | ||||||
|  |     pinLength?: number; | ||||||
|  |     tabindexStart?: number; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   let { label, value = $bindable(''), pinLength = 6, tabindexStart = 0 }: Props = $props(); | ||||||
|  |  | ||||||
|  |   let pinValues = $state(Array.from({ length: pinLength }).fill('')); | ||||||
|  |   let pinCodeInputElements: HTMLInputElement[] = $state([]); | ||||||
|  |  | ||||||
|  |   $effect(() => { | ||||||
|  |     if (value === '') { | ||||||
|  |       pinValues = Array.from({ length: pinLength }).fill(''); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const focusNext = (index: number) => { | ||||||
|  |     pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const focusPrev = (index: number) => { | ||||||
|  |     if (index > 0) { | ||||||
|  |       pinCodeInputElements[index - 1]?.focus(); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleInput = (event: Event, index: number) => { | ||||||
|  |     const target = event.target as HTMLInputElement; | ||||||
|  |     let currentPinValue = target.value; | ||||||
|  |  | ||||||
|  |     if (target.value.length > 1) { | ||||||
|  |       currentPinValue = value.slice(0, 1); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (Number.isNaN(Number(value))) { | ||||||
|  |       pinValues[index] = ''; | ||||||
|  |       target.value = ''; | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pinValues[index] = currentPinValue; | ||||||
|  |  | ||||||
|  |     value = pinValues.join('').trim(); | ||||||
|  |  | ||||||
|  |     if (value && index < pinLength - 1) { | ||||||
|  |       focusNext(index); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) { | ||||||
|  |     const target = event.currentTarget as HTMLInputElement; | ||||||
|  |     const index = pinCodeInputElements.indexOf(target); | ||||||
|  |  | ||||||
|  |     switch (event.key) { | ||||||
|  |       case 'Tab': { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       case 'Backspace': { | ||||||
|  |         if (target.value === '' && index > 0) { | ||||||
|  |           focusPrev(index); | ||||||
|  |           pinValues[index - 1] = ''; | ||||||
|  |         } else if (target.value !== '') { | ||||||
|  |           pinValues[index] = ''; | ||||||
|  |         } | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       case 'ArrowLeft': { | ||||||
|  |         if (index > 0) { | ||||||
|  |           focusPrev(index); | ||||||
|  |         } | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       case 'ArrowRight': { | ||||||
|  |         if (index < pinLength - 1) { | ||||||
|  |           focusNext(index); | ||||||
|  |         } | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       default: { | ||||||
|  |         if (Number.isNaN(Number(event.key))) { | ||||||
|  |           event.preventDefault(); | ||||||
|  |         } | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <div class="flex flex-col gap-1"> | ||||||
|  |   {#if label} | ||||||
|  |     <label class="text-xs text-dark" for={pinCodeInputElements[0]?.id}>{label.toUpperCase()}</label> | ||||||
|  |   {/if} | ||||||
|  |   <div class="flex gap-2"> | ||||||
|  |     {#each { length: pinLength } as _, index (index)} | ||||||
|  |       <input | ||||||
|  |         tabindex={tabindexStart + index} | ||||||
|  |         type="text" | ||||||
|  |         inputmode="numeric" | ||||||
|  |         pattern="[0-9]*" | ||||||
|  |         maxlength="1" | ||||||
|  |         bind:this={pinCodeInputElements[index]} | ||||||
|  |         id="pin-code-{index}" | ||||||
|  |         class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 bg-transparent text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono" | ||||||
|  |         bind:value={pinValues[index]} | ||||||
|  |         onkeydown={handleKeydown} | ||||||
|  |         oninput={(event) => handleInput(event, index)} | ||||||
|  |         aria-label={`PIN digit ${index + 1} of ${pinLength}${label ? ` for ${label}` : ''}`} | ||||||
|  |       /> | ||||||
|  |     {/each} | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
							
								
								
									
										116
									
								
								web/src/lib/components/user-settings-page/PinCodeSettings.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								web/src/lib/components/user-settings-page/PinCodeSettings.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | |||||||
|  | <script lang="ts"> | ||||||
|  |   import { | ||||||
|  |     notificationController, | ||||||
|  |     NotificationType, | ||||||
|  |   } from '$lib/components/shared-components/notification/notification'; | ||||||
|  |   import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte'; | ||||||
|  |   import { handleError } from '$lib/utils/handle-error'; | ||||||
|  |   import { changePinCode, getAuthStatus, setupPinCode } from '@immich/sdk'; | ||||||
|  |   import { Button } from '@immich/ui'; | ||||||
|  |   import { onMount } from 'svelte'; | ||||||
|  |   import { t } from 'svelte-i18n'; | ||||||
|  |   import { fade } from 'svelte/transition'; | ||||||
|  |  | ||||||
|  |   let hasPinCode = $state(false); | ||||||
|  |   let currentPinCode = $state(''); | ||||||
|  |   let newPinCode = $state(''); | ||||||
|  |   let confirmPinCode = $state(''); | ||||||
|  |   let isLoading = $state(false); | ||||||
|  |   let canSubmit = $derived( | ||||||
|  |     (hasPinCode ? currentPinCode.length === 6 : true) && confirmPinCode.length === 6 && newPinCode === confirmPinCode, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   onMount(async () => { | ||||||
|  |     const authStatus = await getAuthStatus(); | ||||||
|  |     hasPinCode = authStatus.pinCode; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const handleSubmit = async (event: Event) => { | ||||||
|  |     event.preventDefault(); | ||||||
|  |     await (hasPinCode ? handleChange() : handleSetup()); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleSetup = async () => { | ||||||
|  |     isLoading = true; | ||||||
|  |     try { | ||||||
|  |       await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } }); | ||||||
|  |  | ||||||
|  |       resetForm(); | ||||||
|  |  | ||||||
|  |       notificationController.show({ | ||||||
|  |         message: $t('pin_code_setup_successfully'), | ||||||
|  |         type: NotificationType.Info, | ||||||
|  |       }); | ||||||
|  |     } catch (error) { | ||||||
|  |       handleError(error, $t('unable_to_setup_pin_code')); | ||||||
|  |     } finally { | ||||||
|  |       isLoading = false; | ||||||
|  |       hasPinCode = true; | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleChange = async () => { | ||||||
|  |     isLoading = true; | ||||||
|  |     try { | ||||||
|  |       await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } }); | ||||||
|  |  | ||||||
|  |       resetForm(); | ||||||
|  |  | ||||||
|  |       notificationController.show({ | ||||||
|  |         message: $t('pin_code_changed_successfully'), | ||||||
|  |         type: NotificationType.Info, | ||||||
|  |       }); | ||||||
|  |     } catch (error) { | ||||||
|  |       handleError(error, $t('unable_to_change_pin_code')); | ||||||
|  |     } finally { | ||||||
|  |       isLoading = false; | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const resetForm = () => { | ||||||
|  |     currentPinCode = ''; | ||||||
|  |     newPinCode = ''; | ||||||
|  |     confirmPinCode = ''; | ||||||
|  |   }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <section class="my-4"> | ||||||
|  |   <div in:fade={{ duration: 200 }}> | ||||||
|  |     <form autocomplete="off" onsubmit={handleSubmit} class="mt-6"> | ||||||
|  |       <div class="flex flex-col gap-6 place-items-center place-content-center"> | ||||||
|  |         {#if hasPinCode} | ||||||
|  |           <p class="text-dark">Change PIN code</p> | ||||||
|  |           <PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} /> | ||||||
|  |  | ||||||
|  |           <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} /> | ||||||
|  |  | ||||||
|  |           <PinCodeInput | ||||||
|  |             label={$t('confirm_new_pin_code')} | ||||||
|  |             bind:value={confirmPinCode} | ||||||
|  |             tabindexStart={13} | ||||||
|  |             pinLength={6} | ||||||
|  |           /> | ||||||
|  |         {:else} | ||||||
|  |           <p class="text-dark">{$t('setup_pin_code')}</p> | ||||||
|  |           <PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} /> | ||||||
|  |  | ||||||
|  |           <PinCodeInput | ||||||
|  |             label={$t('confirm_new_pin_code')} | ||||||
|  |             bind:value={confirmPinCode} | ||||||
|  |             tabindexStart={7} | ||||||
|  |             pinLength={6} | ||||||
|  |           /> | ||||||
|  |         {/if} | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div class="flex justify-end gap-2 mt-4"> | ||||||
|  |         <Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}> | ||||||
|  |           {$t('clear')} | ||||||
|  |         </Button> | ||||||
|  |         <Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}> | ||||||
|  |           {hasPinCode ? $t('save') : $t('create')} | ||||||
|  |         </Button> | ||||||
|  |       </div> | ||||||
|  |     </form> | ||||||
|  |   </div> | ||||||
|  | </section> | ||||||
| @@ -1,24 +1,16 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|   import { page } from '$app/stores'; |   import { page } from '$app/stores'; | ||||||
|  |   import ChangePinCodeSettings from '$lib/components/user-settings-page/PinCodeSettings.svelte'; | ||||||
|  |   import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte'; | ||||||
|  |   import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte'; | ||||||
|  |   import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte'; | ||||||
|  |   import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte'; | ||||||
|  |   import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte'; | ||||||
|   import { OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants'; |   import { OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants'; | ||||||
|   import { featureFlags } from '$lib/stores/server-config.store'; |   import { featureFlags } from '$lib/stores/server-config.store'; | ||||||
|   import { user } from '$lib/stores/user.store'; |   import { user } from '$lib/stores/user.store'; | ||||||
|   import { oauth } from '$lib/utils'; |   import { oauth } from '$lib/utils'; | ||||||
|   import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk'; |   import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk'; | ||||||
|   import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte'; |  | ||||||
|   import SettingAccordion from '../shared-components/settings/setting-accordion.svelte'; |  | ||||||
|   import AppSettings from './app-settings.svelte'; |  | ||||||
|   import ChangePasswordSettings from './change-password-settings.svelte'; |  | ||||||
|   import DeviceList from './device-list.svelte'; |  | ||||||
|   import OAuthSettings from './oauth-settings.svelte'; |  | ||||||
|   import PartnerSettings from './partner-settings.svelte'; |  | ||||||
|   import UserAPIKeyList from './user-api-key-list.svelte'; |  | ||||||
|   import UserProfileSettings from './user-profile-settings.svelte'; |  | ||||||
|   import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte'; |  | ||||||
|   import { t } from 'svelte-i18n'; |  | ||||||
|   import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte'; |  | ||||||
|   import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte'; |  | ||||||
|   import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte'; |  | ||||||
|   import { |   import { | ||||||
|     mdiAccountGroupOutline, |     mdiAccountGroupOutline, | ||||||
|     mdiAccountOutline, |     mdiAccountOutline, | ||||||
| @@ -29,11 +21,21 @@ | |||||||
|     mdiDownload, |     mdiDownload, | ||||||
|     mdiFeatureSearchOutline, |     mdiFeatureSearchOutline, | ||||||
|     mdiKeyOutline, |     mdiKeyOutline, | ||||||
|  |     mdiLockSmart, | ||||||
|     mdiOnepassword, |     mdiOnepassword, | ||||||
|     mdiServerOutline, |     mdiServerOutline, | ||||||
|     mdiTwoFactorAuthentication, |     mdiTwoFactorAuthentication, | ||||||
|   } from '@mdi/js'; |   } from '@mdi/js'; | ||||||
|   import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte'; |   import { t } from 'svelte-i18n'; | ||||||
|  |   import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte'; | ||||||
|  |   import SettingAccordion from '../shared-components/settings/setting-accordion.svelte'; | ||||||
|  |   import AppSettings from './app-settings.svelte'; | ||||||
|  |   import ChangePasswordSettings from './change-password-settings.svelte'; | ||||||
|  |   import DeviceList from './device-list.svelte'; | ||||||
|  |   import OAuthSettings from './oauth-settings.svelte'; | ||||||
|  |   import PartnerSettings from './partner-settings.svelte'; | ||||||
|  |   import UserAPIKeyList from './user-api-key-list.svelte'; | ||||||
|  |   import UserProfileSettings from './user-profile-settings.svelte'; | ||||||
|  |  | ||||||
|   interface Props { |   interface Props { | ||||||
|     keys?: ApiKeyResponseDto[]; |     keys?: ApiKeyResponseDto[]; | ||||||
| @@ -135,6 +137,16 @@ | |||||||
|     <PartnerSettings user={$user} /> |     <PartnerSettings user={$user} /> | ||||||
|   </SettingAccordion> |   </SettingAccordion> | ||||||
|  |  | ||||||
|  |   <SettingAccordion | ||||||
|  |     icon={mdiLockSmart} | ||||||
|  |     key="user-pin-code-settings" | ||||||
|  |     title={$t('user_pin_code_settings')} | ||||||
|  |     subtitle={$t('user_pin_code_settings_description')} | ||||||
|  |     autoScrollTo={true} | ||||||
|  |   > | ||||||
|  |     <ChangePinCodeSettings /> | ||||||
|  |   </SettingAccordion> | ||||||
|  |  | ||||||
|   <SettingAccordion |   <SettingAccordion | ||||||
|     icon={mdiKeyOutline} |     icon={mdiKeyOutline} | ||||||
|     key="user-purchase-settings" |     key="user-purchase-settings" | ||||||
|   | |||||||
| @@ -6,14 +6,17 @@ | |||||||
|   import { handleError } from '$lib/utils/handle-error'; |   import { handleError } from '$lib/utils/handle-error'; | ||||||
|   import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; |   import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; | ||||||
|   import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; |   import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; | ||||||
|   import { mdiAccountEditOutline } from '@mdi/js'; |   import { mdiAccountEditOutline, mdiLockSmart, mdiOnepassword } from '@mdi/js'; | ||||||
|   import { t } from 'svelte-i18n'; |   import { t } from 'svelte-i18n'; | ||||||
|  |  | ||||||
|   interface Props { |   interface Props { | ||||||
|     user: UserAdminResponseDto; |     user: UserAdminResponseDto; | ||||||
|     canResetPassword?: boolean; |     canResetPassword?: boolean; | ||||||
|     onClose: ( |     onClose: ( | ||||||
|       data?: { action: 'update'; data: UserAdminResponseDto } | { action: 'resetPassword'; data: string }, |       data?: | ||||||
|  |         | { action: 'update'; data: UserAdminResponseDto } | ||||||
|  |         | { action: 'resetPassword'; data: string } | ||||||
|  |         | { action: 'resetPinCode' }, | ||||||
|     ) => void; |     ) => void; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -76,6 +79,24 @@ | |||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   const resetUserPincode = async () => { | ||||||
|  |     const isConfirmed = await modalManager.openDialog({ | ||||||
|  |       prompt: $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }), | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     if (!isConfirmed) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } }); | ||||||
|  |  | ||||||
|  |       onClose({ action: 'resetPinCode' }); | ||||||
|  |     } catch (error) { | ||||||
|  |       handleError(error, $t('errors.unable_to_reset_pin_code')); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   // TODO move password reset server-side |   // TODO move password reset server-side | ||||||
|   function generatePassword(length: number = 16) { |   function generatePassword(length: number = 16) { | ||||||
|     let generatedPassword = ''; |     let generatedPassword = ''; | ||||||
| @@ -151,13 +172,34 @@ | |||||||
|   </ModalBody> |   </ModalBody> | ||||||
|  |  | ||||||
|   <ModalFooter> |   <ModalFooter> | ||||||
|     <div class="flex gap-3 w-full"> |     <div class="w-full"> | ||||||
|       {#if canResetPassword} |       <div class="flex gap-3 w-full"> | ||||||
|         <Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword} |         {#if canResetPassword} | ||||||
|           >{$t('reset_password')}</Button |           <Button | ||||||
|  |             shape="round" | ||||||
|  |             color="warning" | ||||||
|  |             variant="filled" | ||||||
|  |             fullWidth | ||||||
|  |             onclick={resetPassword} | ||||||
|  |             leadingIcon={mdiOnepassword} | ||||||
|  |           > | ||||||
|  |             {$t('reset_password')}</Button | ||||||
|  |           > | ||||||
|  |         {/if} | ||||||
|  |  | ||||||
|  |         <Button | ||||||
|  |           shape="round" | ||||||
|  |           color="warning" | ||||||
|  |           variant="filled" | ||||||
|  |           fullWidth | ||||||
|  |           onclick={resetUserPincode} | ||||||
|  |           leadingIcon={mdiLockSmart}>{$t('reset_pin_code')}</Button | ||||||
|         > |         > | ||||||
|       {/if} |       </div> | ||||||
|       <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button> |  | ||||||
|  |       <div class="w-full mt-4"> | ||||||
|  |         <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </ModalFooter> |   </ModalFooter> | ||||||
| </Modal> | </Modal> | ||||||
|   | |||||||
| @@ -74,6 +74,10 @@ | |||||||
|         await refresh(); |         await refresh(); | ||||||
|         break; |         break; | ||||||
|       } |       } | ||||||
|  |       case 'resetPinCode': { | ||||||
|  |         notificationController.show({ type: NotificationType.Info, message: $t('pin_code_reset_successfully') }); | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
| @@ -137,7 +141,7 @@ | |||||||
|                   {#if !immichUser.deletedAt} |                   {#if !immichUser.deletedAt} | ||||||
|                     <IconButton |                     <IconButton | ||||||
|                       shape="round" |                       shape="round" | ||||||
|                       size="small" |                       size="medium" | ||||||
|                       icon={mdiPencilOutline} |                       icon={mdiPencilOutline} | ||||||
|                       title={$t('edit_user')} |                       title={$t('edit_user')} | ||||||
|                       onclick={() => handleEdit(immichUser)} |                       onclick={() => handleEdit(immichUser)} | ||||||
| @@ -146,7 +150,7 @@ | |||||||
|                     {#if immichUser.id !== $user.id} |                     {#if immichUser.id !== $user.id} | ||||||
|                       <IconButton |                       <IconButton | ||||||
|                         shape="round" |                         shape="round" | ||||||
|                         size="small" |                         size="medium" | ||||||
|                         icon={mdiTrashCanOutline} |                         icon={mdiTrashCanOutline} | ||||||
|                         title={$t('delete_user')} |                         title={$t('delete_user')} | ||||||
|                         onclick={() => handleDelete(immichUser)} |                         onclick={() => handleDelete(immichUser)} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user