diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 605993f5e9..64c084fa2e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -346,7 +346,7 @@ jobs: working-directory: ./e2e strategy: matrix: - runner: [mich, ubuntu-24.04-arm] + runner: [ubuntu-latest, ubuntu-24.04-arm] steps: - name: Checkout code @@ -394,7 +394,7 @@ jobs: working-directory: ./e2e strategy: matrix: - runner: [mich, ubuntu-24.04-arm] + runner: [ubuntu-latest, ubuntu-24.04-arm] steps: - name: Checkout code diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 8196186059..8c203860df 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1085,31 +1085,21 @@ describe('/asset', () => { }, ]; - it(`should upload and generate a thumbnail for different file types`, async () => { - // upload in parallel - const assets = await Promise.all( - tests.map(async ({ input }) => { - const filepath = join(testAssetDir, input); - return utils.createAsset(admin.accessToken, { - assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, - }); - }), - ); + it.each(tests)(`should upload and generate a thumbnail for different file types`, async ({ input, expected }) => { + const filepath = join(testAssetDir, input); + const response = await utils.createAsset(admin.accessToken, { + assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, + }); - for (const { id, status } of assets) { - expect(status).toBe(AssetMediaStatus.Created); - // longer timeout as the thumbnail generation from full-size raw files can take a while - await utils.waitForWebsocketEvent({ event: 'assetUpload', id }); - } + expect(response.status).toBe(AssetMediaStatus.Created); + const id = response.id; + // longer timeout as the thumbnail generation from full-size raw files can take a while + await utils.waitForWebsocketEvent({ event: 'assetUpload', id }); - for (const [i, { id }] of assets.entries()) { - const { expected } = tests[i]; - const asset = await utils.getAssetInfo(admin.accessToken, id); - - expect(asset.exifInfo).toBeDefined(); - expect(asset.exifInfo).toMatchObject(expected.exifInfo); - expect(asset).toMatchObject(expected); - } + const asset = await utils.getAssetInfo(admin.accessToken, id); + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo).toMatchObject(expected.exifInfo); + expect(asset).toMatchObject(expected); }); it('should handle a duplicate', async () => { diff --git a/i18n/en.json b/i18n/en.json index 80381dcff9..c17d55872c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,4 +1,14 @@ { + "user_pincode_settings": "PIN Code", + "user_pincode_settings_description": "Manage your PIN code", + "current_pincode": "Current PIN code", + "new_pincode": "New PIN code", + "confirm_new_pincode": "Confirm new PIN code", + "unable_to_change_pincode": "Unable to change PIN code", + "unable_to_create_pincode": "Unable to create PIN code", + "pincode_changed_successfully": "PIN code changed successfully", + "pincode_created_successfully": "PIN code created successfully", + "create_pincode": "Create PIN code", "about": "About", "account": "Account", "account_settings": "Account Settings", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index eb95101926..c83971b0de 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -111,7 +111,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | *AuthenticationApi* | [**changePincode**](doc//AuthenticationApi.md#changepincode) | **POST** /auth/change-pincode | *AuthenticationApi* | [**createPincode**](doc//AuthenticationApi.md#createpincode) | **POST** /auth/create-pincode | -*AuthenticationApi* | [**hasPincode**](doc//AuthenticationApi.md#haspincode) | **GET** /auth/has-pincode | +*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* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | @@ -307,6 +307,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) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 9bf565592d..03f4b4ae27 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'; diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index 170c7b7e79..a8809aa63b 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -157,10 +157,10 @@ class AuthenticationApi { return null; } - /// Performs an HTTP 'GET /auth/has-pincode' operation and returns the [Response]. - Future<Response> hasPincodeWithHttpInfo() async { + /// Performs an HTTP 'GET /auth/status' operation and returns the [Response]. + Future<Response> getAuthStatusWithHttpInfo() async { // ignore: prefer_const_declarations - final apiPath = r'/auth/has-pincode'; + final apiPath = r'/auth/status'; // ignore: prefer_final_locals Object? postBody; @@ -183,8 +183,8 @@ class AuthenticationApi { ); } - Future<bool?> hasPincode() async { - final response = await hasPincodeWithHttpInfo(); + Future<AuthStatusResponseDto?> getAuthStatus() async { + final response = await getAuthStatusWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -192,7 +192,7 @@ class AuthenticationApi { // 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), 'bool',) as bool; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AuthStatusResponseDto',) as AuthStatusResponseDto; } return null; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 066c5a75ac..621a298253 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': 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..1699e10bac --- /dev/null +++ b/mobile/openapi/lib/model/auth_status_response_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 AuthStatusResponseDto { + /// Returns a new [AuthStatusResponseDto] instance. + AuthStatusResponseDto({ + required this.hasPincode, + }); + + bool hasPincode; + + @override + bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto && + other.hasPincode == hasPincode; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (hasPincode.hashCode); + + @override + String toString() => 'AuthStatusResponseDto[hasPincode=$hasPincode]'; + + Map<String, dynamic> toJson() { + final json = <String, dynamic>{}; + json[r'hasPincode'] = this.hasPincode; + 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( + hasPincode: mapValueOfType<bool>(json, r'hasPincode')!, + ); + } + 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>{ + 'hasPincode', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5c974f6fee..36f9297fb1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2315,38 +2315,6 @@ ] } }, - "/auth/has-pincode": { - "get": { - "operationId": "hasPincode", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Authentication" - ] - } - }, "/auth/login": { "post": { "operationId": "login", @@ -2410,6 +2378,80 @@ ] } }, + "/auth/reset-pincode": { + "post": { + "operationId": "resetPincode", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPincodeDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAdminResponseDto" + } + } + }, + "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", @@ -9147,6 +9189,17 @@ ], "type": "string" }, + "AuthStatusResponseDto": { + "properties": { + "hasPincode": { + "type": "boolean" + } + }, + "required": [ + "hasPincode" + ], + "type": "object" + }, "AvatarUpdate": { "properties": { "color": { @@ -11334,6 +11387,17 @@ ], "type": "string" }, + "ResetPincodeDto": { + "properties": { + "userId": { + "type": "string" + } + }, + "required": [ + "userId" + ], + "type": "object" + }, "ReverseGeocodingStateResponseDto": { "properties": { "lastImportFileName": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 2c4457bb13..25393ccab6 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -517,6 +517,9 @@ export type LogoutResponseDto = { redirectUri: string; successful: boolean; }; +export type AuthStatusResponseDto = { + hasPincode: boolean; +}; export type ValidateAccessTokenResponseDto = { authStatus: boolean; }; @@ -2027,14 +2030,6 @@ export function createPincode({ createPincodeDto }: { body: createPincodeDto }))); } -export function hasPincode(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: boolean; - }>("/auth/has-pincode", { - ...opts - })); -} export function login({ loginCredentialDto }: { loginCredentialDto: LoginCredentialDto; }, opts?: Oazapfts.RequestOpts) { @@ -2056,6 +2051,14 @@ export function logout(opts?: Oazapfts.RequestOpts) { method: "POST" })); } +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 16c3b9bfc2..6e6dcaac29 100644 --- a/server/src/controllers/auth.controller.spec.ts +++ b/server/src/controllers/auth.controller.spec.ts @@ -143,13 +143,6 @@ describe(AuthController.name, () => { }); }); - describe('POST /auth/change-pincode', () => { - it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).post('/auth/change-pincode').send({ pincode: '123456', newPincode: '654321' }); - expect(ctx.authenticate).toHaveBeenCalled(); - }); - }); - describe('POST /auth/create-pincode', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).post('/auth/create-pincode').send({ pincode: '123456' }); @@ -157,9 +150,30 @@ describe(AuthController.name, () => { }); }); - describe('GET /auth/has-pincode', () => { + describe('POST /auth/change-pincode', () => { it('should be an authenticated route', async () => { - await request(ctx.getHttpServer()).get('/auth/has-pincode'); + await request(ctx.getHttpServer()).post('/auth/change-pincode').send({ pincode: '123456', newPincode: '654321' }); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('POST /auth/reset-pincode', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/auth/reset-pincode').send({ userId: '123456' }); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a userId', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/auth/reset-pincode').send({ name: 'admin' }); + + expect(status).toEqual(400); + expect(body).toEqual(errorDto.badRequest(['userId should not be empty', 'userId must be a string'])); + }); + }); + + 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 f9608c3541..13eef15175 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -3,12 +3,14 @@ import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { AuthDto, + AuthStatusResponseDto, ChangePasswordDto, ChangePincodeDto, createPincodeDto, LoginCredentialDto, LoginResponseDto, LogoutResponseDto, + ResetPincodeDto, SignUpDto, ValidateAccessTokenResponseDto, } from 'src/dtos/auth.dto'; @@ -91,9 +93,16 @@ export class AuthController { return this.service.changePincode(auth, dto); } - @Get('has-pincode') + @Post('reset-pincode') + @HttpCode(HttpStatus.OK) + @Authenticated({ admin: true }) + resetPincode(@Auth() auth: AuthDto, @Body() dto: ResetPincodeDto): Promise<UserAdminResponseDto> { + return this.service.resetPincode(auth, dto); + } + + @Get('status') @Authenticated() - hasPincode(@Auth() auth: AuthDto): Promise<boolean> { - return this.service.hasPincode(auth); + getAuthStatus(@Auth() auth: AuthDto): Promise<AuthStatusResponseDto> { + return this.service.getAuthStatus(auth); } } diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 1c305b72f8..47897d6fbc 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -100,6 +100,12 @@ export class ChangePincodeDto { newPincode!: string; } +export class ResetPincodeDto { + @IsString() + @IsNotEmpty() + userId!: string; +} + export class ValidateAccessTokenResponseDto { authStatus!: boolean; } @@ -136,3 +142,7 @@ export class OAuthConfigDto { export class OAuthAuthorizeResponseDto { url!: string; } + +export class AuthStatusResponseDto { + hasPincode!: boolean; +} diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 3bd3981c4e..fb4499e0fc 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -926,5 +926,27 @@ describe(AuthService.name, () => { await expect(sut.changePincode(auth, dto)).rejects.toBeInstanceOf(BadRequestException); }); + + it('should reset the pincode', async () => { + const user = factory.userAdmin(); + const auth = factory.auth({ user: { isAdmin: true } }); + const dto = { userId: '123' }; + + mocks.user.update.mockResolvedValue(user); + + await sut.resetPincode(auth, dto); + + expect(mocks.user.update).toHaveBeenCalledWith(dto.userId, { pincode: null }); + }); + + it('should throw if reset pincode by a non-admin user', async () => { + const user = factory.userAdmin(); + const auth = factory.auth({ user: { isAdmin: false } }); + const dto = { userId: '123' }; + + mocks.user.update.mockResolvedValue(user); + + await expect(sut.resetPincode(auth, dto)).rejects.toBeInstanceOf(ForbiddenException); + }); }); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index a137715bce..848a813e29 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -9,12 +9,14 @@ import { StorageCore } from 'src/cores/storage.core'; import { UserAdmin } from 'src/database'; import { AuthDto, + AuthStatusResponseDto, ChangePasswordDto, ChangePincodeDto, LoginCredentialDto, LogoutResponseDto, OAuthCallbackDto, OAuthConfigDto, + ResetPincodeDto, SignUpDto, createPincodeDto, mapLoginResponse, @@ -136,21 +138,11 @@ export class AuthService extends BaseService { } const hasedPincode = await this.cryptoRepository.hashBcrypt(newPincode.toString(), SALT_ROUNDS); - const updatedUser = await this.userRepository.update(user.id, { pincode: hasedPincode }); return mapUserAdmin(updatedUser); } - async hasPincode(auth: AuthDto): Promise<boolean> { - const user = await this.userRepository.getByEmail(auth.user.email, false, true); - if (!user) { - throw new UnauthorizedException(); - } - - return !!user.pincode; - } - async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> { const adminUser = await this.userRepository.getAdmin(); if (adminUser) { @@ -483,4 +475,30 @@ export class AuthService extends BaseService { } return url; } + + async getAuthStatus(auth: AuthDto): Promise<AuthStatusResponseDto> { + const hasPincode = await this.hasPincode(auth); + + return { + hasPincode, + }; + } + + private async hasPincode(auth: AuthDto): Promise<boolean> { + const user = await this.userRepository.getByEmail(auth.user.email, false, true); + if (!user) { + throw new UnauthorizedException(); + } + + return !!user.pincode; + } + + async resetPincode(auth: AuthDto, dto: ResetPincodeDto): Promise<UserAdminResponseDto> { + if (!auth.user.isAdmin) { + throw new ForbiddenException('Only admin can reset pincode'); + } + + const updatedUser = await this.userRepository.update(dto.userId, { pincode: null }); + return mapUserAdmin(updatedUser); + } } diff --git a/web/src/app.html b/web/src/app.html index 18a873b525..832b3265ef 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -107,7 +107,7 @@ To use Immich, you must enable JavaScript or use a JavaScript compatible browser. </noscript> - <body class="bg-immich-bg dark:bg-immich-dark-bg"> + <body class="bg-light text-dark"> <div id="stencil"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 792 792"> <style type="text/css"> diff --git a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte index 00800ab489..1b1a91d163 100644 --- a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte +++ b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte @@ -1,11 +1,11 @@ <script lang="ts"> import Button from '$lib/components/elements/buttons/button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; - import { t } from 'svelte-i18n'; - import { mdiPartyPopper } from '@mdi/js'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { preferences } from '$lib/stores/user.store'; import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils'; + import { mdiPartyPopper } from '@mdi/js'; + import { t } from 'svelte-i18n'; interface Props { onDone: () => void; @@ -14,7 +14,7 @@ let { onDone }: Props = $props(); </script> -<div class="m-auto w-3/4 text-center flex flex-col place-content-center place-items-center dark:text-white my-6"> +<div class="m-auto w-3/4 text-center flex flex-col place-content-center place-items-center my-6"> <Icon path={mdiPartyPopper} class="text-immich-primary dark:text-immich-dark-primary" size="96" /> <p class="text-4xl mt-8 font-bold">{$t('purchase_activated_title')}</p> <p class="text-lg mt-6">{$t('purchase_activated_subtitle')}</p> diff --git a/web/src/lib/components/shared-components/purchasing/purchase-content.svelte b/web/src/lib/components/shared-components/purchasing/purchase-content.svelte index 567fce9281..b46bdcb5e3 100644 --- a/web/src/lib/components/shared-components/purchasing/purchase-content.svelte +++ b/web/src/lib/components/shared-components/purchasing/purchase-content.svelte @@ -1,12 +1,12 @@ <script lang="ts"> - import { handleError } from '$lib/utils/handle-error'; - import ServerPurchaseOptionCard from './server-purchase-option-card.svelte'; - import UserPurchaseOptionCard from './individual-purchase-option-card.svelte'; - import { activateProduct, getActivationKey } from '$lib/utils/license-utils'; import Button from '$lib/components/elements/buttons/button.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { purchaseStore } from '$lib/stores/purchase.store'; + import { handleError } from '$lib/utils/handle-error'; + import { activateProduct, getActivationKey } from '$lib/utils/license-utils'; import { t } from 'svelte-i18n'; + import UserPurchaseOptionCard from './individual-purchase-option-card.svelte'; + import ServerPurchaseOptionCard from './server-purchase-option-card.svelte'; interface Props { onActivate: () => void; @@ -39,13 +39,13 @@ <section class="p-4"> <div> {#if showTitle} - <h1 class="text-4xl font-bold text-immich-primary dark:text-immich-dark-primary tracking-wider"> + <h1 class="text-4xl font-bold tracking-wider"> {$t('purchase_option_title')} </h1> {/if} {#if showMessage} - <div class="mt-2 dark:text-immich-gray"> + <div class="mt-2"> <p> {$t('purchase_panel_info_1')} </p> diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte index d94a0c169e..fe48a68009 100644 --- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte +++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte @@ -4,9 +4,10 @@ import Icon from '$lib/components/elements/icon.svelte'; import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte'; import Portal from '$lib/components/shared-components/portal/portal.svelte'; - import LicenseModal from '$lib/components/shared-components/purchasing/purchase-modal.svelte'; import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte'; import { AppRoute } from '$lib/constants'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; + import PurchaseModal from '$lib/modals/PurchaseModal.svelte'; import { purchaseStore } from '$lib/stores/purchase.store'; import { preferences } from '$lib/stores/user.store'; import { getAccountAge } from '$lib/utils/auth'; @@ -19,7 +20,6 @@ import { fade } from 'svelte/transition'; let showMessage = $state(false); - let isOpen = $state(false); let hoverMessage = $state(false); let hoverButton = $state(false); @@ -27,8 +27,8 @@ const { isPurchased } = purchaseStore; - const openPurchaseModal = () => { - isOpen = true; + const openPurchaseModal = async () => { + await modalManager.open(PurchaseModal); showMessage = false; }; @@ -74,10 +74,6 @@ }); </script> -{#if isOpen} - <LicenseModal onClose={() => (isOpen = false)} /> -{/if} - <div class="license-status ps-4 text-sm"> {#if $isPurchased && $preferences.purchase.showSupportBadge} <button diff --git a/web/src/lib/components/shared-components/side-bar/server-status.svelte b/web/src/lib/components/shared-components/side-bar/server-status.svelte index 49006dfe5a..665decc44f 100644 --- a/web/src/lib/components/shared-components/side-bar/server-status.svelte +++ b/web/src/lib/components/shared-components/side-bar/server-status.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import Icon from '$lib/components/elements/icon.svelte'; - import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; + import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte'; import { userInteraction } from '$lib/stores/user.svelte'; import { websocketStore } from '$lib/stores/websocket'; import { requestServerInfo } from '$lib/utils/auth'; @@ -16,7 +17,6 @@ const { serverVersion, connected } = websocketStore; - let isOpen = $state(false); let info: ServerAboutResponseDto | undefined = $state(); let versions: ServerVersionHistoryResponseDto[] = $state([]); @@ -37,10 +37,6 @@ ); </script> -{#if isOpen && info} - <ServerAboutModal onClose={() => (isOpen = false)} {info} {versions} /> -{/if} - <div class="text-sm flex md:flex ps-5 pe-1 place-items-center place-content-center justify-between min-w-52 overflow-hidden dark:text-immich-dark-fg" > @@ -58,7 +54,11 @@ <div class="flex justify-between justify-items-center"> {#if $connected && version} - <button type="button" onclick={() => (isOpen = true)} class="dark:text-immich-gray flex gap-1"> + <button + type="button" + onclick={() => info && modalManager.open(ServerAboutModal, { versions, info })} + class="dark:text-immich-gray flex gap-1" + > {#if isMain} <Icon path={mdiAlert} size="1.5em" color="#ffcc4d" /> {info?.sourceRef} {:else} 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..70deef0044 --- /dev/null +++ b/web/src/lib/components/user-settings-page/PincodeInput.svelte @@ -0,0 +1,111 @@ +<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([]); + + export function reset() { + pinValues = Array.from({ length: pinLength }).fill(''); + value = ''; + } + + const focusNext = (index: number) => { + if (index < pinLength - 1) { + pincodeInputElements[index + 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 (!/^\d*$/.test(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); + + if (event.key === 'Tab') { + return; + } + + if (event.key === 'Backspace') { + if (target.value === '' && index > 0) { + focusPrev(index); + pinValues[index - 1] = ''; + } else if (target.value !== '') { + pinValues[index] = ''; + } + + return; + } + + if (event.key === 'ArrowLeft' && index > 0) { + focusPrev(index); + return; + } + if (event.key === 'ArrowRight' && index < pinLength - 1) { + focusNext(index); + return; + } + + if (!/^\d$/.test(event.key) && event.key !== 'Backspace') { + event.preventDefault(); + return; + } + } +</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="pincode-{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> 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..b3fd9cf950 --- /dev/null +++ b/web/src/lib/components/user-settings-page/PincodeSettings.svelte @@ -0,0 +1,137 @@ +<script lang="ts"> + import { + notificationController, + NotificationType, + } from '$lib/components/shared-components/notification/notification'; + import PincodeInput from '$lib/components/user-settings-page/PincodeInput.svelte'; + import { changePincode, createPincode, getAuthStatus } from '@immich/sdk'; + import { Button } from '@immich/ui'; + import type { HttpError } from '@sveltejs/kit'; + import { onMount } from 'svelte'; + import { t } from 'svelte-i18n'; + import { fade } from 'svelte/transition'; + + let pincodeFormElement = $state<HTMLFormElement | null>(null); + let hasPincode = $state(false); + let currentPincode = $state(''); + let newPincode = $state(''); + let confirmPincode = $state(''); + let isLoading = $state(false); + + const onSubmit = async (event: Event) => { + event.preventDefault(); + + if (hasPincode) { + await handleChangePincode(); + return; + } + + await handleCreatePincode(); + }; + + onMount(async () => { + const authStatus = await getAuthStatus(); + hasPincode = authStatus.hasPincode; + }); + + const canSubmit = $derived( + (hasPincode ? currentPincode.length === 6 : true) && + newPincode.length === 6 && + confirmPincode.length === 6 && + newPincode === confirmPincode, + ); + + const handleCreatePincode = async () => { + isLoading = true; + try { + await createPincode({ + createPincodeDto: { + pincode: newPincode, + }, + }); + + resetForm(); + + notificationController.show({ + message: $t('pincode_created_successfully'), + type: NotificationType.Info, + }); + } catch (error) { + console.error('Error [createPincode]', error); + notificationController.show({ + message: (error as HttpError)?.body?.message || $t('unable_to_create_pincode'), + type: NotificationType.Error, + }); + } finally { + isLoading = false; + hasPincode = true; + } + }; + + const handleChangePincode = async () => { + isLoading = true; + try { + await changePincode({ + changePincodeDto: { + pincode: currentPincode, + newPincode, + }, + }); + + resetForm(); + + notificationController.show({ + message: $t('pincode_changed_successfully'), + type: NotificationType.Info, + }); + } catch (error) { + console.error('Error [changePincode]', error); + notificationController.show({ + message: (error as HttpError)?.body?.message || $t('unable_to_change_pincode'), + type: NotificationType.Error, + }); + } finally { + isLoading = false; + } + }; + + const resetForm = () => { + currentPincode = ''; + newPincode = ''; + confirmPincode = ''; + pincodeFormElement?.reset(); + }; +</script> + +<section class="my-4"> + <div in:fade={{ duration: 200 }}> + <form bind:this={pincodeFormElement} autocomplete="off" onsubmit={onSubmit} 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_pincode')} bind:value={currentPincode} tabindexStart={1} pinLength={6} /> + + <PincodeInput label={$t('new_pincode')} bind:value={newPincode} tabindexStart={7} pinLength={6} /> + + <PincodeInput + label={$t('confirm_new_pincode')} + bind:value={confirmPincode} + tabindexStart={13} + pinLength={6} + /> + {:else} + <p class="text-dark">Create new PIN code</p> + <PincodeInput label={$t('new_pincode')} bind:value={newPincode} tabindexStart={1} pinLength={6} /> + + <PincodeInput label={$t('confirm_new_pincode')} bind:value={confirmPincode} tabindexStart={7} pinLength={6} /> + {/if} + </div> + + <div class="flex justify-end"> + <Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}> + {hasPincode ? $t('save') : $t('create_pincode')} + </Button> + </div> + </form> + </div> +</section> 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..21f5c4404c 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 @@ <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-pincode-settings" + title={$t('user_pincode_settings')} + subtitle={$t('user_pincode_settings_description')} + autoScrollTo={true} + > + <ChangePincodeSettings /> + </SettingAccordion> + <SettingAccordion icon={mdiKeyOutline} key="user-purchase-settings" diff --git a/web/src/lib/managers/modal-manager.svelte.ts b/web/src/lib/managers/modal-manager.svelte.ts index 73a3351a7e..055df14502 100644 --- a/web/src/lib/managers/modal-manager.svelte.ts +++ b/web/src/lib/managers/modal-manager.svelte.ts @@ -1,15 +1,15 @@ import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import { mount, unmount, type Component, type ComponentProps } from 'svelte'; -type OnCloseData<T> = T extends { onClose: (data: infer R) => void } ? R : never; -type OptionalIfEmpty<T extends object> = keyof T extends never ? undefined : T; +type OnCloseData<T> = T extends { onClose: (data: infer R) => void | Promise<void> } ? R : never; class ModalManager { - open<T extends object, K = OnCloseData<T>>( - Component: Component<T>, - props?: OptionalIfEmpty<Omit<T, 'onClose'>> | Record<string, never>, + open<T = { onClose: (data: unknown) => void }, K = OnCloseData<T>>( + Component: Component<{ onClose: T }>, + props?: Record<string, never>, ): Promise<K>; - open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: OptionalIfEmpty<Omit<T, 'onClose'>>) { + open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: Omit<T, 'onClose'>): Promise<K>; + open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props?: Omit<T, 'onClose'>) { return new Promise<K>((resolve) => { let modal: object = {}; diff --git a/web/src/lib/components/shared-components/purchasing/purchase-modal.svelte b/web/src/lib/modals/PurchaseModal.svelte similarity index 69% rename from web/src/lib/components/shared-components/purchasing/purchase-modal.svelte rename to web/src/lib/modals/PurchaseModal.svelte index eaf6d14674..f771529ad2 100644 --- a/web/src/lib/components/shared-components/purchasing/purchase-modal.svelte +++ b/web/src/lib/modals/PurchaseModal.svelte @@ -1,9 +1,8 @@ <script lang="ts"> - import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import PurchaseActivationSuccess from '$lib/components/shared-components/purchasing/purchase-activation-success.svelte'; import PurchaseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte'; - import Portal from '$lib/components/shared-components/portal/portal.svelte'; + import { Modal, ModalBody } from '@immich/ui'; interface Props { onClose: () => void; @@ -14,8 +13,8 @@ let showProductActivated = $state(false); </script> -<Portal> - <FullScreenModal showLogo title="" {onClose} width="wide"> +<Modal title="" {onClose} size="large"> + <ModalBody> {#if showProductActivated} <PurchaseActivationSuccess onDone={onClose} /> {:else} @@ -26,5 +25,5 @@ showMessage={false} /> {/if} - </FullScreenModal> -</Portal> + </ModalBody> +</Modal> diff --git a/web/src/lib/components/shared-components/server-about-modal.svelte b/web/src/lib/modals/ServerAboutModal.svelte similarity index 96% rename from web/src/lib/components/shared-components/server-about-modal.svelte rename to web/src/lib/modals/ServerAboutModal.svelte index 1284bb126d..f8f70387e6 100644 --- a/web/src/lib/components/shared-components/server-about-modal.svelte +++ b/web/src/lib/modals/ServerAboutModal.svelte @@ -1,12 +1,11 @@ <script lang="ts"> - import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; - import Portal from '$lib/components/shared-components/portal/portal.svelte'; - import { type ServerAboutResponseDto, type ServerVersionHistoryResponseDto } from '@immich/sdk'; - import { DateTime } from 'luxon'; - import { t } from 'svelte-i18n'; - import { mdiAlert } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; import { locale } from '$lib/stores/preferences.store'; + import { type ServerAboutResponseDto, type ServerVersionHistoryResponseDto } from '@immich/sdk'; + import { Modal, ModalBody } from '@immich/ui'; + import { mdiAlert } from '@mdi/js'; + import { DateTime } from 'luxon'; + import { t } from 'svelte-i18n'; interface Props { onClose: () => void; @@ -17,8 +16,8 @@ let { onClose, info, versions }: Props = $props(); </script> -<Portal> - <FullScreenModal title={$t('about')} {onClose}> +<Modal title={$t('about')} {onClose}> + <ModalBody> <div class="flex flex-col sm:grid sm:grid-cols-2 gap-1 text-immich-primary dark:text-immich-dark-primary"> <div> <label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc" @@ -199,5 +198,5 @@ </ul> </div> </div> - </FullScreenModal> -</Portal> + </ModalBody> +</Modal>