diff --git a/i18n/en.json b/i18n/en.json index 80381dcff9..6d683dfc55 100644 --- a/i18n/en.json +++ b/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. Crontab Guru", @@ -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", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5395f46801..a141d465d1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -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) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 9a806a3f20..b2cbe222e8 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -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'; diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index bf987f441e..f850bdf403 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -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 changePinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/pin-code'; + + // ignore: prefer_final_locals + Object? postBody = pinCodeChangeDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [PinCodeChangeDto] pinCodeChangeDto (required): + Future 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 getAuthStatusWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/status'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future 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 resetPinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/pin-code'; + + // ignore: prefer_final_locals + Object? postBody = pinCodeChangeDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [PinCodeChangeDto] pinCodeChangeDto (required): + Future 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 setupPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/pin-code'; + + // ignore: prefer_final_locals + Object? postBody = pinCodeSetupDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [PinCodeSetupDto] pinCodeSetupDto (required): + Future 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: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 041a584296..cdd69307ad 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -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': diff --git a/mobile/openapi/lib/model/auth_status_response_dto.dart b/mobile/openapi/lib/model/auth_status_response_dto.dart new file mode 100644 index 0000000000..203923164f --- /dev/null +++ b/mobile/openapi/lib/model/auth_status_response_dto.dart @@ -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 toJson() { + final json = {}; + 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(); + + return AuthStatusResponseDto( + password: mapValueOfType(json, r'password')!, + pinCode: mapValueOfType(json, r'pinCode')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + 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 = { + 'password', + 'pinCode', + }; +} + diff --git a/mobile/openapi/lib/model/pin_code_change_dto.dart b/mobile/openapi/lib/model/pin_code_change_dto.dart new file mode 100644 index 0000000000..2e9967aa6b --- /dev/null +++ b/mobile/openapi/lib/model/pin_code_change_dto.dart @@ -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 toJson() { + final json = {}; + 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(); + + return PinCodeChangeDto( + newPinCode: mapValueOfType(json, r'newPinCode')!, + password: mapValueOfType(json, r'password'), + pinCode: mapValueOfType(json, r'pinCode'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + 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 = { + 'newPinCode', + }; +} + diff --git a/mobile/openapi/lib/model/pin_code_setup_dto.dart b/mobile/openapi/lib/model/pin_code_setup_dto.dart new file mode 100644 index 0000000000..09933790de --- /dev/null +++ b/mobile/openapi/lib/model/pin_code_setup_dto.dart @@ -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 toJson() { + final json = {}; + 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(); + + return PinCodeSetupDto( + pinCode: mapValueOfType(json, r'pinCode')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + 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 = { + 'pinCode', + }; +} + diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index 951ee8ce84..ee5c006840 100644 --- a/mobile/openapi/lib/model/user_admin_update_dto.dart +++ b/mobile/openapi/lib/model/user_admin_update_dto.dart @@ -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 toJson() { final json = {}; @@ -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(json, r'email'), name: mapValueOfType(json, r'name'), password: mapValueOfType(json, r'password'), + pinCode: mapValueOfType(json, r'pinCode'), quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), shouldChangePassword: mapValueOfType(json, r'shouldChangePassword'), storageLabel: mapValueOfType(json, r'storageLabel'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c4c9e7d193..a98750edaa 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b2abdb0a24..41898e12da 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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; diff --git a/server/src/controllers/auth.controller.spec.ts b/server/src/controllers/auth.controller.spec.ts index 0937fc5324..4129b24124 100644 --- a/server/src/controllers/auth.controller.spec.ts +++ b/server/src/controllers/auth.controller.spec.ts @@ -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(); + }); + }); }); diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 4ee3c26901..56acaa5c6d 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -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 { + return this.service.getAuthStatus(auth); + } + + @Post('pin-code') + @Authenticated() + setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise { + return this.service.setupPinCode(auth, dto); + } + + @Put('pin-code') + @Authenticated() + async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise { + return this.service.changePinCode(auth, dto); + } + + @Delete('pin-code') + @Authenticated() + async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise { + return this.service.resetPinCode(auth, dto); + } } diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index a1978d39dd..cc05d2d860 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -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; +} diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 31275f9c28..9efb531bc7 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -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() diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index 72881feea7..33f2960266 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -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", diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 4d7671ca92..f8710746aa 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -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(); diff --git a/server/src/schema/migrations/1746768490606-AddUserPincode.ts b/server/src/schema/migrations/1746768490606-AddUserPincode.ts new file mode 100644 index 0000000000..12dc3c2d12 --- /dev/null +++ b/server/src/schema/migrations/1746768490606-AddUserPincode.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "users" ADD "pinCode" character varying;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "users" DROP COLUMN "pinCode";`.execute(db); +} diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 7525a739a6..c806d6e3f7 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -37,6 +37,9 @@ export class UserTable { @Column({ default: '' }) password!: Generated; + @Column({ nullable: true }) + pinCode!: string | null; + @CreateDateColumn() createdAt!: Generated; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 75f5b8a52d..82172d6b95 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -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'); + }); + }); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index b250b63a5e..65dd84693b 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -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 { 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 { 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 { @@ -428,4 +483,16 @@ export class AuthService extends BaseService { } return url; } + + async getAuthStatus(auth: AuthDto): Promise { + const user = await this.userRepository.getForPinCode(auth.user.id); + if (!user) { + throw new UnauthorizedException(); + } + + return { + pinCode: !!user.pinCode, + password: !!user.password, + }; + } } diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index c1c6cc49ec..38c0106f4b 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -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; } diff --git a/server/src/validation.ts b/server/src/validation.ts index 26367aeff5..2d160f43ce 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -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 */ diff --git a/web/src/lib/components/user-settings-page/PinCodeInput.svelte b/web/src/lib/components/user-settings-page/PinCodeInput.svelte new file mode 100644 index 0000000000..e149f26851 --- /dev/null +++ b/web/src/lib/components/user-settings-page/PinCodeInput.svelte @@ -0,0 +1,114 @@ + + +
+ {#if label} + + {/if} +
+ {#each { length: pinLength } as _, index (index)} + handleInput(event, index)} + aria-label={`PIN digit ${index + 1} of ${pinLength}${label ? ` for ${label}` : ''}`} + /> + {/each} +
+
diff --git a/web/src/lib/components/user-settings-page/PinCodeSettings.svelte b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte new file mode 100644 index 0000000000..ef122b14e7 --- /dev/null +++ b/web/src/lib/components/user-settings-page/PinCodeSettings.svelte @@ -0,0 +1,116 @@ + + +
+
+
+
+ {#if hasPinCode} +

Change PIN code

+ + + + + + {:else} +

{$t('setup_pin_code')}

+ + + + {/if} +
+ +
+ + +
+
+
+
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index 934fa5708f..32747f5ba6 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -1,24 +1,16 @@