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", | ||||
|   "account": "Account", | ||||
|   "account_settings": "Account Settings", | ||||
| @@ -53,6 +65,7 @@ | ||||
|     "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_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", | ||||
|     "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>", | ||||
| @@ -922,6 +935,7 @@ | ||||
|     "unable_to_remove_reaction": "Unable to remove reaction", | ||||
|     "unable_to_repair_items": "Unable to repair items", | ||||
|     "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_restore_assets": "Unable to restore assets", | ||||
|     "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* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |  | ||||
| *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* | [**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* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |  | ||||
| *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random |  | ||||
| @@ -304,6 +308,7 @@ Class | Method | HTTP request | Description | ||||
|  - [AssetTypeEnum](doc//AssetTypeEnum.md) | ||||
|  - [AssetVisibility](doc//AssetVisibility.md) | ||||
|  - [AudioCodec](doc//AudioCodec.md) | ||||
|  - [AuthStatusResponseDto](doc//AuthStatusResponseDto.md) | ||||
|  - [AvatarUpdate](doc//AvatarUpdate.md) | ||||
|  - [BulkIdResponseDto](doc//BulkIdResponseDto.md) | ||||
|  - [BulkIdsDto](doc//BulkIdsDto.md) | ||||
| @@ -383,6 +388,8 @@ Class | Method | HTTP request | Description | ||||
|  - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) | ||||
|  - [PersonUpdateDto](doc//PersonUpdateDto.md) | ||||
|  - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md) | ||||
|  - [PinCodeChangeDto](doc//PinCodeChangeDto.md) | ||||
|  - [PinCodeSetupDto](doc//PinCodeSetupDto.md) | ||||
|  - [PlacesResponseDto](doc//PlacesResponseDto.md) | ||||
|  - [PurchaseResponse](doc//PurchaseResponse.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_visibility.dart'; | ||||
| part 'model/audio_codec.dart'; | ||||
| part 'model/auth_status_response_dto.dart'; | ||||
| part 'model/avatar_update.dart'; | ||||
| part 'model/bulk_id_response_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_update_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/purchase_response.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; | ||||
|   } | ||||
| 
 | ||||
|   /// 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]. | ||||
|   /// Parameters: | ||||
|   /// | ||||
| @@ -151,6 +231,84 @@ class AuthenticationApi { | ||||
|     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]. | ||||
|   /// 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); | ||||
|         case 'AudioCodec': | ||||
|           return AudioCodecTypeTransformer().decode(value); | ||||
|         case 'AuthStatusResponseDto': | ||||
|           return AuthStatusResponseDto.fromJson(value); | ||||
|         case 'AvatarUpdate': | ||||
|           return AvatarUpdate.fromJson(value); | ||||
|         case 'BulkIdResponseDto': | ||||
| @@ -430,6 +432,10 @@ class ApiClient { | ||||
|           return PersonUpdateDto.fromJson(value); | ||||
|         case 'PersonWithFacesResponseDto': | ||||
|           return PersonWithFacesResponseDto.fromJson(value); | ||||
|         case 'PinCodeChangeDto': | ||||
|           return PinCodeChangeDto.fromJson(value); | ||||
|         case 'PinCodeSetupDto': | ||||
|           return PinCodeSetupDto.fromJson(value); | ||||
|         case 'PlacesResponseDto': | ||||
|           return PlacesResponseDto.fromJson(value); | ||||
|         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.name, | ||||
|     this.password, | ||||
|     this.pinCode, | ||||
|     this.quotaSizeInBytes, | ||||
|     this.shouldChangePassword, | ||||
|     this.storageLabel, | ||||
| @@ -48,6 +49,8 @@ class UserAdminUpdateDto { | ||||
|   /// | ||||
|   String? password; | ||||
| 
 | ||||
|   String? pinCode; | ||||
| 
 | ||||
|   /// Minimum value: 0 | ||||
|   int? quotaSizeInBytes; | ||||
| 
 | ||||
| @@ -67,6 +70,7 @@ class UserAdminUpdateDto { | ||||
|     other.email == email && | ||||
|     other.name == name && | ||||
|     other.password == password && | ||||
|     other.pinCode == pinCode && | ||||
|     other.quotaSizeInBytes == quotaSizeInBytes && | ||||
|     other.shouldChangePassword == shouldChangePassword && | ||||
|     other.storageLabel == storageLabel; | ||||
| @@ -78,12 +82,13 @@ class UserAdminUpdateDto { | ||||
|     (email == null ? 0 : email!.hashCode) + | ||||
|     (name == null ? 0 : name!.hashCode) + | ||||
|     (password == null ? 0 : password!.hashCode) + | ||||
|     (pinCode == null ? 0 : pinCode!.hashCode) + | ||||
|     (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + | ||||
|     (shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode) + | ||||
|     (storageLabel == null ? 0 : storageLabel!.hashCode); | ||||
| 
 | ||||
|   @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() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -107,6 +112,11 @@ class UserAdminUpdateDto { | ||||
|     } else { | ||||
|     //  json[r'password'] = null; | ||||
|     } | ||||
|     if (this.pinCode != null) { | ||||
|       json[r'pinCode'] = this.pinCode; | ||||
|     } else { | ||||
|     //  json[r'pinCode'] = null; | ||||
|     } | ||||
|     if (this.quotaSizeInBytes != null) { | ||||
|       json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; | ||||
|     } else { | ||||
| @@ -138,6 +148,7 @@ class UserAdminUpdateDto { | ||||
|         email: mapValueOfType<String>(json, r'email'), | ||||
|         name: mapValueOfType<String>(json, r'name'), | ||||
|         password: mapValueOfType<String>(json, r'password'), | ||||
|         pinCode: mapValueOfType<String>(json, r'pinCode'), | ||||
|         quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'), | ||||
|         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'), | ||||
|         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": { | ||||
|       "post": { | ||||
|         "operationId": "validateAccessToken", | ||||
| @@ -9031,6 +9164,21 @@ | ||||
|         ], | ||||
|         "type": "string" | ||||
|       }, | ||||
|       "AuthStatusResponseDto": { | ||||
|         "properties": { | ||||
|           "password": { | ||||
|             "type": "boolean" | ||||
|           }, | ||||
|           "pinCode": { | ||||
|             "type": "boolean" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "password", | ||||
|           "pinCode" | ||||
|         ], | ||||
|         "type": "object" | ||||
|       }, | ||||
|       "AvatarUpdate": { | ||||
|         "properties": { | ||||
|           "color": { | ||||
| @@ -10964,6 +11112,37 @@ | ||||
|         ], | ||||
|         "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": { | ||||
|         "properties": { | ||||
|           "admin1name": { | ||||
| @@ -13958,6 +14137,11 @@ | ||||
|           "password": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "pinCode": { | ||||
|             "example": "123456", | ||||
|             "nullable": true, | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "quotaSizeInBytes": { | ||||
|             "format": "int64", | ||||
|             "minimum": 0, | ||||
|   | ||||
| @@ -123,6 +123,7 @@ export type UserAdminUpdateDto = { | ||||
|     email?: string; | ||||
|     name?: string; | ||||
|     password?: string; | ||||
|     pinCode?: string | null; | ||||
|     quotaSizeInBytes?: number | null; | ||||
|     shouldChangePassword?: boolean; | ||||
|     storageLabel?: string | null; | ||||
| @@ -510,6 +511,18 @@ export type LogoutResponseDto = { | ||||
|     redirectUri: string; | ||||
|     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 = { | ||||
|     authStatus: boolean; | ||||
| }; | ||||
| @@ -2017,6 +2030,41 @@ export function logout(opts?: Oazapfts.RequestOpts) { | ||||
|         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) { | ||||
|     return oazapfts.ok(oazapfts.fetchJson<{ | ||||
|         status: 200; | ||||
|   | ||||
| @@ -142,4 +142,50 @@ describe(AuthController.name, () => { | ||||
|       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 { Request, Response } from 'express'; | ||||
| import { | ||||
|   AuthDto, | ||||
|   AuthStatusResponseDto, | ||||
|   ChangePasswordDto, | ||||
|   LoginCredentialDto, | ||||
|   LoginResponseDto, | ||||
|   LogoutResponseDto, | ||||
|   PinCodeChangeDto, | ||||
|   PinCodeSetupDto, | ||||
|   SignUpDto, | ||||
|   ValidateAccessTokenResponseDto, | ||||
| } from 'src/dtos/auth.dto'; | ||||
| @@ -74,4 +77,28 @@ export class AuthController { | ||||
|       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 { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; | ||||
| import { ImmichCookie } from 'src/enum'; | ||||
| import { Optional, toEmail } from 'src/validation'; | ||||
| import { Optional, PinCode, toEmail } from 'src/validation'; | ||||
|  | ||||
| export type CookieResponse = { | ||||
|   isSecure: boolean; | ||||
| @@ -78,6 +78,26 @@ export class ChangePasswordDto { | ||||
|   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 { | ||||
|   authStatus!: boolean; | ||||
| } | ||||
| @@ -114,3 +134,8 @@ export class OAuthConfigDto { | ||||
| export class OAuthAuthorizeResponseDto { | ||||
|   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 { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; | ||||
| 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 { | ||||
|   @Optional() | ||||
| @@ -116,6 +116,9 @@ export class UserAdminUpdateDto { | ||||
|   @IsString() | ||||
|   password?: string; | ||||
|  | ||||
|   @PinCode({ optional: true, nullable: true, emptyToNull: true }) | ||||
|   pinCode?: string | null; | ||||
|  | ||||
|   @Optional() | ||||
|   @IsString() | ||||
|   @IsNotEmpty() | ||||
|   | ||||
| @@ -87,6 +87,16 @@ where | ||||
|   "users"."isAdmin" = $1 | ||||
|   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 | ||||
| select | ||||
|   "id", | ||||
|   | ||||
| @@ -89,13 +89,23 @@ export class UserRepository { | ||||
|     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] }) | ||||
|   getByEmail(email: string, withPassword?: boolean) { | ||||
|   getByEmail(email: string, options?: { withPassword?: boolean }) { | ||||
|     return this.db | ||||
|       .selectFrom('users') | ||||
|       .select(columns.userAdmin) | ||||
|       .select(withMetadata) | ||||
|       .$if(!!withPassword, (eb) => eb.select('password')) | ||||
|       .$if(!!options?.withPassword, (eb) => eb.select('password')) | ||||
|       .where('email', '=', email) | ||||
|       .where('users.deletedAt', 'is', null) | ||||
|       .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: '' }) | ||||
|   password!: Generated<string>; | ||||
|  | ||||
|   @Column({ nullable: true }) | ||||
|   pinCode!: string | null; | ||||
|  | ||||
|   @CreateDateColumn() | ||||
|   createdAt!: Generated<Timestamp>; | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { SALT_ROUNDS } from 'src/constants'; | ||||
| import { UserAdmin } from 'src/database'; | ||||
| import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; | ||||
| import { AuthType, Permission } from 'src/enum'; | ||||
| @@ -118,7 +119,7 @@ describe(AuthService.name, () => { | ||||
|  | ||||
|       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'); | ||||
|     }); | ||||
|  | ||||
| @@ -859,4 +860,77 @@ describe(AuthService.name, () => { | ||||
|       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 { | ||||
|   AuthDto, | ||||
|   AuthStatusResponseDto, | ||||
|   ChangePasswordDto, | ||||
|   LoginCredentialDto, | ||||
|   LogoutResponseDto, | ||||
|   OAuthCallbackDto, | ||||
|   OAuthConfigDto, | ||||
|   PinCodeChangeDto, | ||||
|   PinCodeResetDto, | ||||
|   PinCodeSetupDto, | ||||
|   SignUpDto, | ||||
|   mapLoginResponse, | ||||
| } from 'src/dtos/auth.dto'; | ||||
| @@ -56,9 +60,9 @@ export class AuthService extends BaseService { | ||||
|       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) { | ||||
|       const isAuthenticated = this.validatePassword(dto.password, user); | ||||
|       const isAuthenticated = this.validateSecret(dto.password, user.password); | ||||
|       if (!isAuthenticated) { | ||||
|         user = undefined; | ||||
|       } | ||||
| @@ -86,12 +90,12 @@ export class AuthService extends BaseService { | ||||
|  | ||||
|   async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> { | ||||
|     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) { | ||||
|       throw new UnauthorizedException(); | ||||
|     } | ||||
|  | ||||
|     const valid = this.validatePassword(password, user); | ||||
|     const valid = this.validateSecret(password, user.password); | ||||
|     if (!valid) { | ||||
|       throw new BadRequestException('Wrong password'); | ||||
|     } | ||||
| @@ -103,6 +107,56 @@ export class AuthService extends BaseService { | ||||
|     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> { | ||||
|     const adminUser = await this.userRepository.getAdmin(); | ||||
|     if (adminUser) { | ||||
| @@ -371,11 +425,12 @@ export class AuthService extends BaseService { | ||||
|     throw new UnauthorizedException('Invalid API key'); | ||||
|   } | ||||
|  | ||||
|   private validatePassword(inputPassword: string, user: { password?: string }): boolean { | ||||
|     if (!user || !user.password) { | ||||
|   private validateSecret(inputSecret: string, existingHash?: string | null): boolean { | ||||
|     if (!existingHash) { | ||||
|       return false; | ||||
|     } | ||||
|     return this.cryptoRepository.compareBcrypt(inputPassword, user.password); | ||||
|  | ||||
|     return this.cryptoRepository.compareBcrypt(inputSecret, existingHash); | ||||
|   } | ||||
|  | ||||
|   private async validateSession(tokenValue: string): Promise<AuthDto> { | ||||
| @@ -428,4 +483,16 @@ export class AuthService extends BaseService { | ||||
|     } | ||||
|     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); | ||||
|     } | ||||
|  | ||||
|     if (dto.pinCode) { | ||||
|       dto.pinCode = await this.cryptoRepository.hashBcrypt(dto.pinCode, SALT_ROUNDS); | ||||
|     } | ||||
|  | ||||
|     if (dto.storageLabel === '') { | ||||
|       dto.storageLabel = null; | ||||
|     } | ||||
|   | ||||
| @@ -18,6 +18,7 @@ import { | ||||
|   IsOptional, | ||||
|   IsString, | ||||
|   IsUUID, | ||||
|   Matches, | ||||
|   Validate, | ||||
|   ValidateBy, | ||||
|   ValidateIf, | ||||
| @@ -70,6 +71,22 @@ export class UUIDParamDto { | ||||
|   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 { | ||||
|   nullable?: boolean; | ||||
|   /** 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"> | ||||
|   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 { featureFlags } from '$lib/stores/server-config.store'; | ||||
|   import { user } from '$lib/stores/user.store'; | ||||
|   import { oauth } from '$lib/utils'; | ||||
|   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 { | ||||
|     mdiAccountGroupOutline, | ||||
|     mdiAccountOutline, | ||||
| @@ -29,11 +21,21 @@ | ||||
|     mdiDownload, | ||||
|     mdiFeatureSearchOutline, | ||||
|     mdiKeyOutline, | ||||
|     mdiLockSmart, | ||||
|     mdiOnepassword, | ||||
|     mdiServerOutline, | ||||
|     mdiTwoFactorAuthentication, | ||||
|   } 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 { | ||||
|     keys?: ApiKeyResponseDto[]; | ||||
| @@ -135,6 +137,16 @@ | ||||
|     <PartnerSettings user={$user} /> | ||||
|   </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 | ||||
|     icon={mdiKeyOutline} | ||||
|     key="user-purchase-settings" | ||||
|   | ||||
| @@ -6,14 +6,17 @@ | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; | ||||
|   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'; | ||||
|  | ||||
|   interface Props { | ||||
|     user: UserAdminResponseDto; | ||||
|     canResetPassword?: boolean; | ||||
|     onClose: ( | ||||
|       data?: { action: 'update'; data: UserAdminResponseDto } | { action: 'resetPassword'; data: string }, | ||||
|       data?: | ||||
|         | { action: 'update'; data: UserAdminResponseDto } | ||||
|         | { action: 'resetPassword'; data: string } | ||||
|         | { action: 'resetPinCode' }, | ||||
|     ) => 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 | ||||
|   function generatePassword(length: number = 16) { | ||||
|     let generatedPassword = ''; | ||||
| @@ -151,13 +172,34 @@ | ||||
|   </ModalBody> | ||||
|  | ||||
|   <ModalFooter> | ||||
|     <div class="flex gap-3 w-full"> | ||||
|       {#if canResetPassword} | ||||
|         <Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword} | ||||
|           >{$t('reset_password')}</Button | ||||
|     <div class="w-full"> | ||||
|       <div class="flex gap-3 w-full"> | ||||
|         {#if canResetPassword} | ||||
|           <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} | ||||
|       <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button> | ||||
|       </div> | ||||
|  | ||||
|       <div class="w-full mt-4"> | ||||
|         <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </ModalFooter> | ||||
| </Modal> | ||||
|   | ||||
| @@ -74,6 +74,10 @@ | ||||
|         await refresh(); | ||||
|         break; | ||||
|       } | ||||
|       case 'resetPinCode': { | ||||
|         notificationController.show({ type: NotificationType.Info, message: $t('pin_code_reset_successfully') }); | ||||
|         break; | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
| @@ -137,7 +141,7 @@ | ||||
|                   {#if !immichUser.deletedAt} | ||||
|                     <IconButton | ||||
|                       shape="round" | ||||
|                       size="small" | ||||
|                       size="medium" | ||||
|                       icon={mdiPencilOutline} | ||||
|                       title={$t('edit_user')} | ||||
|                       onclick={() => handleEdit(immichUser)} | ||||
| @@ -146,7 +150,7 @@ | ||||
|                     {#if immichUser.id !== $user.id} | ||||
|                       <IconButton | ||||
|                         shape="round" | ||||
|                         size="small" | ||||
|                         size="medium" | ||||
|                         icon={mdiTrashCanOutline} | ||||
|                         title={$t('delete_user')} | ||||
|                         onclick={() => handleDelete(immichUser)} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user