From 41a127e2ab8d5c3bd130eeb8e342a8668582fe0b Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 12 May 2025 16:56:36 +0200 Subject: [PATCH 01/48] refactor: avatar selector modal (#18228) --- .../navigation-bar/account-info-panel.svelte | 32 ++----------- .../navigation-bar/avatar-selector.svelte | 28 ----------- web/src/lib/modals/AvatarEditModal.svelte | 47 +++++++++++++++++++ 3 files changed, 50 insertions(+), 57 deletions(-) delete mode 100644 web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte create mode 100644 web/src/lib/modals/AvatarEditModal.svelte diff --git a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte index 5b778cf227..c16003ca66 100644 --- a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte @@ -5,15 +5,13 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; import { AppRoute } from '$lib/constants'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; + import AvatarEditModal from '$lib/modals/AvatarEditModal.svelte'; import { user } from '$lib/stores/user.store'; - import { handleError } from '$lib/utils/handle-error'; - import { deleteProfileImage, updateMyUser, type UserAvatarColor } from '@immich/sdk'; import { mdiCog, mdiLogout, mdiPencil, mdiWrench } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - import { NotificationType, notificationController } from '../notification/notification'; import UserAvatar from '../user-avatar.svelte'; - import AvatarSelector from './avatar-selector.svelte'; interface Props { onLogout: () => void; @@ -21,26 +19,6 @@ } let { onLogout, onClose = () => {} }: Props = $props(); - - let isShowSelectAvatar = $state(false); - - const handleSaveProfile = async (color: UserAvatarColor) => { - try { - if ($user.profileImagePath !== '') { - await deleteProfileImage(); - } - - $user = await updateMyUser({ userUpdateMeDto: { avatarColor: color } }); - isShowSelectAvatar = false; - - notificationController.show({ - message: $t('saved_profile'), - type: NotificationType.Info, - }); - } catch (error) { - handleError(error, $t('errors.unable_to_save_profile')); - } - };
(isShowSelectAvatar = true)} + onclick={() => modalManager.show(AvatarEditModal, {})} />
@@ -111,7 +89,3 @@ > - -{#if isShowSelectAvatar} - (isShowSelectAvatar = false)} onChoose={handleSaveProfile} /> -{/if} diff --git a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte deleted file mode 100644 index ce441d553b..0000000000 --- a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - -
-
- {#each colors as color (color)} - - {/each} -
-
-
diff --git a/web/src/lib/modals/AvatarEditModal.svelte b/web/src/lib/modals/AvatarEditModal.svelte new file mode 100644 index 0000000000..2bfee599af --- /dev/null +++ b/web/src/lib/modals/AvatarEditModal.svelte @@ -0,0 +1,47 @@ + + + + +
+ {#each colors as color (color)} + + {/each} +
+
+
From eb8dfa283e061c8dc5f8c7e2d01df1a0420d42f0 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 12 May 2025 14:15:15 -0400 Subject: [PATCH 02/48] fix(web): no rounded map on /map page (#18232) --- .../(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5875309b05..b1fff3a0cd 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -72,7 +72,7 @@ {#if $featureFlags.loaded && $featureFlags.map}
- +
From 3066c8198cb23092bb0cb15af1b6702e7c2c8ab2 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 12 May 2025 16:50:26 -0400 Subject: [PATCH 03/48] feat(web): user detail page (#18230) feat: user detail page --- i18n/en.json | 7 + mobile/openapi/README.md | 1 + mobile/openapi/lib/api/users_admin_api.dart | 83 ++++- open-api/immich-openapi-specs.json | 75 ++++ open-api/typescript-sdk/src/fetch-client.ts | 41 ++- .../src/controllers/user-admin.controller.ts | 11 + server/src/dtos/user.dto.ts | 5 +- server/src/repositories/user.repository.ts | 4 +- server/src/services/user-admin.service.ts | 11 +- .../dialog/confirm-dialog.svelte | 2 +- .../navigation-bar/account-info-panel.svelte | 2 +- .../side-bar/admin-side-bar.svelte | 2 +- web/src/lib/constants.ts | 2 +- web/src/lib/modals/UserEditModal.svelte | 118 +----- web/src/routes/admin/+page.ts | 2 +- web/src/routes/admin/user-management/+page.ts | 19 +- .../{user-management => users}/+page.svelte | 23 +- web/src/routes/admin/users/+page.ts | 18 + web/src/routes/admin/users/[id]/+page.svelte | 343 ++++++++++++++++++ web/src/routes/admin/users/[id]/+page.ts | 31 ++ 20 files changed, 640 insertions(+), 160 deletions(-) rename web/src/routes/admin/{user-management => users}/+page.svelte (91%) create mode 100644 web/src/routes/admin/users/+page.ts create mode 100644 web/src/routes/admin/users/[id]/+page.svelte create mode 100644 web/src/routes/admin/users/[id]/+page.ts diff --git a/i18n/en.json b/i18n/en.json index 2db9976fa6..fde78a34a3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -362,6 +362,7 @@ "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.", "user_delete_immediately": "{user}'s account and assets will be queued for permanent deletion immediately.", "user_delete_immediately_checkbox": "Queue user and assets for immediate deletion", + "user_details": "User Details", "user_management": "User Management", "user_password_has_been_reset": "The user's password has been reset:", "user_password_reset_description": "Please provide the temporary password to the user and inform them they will need to change the password at their next login.", @@ -1290,6 +1291,7 @@ "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", "notification_toggle_setting_description": "Enable email notifications", + "email_notifications": "Email notifications", "notifications": "Notifications", "notifications_setting_description": "Manage notifications", "oauth": "OAuth", @@ -1394,6 +1396,7 @@ "previous_or_next_photo": "Previous or next photo", "primary": "Primary", "privacy": "Privacy", + "profile": "Profile", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", @@ -1753,6 +1756,7 @@ "storage": "Storage space", "storage_label": "Storage label", "storage_usage": "{used} of {available} used", + "storage_quota": "Storage Quota", "submit": "Submit", "suggestions": "Suggestions", "sunrise_on_the_beach": "Sunrise on the beach", @@ -1857,6 +1861,7 @@ "upload_success": "Upload success, refresh the page to see new upload assets.", "upload_to_immich": "Upload to Immich ({count})", "uploading": "Uploading", + "id": "ID", "url": "URL", "usage": "Usage", "use_current_connection": "use current connection", @@ -1864,6 +1869,8 @@ "user": "User", "user_id": "User ID", "user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", + "created_at": "Created", + "updated_at": "Updated", "user_purchase_settings": "Purchase", "user_purchase_settings_description": "Manage your purchase", "user_role_set": "Set {user} as {role}", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index a141d465d1..9a3055911d 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -253,6 +253,7 @@ Class | Method | HTTP request | Description *UsersAdminApi* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} | *UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /admin/users/{id} | *UsersAdminApi* | [**getUserPreferencesAdmin**](doc//UsersAdminApi.md#getuserpreferencesadmin) | **GET** /admin/users/{id}/preferences | +*UsersAdminApi* | [**getUserStatisticsAdmin**](doc//UsersAdminApi.md#getuserstatisticsadmin) | **GET** /admin/users/{id}/statistics | *UsersAdminApi* | [**restoreUserAdmin**](doc//UsersAdminApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore | *UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users | *UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} | diff --git a/mobile/openapi/lib/api/users_admin_api.dart b/mobile/openapi/lib/api/users_admin_api.dart index b4508d7dcd..58263504ce 100644 --- a/mobile/openapi/lib/api/users_admin_api.dart +++ b/mobile/openapi/lib/api/users_admin_api.dart @@ -211,6 +211,76 @@ class UsersAdminApi { return null; } + /// Performs an HTTP 'GET /admin/users/{id}/statistics' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [bool] isFavorite: + /// + /// * [bool] isTrashed: + /// + /// * [AssetVisibility] visibility: + Future getUserStatisticsAdminWithHttpInfo(String id, { bool? isFavorite, bool? isTrashed, AssetVisibility? visibility, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/users/{id}/statistics' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (isFavorite != null) { + queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); + } + if (isTrashed != null) { + queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); + } + if (visibility != null) { + queryParams.addAll(_queryParams('', 'visibility', visibility)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [bool] isFavorite: + /// + /// * [bool] isTrashed: + /// + /// * [AssetVisibility] visibility: + Future getUserStatisticsAdmin(String id, { bool? isFavorite, bool? isTrashed, AssetVisibility? visibility, }) async { + final response = await getUserStatisticsAdminWithHttpInfo(id, isFavorite: isFavorite, isTrashed: isTrashed, visibility: visibility, ); + 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), 'AssetStatsResponseDto',) as AssetStatsResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /admin/users/{id}/restore' operation and returns the [Response]. /// Parameters: /// @@ -262,8 +332,10 @@ class UsersAdminApi { /// Performs an HTTP 'GET /admin/users' operation and returns the [Response]. /// Parameters: /// + /// * [String] id: + /// /// * [bool] withDeleted: - Future searchUsersAdminWithHttpInfo({ bool? withDeleted, }) async { + Future searchUsersAdminWithHttpInfo({ String? id, bool? withDeleted, }) async { // ignore: prefer_const_declarations final apiPath = r'/admin/users'; @@ -274,6 +346,9 @@ class UsersAdminApi { final headerParams = {}; final formParams = {}; + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } if (withDeleted != null) { queryParams.addAll(_queryParams('', 'withDeleted', withDeleted)); } @@ -294,9 +369,11 @@ class UsersAdminApi { /// Parameters: /// + /// * [String] id: + /// /// * [bool] withDeleted: - Future?> searchUsersAdmin({ bool? withDeleted, }) async { - final response = await searchUsersAdminWithHttpInfo( withDeleted: withDeleted, ); + Future?> searchUsersAdmin({ String? id, bool? withDeleted, }) async { + final response = await searchUsersAdminWithHttpInfo( id: id, withDeleted: withDeleted, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index a98750edaa..3c0dc09953 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -345,6 +345,15 @@ "get": { "operationId": "searchUsersAdmin", "parameters": [ + { + "name": "id", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "withDeleted", "required": false, @@ -701,6 +710,72 @@ ] } }, + "/admin/users/{id}/statistics": { + "get": { + "operationId": "getUserStatisticsAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "visibility", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetVisibility" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetStatsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Users (admin)" + ] + } + }, "/albums": { "get": { "operationId": "getAllAlbums", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 41898e12da..144e7f8ac1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -224,6 +224,11 @@ export type UserPreferencesUpdateDto = { sharedLinks?: SharedLinksUpdate; tags?: TagsUpdate; }; +export type AssetStatsResponseDto = { + images: number; + total: number; + videos: number; +}; export type AlbumUserResponseDto = { role: AlbumUserRole; user: UserResponseDto; @@ -462,11 +467,6 @@ export type AssetJobsDto = { assetIds: string[]; name: AssetJobName; }; -export type AssetStatsResponseDto = { - images: number; - total: number; - videos: number; -}; export type UpdateAssetDto = { dateTimeOriginal?: string; description?: string; @@ -1502,13 +1502,15 @@ export function sendTestEmailAdmin({ systemConfigSmtpDto }: { body: systemConfigSmtpDto }))); } -export function searchUsersAdmin({ withDeleted }: { +export function searchUsersAdmin({ id, withDeleted }: { + id?: string; withDeleted?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: UserAdminResponseDto[]; }>(`/admin/users${QS.query(QS.explode({ + id, withDeleted }))}`, { ...opts @@ -1596,6 +1598,23 @@ export function restoreUserAdmin({ id }: { method: "POST" })); } +export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }: { + id: string; + isFavorite?: boolean; + isTrashed?: boolean; + visibility?: AssetVisibility; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetStatsResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}/statistics${QS.query(QS.explode({ + isFavorite, + isTrashed, + visibility + }))}`, { + ...opts + })); +} export function getAllAlbums({ assetId, shared }: { assetId?: string; shared?: boolean; @@ -3552,6 +3571,11 @@ export enum UserStatus { Removing = "removing", Deleted = "deleted" } +export enum AssetVisibility { + Archive = "archive", + Timeline = "timeline", + Hidden = "hidden" +} export enum AlbumUserRole { Editor = "editor", Viewer = "viewer" @@ -3661,11 +3685,6 @@ export enum Permission { AdminUserUpdate = "admin.user.update", AdminUserDelete = "admin.user.delete" } -export enum AssetVisibility { - Archive = "archive", - Timeline = "timeline", - Hidden = "hidden" -} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index 4dfeae949a..83d7caef08 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { @@ -57,6 +58,16 @@ export class UserAdminController { return this.service.delete(auth, id, dto); } + @Get(':id/statistics') + @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) + getUserStatisticsAdmin( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Query() dto: AssetStatsDto, + ): Promise { + return this.service.getStatistics(auth, id, dto); + } + @Get(':id/preferences') @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 9efb531bc7..9d43e53f89 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, PinCode, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; +import { Optional, PinCode, ValidateBoolean, ValidateUUID, toEmail, toSanitized } from 'src/validation'; export class UserUpdateMeDto { @Optional() @@ -67,6 +67,9 @@ export const mapUser = (entity: User | UserAdmin): UserResponseDto => { export class UserAdminSearchDto { @ValidateBoolean({ optional: true }) withDeleted?: boolean; + + @ValidateUUID({ optional: true }) + id?: string; } export class UserAdminCreateDto { diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index f8710746aa..6972479df6 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -14,6 +14,7 @@ import { asUuid } from 'src/utils/database'; type Upsert = Insertable; export interface UserListFilter { + id?: string; withDeleted?: boolean; } @@ -141,12 +142,13 @@ export class UserRepository { { name: 'with deleted', params: [{ withDeleted: true }] }, { name: 'without deleted', params: [{ withDeleted: false }] }, ) - getList({ withDeleted }: UserListFilter = {}) { + getList({ id, withDeleted }: UserListFilter = {}) { return this.db .selectFrom('users') .select(columns.userAdmin) .select(withMetadata) .$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) + .$if(!!id, (eb) => eb.where('users.id', '=', id!)) .orderBy('createdAt', 'desc') .execute(); } diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 38c0106f4b..d1fe5ce67e 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { SALT_ROUNDS } from 'src/constants'; +import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { @@ -18,7 +19,10 @@ import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/uti @Injectable() export class UserAdminService extends BaseService { async search(auth: AuthDto, dto: UserAdminSearchDto): Promise { - const users = await this.userRepository.getList({ withDeleted: dto.withDeleted }); + const users = await this.userRepository.getList({ + id: dto.id, + withDeleted: dto.withDeleted, + }); return users.map((user) => mapUserAdmin(user)); } @@ -109,6 +113,11 @@ export class UserAdminService extends BaseService { return mapUserAdmin(user); } + async getStatistics(auth: AuthDto, id: string, dto: AssetStatsDto): Promise { + const stats = await this.assetRepository.getStatistics(auth.user.id, dto); + return mapStats(stats); + } + async getPreferences(auth: AuthDto, id: string): Promise { await this.findOrFail(id, { withDeleted: true }); const metadata = await this.userRepository.getMetadata(id); diff --git a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte index 3d6582d65f..75c07aebc6 100644 --- a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte +++ b/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte @@ -44,7 +44,7 @@ -
+
{#if !hideCancelButton} {#if $user.isAdmin} - {/if} - - -
- -
- -
+
+ +
diff --git a/web/src/routes/admin/+page.ts b/web/src/routes/admin/+page.ts index 0d53c4ef2b..bab3c1ea66 100644 --- a/web/src/routes/admin/+page.ts +++ b/web/src/routes/admin/+page.ts @@ -3,5 +3,5 @@ import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; export const load = (() => { - redirect(302, AppRoute.ADMIN_USER_MANAGEMENT); + redirect(302, AppRoute.ADMIN_USERS); }) satisfies PageLoad; diff --git a/web/src/routes/admin/user-management/+page.ts b/web/src/routes/admin/user-management/+page.ts index 0a6af40c69..6ff068a1fb 100644 --- a/web/src/routes/admin/user-management/+page.ts +++ b/web/src/routes/admin/user-management/+page.ts @@ -1,18 +1,5 @@ -import { authenticate, requestServerInfo } from '$lib/utils/auth'; -import { getFormatter } from '$lib/utils/i18n'; -import { searchUsersAdmin } from '@immich/sdk'; +import { AppRoute } from '$lib/constants'; +import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); - await requestServerInfo(); - const allUsers = await searchUsersAdmin({ withDeleted: true }); - const $t = await getFormatter(); - - return { - allUsers, - meta: { - title: $t('admin.user_management'), - }, - }; -}) satisfies PageLoad; +export const load = (() => redirect(307, AppRoute.ADMIN_USERS)) satisfies PageLoad; diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/users/+page.svelte similarity index 91% rename from web/src/routes/admin/user-management/+page.svelte rename to web/src/routes/admin/users/+page.svelte index 5b6246be8c..75b35491f6 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -6,7 +6,7 @@ NotificationType, notificationController, } from '$lib/components/shared-components/notification/notification'; - import PasswordResetSuccess from '$lib/forms/password-reset-success.svelte'; + import { AppRoute } from '$lib/constants'; import { modalManager } from '$lib/managers/modal-manager.svelte'; import UserCreateModal from '$lib/modals/UserCreateModal.svelte'; import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte'; @@ -18,7 +18,7 @@ import { websocketEvents } from '$lib/stores/websocket'; import { getByteUnitString } from '$lib/utils/byte-units'; import { UserStatus, searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk'; - import { Button, IconButton } from '@immich/ui'; + import { Button, IconButton, Link } from '@immich/ui'; import { mdiDeleteRestore, mdiInfinity, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; import { DateTime } from 'luxon'; import { onMount } from 'svelte'; @@ -64,20 +64,9 @@ }; const handleEdit = async (dto: UserAdminResponseDto) => { - const result = await modalManager.show(UserEditModal, { user: dto, canResetPassword: dto.id !== $user.id }); - switch (result?.action) { - case 'resetPassword': { - await modalManager.show(PasswordResetSuccess, { newPassword: result.data }); - break; - } - case 'update': { - await refresh(); - break; - } - case 'resetPinCode': { - notificationController.show({ type: NotificationType.Info, message: $t('pin_code_reset_successfully') }); - break; - } + const result = await modalManager.show(UserEditModal, { user: dto }); + if (result) { + await refresh(); } }; @@ -123,7 +112,7 @@ : 'bg-immich-bg dark:bg-immich-dark-gray/50'}" > {immichUser.email}{immichUser.email} {immichUser.name} diff --git a/web/src/routes/admin/users/+page.ts b/web/src/routes/admin/users/+page.ts new file mode 100644 index 0000000000..0a6af40c69 --- /dev/null +++ b/web/src/routes/admin/users/+page.ts @@ -0,0 +1,18 @@ +import { authenticate, requestServerInfo } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { searchUsersAdmin } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async () => { + await authenticate({ admin: true }); + await requestServerInfo(); + const allUsers = await searchUsersAdmin({ withDeleted: true }); + const $t = await getFormatter(); + + return { + allUsers, + meta: { + title: $t('admin.user_management'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte new file mode 100644 index 0000000000..b0a9327fcc --- /dev/null +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -0,0 +1,343 @@ + + + + {#snippet buttons()} + + {#if canResetPassword} + + {/if} + + + + + + {/snippet} +
+ +
+
+ + {user.name} +
+
+
+ + + +
+
+
+ + +
+ + {$t('profile')} +
+
+ + +
+ {$t('name')} + {user.name} +
+
+ {$t('email')} + {user.email} +
+
+ {$t('created_at')} + {user.createdAt} +
+
+ {$t('updated_at')} + {user.updatedAt} +
+
+ {$t('id')} + {user.id} +
+
+
+
+
+ + +
+ + {$t('features')} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ + {$t('storage_quota')} +
+
+ +
+ {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} + + {$t('storage_usage', { + values: { + used: getByteUnitString(usedBytes, $locale, 3), + available: getByteUnitString(availableBytes, $locale, 3), + }, + })} + + {:else} + + + {$t('unlimited')} + + {/if} +
+ + {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} +
+

{$t('storage')}

+
+
+
+
+ {/if} +
+
+
+
+
+
diff --git a/web/src/routes/admin/users/[id]/+page.ts b/web/src/routes/admin/users/[id]/+page.ts new file mode 100644 index 0000000000..ddf3ddbef7 --- /dev/null +++ b/web/src/routes/admin/users/[id]/+page.ts @@ -0,0 +1,31 @@ +import { AppRoute } from '$lib/constants'; +import { authenticate, requestServerInfo } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getUserPreferencesAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params }) => { + await authenticate({ admin: true }); + await requestServerInfo(); + const [user] = await searchUsersAdmin({ id: params.id }).catch(() => []); + if (!user) { + redirect(302, AppRoute.ADMIN_USERS); + } + + const [userPreferences, userStatistics] = await Promise.all([ + getUserPreferencesAdmin({ id: user.id }), + getUserStatisticsAdmin({ id: user.id }), + ]); + + const $t = await getFormatter(); + + return { + user, + userPreferences, + userStatistics, + meta: { + title: $t('admin.user_details'), + }, + }; +}) satisfies PageLoad; From 7544a678ec2a68df6163a9c5213c8e749b27a14a Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 12 May 2025 23:17:01 +0200 Subject: [PATCH 04/48] refactor: remove unnecessary bg-color attributes and move to ui lib vars (#18234) --- .../server-stats/server-stats-panel.svelte | 4 +--- .../components/album-page/album-title.svelte | 6 ++--- .../components/album-page/album-viewer.svelte | 24 +++++++++---------- .../album-page/albums-table-row.svelte | 2 +- .../asset-viewer/asset-viewer.svelte | 6 ++--- .../asset-viewer/download-panel.svelte | 2 +- .../faces-page/assign-face-side-panel.svelte | 20 ++++++++-------- .../manage-people-visibility.svelte | 4 ++-- .../faces-page/merge-face-selector.svelte | 8 +++---- .../faces-page/people-search.svelte | 2 +- .../faces-page/person-side-panel.svelte | 20 ++++++++-------- .../faces-page/unmerge-face-selector.svelte | 6 ++--- .../forms/library-import-paths-form.svelte | 8 ++----- .../forms/library-scan-settings-form.svelte | 8 ++----- .../lib/components/layouts/ErrorLayout.svelte | 6 ++--- .../layouts/user-page-layout.svelte | 2 +- .../onboarding-page/onboarding-theme.svelte | 4 ++-- .../photos-page/asset-date-group.svelte | 9 ++++--- .../components/photos-page/skeleton.svelte | 2 +- .../individual-shared-viewer.svelte | 2 +- .../album-selection-modal.svelte | 16 ++++++------- .../shared-components/control-app-bar.svelte | 4 ++-- .../navigation-bar/navigation-bar.svelte | 2 +- .../scrubber/scrubber.svelte | 6 ++--- .../side-bar/side-bar-section.svelte | 2 +- .../upload-asset-preview.svelte | 6 +---- .../user-api-key-list.svelte | 6 ++--- .../user-usage-statistic.svelte | 8 +++---- .../[[assetId=id]]/+page.svelte | 8 +++---- .../[[assetId=id]]/+page.svelte | 2 +- web/src/routes/(user)/people/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 5 +--- .../[[assetId=id]]/+page.svelte | 18 +++++++------- .../admin/library-management/+page.svelte | 4 +--- web/src/routes/admin/users/+page.svelte | 6 ++--- 36 files changed, 106 insertions(+), 136 deletions(-) diff --git a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte index 3dcd3b4594..2f8d391954 100644 --- a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte +++ b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte @@ -103,9 +103,7 @@ class="block max-h-[320px] w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg" > {#each stats.usageByUser as user (user.userId)} - + {user.userName} {user.photos.toLocaleString($locale)} ({getByteUnitString(user.usagePhotos, $locale, 0)}) - import { updateAlbumInfo } from '@immich/sdk'; - import { handleError } from '$lib/utils/handle-error'; import { shortcut } from '$lib/actions/shortcut'; + import { handleError } from '$lib/utils/handle-error'; + import { updateAlbumInfo } from '@immich/sdk'; import { t } from 'svelte-i18n'; interface Props { @@ -40,7 +40,7 @@ onblur={handleUpdateName} class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned ? 'hover:border-gray-400' - : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" + : 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" type="text" bind:value={newAlbumName} disabled={!isOwned} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 1f15e22d9e..f3688e780c 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -1,11 +1,18 @@ goto(`${AppRoute.ALBUMS}/${album.id}`)} {oncontextmenu} > diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 5606da31a9..1e3c6135f0 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -529,7 +529,7 @@
($isShowDetail = false)} /> @@ -540,7 +540,7 @@
@@ -589,7 +589,7 @@

{$t('downloading').toUpperCase()}

diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index 19a99fdb94..c1906b70e0 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -1,20 +1,20 @@
-
+
@@ -33,7 +33,7 @@
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index d15b371049..747dda91a6 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -51,7 +51,7 @@
diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index 6cf5530ece..03f5e64963 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -1,5 +1,4 @@
@@ -631,13 +639,13 @@ {/if} - {#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor) as { user } (user.id)} - {/each} @@ -649,7 +657,7 @@ color="gray" size="20" icon={mdiDotsVertical} - onclick={() => (viewMode = AlbumPageViewMode.VIEW_USERS)} + onclick={handleEditUsers} /> {/if} @@ -722,15 +730,6 @@ {/if}
-{#if viewMode === AlbumPageViewMode.VIEW_USERS} - (viewMode = AlbumPageViewMode.VIEW)} - {album} - onRemove={(userId) => handleRemoveUser(userId, AlbumPageViewMode.VIEW_USERS)} - onRefreshAlbum={refreshAlbum} - /> -{/if} - {#if viewMode === AlbumPageViewMode.OPTIONS && $user} Date: Tue, 13 May 2025 19:29:55 +0200 Subject: [PATCH 21/48] chore: use full action versions in comment (#18260) * Update pr-label-validation.yml * Update pr-labeler.yml * Update prepare-release.yml * Update preview-label.yaml * Update sdk.yml * Update static_analysis.yml * Update test.yml * Update weblate-lock.yml * Update build-mobile.yml * Update cache-cleanup.yml * Update cli.yml * Update codeql-analysis.yml * Update docker.yml * Update docs-build.yml * Update docs-deploy.yml * Update docs-destroy.yml * Update fix-format.yml --- .github/workflows/build-mobile.yml | 12 ++-- .github/workflows/cache-cleanup.yml | 2 +- .github/workflows/cli.yml | 10 ++-- .github/workflows/codeql-analysis.yml | 8 +-- .github/workflows/docker.yml | 8 +-- .github/workflows/docs-build.yml | 10 ++-- .github/workflows/docs-deploy.yml | 21 +++---- .github/workflows/docs-destroy.yml | 6 +- .github/workflows/fix-format.yml | 10 ++-- .github/workflows/pr-label-validation.yml | 2 +- .github/workflows/pr-labeler.yml | 2 +- .github/workflows/prepare-release.yml | 16 +++--- .github/workflows/preview-label.yaml | 4 +- .github/workflows/sdk.yml | 4 +- .github/workflows/static_analysis.yml | 16 +++--- .github/workflows/test.yml | 70 +++++++++++------------ .github/workflows/weblate-lock.yml | 6 +- 17 files changed, 104 insertions(+), 103 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 7217b5267e..0fcc4f1d7c 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -35,12 +35,12 @@ jobs: should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }} steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - id: found_paths - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: filters: | mobile: @@ -61,19 +61,19 @@ jobs: runs-on: macos-14 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false - - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: distribution: 'zulu' java-version: '17' cache: 'gradle' - name: Setup Flutter SDK - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2 + uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2.19.0 with: channel: 'stable' flutter-version-file: ./mobile/pubspec.yaml @@ -104,7 +104,7 @@ jobs: flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64 - name: Publish Android Artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-apk-signed path: mobile/build/app/outputs/flutter-apk/*.apk diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml index 84adde08cf..68ab8af24e 100644 --- a/.github/workflows/cache-cleanup.yml +++ b/.github/workflows/cache-cleanup.yml @@ -19,7 +19,7 @@ jobs: actions: write steps: - name: Check out code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index c4c522fe3f..4e0bf12fdc 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -29,12 +29,12 @@ jobs: working-directory: ./cli steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false # Setup .npmrc file to publish to npm - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './cli/.nvmrc' registry-url: 'https://registry.npmjs.org' @@ -59,7 +59,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false @@ -70,7 +70,7 @@ jobs: uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Login to GitHub Container Registry - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 if: ${{ !github.event.pull_request.head.repo.fork }} with: registry: ghcr.io @@ -85,7 +85,7 @@ jobs: - name: Generate docker image tags id: metadata - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5 + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 with: flavor: | latest=false diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 415fdb880a..57c84ff5de 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -44,13 +44,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 + uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -63,7 +63,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 + uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -76,6 +76,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 + uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 056aa7e6f4..6912b02e55 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -24,11 +24,11 @@ jobs: should_run_ml: ${{ steps.found_paths.outputs.machine-learning == 'true' || steps.should_force.outputs.should_force == 'true' }} steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - id: found_paths - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: filters: | server: @@ -60,7 +60,7 @@ jobs: suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn'] steps: - name: Login to GitHub Container Registry - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -89,7 +89,7 @@ jobs: suffix: [''] steps: - name: Login to GitHub Container Registry - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index aaa1780657..32010728cf 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -21,11 +21,11 @@ jobs: should_run: ${{ steps.found_paths.outputs.docs == 'true' || steps.should_force.outputs.should_force == 'true' }} steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - id: found_paths - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: filters: | docs: @@ -49,12 +49,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './docs/.nvmrc' @@ -68,7 +68,7 @@ jobs: run: npm run build - name: Upload build output - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: docs-build-output path: docs/build/ diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index fd12423fd9..73c5d5945a 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -20,7 +20,7 @@ jobs: run: echo 'The triggering workflow did not succeed' && exit 1 - name: Get artifact id: get-artifact - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ @@ -38,7 +38,7 @@ jobs: return { found: true, id: matchArtifact.id }; - name: Determine deploy parameters id: parameters - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 env: HEAD_SHA: ${{ github.event.workflow_run.head_sha }} with: @@ -108,13 +108,13 @@ jobs: if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }} steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Load parameters id: parameters - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 env: PARAM_JSON: ${{ needs.checks.outputs.parameters }} with: @@ -125,7 +125,7 @@ jobs: core.setOutput("shouldDeploy", parameters.shouldDeploy); - name: Download artifact - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 env: ARTIFACT_JSON: ${{ needs.checks.outputs.artifact }} with: @@ -150,7 +150,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2 + uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5 with: tg_version: '0.58.12' tofu_version: '1.7.1' @@ -165,7 +165,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2 + uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5 with: tg_version: '0.58.12' tofu_version: '1.7.1' @@ -181,7 +181,8 @@ jobs: echo "output=$CLEANED" >> $GITHUB_OUTPUT - name: Publish to Cloudflare Pages - uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1 + # TODO: Action is deprecated + uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca # v1.5.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -198,7 +199,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2 + uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5 with: tg_version: '0.58.12' tofu_version: '1.7.1' @@ -206,7 +207,7 @@ jobs: tg_command: 'apply' - name: Comment - uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3 + uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0 if: ${{ steps.parameters.outputs.event == 'pr' }} with: number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }} diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index 0da258de09..778cba77e1 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false @@ -25,7 +25,7 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }} - uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2 + uses: gruntwork-io/terragrunt-action@9559e51d05873b0ea467c42bbabcb5c067642ccc # v2.1.5 with: tg_version: '0.58.12' tofu_version: '1.7.1' @@ -33,7 +33,7 @@ jobs: tg_command: 'destroy -refresh=false' - name: Comment - uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3 + uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0 with: number: ${{ github.event.number }} delete: true diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index e4911e69ce..7a90747c12 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -16,20 +16,20 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2 + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: 'Checkout' - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.ref }} token: ${{ steps.generate-token.outputs.token }} persist-credentials: true - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './server/.nvmrc' @@ -37,13 +37,13 @@ jobs: run: make install-all && make format-all - name: Commit and push - uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9 + uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 with: default_author: github_actions message: 'chore: fix formatting' - name: Remove label - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 if: always() with: script: | diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml index c5e5131920..db7c0b09ea 100644 --- a/.github/workflows/pr-label-validation.yml +++ b/.github/workflows/pr-label-validation.yml @@ -14,7 +14,7 @@ jobs: pull-requests: write steps: - name: Require PR to have a changelog label - uses: mheap/github-action-required-labels@388fd6af37b34cdfe5a23b37060e763217e58b03 # v5 + uses: mheap/github-action-required-labels@388fd6af37b34cdfe5a23b37060e763217e58b03 # v5.5.0 with: mode: exactly count: 1 diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 75c6836ab9..ad73c78cf8 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -11,4 +11,4 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 + - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 0f2a153de2..f1995bb866 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -32,19 +32,19 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2 + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: true - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Bump version env: @@ -54,7 +54,7 @@ jobs: - name: Commit and tag id: push-tag - uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9 + uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 with: default_author: github_actions message: 'chore: version ${{ env.IMMICH_VERSION }}' @@ -83,24 +83,24 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2 + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 with: app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: false - name: Download APK - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-apk-signed - name: Create draft release - uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2 + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 with: draft: true tag_name: ${{ env.IMMICH_VERSION }} diff --git a/.github/workflows/preview-label.yaml b/.github/workflows/preview-label.yaml index 4c445f13d0..edd9dfdae9 100644 --- a/.github/workflows/preview-label.yaml +++ b/.github/workflows/preview-label.yaml @@ -13,7 +13,7 @@ jobs: permissions: pull-requests: write steps: - - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2 + - uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2 with: message-id: 'preview-status' message: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.cloud/' @@ -24,7 +24,7 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | github.rest.issues.removeLabel({ diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 482faf29f4..bb3ae8f27f 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -16,12 +16,12 @@ jobs: run: working-directory: ./open-api/typescript-sdk steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false # Setup .npmrc file to publish to npm - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './open-api/typescript-sdk/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 41e8f03c90..e8cd14a93d 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -20,11 +20,11 @@ jobs: should_run: ${{ steps.found_paths.outputs.mobile == 'true' || steps.should_force.outputs.should_force == 'true' }} steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - id: found_paths - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: filters: | mobile: @@ -44,12 +44,12 @@ jobs: contents: read steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Flutter SDK - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2 + uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2.19.0 with: channel: 'stable' flutter-version-file: ./mobile/pubspec.yaml @@ -67,7 +67,7 @@ jobs: working-directory: ./mobile - name: Find file changes - uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20 + uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 id: verify-changed-files with: files: | @@ -105,12 +105,12 @@ jobs: actions: read steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Install the latest version of uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Run zizmor 🌈 run: uvx zizmor --format=sarif . > results.sarif @@ -118,7 +118,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 + uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: sarif_file: results.sarif category: zizmor diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d52ca4e6f7..f36b01518e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,12 +28,12 @@ jobs: should_run_.github: ${{ steps.found_paths.outputs['.github'] == 'true' || steps.should_force.outputs.should_force == 'true' }} # redundant to have should_force but if someone changes the trigger then this won't have to be changed steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - id: found_paths - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: filters: | web: @@ -73,12 +73,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './server/.nvmrc' @@ -114,12 +114,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './cli/.nvmrc' @@ -159,12 +159,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './cli/.nvmrc' @@ -197,12 +197,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './web/.nvmrc' @@ -238,12 +238,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './web/.nvmrc' @@ -275,12 +275,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './e2e/.nvmrc' @@ -318,12 +318,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './server/.nvmrc' @@ -350,13 +350,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false submodules: 'recursive' - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './e2e/.nvmrc' @@ -398,13 +398,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false submodules: 'recursive' - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './e2e/.nvmrc' @@ -452,12 +452,12 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Flutter SDK - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2 + uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2.19.0 with: channel: 'stable' flutter-version-file: ./mobile/pubspec.yaml @@ -476,13 +476,13 @@ jobs: run: working-directory: ./machine-learning steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) # with: # python-version: 3.11 @@ -516,12 +516,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './.github/.nvmrc' @@ -538,7 +538,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false @@ -557,12 +557,12 @@ jobs: contents: read steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './server/.nvmrc' @@ -576,7 +576,7 @@ jobs: run: make open-api - name: Find file changes - uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20 + uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 id: verify-changed-files with: files: | @@ -618,12 +618,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: './server/.nvmrc' @@ -644,7 +644,7 @@ jobs: run: npm run migrations:generate src/TestMigration - name: Find file changes - uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20 + uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 id: verify-changed-files with: files: | @@ -665,7 +665,7 @@ jobs: DB_URL: postgres://postgres:postgres@localhost:5432/immich - name: Find file changes - uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20 + uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 id: verify-changed-sql-files with: files: | diff --git a/.github/workflows/weblate-lock.yml b/.github/workflows/weblate-lock.yml index 2d644955bc..b762ac636c 100644 --- a/.github/workflows/weblate-lock.yml +++ b/.github/workflows/weblate-lock.yml @@ -15,11 +15,11 @@ jobs: should_run: ${{ steps.found_paths.outputs.i18n == 'true' && github.head_ref != 'chore/translations'}} steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - id: found_paths - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: filters: | i18n: @@ -38,7 +38,7 @@ jobs: exit 1 fi - name: Find Pull Request - uses: juliangruber/find-pull-request-action@48b6133aa6c826f267ebd33aa2d29470f9d9e7d0 # v1 + uses: juliangruber/find-pull-request-action@48b6133aa6c826f267ebd33aa2d29470f9d9e7d0 # v1.9.0 id: find-pr with: branch: chore/translations From 0cd51ae9c5f16062890a5757bf55e9a8586beb1f Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 13 May 2025 19:32:34 +0200 Subject: [PATCH 22/48] fix: detail panel background (#18269) --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 2 +- web/src/lib/components/asset-viewer/detail-panel.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 1e3c6135f0..b180e923b2 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -529,7 +529,7 @@
($isShowDetail = false)} /> diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index aa5909f717..24329d13bf 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -156,7 +156,7 @@ } -
+

{$t('info')}

From ca06d0aa83dca936a6005ac0f79cc7b347efb7a3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 13 May 2025 13:54:49 -0400 Subject: [PATCH 23/48] chore(deps): update base-image (major) (#18256) chore(deps): update base-image Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 5c0ef076c4..10ab8c7e9a 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202504081114@sha256:250ab051cb0bdefdaf7d4069f3de9eada4c0288360ba1143a0e607a202b305b1 AS dev +FROM ghcr.io/immich-app/base-server-dev:202505131114@sha256:cf4507bbbf307e9b6d8ee9418993321f2b85867da8ce14d0a20ccaf9574cb995 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -43,7 +43,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:202504081114@sha256:8353bcbdb4e6579300adfa0d8b5892abefa42ebfc99740050cbfb38ab83a0605 +FROM ghcr.io/immich-app/base-server-prod:202505061115@sha256:9971d3a089787f0bd01f4682141d3665bcf5efb3e101a88e394ffd25bee4eedb WORKDIR /usr/src/app ENV NODE_ENV=production \ From 15e894b9b54a4b2a7cf34a84c043be02306a507a Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 13 May 2025 22:25:57 +0200 Subject: [PATCH 24/48] fix: z-index issues (#18275) --- .../faces-page/merge-suggestion-modal.svelte | 4 +- .../components/faces-page/people-card.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 64 +++++++++---------- .../admin/library-management/+page.svelte | 38 +++++------ 4 files changed, 54 insertions(+), 54 deletions(-) diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte index d4f8a6f416..3aedfd3450 100644 --- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte +++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte @@ -1,14 +1,14 @@ -{#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS} - a.id)} - personAssets={person} - onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} - onConfirm={handleUnmerge} - /> -{/if} - -{#if viewMode === PersonPageViewMode.SUGGEST_MERGE && personMerge1 && personMerge2} - (viewMode = PersonPageViewMode.VIEW_ASSETS)} - onReject={changeName} - onConfirm={handleMergeSamePerson} - /> -{/if} - -{#if viewMode === PersonPageViewMode.BIRTH_DATE} - (viewMode = PersonPageViewMode.VIEW_ASSETS)} - onUpdate={handleSetBirthDate} - /> -{/if} - -{#if viewMode === PersonPageViewMode.MERGE_PEOPLE} - -{/if} -
+{#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS} + a.id)} + personAssets={person} + onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} + onConfirm={handleUnmerge} + /> +{/if} + +{#if viewMode === PersonPageViewMode.SUGGEST_MERGE && personMerge1 && personMerge2} + (viewMode = PersonPageViewMode.VIEW_ASSETS)} + onReject={changeName} + onConfirm={handleMergeSamePerson} + /> +{/if} + +{#if viewMode === PersonPageViewMode.BIRTH_DATE} + (viewMode = PersonPageViewMode.VIEW_ASSETS)} + onUpdate={handleSetBirthDate} + /> +{/if} + +{#if viewMode === PersonPageViewMode.MERGE_PEOPLE} + +{/if} +
{#if assetInteraction.selectionActive} -{#if toCreateLibrary} - (toCreateLibrary = false)} /> -{/if} - -{#if toAddImportPath} - { - toAddImportPath = false; - if (updateLibraryIndex) { - onEditImportPathClicked(updateLibraryIndex); - } - }} - /> -{/if} - {#snippet buttons()}
@@ -391,3 +372,22 @@ onCancel={() => (renameLibrary = undefined)} /> {/if} + +{#if toCreateLibrary} + (toCreateLibrary = false)} /> +{/if} + +{#if toAddImportPath} + { + toAddImportPath = false; + if (updateLibraryIndex) { + onEditImportPathClicked(updateLibraryIndex); + } + }} + /> +{/if} From b3b774cfe539d6b190dcf2f4b692161aa59047a2 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 13 May 2025 23:52:56 +0200 Subject: [PATCH 25/48] fix: memory lane memory title (#18277) --- web/src/lib/components/photos-page/memory-lane.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 6b5292f762..78516a767f 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -85,12 +85,12 @@ alt={$t('memory_lane_title', { values: { title: $getAltText(memory.assets[0]) } })} draggable="false" /> -

- {$memoryLaneTitle(memory)} -

+

+ {$memoryLaneTitle(memory)} +

{/each}
From c9d45eee86092ad59b52bc674c028938174c5a99 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 14 May 2025 13:52:04 +0200 Subject: [PATCH 26/48] refactor: duplicates information modal (#18282) --- .../shared-components/duplicates-modal.svelte | 20 ----------------- .../modals/DuplicatesInformationModal.svelte | 22 +++++++++++++++++++ .../[[assetId=id]]/+page.svelte | 10 ++------- 3 files changed, 24 insertions(+), 28 deletions(-) delete mode 100644 web/src/lib/components/shared-components/duplicates-modal.svelte create mode 100644 web/src/lib/modals/DuplicatesInformationModal.svelte diff --git a/web/src/lib/components/shared-components/duplicates-modal.svelte b/web/src/lib/components/shared-components/duplicates-modal.svelte deleted file mode 100644 index c9abea1377..0000000000 --- a/web/src/lib/components/shared-components/duplicates-modal.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - - -
-

{$t('deduplication_info_description')}

-
    -
  1. {$t('deduplication_criteria_1')}
  2. -
  3. {$t('deduplication_criteria_2')}
  4. -
-
-
diff --git a/web/src/lib/modals/DuplicatesInformationModal.svelte b/web/src/lib/modals/DuplicatesInformationModal.svelte new file mode 100644 index 0000000000..b32165a1ae --- /dev/null +++ b/web/src/lib/modals/DuplicatesInformationModal.svelte @@ -0,0 +1,22 @@ + + + + +
+

{$t('deduplication_info_description')}

+
    +
  1. {$t('deduplication_criteria_1')}
  2. +
  3. {$t('deduplication_criteria_2')}
  4. +
+
+
+
diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 0bb581b885..bbad882b24 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,13 +1,13 @@ - - -
-

- {$t('birthdate_set_description')} -

-
- - onSubmit(e)} autocomplete="off" id="set-birth-date-form"> -
- -
- - - {#snippet stickyBottom()} - - - {/snippet} -
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 3a1f8cf3de..bec3b0ceaa 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -384,7 +384,6 @@ export enum PersonPageViewMode { SELECT_PERSON = 'select-person', MERGE_PEOPLE = 'merge-people', SUGGEST_MERGE = 'suggest-merge', - BIRTH_DATE = 'birth-date', UNASSIGN_ASSETS = 'unassign-faces', } diff --git a/web/src/lib/modals/PersonEditBirthDateModal.svelte b/web/src/lib/modals/PersonEditBirthDateModal.svelte new file mode 100644 index 0000000000..2891d261ce --- /dev/null +++ b/web/src/lib/modals/PersonEditBirthDateModal.svelte @@ -0,0 +1,67 @@ + + + + +
+

+ {$t('birthdate_set_description')} +

+
+ +
handleUpdateBirthDate()} autocomplete="off" id="set-birth-date-form"> +
+ +
+
+
+ + +
+ + +
+
+
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 0cadd4b672..f53d364611 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -9,13 +9,14 @@ import PeopleCard from '$lib/components/faces-page/people-card.svelte'; import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte'; import SearchPeople from '$lib/components/faces-page/people-search.svelte'; - import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import { notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; import { ActionQueryParameterValue, AppRoute, QueryParameter, SessionStorageKey } from '$lib/constants'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; + import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte'; import { locale } from '$lib/stores/preferences.store'; import { websocketEvents } from '$lib/stores/websocket'; import { handlePromiseError } from '$lib/utils'; @@ -45,7 +46,6 @@ let selectHidden = $state(false); let searchName = $state(''); - let showSetBirthDateModal = $state(false); let showMergeModal = $state(false); let newName = $state(''); let currentPage = $state(1); @@ -53,7 +53,7 @@ let personMerge1 = $state(); let personMerge2 = $state(); let potentialMergePeople: PersonResponseDto[] = $state([]); - let edittingPerson: PersonResponseDto | null = $state(null); + let editingPerson: PersonResponseDto | null = $state(null); let searchedPeopleLocal: PersonResponseDto[] = $state([]); let innerHeight = $state(0); let searchPeopleElement = $state>(); @@ -135,7 +135,7 @@ const [personToMerge, personToBeMergedIn] = response; showMergeModal = false; - if (!edittingPerson) { + if (!editingPerson) { return; } try { @@ -155,7 +155,7 @@ } catch (error) { handleError(error, $t('errors.unable_to_save_name')); } - if (personToBeMergedIn.name !== newName && edittingPerson.id === personToBeMergedIn.id) { + if (personToBeMergedIn.name !== newName && editingPerson.id === personToBeMergedIn.id) { /* * * If the user merges one of the suggested people into the person he's editing it, it's merging the suggested person AND renames @@ -181,11 +181,6 @@ } }; - const handleSetBirthDate = (detail: PersonResponseDto) => { - showSetBirthDateModal = true; - edittingPerson = detail; - }; - const handleHidePerson = async (detail: PersonResponseDto) => { try { const updatedPerson = await updatePerson({ @@ -234,31 +229,19 @@ ); }; - const submitBirthDateChange = async (value: string) => { - showSetBirthDateModal = false; - if (!edittingPerson || value === edittingPerson.birthDate) { + const handleChangeBirthDate = async (person: PersonResponseDto) => { + const updatedPerson = await modalManager.show(PersonEditBirthDateModal, { person }); + + if (!updatedPerson) { return; } - try { - const updatedPerson = await updatePerson({ - id: edittingPerson.id, - personUpdateDto: { birthDate: value.length > 0 ? value : null }, - }); - - people = people.map((person: PersonResponseDto) => { - if (person.id === updatedPerson.id) { - return updatedPerson; - } - return person; - }); - notificationController.show({ - message: $t('birthdate_saved'), - type: NotificationType.Info, - }); - } catch (error) { - handleError(error, $t('errors.unable_to_save_name')); - } + people = people.map((person: PersonResponseDto) => { + if (person.id === updatedPerson.id) { + return updatedPerson; + } + return person; + }); }; const onResetSearchBar = async () => { @@ -274,7 +257,7 @@ let showPeople = $derived(searchName ? searchedPeopleLocal : visiblePeople); const onNameChangeInputFocus = (person: PersonResponseDto) => { - edittingPerson = person; + editingPerson = person; newName = person.name; }; @@ -414,7 +397,7 @@ > handleSetBirthDate(person)} + onSetBirthDate={() => handleChangeBirthDate(person)} onMergePeople={() => handleMergePeople(person)} onHidePerson={() => handleHidePerson(person)} onToggleFavorite={() => handleToggleFavorite(person)} @@ -444,14 +427,6 @@
{/if} - - {#if showSetBirthDateModal} - (showSetBirthDateModal = false)} - onUpdate={submitBirthDateChange} - /> - {/if} {#if selectHidden} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index bfc7a9eb85..70500ca755 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -8,7 +8,6 @@ import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte'; import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte'; import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte'; - import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte'; import UnMergeFaceSelector from '$lib/components/faces-page/unmerge-face-selector.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; @@ -31,6 +30,8 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; + import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets-store.svelte'; @@ -322,27 +323,19 @@ await changeName(); }; - const handleSetBirthDate = async (birthDate: string) => { - try { - viewMode = PersonPageViewMode.VIEW_ASSETS; - person.birthDate = birthDate; + const handleSetBirthDate = async () => { + const updatedPerson = await modalManager.show(PersonEditBirthDateModal, { person }); - const updatedPerson = await updatePerson({ - id: person.id, - personUpdateDto: { birthDate: birthDate.length > 0 ? birthDate : null }, - }); - - people = people.map((person: PersonResponseDto) => { - if (person.id === updatedPerson.id) { - return updatedPerson; - } - return person; - }); - - notificationController.show({ message: $t('date_of_birth_saved'), type: NotificationType.Info }); - } catch (error) { - handleError(error, $t('errors.unable_to_save_date_of_birth')); + if (!updatedPerson) { + return; } + + people = people.map((person: PersonResponseDto) => { + if (person.id === updatedPerson.id) { + return updatedPerson; + } + return person; + }); }; const handleGoBack = async () => { @@ -389,7 +382,7 @@ onSelect={handleSelectFeaturePhoto} onEscape={handleEscape} > - {#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE || viewMode === PersonPageViewMode.BIRTH_DATE} + {#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE}
{/if} -{#if viewMode === PersonPageViewMode.BIRTH_DATE} - (viewMode = PersonPageViewMode.VIEW_ASSETS)} - onUpdate={handleSetBirthDate} - /> -{/if} - {#if viewMode === PersonPageViewMode.MERGE_PEOPLE} {/if} @@ -568,7 +553,7 @@ {:else} - {#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE || viewMode === PersonPageViewMode.BIRTH_DATE} + {#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE} goto(previousRoute)}> {#snippet trailing()} @@ -582,11 +567,7 @@ icon={person.isHidden ? mdiEyeOutline : mdiEyeOffOutline} onClick={() => toggleHidePerson()} /> - (viewMode = PersonPageViewMode.BIRTH_DATE)} - /> + Date: Wed, 14 May 2025 11:23:57 -0400 Subject: [PATCH 28/48] refactor: admin sidebar (#18276) --- web/package-lock.json | 10 ++--- web/package.json | 2 +- web/src/app.css | 2 - .../components/layouts/AdminPageLayout.svelte | 43 +++++++++++++++++++ .../lib/components/layouts/PageContent.svelte | 26 +++++++++++ .../layouts/user-page-layout.svelte | 5 --- .../navigation-bar/navigation-bar.svelte | 12 ++++-- .../side-bar/admin-side-bar.svelte | 18 -------- web/src/routes/admin/jobs-status/+page.svelte | 6 +-- .../admin/library-management/+page.svelte | 6 +-- .../routes/admin/server-status/+page.svelte | 8 ++-- .../routes/admin/system-settings/+page.svelte | 6 +-- web/src/routes/admin/users/+page.svelte | 6 +-- web/src/routes/admin/users/[id]/+page.svelte | 6 +-- web/tailwind.config.js | 2 +- 15 files changed, 103 insertions(+), 55 deletions(-) create mode 100644 web/src/lib/components/layouts/AdminPageLayout.svelte create mode 100644 web/src/lib/components/layouts/PageContent.svelte delete mode 100644 web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte diff --git a/web/package-lock.json b/web/package-lock.json index 764e975518..76278058f1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.19.1", + "@immich/ui": "^0.20.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", @@ -88,7 +88,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.14.1", + "@types/node": "^22.15.16", "typescript": "^5.3.3" } }, @@ -1337,9 +1337,9 @@ "link": true }, "node_modules/@immich/ui": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.19.1.tgz", - "integrity": "sha512-PyJ+OAEgBu1HTScMMui2KpBjMYkCw3nhVloYorOaB5lKOlNh7mqz5xBCNo/UVwxLXyAOFuBLU05lv3hWNveSKQ==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.20.0.tgz", + "integrity": "sha512-euK3N0AhQLB28qFteorRKyDUdet3UpA9MEAd8eBLbTtTFZKvZismBGa4J7pHbQrSkuOlbmJD5LJuM575q8zigQ==", "license": "GNU Affero General Public License version 3", "dependencies": { "@mdi/js": "^7.4.47", diff --git a/web/package.json b/web/package.json index 2806b34b32..8a9f6472b6 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.19.1", + "@immich/ui": "^0.20.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/app.css b/web/src/app.css index c3276010c8..329d9ce82d 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -31,7 +31,6 @@ --immich-ui-danger: 200 60 60; --immich-ui-warning: 216 143 64; --immich-ui-info: 8 111 230; - --immich-ui-default-border: 209 213 219; --immich-ui-gray: 246 246 246; } @@ -44,7 +43,6 @@ --immich-ui-success: 72 237 152; --immich-ui-warning: 254 197 132; --immich-ui-info: 121 183 254; - --immich-ui-default-border: 55 65 81; --immich-ui-gray: 33 33 33; } } diff --git a/web/src/lib/components/layouts/AdminPageLayout.svelte b/web/src/lib/components/layouts/AdminPageLayout.svelte new file mode 100644 index 0000000000..4693035a43 --- /dev/null +++ b/web/src/lib/components/layouts/AdminPageLayout.svelte @@ -0,0 +1,43 @@ + + + + + + + +
+
+ + + + + +
+ +
+ +
+
+
+ + +
diff --git a/web/src/lib/components/layouts/PageContent.svelte b/web/src/lib/components/layouts/PageContent.svelte new file mode 100644 index 0000000000..bfd291b074 --- /dev/null +++ b/web/src/lib/components/layouts/PageContent.svelte @@ -0,0 +1,26 @@ + + +
+
+
{title}
+ {@render buttons?.()} +
+ + + {@render children?.()} + + +
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index b1fdd89a6d..8ecddaab78 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -5,7 +5,6 @@ - - - - - - - - - - diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index 6636d748cf..9985fd949d 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -1,6 +1,6 @@ - + {#snippet buttons()}
- + diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 2e13e5997d..bd6a834427 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -40,7 +40,7 @@ export default { }, borderColor: ({ theme }) => ({ ...theme('colors'), - DEFAULT: 'rgb(var(--immich-ui-default-border) / )', + DEFAULT: 'rgb(var(--immich-ui-gray) / )', }), fontFamily: { 'immich-mono': ['Overpass Mono', 'monospace'], From 3944f5d73bb94740ceb00a315c3aa57c9ab977cd Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 14 May 2025 18:02:25 +0200 Subject: [PATCH 29/48] fix: mobile sidebar (#18286) --- .../shared-components/side-bar/side-bar-section.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte index bf87ba1465..cf8e569e7e 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte @@ -35,7 +35,7 @@ id="sidebar" aria-label={ariaLabel} tabindex="-1" - class="immich-scrollbar relative z-[1] w-0 sidebar:w-[16rem] overflow-y-auto overflow-x-hidden pt-8 transition-all duration-200" + class="immich-scrollbar relative z-[1] w-0 sidebar:w-[16rem] overflow-y-auto overflow-x-hidden pt-8 transition-all duration-200 bg-light" class:shadow-2xl={isExpanded} class:dark:border-e-immich-dark-gray={isExpanded} class:border-r={isExpanded} From fac1beb7d830b59a9e7bcf6dc604060d45730185 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 14 May 2025 12:09:10 -0400 Subject: [PATCH 30/48] refactor: buy immich (#18289) * refactor: buy container * refactor: buy immich --- .../purchasing/purchase-content.svelte | 87 +++++++++---------- web/src/routes/(user)/buy/+page.svelte | 24 ++--- 2 files changed, 52 insertions(+), 59 deletions(-) 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 b46bdcb5e3..637ed18869 100644 --- a/web/src/lib/components/shared-components/purchasing/purchase-content.svelte +++ b/web/src/lib/components/shared-components/purchasing/purchase-content.svelte @@ -4,6 +4,7 @@ import { purchaseStore } from '$lib/stores/purchase.store'; import { handleError } from '$lib/utils/handle-error'; import { activateProduct, getActivationKey } from '$lib/utils/license-utils'; + import { Heading } from '@immich/ui'; import { t } from 'svelte-i18n'; import UserPurchaseOptionCard from './individual-purchase-option-card.svelte'; import ServerPurchaseOptionCard from './server-purchase-option-card.svelte'; @@ -36,52 +37,50 @@ }; -
-
- {#if showTitle} -

- {$t('purchase_option_title')} -

- {/if} +
+ {#if showTitle} + + {$t('purchase_option_title')} + + {/if} - {#if showMessage} -
-

- {$t('purchase_panel_info_1')} -

-
-

- {$t('purchase_panel_info_2')} -

-
-
- {/if} - -
- - + {#if showMessage} +
+

+ {$t('purchase_panel_info_1')} +

+
+

+ {$t('purchase_panel_info_2')} +

+
+ {/if} -
-

{$t('purchase_input_suggestion')}

-
- - -
-
+
+ + +
+ +
+

{$t('purchase_input_suggestion')}

+
+ + +
diff --git a/web/src/routes/(user)/buy/+page.svelte b/web/src/routes/(user)/buy/+page.svelte index eb0194c447..e8bfd5451b 100644 --- a/web/src/routes/(user)/buy/+page.svelte +++ b/web/src/routes/(user)/buy/+page.svelte @@ -3,13 +3,13 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import LicenseActivationSuccess from '$lib/components/shared-components/purchasing/purchase-activation-success.svelte'; import LicenseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte'; + import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte'; import { AppRoute } from '$lib/constants'; + import { purchaseStore } from '$lib/stores/purchase.store'; + import { Alert, Container, Stack } from '@immich/ui'; + import { mdiAlertCircleOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; - import Icon from '$lib/components/elements/icon.svelte'; - import { mdiAlertCircleOutline } from '@mdi/js'; - import { purchaseStore } from '$lib/stores/purchase.store'; - import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte'; interface Props { data: PageData; @@ -21,16 +21,10 @@ -
-
+ + {#if data.isActivated === false} - + {/if} {#if $isPurchased} @@ -46,6 +40,6 @@ }} /> {/if} -
-
+ +
From 77b0505006e1b6c6b3abe43e050af58dc187deeb Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 14 May 2025 12:30:47 -0400 Subject: [PATCH 31/48] refactor: layout components (#18290) --- .../components/layouts/AdminPageLayout.svelte | 37 +++++++------------ .../lib/components/layouts/PageContent.svelte | 24 +++--------- .../lib/components/layouts/TitleLayout.svelte | 27 ++++++++++++++ web/src/lib/sidebars/AdminSidebar.svelte | 21 +++++++++++ 4 files changed, 67 insertions(+), 42 deletions(-) create mode 100644 web/src/lib/components/layouts/TitleLayout.svelte create mode 100644 web/src/lib/sidebars/AdminSidebar.svelte diff --git a/web/src/lib/components/layouts/AdminPageLayout.svelte b/web/src/lib/components/layouts/AdminPageLayout.svelte index 4693035a43..5a580dbde8 100644 --- a/web/src/lib/components/layouts/AdminPageLayout.svelte +++ b/web/src/lib/components/layouts/AdminPageLayout.svelte @@ -1,22 +1,19 @@ @@ -24,20 +21,14 @@ -
-
- - - - - -
- -
- -
-
+
- + + + + {@render children?.()} + + +
diff --git a/web/src/lib/components/layouts/PageContent.svelte b/web/src/lib/components/layouts/PageContent.svelte index bfd291b074..150aaecf43 100644 --- a/web/src/lib/components/layouts/PageContent.svelte +++ b/web/src/lib/components/layouts/PageContent.svelte @@ -1,26 +1,12 @@ -
-
-
{title}
- {@render buttons?.()} -
- - - {@render children?.()} - - -
+ diff --git a/web/src/lib/components/layouts/TitleLayout.svelte b/web/src/lib/components/layouts/TitleLayout.svelte new file mode 100644 index 0000000000..1beab45586 --- /dev/null +++ b/web/src/lib/components/layouts/TitleLayout.svelte @@ -0,0 +1,27 @@ + + +
+
+
+
{title}
+ {#if description} + {description} + {/if} +
+ {@render buttons?.()} +
+ {@render children?.()} +
diff --git a/web/src/lib/sidebars/AdminSidebar.svelte b/web/src/lib/sidebars/AdminSidebar.svelte new file mode 100644 index 0000000000..2fecaebf49 --- /dev/null +++ b/web/src/lib/sidebars/AdminSidebar.svelte @@ -0,0 +1,21 @@ + + +
+
+ + + + + +
+ +
+ +
+
From 7d95bad5cb2187dea4b432661791732e1ad9cb90 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 14 May 2025 12:30:55 -0400 Subject: [PATCH 32/48] refactor: user settings container (#18291) --- web/src/routes/(user)/user-settings/+page.svelte | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte index 028941cdd6..c434bc7de6 100644 --- a/web/src/routes/(user)/user-settings/+page.svelte +++ b/web/src/routes/(user)/user-settings/+page.svelte @@ -4,6 +4,7 @@ import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte'; import { modalManager } from '$lib/managers/modal-manager.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; + import { Container } from '@immich/ui'; import { mdiKeyboard } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -23,9 +24,7 @@ onclick={() => modalManager.show(ShortcutsModal, {})} /> {/snippet} -
-
- -
-
+ + + From f357f3324f967c952bb1b37af2e32d4076f5067d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 14 May 2025 14:12:57 -0400 Subject: [PATCH 33/48] refactor: default border color (#18292) --- web/src/app.css | 4 ++++ web/src/lib/components/layouts/user-page-layout.svelte | 4 +--- .../navigation-bar/navigation-bar.svelte | 2 +- .../shared-components/settings/setting-accordion.svelte | 8 ++++---- web/tailwind.config.js | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/web/src/app.css b/web/src/app.css index 329d9ce82d..211d34bb6c 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -32,6 +32,8 @@ --immich-ui-warning: 216 143 64; --immich-ui-info: 8 111 230; --immich-ui-gray: 246 246 246; + + --immich-ui-default-border: 209 213 219; } .dark { @@ -44,6 +46,8 @@ --immich-ui-warning: 254 197 132; --immich-ui-info: 121 183 254; --immich-ui-gray: 33 33 33; + + --immich-ui-default-border: 55 65 81; } } diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 8ecddaab78..d5e3811ca5 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -65,9 +65,7 @@
{#if title || buttons} -
+
{#if title}
{title}
diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index b0b3c1f31e..3b6caf8668 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -57,7 +57,7 @@ >
diff --git a/web/src/lib/components/shared-components/settings/setting-accordion.svelte b/web/src/lib/components/shared-components/settings/setting-accordion.svelte index 5ae41c0551..f48d14ea30 100755 --- a/web/src/lib/components/shared-components/settings/setting-accordion.svelte +++ b/web/src/lib/components/shared-components/settings/setting-accordion.svelte @@ -1,8 +1,8 @@ - + - + diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-section.spec.ts b/web/src/lib/components/sidebar/sidebar.spec.ts similarity index 93% rename from web/src/lib/components/shared-components/side-bar/side-bar-section.spec.ts rename to web/src/lib/components/sidebar/sidebar.spec.ts index 16c985ce35..cf9ecabada 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-section.spec.ts +++ b/web/src/lib/components/sidebar/sidebar.spec.ts @@ -1,4 +1,4 @@ -import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; +import SideBarSection from '$lib/components/sidebar/sidebar.svelte'; import { sidebarStore } from '$lib/stores/sidebar.svelte'; import { render, screen } from '@testing-library/svelte'; import { vi } from 'vitest'; @@ -22,7 +22,7 @@ vi.mock('$lib/stores/sidebar.svelte', () => ({ }, })); -describe('SideBarSection component', () => { +describe('Sidebar component', () => { beforeEach(() => { vi.resetAllMocks(); mocks.mobileDevice.isFullSidebar = false; diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte b/web/src/lib/components/sidebar/sidebar.svelte similarity index 100% rename from web/src/lib/components/shared-components/side-bar/side-bar-section.svelte rename to web/src/lib/components/sidebar/sidebar.svelte diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index 0f0f194a57..2daf63b9e3 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -17,10 +17,10 @@ import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; - import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte'; import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; + import Sidebar from '$lib/components/sidebar/sidebar.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { Viewport } from '$lib/stores/assets-store.svelte'; @@ -130,7 +130,7 @@ {#snippet sidebar()} - +
{$t('explorer').toUpperCase()}
@@ -143,7 +143,7 @@ />
- + {/snippet} diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index 52667abc94..3825268950 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -10,10 +10,10 @@ NotificationType, } from '$lib/components/shared-components/notification/notification'; import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; - import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte'; import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; + import Sidebar from '$lib/components/sidebar/sidebar.svelte'; import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants'; import { modalManager } from '$lib/managers/modal-manager.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; @@ -142,7 +142,7 @@ {#snippet sidebar()} - +
{$t('explorer').toUpperCase()}
@@ -156,7 +156,7 @@ />
- + {/snippet} {#snippet buttons()} From cd03d0c0f24cfe3951fbee8513e1795a7cb673ec Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 15 May 2025 02:30:24 +0200 Subject: [PATCH 35/48] refactor: person merge suggestion modal (#18287) --- .../faces-page/merge-suggestion-modal.svelte | 126 --------------- web/src/lib/constants.ts | 1 - .../modals/PersonMergeSuggestionModal.svelte | 147 ++++++++++++++++++ web/src/routes/(user)/people/+page.svelte | 105 +++++-------- .../[[assetId=id]]/+page.svelte | 73 ++++----- 5 files changed, 214 insertions(+), 238 deletions(-) delete mode 100644 web/src/lib/components/faces-page/merge-suggestion-modal.svelte create mode 100644 web/src/lib/modals/PersonMergeSuggestionModal.svelte diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte deleted file mode 100644 index 3aedfd3450..0000000000 --- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte +++ /dev/null @@ -1,126 +0,0 @@ - - - -
- {#if !choosePersonToMerge} -
- -
-
- ([personMerge1, personMerge2] = [personMerge2, personMerge1])} - /> -
- - - {:else} -
-
- -
-
-
- {#each potentialMergePeople as person (person.id)} -
- -
- {/each} -
-
-
- {/if} -
- -
-

{$t('are_these_the_same_person')}

-
-
-

{$t('they_will_be_merged_together')}

-
- - {#snippet stickyBottom()} - - - {/snippet} -
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index bec3b0ceaa..e4603217e0 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -383,7 +383,6 @@ export enum PersonPageViewMode { VIEW_ASSETS = 'view-assets', SELECT_PERSON = 'select-person', MERGE_PEOPLE = 'merge-people', - SUGGEST_MERGE = 'suggest-merge', UNASSIGN_ASSETS = 'unassign-faces', } diff --git a/web/src/lib/modals/PersonMergeSuggestionModal.svelte b/web/src/lib/modals/PersonMergeSuggestionModal.svelte new file mode 100644 index 0000000000..e762b30c03 --- /dev/null +++ b/web/src/lib/modals/PersonMergeSuggestionModal.svelte @@ -0,0 +1,147 @@ + + + + +
+ {#if !choosePersonToMerge} +
+ +
+
+ ([personToMerge, personToBeMergedInto] = [personToBeMergedInto, personToMerge])} + /> +
+ + + {:else} +
+
+ +
+
+
+ {#each potentialMergePeople as person (person.id)} +
+ +
+ {/each} +
+
+
+ {/if} +
+ +
+

{$t('are_these_the_same_person')}

+
+
+

{$t('they_will_be_merged_together')}

+
+
+ + +
+ + +
+
+
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index f53d364611..7ee95a5f7d 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -3,9 +3,9 @@ import { page } from '$app/stores'; import { focusTrap } from '$lib/actions/focus-trap'; import { scrollMemory } from '$lib/actions/scroll-memory'; + import { shortcut } from '$lib/actions/shortcut'; import Icon from '$lib/components/elements/icon.svelte'; import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte'; - import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte'; import PeopleCard from '$lib/components/faces-page/people-card.svelte'; import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte'; import SearchPeople from '$lib/components/faces-page/people-search.svelte'; @@ -17,19 +17,13 @@ import { ActionQueryParameterValue, AppRoute, QueryParameter, SessionStorageKey } from '$lib/constants'; import { modalManager } from '$lib/managers/modal-manager.svelte'; import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte'; + import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte'; import { locale } from '$lib/stores/preferences.store'; import { websocketEvents } from '$lib/stores/websocket'; import { handlePromiseError } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { clearQueryParam } from '$lib/utils/navigation'; - import { - getAllPeople, - getPerson, - mergePerson, - searchPerson, - updatePerson, - type PersonResponseDto, - } from '@immich/sdk'; + import { getAllPeople, getPerson, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; import { Button } from '@immich/ui'; import { mdiAccountOff, mdiEyeOutline } from '@mdi/js'; import { onMount } from 'svelte'; @@ -46,7 +40,6 @@ let selectHidden = $state(false); let searchName = $state(''); - let showMergeModal = $state(false); let newName = $state(''); let currentPage = $state(1); let nextPage = $state(data.people.hasNextPage ? 2 : null); @@ -131,42 +124,41 @@ } }; - const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => { - const [personToMerge, personToBeMergedIn] = response; - showMergeModal = false; - - if (!editingPerson) { + const handleMerge = async () => { + if (!editingPerson || !personMerge1 || !personMerge2) { return; } - try { - await mergePerson({ - id: personToBeMergedIn.id, - mergePersonDto: { ids: [personToMerge.id] }, - }); - const mergedPerson = await getPerson({ id: personToBeMergedIn.id }); + const response = await modalManager.show(PersonMergeSuggestionModal, { + personToMerge: personMerge1, + personToBeMergedInto: personMerge2, + potentialMergePeople, + }); - people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); - people = people.map((person: PersonResponseDto) => (person.id === personToBeMergedIn.id ? mergedPerson : person)); - notificationController.show({ - message: $t('merge_people_successfully'), - type: NotificationType.Info, - }); - } catch (error) { - handleError(error, $t('errors.unable_to_save_name')); + if (!response) { + await updateName(personMerge1.id, newName); + return; } - if (personToBeMergedIn.name !== newName && editingPerson.id === personToBeMergedIn.id) { + + const [personToMerge, personToBeMergedInto] = response; + + const mergedPerson = await getPerson({ id: personToBeMergedInto.id }); + + people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); + people = people.map((person: PersonResponseDto) => (person.id === personToBeMergedInto.id ? mergedPerson : person)); + + if (personToBeMergedInto.name !== newName && editingPerson.id === personToBeMergedInto.id) { /* * - * If the user merges one of the suggested people into the person he's editing it, it's merging the suggested person AND renames + * If the user merges one of the suggested people into the person he's editing, it's merging the suggested person AND renames * the person he's editing * */ try { - await updatePerson({ id: personToBeMergedIn.id, personUpdateDto: { name: newName } }); + await updatePerson({ id: personToBeMergedInto.id, personUpdateDto: { name: newName } }); for (const person of people) { - if (person.id === personToBeMergedIn.id) { + if (person.id === personToBeMergedInto.id) { person.name = newName; break; } @@ -263,7 +255,7 @@ const onNameChangeSubmit = async (name: string, targetPerson: PersonResponseDto) => { try { - if (name == targetPerson.name || showMergeModal) { + if (name == targetPerson.name) { return; } @@ -285,7 +277,7 @@ !person.isHidden, ) .slice(0, 3); - showMergeModal = true; + await handleMerge(); return; } await updateName(targetPerson.id, name); @@ -315,32 +307,10 @@ (person) => person.name.toLowerCase() === name.toLowerCase() && person.id !== personId && person.name, ); }; - - const handleMergeCancel = async () => { - if (!personMerge1) { - return; - } - - await updateName(personMerge1.id, newName); - showMergeModal = false; - }; -{#if showMergeModal && personMerge1 && personMerge2} - { - showMergeModal = false; - }} - onReject={() => handleMergeCancel()} - onConfirm={handleMergeSamePerson} - /> -{/if} - handleToggleFavorite(person)} /> -
onNameChangeSubmit(newName, person)}> - onNameChangeInputFocus(person)} - onfocusout={() => onNameChangeSubmit(newName, person)} - oninput={(event) => onNameChangeInputUpdate(event)} - /> -
+ e.currentTarget.blur() }} + onfocusin={() => onNameChangeInputFocus(person)} + onfocusout={() => onNameChangeSubmit(newName, person)} + oninput={(event) => onNameChangeInputUpdate(event)} + />
{/snippet} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 70500ca755..1c63cf8d05 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -7,7 +7,6 @@ import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte'; import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte'; - import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte'; import UnMergeFaceSelector from '$lib/components/faces-page/unmerge-face-selector.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; @@ -32,6 +31,7 @@ import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; import { modalManager } from '$lib/managers/modal-manager.svelte'; import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte'; + import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets-store.svelte'; @@ -44,7 +44,6 @@ import { AssetVisibility, getPersonStatistics, - mergePerson, searchPerson, updatePerson, type AssetResponseDto, @@ -122,7 +121,7 @@ }); const handleEscape = async () => { - if ($showAssetViewer || viewMode === PersonPageViewMode.SUGGEST_MERGE) { + if ($showAssetViewer) { return; } if (assetInteraction.selectionActive) { @@ -220,31 +219,32 @@ viewMode = PersonPageViewMode.VIEW_ASSETS; }; - const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => { - const [personToMerge, personToBeMergedIn] = response; - viewMode = PersonPageViewMode.VIEW_ASSETS; - isEditingName = false; - try { - await mergePerson({ - id: personToBeMergedIn.id, - mergePersonDto: { ids: [personToMerge.id] }, - }); - notificationController.show({ - message: $t('merge_people_successfully'), - type: NotificationType.Info, - }); - people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); - if (personToBeMergedIn.name != personName && person.id === personToBeMergedIn.id) { - await updateAssetCount(); - return; - } - await goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true }); - } catch (error) { - handleError(error, $t('errors.unable_to_save_name')); + const handleMergeSuggestion = async () => { + if (!personMerge1 || !personMerge2) { + return; } + + const result = await modalManager.show(PersonMergeSuggestionModal, { + personToMerge: personMerge1, + personToBeMergedInto: personMerge2, + potentialMergePeople, + }); + + if (!result) { + return; + } + + const [personToMerge, personToBeMergedInto] = result; + + people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); + if (personToBeMergedInto.name != personName && person.id === personToBeMergedInto.id) { + await updateAssetCount(); + return; + } + await goto(`${AppRoute.PEOPLE}/${personToBeMergedInto.id}`, { replaceState: true }); }; - const handleSuggestPeople = (person2: PersonResponseDto) => { + const handleSuggestPeople = async (person2: PersonResponseDto) => { isEditingName = false; if (person.id !== person2.id) { potentialMergePeople = []; @@ -252,7 +252,8 @@ personMerge1 = person; personMerge2 = person2; isSuggestionSelectedByUser = true; - viewMode = PersonPageViewMode.SUGGEST_MERGE; + + await handleMergeSuggestion(); } }; @@ -280,9 +281,6 @@ }; const handleCancelEditName = () => { - if (viewMode === PersonPageViewMode.SUGGEST_MERGE) { - return; - } isSearchingPeople = false; isEditingName = false; }; @@ -317,7 +315,7 @@ !person.isHidden, ) .slice(0, 3); - viewMode = PersonPageViewMode.SUGGEST_MERGE; + await handleMergeSuggestion(); return; } await changeName(); @@ -382,7 +380,7 @@ onSelect={handleSelectFeaturePhoto} onEscape={handleEscape} > - {#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE} + {#if viewMode === PersonPageViewMode.VIEW_ASSETS}
{/if} -{#if viewMode === PersonPageViewMode.SUGGEST_MERGE && personMerge1 && personMerge2} - (viewMode = PersonPageViewMode.VIEW_ASSETS)} - onReject={changeName} - onConfirm={handleMergeSamePerson} - /> -{/if} - {#if viewMode === PersonPageViewMode.MERGE_PEOPLE} {/if} @@ -553,7 +540,7 @@ {:else} - {#if viewMode === PersonPageViewMode.VIEW_ASSETS || viewMode === PersonPageViewMode.SUGGEST_MERGE} + {#if viewMode === PersonPageViewMode.VIEW_ASSETS} goto(previousRoute)}> {#snippet trailing()} From 3a0ddfb92daee9a55d569c59abc0427a16d626bf Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 14 May 2025 23:13:13 -0400 Subject: [PATCH 36/48] fix(server): vacuum after deleting people (#18299) * vacuum after deleting people * update sql --- server/src/queries/person.repository.sql | 12 ------------ server/src/repositories/person.repository.ts | 6 +----- server/src/services/person.service.spec.ts | 16 ++++++++++++++-- server/src/services/person.service.ts | 2 ++ .../test/repositories/person.repository.mock.ts | 1 + 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index c77d9835fa..2ab0045e32 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -13,12 +13,6 @@ set "personId" = $1 where "asset_faces"."sourceType" = $2 -VACUUM -ANALYZE asset_faces, -face_search, -person -REINDEX TABLE asset_faces -REINDEX TABLE person -- PersonRepository.delete delete from "person" @@ -29,12 +23,6 @@ where delete from "asset_faces" where "asset_faces"."sourceType" = $1 -VACUUM -ANALYZE asset_faces, -face_search, -person -REINDEX TABLE asset_faces -REINDEX TABLE person -- PersonRepository.getAllWithoutFaces select diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 789c47ccaf..478ff15d53 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -105,8 +105,6 @@ export class PersonRepository { .set({ personId: null }) .where('asset_faces.sourceType', '=', sourceType) .execute(); - - await this.vacuum({ reindexVectors: false }); } @GenerateSql({ params: [DummyValue.UUID] }) @@ -121,8 +119,6 @@ export class PersonRepository { @GenerateSql({ params: [{ sourceType: SourceType.EXIF }] }) async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.db.deleteFrom('asset_faces').where('asset_faces.sourceType', '=', sourceType).execute(); - - await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } getAllFaces(options: GetAllFacesOptions = {}) { @@ -519,7 +515,7 @@ export class PersonRepository { await this.db.updateTable('asset_faces').set({ deletedAt: new Date() }).where('asset_faces.id', '=', id).execute(); } - private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { + async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db); await sql`REINDEX TABLE asset_faces`.execute(this.db); await sql`REINDEX TABLE person`.execute(this.db); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 52e5ff03ee..d9df2225f4 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -459,6 +459,7 @@ describe(PersonService.name, () => { await sut.handleQueueDetectFaces({ force: false }); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(false); + expect(mocks.person.vacuum).not.toHaveBeenCalled(); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, @@ -475,6 +476,7 @@ describe(PersonService.name, () => { expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]); + expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ @@ -492,6 +494,7 @@ describe(PersonService.name, () => { expect(mocks.person.delete).not.toHaveBeenCalled(); expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); + expect(mocks.person.vacuum).not.toHaveBeenCalled(); expect(mocks.storage.unlink).not.toHaveBeenCalled(); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ @@ -521,6 +524,7 @@ describe(PersonService.name, () => { ]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); }); }); @@ -584,6 +588,7 @@ describe(PersonService.name, () => { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); + expect(mocks.person.vacuum).not.toHaveBeenCalled(); }); it('should queue all assets', async () => { @@ -611,6 +616,7 @@ describe(PersonService.name, () => { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); + expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false }); }); it('should run nightly if new face has been added since last run', async () => { @@ -629,11 +635,14 @@ describe(PersonService.name, () => { mocks.person.getAllWithoutFaces.mockResolvedValue([]); mocks.person.unassignFaces.mockResolvedValue(); - await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); + await sut.handleQueueRecognizeFaces({ force: false, nightly: true }); expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); - expect(mocks.person.getAllFaces).toHaveBeenCalledWith(undefined); + expect(mocks.person.getAllFaces).toHaveBeenCalledWith({ + personId: null, + sourceType: SourceType.MACHINE_LEARNING, + }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -643,6 +652,7 @@ describe(PersonService.name, () => { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); + expect(mocks.person.vacuum).not.toHaveBeenCalled(); }); it('should skip nightly if no new face has been added since last run', async () => { @@ -660,6 +670,7 @@ describe(PersonService.name, () => { expect(mocks.person.getAllFaces).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); + expect(mocks.person.vacuum).not.toHaveBeenCalled(); }); it('should delete existing people if forced', async () => { @@ -688,6 +699,7 @@ describe(PersonService.name, () => { ]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false }); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e6161b8f9c..23ba562ba6 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -259,6 +259,7 @@ export class PersonService extends BaseService { if (force) { await this.personRepository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); + await this.personRepository.vacuum({ reindexVectors: true }); } let jobs: JobItem[] = []; @@ -409,6 +410,7 @@ export class PersonService extends BaseService { if (force) { await this.personRepository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); + await this.personRepository.vacuum({ reindexVectors: false }); } else if (waiting) { this.logger.debug( `Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`, diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 59377576b1..2875c9ada5 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -33,5 +33,6 @@ export const newPersonRepositoryMock = (): Mocked Date: Wed, 14 May 2025 23:23:34 -0400 Subject: [PATCH 37/48] fix(server): do not filter out assets without preview path for person thumbnail generation (#18300) * allow assets without preview path * update sql * Update person.repository.ts Co-authored-by: Jason Rasmussen * update sql, e2e --------- Co-authored-by: Jason Rasmussen --- e2e/src/api/specs/asset.e2e-spec.ts | 2 -- server/src/queries/person.repository.sql | 14 ++++++++++---- server/src/repositories/person.repository.ts | 14 +++++++++----- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 8c203860df..4673db5426 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -202,7 +202,6 @@ describe('/asset', () => { { name: 'Marie Curie', birthDate: null, - thumbnailPath: '', isHidden: false, faces: [ { @@ -219,7 +218,6 @@ describe('/asset', () => { { name: 'Pierre Curie', birthDate: null, - thumbnailPath: '', isHidden: false, faces: [ { diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 2ab0045e32..659abbde03 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -133,18 +133,24 @@ select "asset_faces"."imageHeight" as "oldHeight", "assets"."type", "assets"."originalPath", - "asset_files"."path" as "previewPath", - "exif"."orientation" as "exifOrientation" + "exif"."orientation" as "exifOrientation", + ( + select + "asset_files"."path" + from + "asset_files" + where + "asset_files"."assetId" = "assets"."id" + and "asset_files"."type" = 'preview' + ) as "previewPath" from "person" inner join "asset_faces" on "asset_faces"."id" = "person"."faceAssetId" inner join "assets" on "asset_faces"."assetId" = "assets"."id" left join "exif" on "exif"."assetId" = "assets"."id" - left join "asset_files" on "asset_files"."assetId" = "assets"."id" where "person"."id" = $1 and "asset_faces"."deletedAt" is null - and "asset_files"."type" = $2 -- PersonRepository.reassignFace update "asset_faces" diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 478ff15d53..0b48e57f7a 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFaces, DB, FaceSearch, Person } from 'src/db'; @@ -261,7 +261,6 @@ export class PersonRepository { .innerJoin('asset_faces', 'asset_faces.id', 'person.faceAssetId') .innerJoin('assets', 'asset_faces.assetId', 'assets.id') .leftJoin('exif', 'exif.assetId', 'assets.id') - .leftJoin('asset_files', 'asset_files.assetId', 'assets.id') .select([ 'person.ownerId', 'asset_faces.boundingBoxX1 as x1', @@ -272,13 +271,18 @@ export class PersonRepository { 'asset_faces.imageHeight as oldHeight', 'assets.type', 'assets.originalPath', - 'asset_files.path as previewPath', 'exif.orientation as exifOrientation', ]) + .select((eb) => + eb + .selectFrom('asset_files') + .select('asset_files.path') + .whereRef('asset_files.assetId', '=', 'assets.id') + .where('asset_files.type', '=', sql.lit(AssetFileType.PREVIEW)) + .as('previewPath'), + ) .where('person.id', '=', id) .where('asset_faces.deletedAt', 'is', null) - .where('asset_files.type', '=', AssetFileType.PREVIEW) - .$narrowType<{ exifImageWidth: NotNull; exifImageHeight: NotNull }>() .executeTakeFirst(); } From 709a7b70aa10805c6eeb0fb82bc6ed0e485a7019 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 14 May 2025 23:34:22 -0400 Subject: [PATCH 38/48] chore: no sql generation for queries with side effects (#18301) no sql generation for queries with side effects --- server/src/queries/asset.repository.sql | 31 +++++++++++++ server/src/queries/audit.repository.sql | 5 --- server/src/queries/memory.repository.sql | 6 --- server/src/queries/move.repository.sql | 13 ------ .../src/queries/notification.repository.sql | 18 -------- server/src/queries/partner.repository.sql | 44 ------------------- server/src/queries/person.repository.sql | 29 +----------- .../queries/system.metadata.repository.sql | 9 ---- server/src/queries/tag.repository.sql | 25 +++++------ .../queries/version.history.repository.sql | 8 ---- server/src/repositories/asset.repository.ts | 8 +--- server/src/repositories/audit.repository.ts | 1 - server/src/repositories/memory.repository.ts | 1 - server/src/repositories/move.repository.ts | 3 +- .../repositories/notification.repository.ts | 1 - server/src/repositories/partner.repository.ts | 1 - server/src/repositories/person.repository.ts | 5 +-- .../system-metadata.repository.ts | 1 - server/src/repositories/tag.repository.ts | 5 +-- .../version-history.repository.ts | 1 - 20 files changed, 48 insertions(+), 167 deletions(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 4a3fbf0e39..4564971ac2 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -432,3 +432,34 @@ where and "assets"."updatedAt" > $3 limit $4 + +-- AssetRepository.detectOfflineExternalAssets +update "assets" +set + "isOffline" = $1, + "deletedAt" = $2 +where + "isOffline" = $3 + and "isExternal" = $4 + and "libraryId" = $5::uuid + and ( + not "originalPath" like $6 + or "originalPath" like $7 + ) + +-- AssetRepository.filterNewExternalAssetPaths +select + "path" +from + unnest(array[$1]::text[]) as "path" +where + not exists ( + select + "originalPath" + from + "assets" + where + "assets"."originalPath" = "path" + and "libraryId" = $2::uuid + and "isExternal" = $3 + ) diff --git a/server/src/queries/audit.repository.sql b/server/src/queries/audit.repository.sql index 3c83d2d3e8..b1a10abf48 100644 --- a/server/src/queries/audit.repository.sql +++ b/server/src/queries/audit.repository.sql @@ -14,8 +14,3 @@ order by "audit"."entityId" desc, "audit"."entityType" desc, "audit"."createdAt" desc - --- AuditRepository.removeBefore -delete from "audit" -where - "createdAt" < $1 diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index d44d017045..e9e7340bf6 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -1,11 +1,5 @@ -- NOTE: This file is auto generated by ./sql-generator --- MemoryRepository.cleanup -delete from "memories" -where - "createdAt" < $1 - and "isSaved" = $2 - -- MemoryRepository.search select "memories".*, diff --git a/server/src/queries/move.repository.sql b/server/src/queries/move.repository.sql index a65c7a8b85..50c9ad7dd9 100644 --- a/server/src/queries/move.repository.sql +++ b/server/src/queries/move.repository.sql @@ -16,19 +16,6 @@ where returning * --- MoveRepository.cleanMoveHistory -delete from "move_history" -where - "move_history"."entityId" not in ( - select - "id" - from - "assets" - where - "assets"."id" = "move_history"."entityId" - ) - and "move_history"."pathType" = 'original' - -- MoveRepository.cleanMoveHistorySingle delete from "move_history" where diff --git a/server/src/queries/notification.repository.sql b/server/src/queries/notification.repository.sql index c55e00d226..f7e211d80a 100644 --- a/server/src/queries/notification.repository.sql +++ b/server/src/queries/notification.repository.sql @@ -1,23 +1,5 @@ -- NOTE: This file is auto generated by ./sql-generator --- NotificationRepository.cleanup -delete from "notifications" -where - ( - ( - "deletedAt" is not null - and "deletedAt" < $1 - ) - or ( - "readAt" > $2 - and "createdAt" < $3 - ) - or ( - "readAt" = $4 - and "createdAt" < $5 - ) - ) - -- NotificationRepository.search select "id", diff --git a/server/src/queries/partner.repository.sql b/server/src/queries/partner.repository.sql index e7170f367e..100f1bc638 100644 --- a/server/src/queries/partner.repository.sql +++ b/server/src/queries/partner.repository.sql @@ -100,50 +100,6 @@ where "sharedWithId" = $1 and "sharedById" = $2 --- PartnerRepository.create -insert into - "partners" ("sharedWithId", "sharedById") -values - ($1, $2) -returning - *, - ( - select - to_json(obj) - from - ( - select - "id", - "name", - "email", - "avatarColor", - "profileImagePath", - "profileChangedAt" - from - "users" as "sharedBy" - where - "sharedBy"."id" = "partners"."sharedById" - ) as obj - ) as "sharedBy", - ( - select - to_json(obj) - from - ( - select - "id", - "name", - "email", - "avatarColor", - "profileImagePath", - "profileChangedAt" - from - "users" as "sharedWith" - where - "sharedWith"."id" = "partners"."sharedWithId" - ) as obj - ) as "sharedWith" - -- PartnerRepository.update update "partners" set diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 659abbde03..fefc25ee6a 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -7,22 +7,10 @@ set where "asset_faces"."personId" = $2 --- PersonRepository.unassignFaces -update "asset_faces" -set - "personId" = $1 -where - "asset_faces"."sourceType" = $2 - -- PersonRepository.delete delete from "person" where - "person"."id" in $1 - --- PersonRepository.deleteFaces -delete from "asset_faces" -where - "asset_faces"."sourceType" = $1 + "person"."id" in ($1) -- PersonRepository.getAllWithoutFaces select @@ -216,21 +204,6 @@ where "person"."ownerId" = $3 and "asset_faces"."deletedAt" is null --- PersonRepository.refreshFaces -with - "added_embeddings" as ( - insert into - "face_search" ("faceId", "embedding") - values - ($1, $2) - ) -select -from - ( - select - 1 - ) as "dummy" - -- PersonRepository.getFacesByIds select "asset_faces".*, diff --git a/server/src/queries/system.metadata.repository.sql b/server/src/queries/system.metadata.repository.sql index c4fd7b96f8..8bdf1b3ad7 100644 --- a/server/src/queries/system.metadata.repository.sql +++ b/server/src/queries/system.metadata.repository.sql @@ -8,15 +8,6 @@ from where "key" = $1 --- SystemMetadataRepository.set -insert into - "system_metadata" ("key", "value") -values - ($1, $2) -on conflict ("key") do update -set - "value" = $3 - -- SystemMetadataRepository.delete delete from "system_metadata" where diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql index d728d3af88..af757d96b7 100644 --- a/server/src/queries/tag.repository.sql +++ b/server/src/queries/tag.repository.sql @@ -58,7 +58,7 @@ from where "userId" = $1 order by - "value" asc + "value" -- TagRepository.create insert into @@ -94,6 +94,15 @@ where "tagsId" = $1 and "assetsId" in ($2) +-- TagRepository.upsertAssetIds +insert into + "tag_asset" ("assetId", "tagsIds") +values + ($1, $2) +on conflict do nothing +returning + * + -- TagRepository.replaceAssetTags begin delete from "tag_asset" @@ -107,17 +116,3 @@ on conflict do nothing returning * rollback - --- TagRepository.deleteEmptyTags -begin -select - "tags"."id", - count("assets"."id") as "count" -from - "assets" - inner join "tag_asset" on "tag_asset"."assetsId" = "assets"."id" - inner join "tags_closure" on "tags_closure"."id_descendant" = "tag_asset"."tagsId" - inner join "tags" on "tags"."id" = "tags_closure"."id_descendant" -group by - "tags"."id" -commit diff --git a/server/src/queries/version.history.repository.sql b/server/src/queries/version.history.repository.sql index a9805e8c25..2e898cac31 100644 --- a/server/src/queries/version.history.repository.sql +++ b/server/src/queries/version.history.repository.sql @@ -15,11 +15,3 @@ from "version_history" order by "createdAt" desc - --- VersionHistoryRepository.create -insert into - "version_history" ("version") -values - ($1) -returning - * diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 9bd115089f..d49124b04b 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -817,9 +817,7 @@ export class AssetRepository { .execute(); } - @GenerateSql({ - params: [{ libraryId: DummyValue.UUID, importPaths: [DummyValue.STRING], exclusionPatterns: [DummyValue.STRING] }], - }) + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING], [DummyValue.STRING]] }) async detectOfflineExternalAssets( libraryId: string, importPaths: string[], @@ -846,9 +844,7 @@ export class AssetRepository { .executeTakeFirstOrThrow(); } - @GenerateSql({ - params: [{ libraryId: DummyValue.UUID, paths: [DummyValue.STRING] }], - }) + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) async filterNewExternalAssetPaths(libraryId: string, paths: string[]): Promise { const result = await this.db .selectFrom(unnest(paths).as('path')) diff --git a/server/src/repositories/audit.repository.ts b/server/src/repositories/audit.repository.ts index 48d7f28d12..1193e26ebe 100644 --- a/server/src/repositories/audit.repository.ts +++ b/server/src/repositories/audit.repository.ts @@ -38,7 +38,6 @@ export class AuditRepository { return records.map(({ entityId }) => entityId); } - @GenerateSql({ params: [DummyValue.DATE] }) async removeBefore(before: Date): Promise { await this.db.deleteFrom('audit').where('createdAt', '<', before).execute(); } diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 44c7c30857..1a1ea2827b 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -12,7 +12,6 @@ import { IBulkAsset } from 'src/types'; export class MemoryRepository implements IBulkAsset { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [DummyValue.UUID] }) cleanup() { return this.db .deleteFrom('memories') diff --git a/server/src/repositories/move.repository.ts b/server/src/repositories/move.repository.ts index 21c52aec65..a21167fffd 100644 --- a/server/src/repositories/move.repository.ts +++ b/server/src/repositories/move.repository.ts @@ -37,7 +37,6 @@ export class MoveRepository { return this.db.deleteFrom('move_history').where('id', '=', id).returningAll().executeTakeFirstOrThrow(); } - @GenerateSql() async cleanMoveHistory(): Promise { await this.db .deleteFrom('move_history') @@ -52,7 +51,7 @@ export class MoveRepository { .execute(); } - @GenerateSql() + @GenerateSql({ params: [DummyValue.UUID] }) async cleanMoveHistorySingle(assetId: string): Promise { await this.db .deleteFrom('move_history') diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index 112bb97e60..b35f532094 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -9,7 +9,6 @@ import { NotificationSearchDto } from 'src/dtos/notification.dto'; export class NotificationRepository { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [DummyValue.UUID] }) cleanup() { return this.db .deleteFrom('notifications') diff --git a/server/src/repositories/partner.repository.ts b/server/src/repositories/partner.repository.ts index ea762d0aaf..31350541ca 100644 --- a/server/src/repositories/partner.repository.ts +++ b/server/src/repositories/partner.repository.ts @@ -47,7 +47,6 @@ export class PartnerRepository { .executeTakeFirst(); } - @GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] }) create(values: Insertable) { return this.db .insertInto('partners') diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 0b48e57f7a..ad18d7ed67 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -98,7 +98,6 @@ export class PersonRepository { return Number(result.numChangedRows ?? 0); } - @GenerateSql({ params: [{ sourceType: SourceType.EXIF }] }) async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { await this.db .updateTable('asset_faces') @@ -107,7 +106,7 @@ export class PersonRepository { .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) + @GenerateSql({ params: [[DummyValue.UUID]] }) async delete(ids: string[]): Promise { if (ids.length === 0) { return; @@ -116,7 +115,6 @@ export class PersonRepository { await this.db.deleteFrom('person').where('person.id', 'in', ids).execute(); } - @GenerateSql({ params: [{ sourceType: SourceType.EXIF }] }) async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.db.deleteFrom('asset_faces').where('asset_faces.sourceType', '=', sourceType).execute(); } @@ -400,7 +398,6 @@ export class PersonRepository { return results.map(({ id }) => id); } - @GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] }) async refreshFaces( facesToAdd: (Insertable & { assetId: string })[], faceIdsToRemove: string[], diff --git a/server/src/repositories/system-metadata.repository.ts b/server/src/repositories/system-metadata.repository.ts index 2038f204f7..fcccde6a5c 100644 --- a/server/src/repositories/system-metadata.repository.ts +++ b/server/src/repositories/system-metadata.repository.ts @@ -26,7 +26,6 @@ export class SystemMetadataRepository { return metadata.value as SystemMetadata[T]; } - @GenerateSql({ params: ['metadata_key', { foo: 'bar' }] }) async set(key: T, value: SystemMetadata[T]): Promise { await this.db .insertInto('system_metadata') diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 9a3b33188f..a7cdc9554c 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -68,7 +68,7 @@ export class TagRepository { @GenerateSql({ params: [DummyValue.UUID] }) getAll(userId: string) { - return this.db.selectFrom('tags').select(columns.tag).where('userId', '=', userId).orderBy('value asc').execute(); + return this.db.selectFrom('tags').select(columns.tag).where('userId', '=', userId).orderBy('value').execute(); } @GenerateSql({ params: [{ userId: DummyValue.UUID, color: DummyValue.STRING, value: DummyValue.STRING }] }) @@ -126,7 +126,7 @@ export class TagRepository { await this.db.deleteFrom('tag_asset').where('tagsId', '=', tagId).where('assetsId', 'in', assetIds).execute(); } - @GenerateSql({ params: [{ assetId: DummyValue.UUID, tagsIds: [DummyValue.UUID] }] }) + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagsIds: [DummyValue.UUID] }]] }) @Chunked() upsertAssetIds(items: Insertable[]) { if (items.length === 0) { @@ -160,7 +160,6 @@ export class TagRepository { }); } - @GenerateSql() async deleteEmptyTags() { // TODO rewrite as a single statement await this.db.transaction().execute(async (tx) => { diff --git a/server/src/repositories/version-history.repository.ts b/server/src/repositories/version-history.repository.ts index 063ee0da84..b1d2696164 100644 --- a/server/src/repositories/version-history.repository.ts +++ b/server/src/repositories/version-history.repository.ts @@ -18,7 +18,6 @@ export class VersionHistoryRepository { return this.db.selectFrom('version_history').selectAll().orderBy('createdAt', 'desc').executeTakeFirst(); } - @GenerateSql({ params: [{ version: 'v1.123.0' }] }) create(version: Insertable) { return this.db.insertInto('version_history').values(version).returningAll().executeTakeFirstOrThrow(); } From 4935f3e0bbf699ef83f219a9f32d53b4cbe7822e Mon Sep 17 00:00:00 2001 From: Ruslan Date: Thu, 15 May 2025 18:32:31 +0300 Subject: [PATCH 39/48] fix(docs): Update old jellyfin docs links (#18311) Update old jellyfin docs links Updated old links to jellyfin docs --- docs/docs/features/hardware-transcoding.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/features/hardware-transcoding.md b/docs/docs/features/hardware-transcoding.md index 18c7f6b298..d28cd97de0 100644 --- a/docs/docs/features/hardware-transcoding.md +++ b/docs/docs/features/hardware-transcoding.md @@ -121,6 +121,6 @@ Once this is done, you can continue to step 3 of "Basic Setup". [hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.transcoding.yml [nvct]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html -[jellyfin-lp]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#configure-and-verify-lp-mode-on-linux -[jellyfin-kernel-bug]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#known-issues-and-limitations +[jellyfin-lp]: https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/intel#low-power-encoding +[jellyfin-kernel-bug]: https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/intel#known-issues-and-limitations-on-linux [libmali-rockchip]: https://github.com/tsukumijima/libmali-rockchip/releases From b7b0b9b6d8d7ed1ca03ad6fdccb1b1a7d499af44 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 15 May 2025 09:35:21 -0600 Subject: [PATCH 40/48] feat: locked/private view (#18268) * feat: locked/private view * feat: locked/private view * pr feedback * fix: redirect loop * pr feedback --- i18n/en.json | 16 +++ mobile/lib/utils/openapi_patching.dart | 1 + mobile/openapi/README.md | 1 + .../openapi/lib/api/authentication_api.dart | 39 ++++++ .../openapi/lib/model/asset_response_dto.dart | 94 +++++++++++++- .../openapi/lib/model/asset_visibility.dart | 3 + .../lib/model/auth_status_response_dto.dart | 10 +- mobile/openapi/lib/model/sync_asset_v1.dart | 3 + open-api/immich-openapi-specs.json | 57 ++++++++- open-api/typescript-sdk/src/fetch-client.ts | 20 ++- server/src/controllers/auth.controller.ts | 7 ++ .../src/controllers/search.controller.spec.ts | 2 +- server/src/database.ts | 4 +- server/src/db.d.ts | 1 + server/src/dtos/asset-response.dto.ts | 2 + server/src/dtos/auth.dto.ts | 1 + server/src/enum.ts | 1 + server/src/queries/access.repository.sql | 1 + server/src/queries/album.repository.sql | 5 + server/src/queries/session.repository.sql | 1 + server/src/repositories/access.repository.ts | 3 +- server/src/repositories/album.repository.ts | 6 +- .../1746844028242-AddLockedVisibilityEnum.ts | 9 ++ .../1746987967923-AddPinExpiresAtColumn.ts | 9 ++ server/src/schema/tables/session.table.ts | 3 + server/src/services/album.service.spec.ts | 9 +- .../src/services/asset-media.service.spec.ts | 10 +- server/src/services/asset.service.spec.ts | 1 + server/src/services/asset.service.ts | 6 +- server/src/services/auth.service.spec.ts | 12 +- server/src/services/auth.service.ts | 37 ++++++ server/src/services/metadata.service.spec.ts | 6 +- server/src/services/metadata.service.ts | 2 +- server/src/services/session.service.spec.ts | 1 + .../src/services/shared-link.service.spec.ts | 2 + server/src/utils/access.ts | 14 +-- server/test/fixtures/auth.stub.ts | 6 +- server/test/fixtures/shared-link.stub.ts | 1 + server/test/small.factory.ts | 3 +- .../components/asset-viewer/actions/action.ts | 2 + .../actions/set-visibility-action.svelte | 60 +++++++++ .../asset-viewer/asset-viewer-nav-bar.svelte | 49 +++++--- .../components/layouts/AuthPageLayout.svelte | 19 +-- .../photos-page/actions/delete-assets.svelte | 8 +- .../actions/select-all-assets.svelte | 30 +++-- .../actions/set-visibility-action.svelte | 72 +++++++++++ .../components/photos-page/asset-grid.svelte | 13 +- .../empty-placeholder.svelte | 9 +- .../side-bar/user-sidebar.svelte | 10 ++ .../PinCodeChangeForm.svelte | 79 ++++++++++++ .../PinCodeCreateForm.svelte | 72 +++++++++++ .../user-settings-page/PinCodeInput.svelte | 29 ++++- .../user-settings-page/PinCodeSettings.svelte | 118 +++--------------- web/src/lib/constants.ts | 4 + web/src/lib/utils/actions.ts | 1 + .../[[assetId=id]]/+page.svelte | 76 +++++++++++ .../[[photos=photos]]/[[assetId=id]]/+page.ts | 28 +++++ .../(user)/photos/[[assetId=id]]/+page.svelte | 7 ++ web/src/routes/auth/pin-prompt/+page.svelte | 84 +++++++++++++ web/src/routes/auth/pin-prompt/+page.ts | 22 ++++ web/src/test-data/factories/asset-factory.ts | 3 +- 61 files changed, 1018 insertions(+), 186 deletions(-) create mode 100644 server/src/schema/migrations/1746844028242-AddLockedVisibilityEnum.ts create mode 100644 server/src/schema/migrations/1746987967923-AddPinExpiresAtColumn.ts create mode 100644 web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte create mode 100644 web/src/lib/components/photos-page/actions/set-visibility-action.svelte create mode 100644 web/src/lib/components/user-settings-page/PinCodeChangeForm.svelte create mode 100644 web/src/lib/components/user-settings-page/PinCodeCreateForm.svelte create mode 100644 web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte create mode 100644 web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts create mode 100644 web/src/routes/auth/pin-prompt/+page.svelte create mode 100644 web/src/routes/auth/pin-prompt/+page.ts diff --git a/i18n/en.json b/i18n/en.json index b712faa3c2..05b236b33a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,4 +1,19 @@ { + "new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page", + "enter_your_pin_code": "Enter your PIN code", + "enter_your_pin_code_subtitle": "Enter your PIN code to access the locked folder", + "pin_verification": "PIN code verification", + "wrong_pin_code": "Wrong PIN code", + "nothing_here_yet": "Nothing here yet", + "move_to_locked_folder": "Move to Locked Folder", + "remove_from_locked_folder": "Remove from Locked Folder", + "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the Locked Folder", + "remove_from_locked_folder_confirmation": "Are you sure you want to move these photos and videos out of Locked Folder? They will be visible in your library", + "move": "Move", + "no_locked_photos_message": "Photos and videos in Locked Folder are hidden and won't show up as you browser your library.", + "locked_folder": "Locked Folder", + "add_to_locked_folder": "Add to Locked Folder", + "move_off_locked_folder": "Move out of Locked Folder", "user_pin_code_settings": "PIN Code", "user_pin_code_settings_description": "Manage your PIN code", "current_pin_code": "Current PIN code", @@ -837,6 +852,7 @@ "error_saving_image": "Error: {error}", "error_title": "Error - Something went wrong", "errors": { + "unable_to_move_to_locked_folder": "Unable to move to locked folder", "cannot_navigate_next_asset": "Cannot navigate to the next asset", "cannot_navigate_previous_asset": "Cannot navigate to previous asset", "cant_apply_changes": "Can't apply changes", diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 708aec603f..d054749b1e 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -29,6 +29,7 @@ dynamic upgradeDto(dynamic value, String targetType) { case 'UserResponseDto': if (value is Map) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); + addDefault(value, 'visibility', AssetVisibility.timeline); } break; case 'UserAdminResponseDto': diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9a3055911d..3aed98adf1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -117,6 +117,7 @@ Class | Method | HTTP request | Description *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 | +*AuthenticationApi* | [**verifyPinCode**](doc//AuthenticationApi.md#verifypincode) | **POST** /auth/pin-code/verify | *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index f850bdf403..446a0616ed 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -396,4 +396,43 @@ class AuthenticationApi { } return null; } + + /// Performs an HTTP 'POST /auth/pin-code/verify' operation and returns the [Response]. + /// Parameters: + /// + /// * [PinCodeSetupDto] pinCodeSetupDto (required): + Future verifyPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/pin-code/verify'; + + // 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 verifyPinCode(PinCodeSetupDto pinCodeSetupDto,) async { + final response = await verifyPinCodeWithHttpInfo(pinCodeSetupDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } } diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 5f01f84419..74af8bd1eb 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -43,6 +43,7 @@ class AssetResponseDto { required this.type, this.unassignedFaces = const [], required this.updatedAt, + required this.visibility, }); /// base64 encoded sha1 hash @@ -132,6 +133,8 @@ class AssetResponseDto { DateTime updatedAt; + AssetResponseDtoVisibilityEnum visibility; + @override bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && other.checksum == checksum && @@ -163,7 +166,8 @@ class AssetResponseDto { other.thumbhash == thumbhash && other.type == type && _deepEquality.equals(other.unassignedFaces, unassignedFaces) && - other.updatedAt == updatedAt; + other.updatedAt == updatedAt && + other.visibility == visibility; @override int get hashCode => @@ -197,10 +201,11 @@ class AssetResponseDto { (thumbhash == null ? 0 : thumbhash!.hashCode) + (type.hashCode) + (unassignedFaces.hashCode) + - (updatedAt.hashCode); + (updatedAt.hashCode) + + (visibility.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]'; Map toJson() { final json = {}; @@ -270,6 +275,7 @@ class AssetResponseDto { json[r'type'] = this.type; json[r'unassignedFaces'] = this.unassignedFaces; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'visibility'] = this.visibility; return json; } @@ -312,6 +318,7 @@ class AssetResponseDto { type: AssetTypeEnum.fromJson(json[r'type'])!, unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), updatedAt: mapDateTime(json, r'updatedAt', r'')!, + visibility: AssetResponseDtoVisibilityEnum.fromJson(json[r'visibility'])!, ); } return null; @@ -378,6 +385,87 @@ class AssetResponseDto { 'thumbhash', 'type', 'updatedAt', + 'visibility', }; } + +class AssetResponseDtoVisibilityEnum { + /// Instantiate a new enum with the provided [value]. + const AssetResponseDtoVisibilityEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const archive = AssetResponseDtoVisibilityEnum._(r'archive'); + static const timeline = AssetResponseDtoVisibilityEnum._(r'timeline'); + static const hidden = AssetResponseDtoVisibilityEnum._(r'hidden'); + static const locked = AssetResponseDtoVisibilityEnum._(r'locked'); + + /// List of all possible values in this [enum][AssetResponseDtoVisibilityEnum]. + static const values = [ + archive, + timeline, + hidden, + locked, + ]; + + static AssetResponseDtoVisibilityEnum? fromJson(dynamic value) => AssetResponseDtoVisibilityEnumTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetResponseDtoVisibilityEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetResponseDtoVisibilityEnum] to String, +/// and [decode] dynamic data back to [AssetResponseDtoVisibilityEnum]. +class AssetResponseDtoVisibilityEnumTypeTransformer { + factory AssetResponseDtoVisibilityEnumTypeTransformer() => _instance ??= const AssetResponseDtoVisibilityEnumTypeTransformer._(); + + const AssetResponseDtoVisibilityEnumTypeTransformer._(); + + String encode(AssetResponseDtoVisibilityEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetResponseDtoVisibilityEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AssetResponseDtoVisibilityEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'archive': return AssetResponseDtoVisibilityEnum.archive; + case r'timeline': return AssetResponseDtoVisibilityEnum.timeline; + case r'hidden': return AssetResponseDtoVisibilityEnum.hidden; + case r'locked': return AssetResponseDtoVisibilityEnum.locked; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetResponseDtoVisibilityEnumTypeTransformer] instance. + static AssetResponseDtoVisibilityEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/asset_visibility.dart b/mobile/openapi/lib/model/asset_visibility.dart index 4d0c7ee8d3..498bf17c38 100644 --- a/mobile/openapi/lib/model/asset_visibility.dart +++ b/mobile/openapi/lib/model/asset_visibility.dart @@ -26,12 +26,14 @@ class AssetVisibility { static const archive = AssetVisibility._(r'archive'); static const timeline = AssetVisibility._(r'timeline'); static const hidden = AssetVisibility._(r'hidden'); + static const locked = AssetVisibility._(r'locked'); /// List of all possible values in this [enum][AssetVisibility]. static const values = [ archive, timeline, hidden, + locked, ]; static AssetVisibility? fromJson(dynamic value) => AssetVisibilityTypeTransformer().decode(value); @@ -73,6 +75,7 @@ class AssetVisibilityTypeTransformer { case r'archive': return AssetVisibility.archive; case r'timeline': return AssetVisibility.timeline; case r'hidden': return AssetVisibility.hidden; + case r'locked': return AssetVisibility.locked; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/auth_status_response_dto.dart b/mobile/openapi/lib/model/auth_status_response_dto.dart index 203923164f..0ccd87114e 100644 --- a/mobile/openapi/lib/model/auth_status_response_dto.dart +++ b/mobile/openapi/lib/model/auth_status_response_dto.dart @@ -13,30 +13,36 @@ part of openapi.api; class AuthStatusResponseDto { /// Returns a new [AuthStatusResponseDto] instance. AuthStatusResponseDto({ + required this.isElevated, required this.password, required this.pinCode, }); + bool isElevated; + bool password; bool pinCode; @override bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto && + other.isElevated == isElevated && other.password == password && other.pinCode == pinCode; @override int get hashCode => // ignore: unnecessary_parenthesis + (isElevated.hashCode) + (password.hashCode) + (pinCode.hashCode); @override - String toString() => 'AuthStatusResponseDto[password=$password, pinCode=$pinCode]'; + String toString() => 'AuthStatusResponseDto[isElevated=$isElevated, password=$password, pinCode=$pinCode]'; Map toJson() { final json = {}; + json[r'isElevated'] = this.isElevated; json[r'password'] = this.password; json[r'pinCode'] = this.pinCode; return json; @@ -51,6 +57,7 @@ class AuthStatusResponseDto { final json = value.cast(); return AuthStatusResponseDto( + isElevated: mapValueOfType(json, r'isElevated')!, password: mapValueOfType(json, r'password')!, pinCode: mapValueOfType(json, r'pinCode')!, ); @@ -100,6 +107,7 @@ class AuthStatusResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'isElevated', 'password', 'pinCode', }; diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart index e1d3199428..f5d59b6ae9 100644 --- a/mobile/openapi/lib/model/sync_asset_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_v1.dart @@ -293,12 +293,14 @@ class SyncAssetV1VisibilityEnum { static const archive = SyncAssetV1VisibilityEnum._(r'archive'); static const timeline = SyncAssetV1VisibilityEnum._(r'timeline'); static const hidden = SyncAssetV1VisibilityEnum._(r'hidden'); + static const locked = SyncAssetV1VisibilityEnum._(r'locked'); /// List of all possible values in this [enum][SyncAssetV1VisibilityEnum]. static const values = [ archive, timeline, hidden, + locked, ]; static SyncAssetV1VisibilityEnum? fromJson(dynamic value) => SyncAssetV1VisibilityEnumTypeTransformer().decode(value); @@ -340,6 +342,7 @@ class SyncAssetV1VisibilityEnumTypeTransformer { case r'archive': return SyncAssetV1VisibilityEnum.archive; case r'timeline': return SyncAssetV1VisibilityEnum.timeline; case r'hidden': return SyncAssetV1VisibilityEnum.hidden; + case r'locked': return SyncAssetV1VisibilityEnum.locked; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3c0dc09953..2dbec35079 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2470,6 +2470,41 @@ ] } }, + "/auth/pin-code/verify": { + "post": { + "operationId": "verifyPinCode", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PinCodeSetupDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Authentication" + ] + } + }, "/auth/status": { "get": { "operationId": "getAuthStatus", @@ -9150,6 +9185,15 @@ "updatedAt": { "format": "date-time", "type": "string" + }, + "visibility": { + "enum": [ + "archive", + "timeline", + "hidden", + "locked" + ], + "type": "string" } }, "required": [ @@ -9171,7 +9215,8 @@ "ownerId", "thumbhash", "type", - "updatedAt" + "updatedAt", + "visibility" ], "type": "object" }, @@ -9226,7 +9271,8 @@ "enum": [ "archive", "timeline", - "hidden" + "hidden", + "locked" ], "type": "string" }, @@ -9241,6 +9287,9 @@ }, "AuthStatusResponseDto": { "properties": { + "isElevated": { + "type": "boolean" + }, "password": { "type": "boolean" }, @@ -9249,6 +9298,7 @@ } }, "required": [ + "isElevated", "password", "pinCode" ], @@ -12664,7 +12714,8 @@ "enum": [ "archive", "timeline", - "hidden" + "hidden", + "locked" ], "type": "string" } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 144e7f8ac1..ad7413e6fd 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -329,6 +329,7 @@ export type AssetResponseDto = { "type": AssetTypeEnum; unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; updatedAt: string; + visibility: Visibility; }; export type AlbumResponseDto = { albumName: string; @@ -520,6 +521,7 @@ export type PinCodeSetupDto = { pinCode: string; }; export type AuthStatusResponseDto = { + isElevated: boolean; password: boolean; pinCode: boolean; }; @@ -2076,6 +2078,15 @@ export function changePinCode({ pinCodeChangeDto }: { body: pinCodeChangeDto }))); } +export function verifyPinCode({ pinCodeSetupDto }: { + pinCodeSetupDto: PinCodeSetupDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/auth/pin-code/verify", oazapfts.json({ + ...opts, + method: "POST", + body: pinCodeSetupDto + }))); +} export function getAuthStatus(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3574,7 +3585,8 @@ export enum UserStatus { export enum AssetVisibility { Archive = "archive", Timeline = "timeline", - Hidden = "hidden" + Hidden = "hidden", + Locked = "locked" } export enum AlbumUserRole { Editor = "editor", @@ -3591,6 +3603,12 @@ export enum AssetTypeEnum { Audio = "AUDIO", Other = "OTHER" } +export enum Visibility { + Archive = "archive", + Timeline = "timeline", + Hidden = "hidden", + Locked = "locked" +} export enum AssetOrder { Asc = "asc", Desc = "desc" diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 56acaa5c6d..5d3ba8be95 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -101,4 +101,11 @@ export class AuthController { async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise { return this.service.resetPinCode(auth, dto); } + + @Post('pin-code/verify') + @HttpCode(HttpStatus.OK) + @Authenticated() + async verifyPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise { + return this.service.verifyPinCode(auth, dto); + } } diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index 14130fabcb..39d2cb8fcd 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -66,7 +66,7 @@ describe(SearchController.name, () => { .send({ visibility: 'immich' }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden']), + errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden, locked']), ); }); diff --git a/server/src/database.ts b/server/src/database.ts index a13b074448..29c746aa1f 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -200,6 +200,7 @@ export type Album = Selectable & { export type AuthSession = { id: string; + hasElevatedPermission: boolean; }; export type Partner = { @@ -233,6 +234,7 @@ export type Session = { updatedAt: Date; deviceOS: string; deviceType: string; + pinExpiresAt: Date | null; }; export type Exif = Omit, 'updatedAt' | 'updateId'>; @@ -306,7 +308,7 @@ export const columns = { 'users.quotaSizeInBytes', ], authApiKey: ['api_keys.id', 'api_keys.permissions'], - authSession: ['sessions.id', 'sessions.updatedAt'], + authSession: ['sessions.id', 'sessions.updatedAt', 'sessions.pinExpiresAt'], authSharedLink: [ 'shared_links.id', 'shared_links.userId', diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 1b039f9982..1fd7fdc22b 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -347,6 +347,7 @@ export interface Sessions { updatedAt: Generated; updateId: Generated; userId: string; + pinExpiresAt: Timestamp | null; } export interface SessionSyncCheckpoints { diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 480ad0b9b9..2a44a34b58 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -43,6 +43,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { isArchived!: boolean; isTrashed!: boolean; isOffline!: boolean; + visibility!: AssetVisibility; exifInfo?: ExifResponseDto; tags?: TagResponseDto[]; people?: PersonWithFacesResponseDto[]; @@ -184,6 +185,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false, isArchived: entity.visibility === AssetVisibility.ARCHIVE, isTrashed: !!entity.deletedAt, + visibility: entity.visibility, duration: entity.duration ?? '0:00:00.00000', exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index cc05d2d860..8644426ab2 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -138,4 +138,5 @@ export class OAuthAuthorizeResponseDto { export class AuthStatusResponseDto { pinCode!: boolean; password!: boolean; + isElevated!: boolean; } diff --git a/server/src/enum.ts b/server/src/enum.ts index f214593975..fedfaa6b79 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -627,4 +627,5 @@ export enum AssetVisibility { * Video part of the LivePhotos and MotionPhotos */ HIDDEN = 'hidden', + LOCKED = 'locked', } diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index f550c5b0c1..c73f44c19d 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -98,6 +98,7 @@ from where "assets"."id" in ($1) and "assets"."ownerId" = $2 + and "assets"."visibility" != $3 -- AccessRepository.asset.checkPartnerAccess select diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index f4eb6a9929..2b351368ef 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -392,6 +392,11 @@ where order by "albums"."createdAt" desc +-- AlbumRepository.removeAssetsFromAll +delete from "albums_assets_assets" +where + "albums_assets_assets"."assetsId" in ($1) + -- AlbumRepository.getAssetIds select * diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index eea2356897..c2daa2a49c 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -12,6 +12,7 @@ where select "sessions"."id", "sessions"."updatedAt", + "sessions"."pinExpiresAt", ( select to_json(obj) diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 5680ce2c64..b25007c4ea 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -168,7 +168,7 @@ class AssetAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, assetIds: Set) { + async checkOwnerAccess(userId: string, assetIds: Set, hasElevatedPermission: boolean | undefined) { if (assetIds.size === 0) { return new Set(); } @@ -178,6 +178,7 @@ class AssetAccess { .select('assets.id') .where('assets.id', 'in', [...assetIds]) .where('assets.ownerId', '=', userId) + .$if(!hasElevatedPermission, (eb) => eb.where('assets.visibility', '!=', AssetVisibility.LOCKED)) .execute() .then((assets) => new Set(assets.map((asset) => asset.id))); } diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 1768135210..c8bdae6d31 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -220,8 +220,10 @@ export class AlbumRepository { await this.db.deleteFrom('albums').where('ownerId', '=', userId).execute(); } - async removeAsset(assetId: string): Promise { - await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', '=', assetId).execute(); + @GenerateSql({ params: [[DummyValue.UUID]] }) + @Chunked() + async removeAssetsFromAll(assetIds: string[]): Promise { + await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', 'in', assetIds).execute(); } @Chunked({ paramIndex: 1 }) diff --git a/server/src/schema/migrations/1746844028242-AddLockedVisibilityEnum.ts b/server/src/schema/migrations/1746844028242-AddLockedVisibilityEnum.ts new file mode 100644 index 0000000000..9a344be66d --- /dev/null +++ b/server/src/schema/migrations/1746844028242-AddLockedVisibilityEnum.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TYPE "asset_visibility_enum" ADD VALUE IF NOT EXISTS 'locked';`.execute(db); +} + +export async function down(): Promise { + // noop +} diff --git a/server/src/schema/migrations/1746987967923-AddPinExpiresAtColumn.ts b/server/src/schema/migrations/1746987967923-AddPinExpiresAtColumn.ts new file mode 100644 index 0000000000..b0f7d072d5 --- /dev/null +++ b/server/src/schema/migrations/1746987967923-AddPinExpiresAtColumn.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" ADD "pinExpiresAt" timestamp with time zone;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" DROP COLUMN "pinExpiresAt";`.execute(db); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index ad43d0d6e4..090b469b54 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -36,4 +36,7 @@ export class SessionTable { @UpdateIdColumn({ indexName: 'IDX_sessions_update_id' }) updateId!: string; + + @Column({ type: 'timestamp with time zone', nullable: true }) + pinExpiresAt!: Date | null; } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 9a3bb605f7..c2b792d091 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -163,7 +163,7 @@ describe(AlbumService.name, () => { ); expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false); expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.empty.id, userId: 'user-id', @@ -207,6 +207,7 @@ describe(AlbumService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['asset-1', 'asset-2']), + false, ); }); }); @@ -688,7 +689,11 @@ describe(AlbumService.name, () => { { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, ]); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( + authStub.admin.user.id, + new Set(['asset-1']), + false, + ); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 8490e8aaea..bb8f7115b8 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -481,7 +481,11 @@ describe(AssetMediaService.name, () => { it('should require the asset.download permission', async () => { await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( + authStub.admin.user.id, + new Set(['asset-1']), + undefined, + ); expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); @@ -512,7 +516,7 @@ describe(AssetMediaService.name, () => { it('should require asset.view permissions', async () => { await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined); expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); }); @@ -611,7 +615,7 @@ describe(AssetMediaService.name, () => { it('should require asset.view permissions', async () => { await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined); expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); }); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 1e4cfddcf5..333f4530de 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -122,6 +122,7 @@ describe(AssetService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), + undefined, ); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 3ab6fcb8a7..556641fdb0 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -14,7 +14,7 @@ import { mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum'; +import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { ISidecarWriteJob, JobItem, JobOf } from 'src/types'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; @@ -125,6 +125,10 @@ export class AssetService extends BaseService { options.rating !== undefined ) { await this.assetRepository.updateAll(ids, options); + + if (options.visibility === AssetVisibility.LOCKED) { + await this.albumRepository.removeAssetsFromAll(ids); + } } } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 82172d6b95..fb1a5ae042 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -253,6 +253,7 @@ describe(AuthService.name, () => { id: session.id, updatedAt: session.updatedAt, user: factory.authUser(), + pinExpiresAt: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -265,7 +266,7 @@ describe(AuthService.name, () => { }), ).resolves.toEqual({ user: sessionWithToken.user, - session: { id: session.id }, + session: { id: session.id, hasElevatedPermission: false }, }); }); }); @@ -376,6 +377,7 @@ describe(AuthService.name, () => { id: session.id, updatedAt: session.updatedAt, user: factory.authUser(), + pinExpiresAt: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -388,7 +390,7 @@ describe(AuthService.name, () => { }), ).resolves.toEqual({ user: sessionWithToken.user, - session: { id: session.id }, + session: { id: session.id, hasElevatedPermission: false }, }); }); @@ -398,6 +400,7 @@ describe(AuthService.name, () => { id: session.id, updatedAt: session.updatedAt, user: factory.authUser(), + pinExpiresAt: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -417,6 +420,7 @@ describe(AuthService.name, () => { id: session.id, updatedAt: session.updatedAt, user: factory.authUser(), + pinExpiresAt: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -916,13 +920,17 @@ describe(AuthService.name, () => { describe('resetPinCode', () => { it('should reset the PIN code', async () => { + const currentSession = factory.session(); const user = factory.userAdmin(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); + mocks.session.getByUserId.mockResolvedValue([currentSession]); + mocks.session.update.mockResolvedValue(currentSession); await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); + expect(mocks.session.update).toHaveBeenCalledWith(currentSession.id, { pinExpiresAt: null }); }); it('should throw if the PIN code does not match', async () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 65dd84693b..496c252643 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -126,6 +126,10 @@ export class AuthService extends BaseService { this.resetPinChecks(user, dto); await this.userRepository.update(auth.user.id, { pinCode: null }); + const sessions = await this.sessionRepository.getByUserId(auth.user.id); + for (const session of sessions) { + await this.sessionRepository.update(session.id, { pinExpiresAt: null }); + } } async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) { @@ -444,10 +448,25 @@ export class AuthService extends BaseService { await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() }); } + // Pin check + let hasElevatedPermission = false; + + if (session.pinExpiresAt) { + const pinExpiresAt = DateTime.fromJSDate(session.pinExpiresAt); + hasElevatedPermission = pinExpiresAt > now; + + if (hasElevatedPermission && now.plus({ minutes: 5 }) > pinExpiresAt) { + await this.sessionRepository.update(session.id, { + pinExpiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate(), + }); + } + } + return { user: session.user, session: { id: session.id, + hasElevatedPermission, }, }; } @@ -455,6 +474,23 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Invalid user token'); } + async verifyPinCode(auth: AuthDto, dto: PinCodeSetupDto): Promise { + const user = await this.userRepository.getForPinCode(auth.user.id); + if (!user) { + throw new UnauthorizedException(); + } + + this.resetPinChecks(user, { pinCode: dto.pinCode }); + + if (!auth.session) { + throw new BadRequestException('Session is missing'); + } + + await this.sessionRepository.update(auth.session.id, { + pinExpiresAt: new Date(DateTime.now().plus({ minutes: 15 }).toJSDate()), + }); + } + private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) { const key = this.cryptoRepository.newPassword(32); const token = this.cryptoRepository.hashSha256(key); @@ -493,6 +529,7 @@ export class AuthService extends BaseService { return { pinCode: !!user.pinCode, password: !!user.password, + isElevated: !!auth.session?.hasElevatedPermission, }; } } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 28cb42a16b..7b2cba1250 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1310,7 +1310,7 @@ describe(MetadataService.name, () => { expect(mocks.asset.update).not.toHaveBeenCalledWith( expect.objectContaining({ visibility: AssetVisibility.HIDDEN }), ); - expect(mocks.album.removeAsset).not.toHaveBeenCalled(); + expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled(); }); it('should handle not finding a match', async () => { @@ -1331,7 +1331,7 @@ describe(MetadataService.name, () => { expect(mocks.asset.update).not.toHaveBeenCalledWith( expect.objectContaining({ visibility: AssetVisibility.HIDDEN }), ); - expect(mocks.album.removeAsset).not.toHaveBeenCalled(); + expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled(); }); it('should link photo and video', async () => { @@ -1356,7 +1356,7 @@ describe(MetadataService.name, () => { id: assetStub.livePhotoMotionAsset.id, visibility: AssetVisibility.HIDDEN, }); - expect(mocks.album.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); + expect(mocks.album.removeAssetsFromAll).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); }); it('should notify clients on live photo link', async () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 3497b808da..109f5f6936 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -158,7 +158,7 @@ export class MetadataService extends BaseService { await Promise.all([ this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }), this.assetRepository.update({ id: motionAsset.id, visibility: AssetVisibility.HIDDEN }), - this.albumRepository.removeAsset(motionAsset.id), + this.albumRepository.removeAssetsFromAll([motionAsset.id]), ]); await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index c3ab5619be..6e26b26407 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -34,6 +34,7 @@ describe('SessionService', () => { token: '420', userId: '42', updateId: 'uuid-v7', + pinExpiresAt: null, }, ]); mocks.session.delete.mockResolvedValue(); diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 66a0a925c7..b3b4c4b1cf 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -156,6 +156,7 @@ describe(SharedLinkService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), + false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, @@ -186,6 +187,7 @@ describe(SharedLinkService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), + false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index b04d23f114..e2fe7429f3 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -81,7 +81,7 @@ const checkSharedLinkAccess = async ( case Permission.ASSET_SHARE: { // TODO: fix this to not use sharedLink.userId for access control - return await access.asset.checkOwnerAccess(sharedLink.userId, ids); + return await access.asset.checkOwnerAccess(sharedLink.userId, ids, false); } case Permission.ALBUM_READ: { @@ -119,38 +119,38 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe } case Permission.ASSET_READ: { - const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); return setUnion(isOwner, isAlbum, isPartner); } case Permission.ASSET_SHARE: { - const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); return setUnion(isOwner, isPartner); } case Permission.ASSET_VIEW: { - const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); return setUnion(isOwner, isAlbum, isPartner); } case Permission.ASSET_DOWNLOAD: { - const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); return setUnion(isOwner, isAlbum, isPartner); } case Permission.ASSET_UPDATE: { - return await access.asset.checkOwnerAccess(auth.user.id, ids); + return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } case Permission.ASSET_DELETE: { - return await access.asset.checkOwnerAccess(auth.user.id, ids); + return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } case Permission.ALBUM_READ: { diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 9ef55398d3..3e5825c0cc 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,4 +1,4 @@ -import { Session } from 'src/database'; +import { AuthSession } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; const authUser = { @@ -26,7 +26,7 @@ export const authStub = { user: authUser.user1, session: { id: 'token-id', - } as Session, + } as AuthSession, }), user2: Object.freeze({ user: { @@ -39,7 +39,7 @@ export const authStub = { }, session: { id: 'token-id', - } as Session, + } as AuthSession, }), adminSharedLink: Object.freeze({ user: authUser.admin, diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index fc4b74ba2d..f3096280d9 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -70,6 +70,7 @@ const assetResponse: AssetResponseDto = { isTrashed: false, libraryId: 'library-id', hasMetadata: true, + visibility: AssetVisibility.TIMELINE, }; const assetResponseWithoutMetadata = { diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 94ae3b74aa..01091854fa 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -58,7 +58,7 @@ const authFactory = ({ } if (session) { - auth.session = { id: session.id }; + auth.session = { id: session.id, hasElevatedPermission: false }; } if (sharedLink) { @@ -127,6 +127,7 @@ const sessionFactory = (session: Partial = {}) => ({ deviceType: 'mobile', token: 'abc123', userId: newUuid(), + pinExpiresAt: newDate(), ...session, }); diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index 40b189080f..d85325b59a 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -13,6 +13,8 @@ type ActionMap = { [AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto }; [AssetAction.UNSTACK]: { assets: AssetResponseDto[] }; [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto }; + [AssetAction.SET_VISIBILITY_LOCKED]: { asset: AssetResponseDto }; + [AssetAction.SET_VISIBILITY_TIMELINE]: { asset: AssetResponseDto }; }; export type Action = { diff --git a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte new file mode 100644 index 0000000000..6a7f6d3078 --- /dev/null +++ b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte @@ -0,0 +1,60 @@ + + + toggleLockedVisibility()} + text={isLocked ? $t('move_off_locked_folder') : $t('add_to_locked_folder')} + icon={isLocked ? mdiFolderMoveOutline : mdiEyeOffOutline} +/> diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index b0ac455bc8..9436dc13c8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -12,6 +12,7 @@ import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte'; import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte'; import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte'; + import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte'; import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte'; import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte'; import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte'; @@ -27,6 +28,7 @@ import { AssetJobName, AssetTypeEnum, + Visibility, type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, @@ -91,6 +93,7 @@ const sharedLink = getSharedLink(); let isOwner = $derived($user && asset.ownerId === $user?.id); let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline); + let isLocked = $derived(asset.visibility === Visibility.Locked); // $: showEditorButton = // isOwner && @@ -112,7 +115,7 @@ {/if}
- {#if !asset.isTrashed && $user} + {#if !asset.isTrashed && $user && !isLocked} {/if} {#if asset.isOffline} @@ -159,17 +162,20 @@ - {#if showSlideshow} + {#if showSlideshow && !isLocked} {/if} {#if showDownloadButton} {/if} - {#if asset.isTrashed} - - {:else} - - + + {#if !isLocked} + {#if asset.isTrashed} + + {:else} + + + {/if} {/if} {#if isOwner} @@ -183,21 +189,28 @@ {#if person} {/if} - {#if asset.type === AssetTypeEnum.Image} + {#if asset.type === AssetTypeEnum.Image && !isLocked} {/if} - - openFileUploadDialog({ multiple: false, assetId: asset.id })} - text={$t('replace_with_upload')} - /> - {#if !asset.isArchived && !asset.isTrashed} + + {#if !isLocked} + goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)} - text={$t('view_in_timeline')} + icon={mdiUpload} + onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })} + text={$t('replace_with_upload')} /> + {#if !asset.isArchived && !asset.isTrashed} + goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)} + text={$t('view_in_timeline')} + /> + {/if} + {/if} + + {#if !asset.isTrashed} + {/if}
@@ -18,12 +19,14 @@
- - - - {title} - - + {#if withHeader} + + + + {title} + + + {/if} {@render children?.()} diff --git a/web/src/lib/components/photos-page/actions/delete-assets.svelte b/web/src/lib/components/photos-page/actions/delete-assets.svelte index 75bdc0f8a6..5cdcffb937 100644 --- a/web/src/lib/components/photos-page/actions/delete-assets.svelte +++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte @@ -1,12 +1,12 @@ -{#if $isSelectingAllAssets} - +{#if withText} + {:else} - + {/if} diff --git a/web/src/lib/components/photos-page/actions/set-visibility-action.svelte b/web/src/lib/components/photos-page/actions/set-visibility-action.svelte new file mode 100644 index 0000000000..c11ba114ce --- /dev/null +++ b/web/src/lib/components/photos-page/actions/set-visibility-action.svelte @@ -0,0 +1,72 @@ + + +{#if menuItem} + +{:else} + +{/if} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index dd17874a61..508e3dea6c 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -39,7 +39,13 @@ enableRouting: boolean; assetStore: AssetStore; assetInteraction: AssetInteraction; - removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null; + removeAction?: + | AssetAction.UNARCHIVE + | AssetAction.ARCHIVE + | AssetAction.FAVORITE + | AssetAction.UNFAVORITE + | AssetAction.SET_VISIBILITY_TIMELINE + | null; withStacked?: boolean; showArchiveIcon?: boolean; isShared?: boolean; @@ -417,7 +423,9 @@ case AssetAction.TRASH: case AssetAction.RESTORE: case AssetAction.DELETE: - case AssetAction.ARCHIVE: { + case AssetAction.ARCHIVE: + case AssetAction.SET_VISIBILITY_LOCKED: + case AssetAction.SET_VISIBILITY_TIMELINE: { // find the next asset to show or close the viewer // eslint-disable-next-line @typescript-eslint/no-unused-expressions (await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset })); @@ -445,6 +453,7 @@ case AssetAction.UNSTACK: { updateUnstackedAssetInTimeline(assetStore, action.assets); + break; } } }; diff --git a/web/src/lib/components/shared-components/empty-placeholder.svelte b/web/src/lib/components/shared-components/empty-placeholder.svelte index 922d7ad92f..63c30a0c4a 100644 --- a/web/src/lib/components/shared-components/empty-placeholder.svelte +++ b/web/src/lib/components/shared-components/empty-placeholder.svelte @@ -6,9 +6,10 @@ text: string; fullWidth?: boolean; src?: string; + title?: string; } - let { onClick = undefined, text, fullWidth = false, src = empty1Url }: Props = $props(); + let { onClick = undefined, text, fullWidth = false, src = empty1Url, title }: Props = $props(); let width = $derived(fullWidth ? 'w-full' : 'w-1/2'); @@ -24,5 +25,9 @@ class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}" > -

{text}

+ + {#if title} +

{title}

+ {/if} +

{text}

diff --git a/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte b/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte index 08911b4ef5..74cf69b08e 100644 --- a/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte +++ b/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte @@ -19,6 +19,8 @@ mdiImageMultiple, mdiImageMultipleOutline, mdiLink, + mdiLock, + mdiLockOutline, mdiMagnify, mdiMap, mdiMapOutline, @@ -40,6 +42,7 @@ let isSharingSelected: boolean = $state(false); let isTrashSelected: boolean = $state(false); let isUtilitiesSelected: boolean = $state(false); + let isLockedFolderSelected: boolean = $state(false); @@ -128,6 +131,13 @@ icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline} > + + {#if $featureFlags.trash} + 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 } from '@immich/sdk'; + import { Button } from '@immich/ui'; + import { t } from 'svelte-i18n'; + import { fade } from 'svelte/transition'; + + let currentPinCode = $state(''); + let newPinCode = $state(''); + let confirmPinCode = $state(''); + let isLoading = $state(false); + let canSubmit = $derived(currentPinCode.length === 6 && confirmPinCode.length === 6 && newPinCode === confirmPinCode); + + interface Props { + onChanged?: () => void; + } + + let { onChanged }: Props = $props(); + + const handleSubmit = async (event: Event) => { + event.preventDefault(); + await handleChangePinCode(); + }; + + const handleChangePinCode = async () => { + isLoading = true; + try { + await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } }); + + resetForm(); + + notificationController.show({ + message: $t('pin_code_changed_successfully'), + type: NotificationType.Info, + }); + + onChanged?.(); + } catch (error) { + handleError(error, $t('unable_to_change_pin_code')); + } finally { + isLoading = false; + } + }; + + const resetForm = () => { + currentPinCode = ''; + newPinCode = ''; + confirmPinCode = ''; + }; + + +
+
+
+
+

{$t('change_pin_code')}

+ + + + + +
+ +
+ + +
+
+
+
diff --git a/web/src/lib/components/user-settings-page/PinCodeCreateForm.svelte b/web/src/lib/components/user-settings-page/PinCodeCreateForm.svelte new file mode 100644 index 0000000000..ae07e976b7 --- /dev/null +++ b/web/src/lib/components/user-settings-page/PinCodeCreateForm.svelte @@ -0,0 +1,72 @@ + + +
+
+ {#if showLabel} +

{$t('setup_pin_code')}

+ {/if} + + + +
+ +
+ + +
+
diff --git a/web/src/lib/components/user-settings-page/PinCodeInput.svelte b/web/src/lib/components/user-settings-page/PinCodeInput.svelte index e149f26851..01de7b3563 100644 --- a/web/src/lib/components/user-settings-page/PinCodeInput.svelte +++ b/web/src/lib/components/user-settings-page/PinCodeInput.svelte @@ -1,12 +1,25 @@
-
-
-
- {#if hasPinCode} -

{$t('change_pin_code')}

- - - - - - {:else} -

{$t('setup_pin_code')}

- - - - {/if} -
- -
- - -
-
-
+ {#if hasPinCode} +
+ +
+ {:else} +
+ (hasPinCode = true)} /> +
+ {/if}
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index e4603217e0..167c976eeb 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -10,6 +10,8 @@ export enum AssetAction { ADD_TO_ALBUM = 'add-to-album', UNSTACK = 'unstack', KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others', + SET_VISIBILITY_LOCKED = 'set-visibility-locked', + SET_VISIBILITY_TIMELINE = 'set-visibility-timeline', } export enum AppRoute { @@ -43,12 +45,14 @@ export enum AppRoute { AUTH_REGISTER = '/auth/register', AUTH_CHANGE_PASSWORD = '/auth/change-password', AUTH_ONBOARDING = '/auth/onboarding', + AUTH_PIN_PROMPT = '/auth/pin-prompt', UTILITIES = '/utilities', DUPLICATES = '/utilities/duplicates', FOLDERS = '/folders', TAGS = '/tags', + LOCKED = '/locked', } export enum ProjectionType { diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index 472f55cbca..45fc21a7d9 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -15,6 +15,7 @@ export type OnArchive = (ids: string[], isArchived: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnStack = (result: StackResponse) => void; export type OnUnstack = (assets: AssetResponseDto[]) => void; +export type OnSetVisibility = (ids: string[]) => void; export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => { const $t = get(t); diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000..49b40866dd --- /dev/null +++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,76 @@ + + + +{#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > + + + + + + + assetStore.removeAssets(assetIds)} /> + + +{/if} + + + + {#snippet empty()} + + {/snippet} + + diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..9b9d86a4b3 --- /dev/null +++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,28 @@ +import { AppRoute } from '$lib/constants'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { getAuthStatus } from '@immich/sdk'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(); + const { isElevated, pinCode } = await getAuthStatus(); + + if (!isElevated || !pinCode) { + const continuePath = encodeURIComponent(url.pathname); + const redirectPath = `${AppRoute.AUTH_PIN_PROMPT}?continue=${continuePath}`; + + redirect(302, redirectPath); + } + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + return { + asset, + meta: { + title: $t('locked_folder'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 73f04380a5..20f4ca0abc 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -12,6 +12,7 @@ import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import LinkLivePhotoAction from '$lib/components/photos-page/actions/link-live-photo-action.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; + import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte'; import StackAction from '$lib/components/photos-page/actions/stack-action.svelte'; import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; @@ -75,6 +76,11 @@ assetStore.updateAssets([still]); }; + const handleSetVisibility = (assetIds: string[]) => { + assetStore.removeAssets(assetIds); + assetInteraction.clearMultiselect(); + }; + beforeNavigate(() => { isFaceEditMode.value = false; }); @@ -142,6 +148,7 @@ {/if} assetStore.removeAssets(assetIds)} /> +
diff --git a/web/src/routes/auth/pin-prompt/+page.svelte b/web/src/routes/auth/pin-prompt/+page.svelte new file mode 100644 index 0000000000..91480cd35c --- /dev/null +++ b/web/src/routes/auth/pin-prompt/+page.svelte @@ -0,0 +1,84 @@ + + + + {#if hasPinCode} +
+
+ {#if isVerified} +
+ +
+ {:else} +
+ +
+ {/if} + +

{$t('enter_your_pin_code_subtitle')}

+ + onPinFilled(pinCode, true)} + /> +
+
+ {:else} +
+
+
+ +
+

+ {$t('new_pin_code_subtitle')} +

+ (hasPinCode = true)} /> +
+
+ {/if} +
diff --git a/web/src/routes/auth/pin-prompt/+page.ts b/web/src/routes/auth/pin-prompt/+page.ts new file mode 100644 index 0000000000..e2b79605d8 --- /dev/null +++ b/web/src/routes/auth/pin-prompt/+page.ts @@ -0,0 +1,22 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAuthStatus } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(); + + const { pinCode } = await getAuthStatus(); + + const continuePath = url.searchParams.get('continue'); + + const $t = await getFormatter(); + + return { + meta: { + title: $t('pin_verification'), + }, + hasPinCode: !!pinCode, + continuePath, + }; +}) satisfies PageLoad; diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 656c4143a7..b727286590 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; +import { AssetTypeEnum, Visibility, type AssetResponseDto } from '@immich/sdk'; import { Sync } from 'factory.ts'; export const assetFactory = Sync.makeFactory({ @@ -24,4 +24,5 @@ export const assetFactory = Sync.makeFactory({ checksum: Sync.each(() => faker.string.alphanumeric(28)), isOffline: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()), + visibility: Visibility.Timeline, }); From 7146ec99b121b950aec30b24bf876c33152040f7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 May 2025 11:44:10 -0400 Subject: [PATCH 41/48] chore: use default theme config (#18314) --- web/package-lock.json | 8 ++++---- web/package.json | 2 +- web/src/app.css | 28 ---------------------------- web/src/routes/+layout.svelte | 1 + web/tailwind.config.js | 17 +++++------------ 5 files changed, 11 insertions(+), 45 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 76278058f1..12d65473c9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.20.0", + "@immich/ui": "^0.21.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", @@ -1337,9 +1337,9 @@ "link": true }, "node_modules/@immich/ui": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.20.0.tgz", - "integrity": "sha512-euK3N0AhQLB28qFteorRKyDUdet3UpA9MEAd8eBLbTtTFZKvZismBGa4J7pHbQrSkuOlbmJD5LJuM575q8zigQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.21.1.tgz", + "integrity": "sha512-ofDbLMYgM3Bnrv1nCbyPV5Gw9PdWvyhTAJPtojw4C3r2m7CbRW1kJDHt5M79n6xAVgjMOFyre1lOE5cwSSvRQA==", "license": "GNU Affero General Public License version 3", "dependencies": { "@mdi/js": "^7.4.47", diff --git a/web/package.json b/web/package.json index 8a9f6472b6..7bf5e36189 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.20.0", + "@immich/ui": "^0.21.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/app.css b/web/src/app.css index 211d34bb6c..1693aacab8 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -21,34 +21,6 @@ --immich-dark-success: 56 142 60; --immich-dark-warning: 245 124 0; } - - :root { - /* light */ - --immich-ui-primary: 66 80 175; - --immich-ui-dark: 58 58 58; - --immich-ui-light: 255 255 255; - --immich-ui-success: 16 188 99; - --immich-ui-danger: 200 60 60; - --immich-ui-warning: 216 143 64; - --immich-ui-info: 8 111 230; - --immich-ui-gray: 246 246 246; - - --immich-ui-default-border: 209 213 219; - } - - .dark { - /* dark */ - --immich-ui-primary: 172 203 250; - --immich-ui-light: 0 0 0; - --immich-ui-dark: 229 231 235; - --immich-ui-danger: 246 125 125; - --immich-ui-success: 72 237 152; - --immich-ui-warning: 254 197 132; - --immich-ui-info: 121 183 254; - --immich-ui-gray: 33 33 33; - - --immich-ui-default-border: 55 65 81; - } } @font-face { diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 3a6320a265..fe0c680ec3 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -16,6 +16,7 @@ import { copyToClipboard } from '$lib/utils'; import { isAssetViewerRoute } from '$lib/utils/navigation'; import { setTranslations } from '@immich/ui'; + import '@immich/ui/theme/default.css'; import { onMount, type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; import { run } from 'svelte/legacy'; diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 2e13e5997d..ae241a44bb 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -1,5 +1,8 @@ +import { tailwindConfig } from '@immich/ui/theme/default.js'; import plugin from 'tailwindcss/plugin'; +const { colors, borderColor } = tailwindConfig(); + /** @type {import('tailwindcss').Config} */ export default { content: [ @@ -29,19 +32,9 @@ export default { 'immich-dark-success': 'rgb(var(--immich-dark-success) / )', 'immich-dark-warning': 'rgb(var(--immich-dark-warning) / )', - primary: 'rgb(var(--immich-ui-primary) / )', - light: 'rgb(var(--immich-ui-light) / )', - dark: 'rgb(var(--immich-ui-dark) / )', - success: 'rgb(var(--immich-ui-success) / )', - danger: 'rgb(var(--immich-ui-danger) / )', - warning: 'rgb(var(--immich-ui-warning) / )', - info: 'rgb(var(--immich-ui-info) / )', - subtle: 'rgb(var(--immich-ui-gray) / )', + ...colors, }, - borderColor: ({ theme }) => ({ - ...theme('colors'), - DEFAULT: 'rgb(var(--immich-ui-default-border) / )', - }), + borderColor, fontFamily: { 'immich-mono': ['Overpass Mono', 'monospace'], }, From 585997d46f688b21ae88d6f6a0a3c04082973927 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 15 May 2025 20:28:20 +0200 Subject: [PATCH 42/48] fix: person edit sidebar cursedness (#18318) --- web/src/lib/components/faces-page/assign-face-side-panel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index e8a774a364..d45a8d2320 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -74,7 +74,7 @@
{#if !searchFaces} From 61173290579226e5790e64dfafec7c87701ebb1d Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 15 May 2025 13:34:33 -0500 Subject: [PATCH 43/48] feat: add session creation endpoint (#18295) --- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/sessions_api.dart | 47 ++++++ mobile/openapi/lib/api_client.dart | 4 + mobile/openapi/lib/model/permission.dart | 3 + .../openapi/lib/model/session_create_dto.dart | 145 +++++++++++++++++ .../model/session_create_response_dto.dart | 147 ++++++++++++++++++ open-api/immich-openapi-specs.json | 92 +++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 28 ++++ server/src/controllers/session.controller.ts | 10 +- server/src/db.d.ts | 2 + server/src/dtos/session.dto.ts | 24 +++ server/src/enum.ts | 1 + server/src/queries/session.repository.sql | 4 + server/src/repositories/crypto.repository.ts | 2 +- server/src/repositories/session.repository.ts | 17 ++ .../1747329504572-AddNewSessionColumns.ts | 15 ++ server/src/schema/tables/session.table.ts | 6 + server/src/services/api-key.service.spec.ts | 8 +- server/src/services/api-key.service.ts | 7 +- server/src/services/auth.service.ts | 8 +- server/src/services/cli.service.ts | 2 +- server/src/services/session.service.spec.ts | 25 +-- server/src/services/session.service.ts | 33 ++-- .../repositories/crypto.repository.mock.ts | 2 +- server/test/small.factory.ts | 2 + .../user-settings-page/device-card.svelte | 3 + 27 files changed, 592 insertions(+), 50 deletions(-) create mode 100644 mobile/openapi/lib/model/session_create_dto.dart create mode 100644 mobile/openapi/lib/model/session_create_response_dto.dart create mode 100644 server/src/schema/migrations/1747329504572-AddNewSessionColumns.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3aed98adf1..9544b2ddab 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -194,6 +194,7 @@ Class | Method | HTTP request | Description *ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history | *ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping | *ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license | +*SessionsApi* | [**createSession**](doc//SessionsApi.md#createsession) | **POST** /sessions | *SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | *SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | *SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | @@ -420,6 +421,8 @@ Class | Method | HTTP request | Description - [ServerThemeDto](doc//ServerThemeDto.md) - [ServerVersionHistoryResponseDto](doc//ServerVersionHistoryResponseDto.md) - [ServerVersionResponseDto](doc//ServerVersionResponseDto.md) + - [SessionCreateDto](doc//SessionCreateDto.md) + - [SessionCreateResponseDto](doc//SessionCreateResponseDto.md) - [SessionResponseDto](doc//SessionResponseDto.md) - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) - [SharedLinkEditDto](doc//SharedLinkEditDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index b2cbe222e8..d0e39e0965 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -218,6 +218,8 @@ part 'model/server_storage_response_dto.dart'; part 'model/server_theme_dto.dart'; part 'model/server_version_history_response_dto.dart'; part 'model/server_version_response_dto.dart'; +part 'model/session_create_dto.dart'; +part 'model/session_create_response_dto.dart'; part 'model/session_response_dto.dart'; part 'model/shared_link_create_dto.dart'; part 'model/shared_link_edit_dto.dart'; diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index 203f801b72..9f850fb4c8 100644 --- a/mobile/openapi/lib/api/sessions_api.dart +++ b/mobile/openapi/lib/api/sessions_api.dart @@ -16,6 +16,53 @@ class SessionsApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /sessions' operation and returns the [Response]. + /// Parameters: + /// + /// * [SessionCreateDto] sessionCreateDto (required): + Future createSessionWithHttpInfo(SessionCreateDto sessionCreateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/sessions'; + + // ignore: prefer_final_locals + Object? postBody = sessionCreateDto; + + 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: + /// + /// * [SessionCreateDto] sessionCreateDto (required): + Future createSession(SessionCreateDto sessionCreateDto,) async { + final response = await createSessionWithHttpInfo(sessionCreateDto,); + 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), 'SessionCreateResponseDto',) as SessionCreateResponseDto; + + } + return null; + } + /// Performs an HTTP 'DELETE /sessions' operation and returns the [Response]. Future deleteAllSessionsWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index cdd69307ad..f40d09ecc3 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -492,6 +492,10 @@ class ApiClient { return ServerVersionHistoryResponseDto.fromJson(value); case 'ServerVersionResponseDto': return ServerVersionResponseDto.fromJson(value); + case 'SessionCreateDto': + return SessionCreateDto.fromJson(value); + case 'SessionCreateResponseDto': + return SessionCreateResponseDto.fromJson(value); case 'SessionResponseDto': return SessionResponseDto.fromJson(value); case 'SharedLinkCreateDto': diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 1735bc2eb5..73ecbd5868 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -81,6 +81,7 @@ class Permission { static const personPeriodStatistics = Permission._(r'person.statistics'); static const personPeriodMerge = Permission._(r'person.merge'); static const personPeriodReassign = Permission._(r'person.reassign'); + static const sessionPeriodCreate = Permission._(r'session.create'); static const sessionPeriodRead = Permission._(r'session.read'); static const sessionPeriodUpdate = Permission._(r'session.update'); static const sessionPeriodDelete = Permission._(r'session.delete'); @@ -166,6 +167,7 @@ class Permission { personPeriodStatistics, personPeriodMerge, personPeriodReassign, + sessionPeriodCreate, sessionPeriodRead, sessionPeriodUpdate, sessionPeriodDelete, @@ -286,6 +288,7 @@ class PermissionTypeTransformer { case r'person.statistics': return Permission.personPeriodStatistics; case r'person.merge': return Permission.personPeriodMerge; case r'person.reassign': return Permission.personPeriodReassign; + case r'session.create': return Permission.sessionPeriodCreate; case r'session.read': return Permission.sessionPeriodRead; case r'session.update': return Permission.sessionPeriodUpdate; case r'session.delete': return Permission.sessionPeriodDelete; diff --git a/mobile/openapi/lib/model/session_create_dto.dart b/mobile/openapi/lib/model/session_create_dto.dart new file mode 100644 index 0000000000..aacf1150a5 --- /dev/null +++ b/mobile/openapi/lib/model/session_create_dto.dart @@ -0,0 +1,145 @@ +// +// 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 SessionCreateDto { + /// Returns a new [SessionCreateDto] instance. + SessionCreateDto({ + this.deviceOS, + this.deviceType, + this.duration, + }); + + /// + /// 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? deviceOS; + + /// + /// 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? deviceType; + + /// session duration, in seconds + /// + /// Minimum value: 1 + /// + /// 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. + /// + num? duration; + + @override + bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto && + other.deviceOS == deviceOS && + other.deviceType == deviceType && + other.duration == duration; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (deviceOS == null ? 0 : deviceOS!.hashCode) + + (deviceType == null ? 0 : deviceType!.hashCode) + + (duration == null ? 0 : duration!.hashCode); + + @override + String toString() => 'SessionCreateDto[deviceOS=$deviceOS, deviceType=$deviceType, duration=$duration]'; + + Map toJson() { + final json = {}; + if (this.deviceOS != null) { + json[r'deviceOS'] = this.deviceOS; + } else { + // json[r'deviceOS'] = null; + } + if (this.deviceType != null) { + json[r'deviceType'] = this.deviceType; + } else { + // json[r'deviceType'] = null; + } + if (this.duration != null) { + json[r'duration'] = this.duration; + } else { + // json[r'duration'] = null; + } + return json; + } + + /// Returns a new [SessionCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SessionCreateDto? fromJson(dynamic value) { + upgradeDto(value, "SessionCreateDto"); + if (value is Map) { + final json = value.cast(); + + return SessionCreateDto( + deviceOS: mapValueOfType(json, r'deviceOS'), + deviceType: mapValueOfType(json, r'deviceType'), + duration: num.parse('${json[r'duration']}'), + ); + } + 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 = SessionCreateDto.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 = SessionCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SessionCreateDto-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] = SessionCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart new file mode 100644 index 0000000000..1ef346c96a --- /dev/null +++ b/mobile/openapi/lib/model/session_create_response_dto.dart @@ -0,0 +1,147 @@ +// +// 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 SessionCreateResponseDto { + /// Returns a new [SessionCreateResponseDto] instance. + SessionCreateResponseDto({ + required this.createdAt, + required this.current, + required this.deviceOS, + required this.deviceType, + required this.id, + required this.token, + required this.updatedAt, + }); + + String createdAt; + + bool current; + + String deviceOS; + + String deviceType; + + String id; + + String token; + + String updatedAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is SessionCreateResponseDto && + other.createdAt == createdAt && + other.current == current && + other.deviceOS == deviceOS && + other.deviceType == deviceType && + other.id == id && + other.token == token && + other.updatedAt == updatedAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (createdAt.hashCode) + + (current.hashCode) + + (deviceOS.hashCode) + + (deviceType.hashCode) + + (id.hashCode) + + (token.hashCode) + + (updatedAt.hashCode); + + @override + String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, id=$id, token=$token, updatedAt=$updatedAt]'; + + Map toJson() { + final json = {}; + json[r'createdAt'] = this.createdAt; + json[r'current'] = this.current; + json[r'deviceOS'] = this.deviceOS; + json[r'deviceType'] = this.deviceType; + json[r'id'] = this.id; + json[r'token'] = this.token; + json[r'updatedAt'] = this.updatedAt; + return json; + } + + /// Returns a new [SessionCreateResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SessionCreateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SessionCreateResponseDto"); + if (value is Map) { + final json = value.cast(); + + return SessionCreateResponseDto( + createdAt: mapValueOfType(json, r'createdAt')!, + current: mapValueOfType(json, r'current')!, + deviceOS: mapValueOfType(json, r'deviceOS')!, + deviceType: mapValueOfType(json, r'deviceType')!, + id: mapValueOfType(json, r'id')!, + token: mapValueOfType(json, r'token')!, + updatedAt: mapValueOfType(json, r'updatedAt')!, + ); + } + 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 = SessionCreateResponseDto.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 = SessionCreateResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SessionCreateResponseDto-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] = SessionCreateResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'createdAt', + 'current', + 'deviceOS', + 'deviceType', + 'id', + 'token', + 'updatedAt', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2dbec35079..d4a1e219c9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5618,6 +5618,46 @@ "tags": [ "Sessions" ] + }, + "post": { + "operationId": "createSession", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionCreateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] } }, "/sessions/{id}": { @@ -11052,6 +11092,7 @@ "person.statistics", "person.merge", "person.reassign", + "session.create", "session.read", "session.update", "session.delete", @@ -12038,6 +12079,57 @@ ], "type": "object" }, + "SessionCreateDto": { + "properties": { + "deviceOS": { + "type": "string" + }, + "deviceType": { + "type": "string" + }, + "duration": { + "description": "session duration, in seconds", + "minimum": 1, + "type": "number" + } + }, + "type": "object" + }, + "SessionCreateResponseDto": { + "properties": { + "createdAt": { + "type": "string" + }, + "current": { + "type": "boolean" + }, + "deviceOS": { + "type": "string" + }, + "deviceType": { + "type": "string" + }, + "id": { + "type": "string" + }, + "token": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "createdAt", + "current", + "deviceOS", + "deviceType", + "id", + "token", + "updatedAt" + ], + "type": "object" + }, "SessionResponseDto": { "properties": { "createdAt": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ad7413e6fd..de0a723ffa 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1078,6 +1078,21 @@ export type SessionResponseDto = { id: string; updatedAt: string; }; +export type SessionCreateDto = { + deviceOS?: string; + deviceType?: string; + /** session duration, in seconds */ + duration?: number; +}; +export type SessionCreateResponseDto = { + createdAt: string; + current: boolean; + deviceOS: string; + deviceType: string; + id: string; + token: string; + updatedAt: string; +}; export type SharedLinkResponseDto = { album?: AlbumResponseDto; allowDownload: boolean; @@ -2917,6 +2932,18 @@ export function getSessions(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function createSession({ sessionCreateDto }: { + sessionCreateDto: SessionCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: SessionCreateResponseDto; + }>("/sessions", oazapfts.json({ + ...opts, + method: "POST", + body: sessionCreateDto + }))); +} export function deleteSession({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { @@ -3678,6 +3705,7 @@ export enum Permission { PersonStatistics = "person.statistics", PersonMerge = "person.merge", PersonReassign = "person.reassign", + SessionCreate = "session.create", SessionRead = "session.read", SessionUpdate = "session.update", SessionDelete = "session.delete", diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index d526c2e599..addcfd8fe9 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -1,7 +1,7 @@ -import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { SessionResponseDto } from 'src/dtos/session.dto'; +import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto } from 'src/dtos/session.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { SessionService } from 'src/services/session.service'; @@ -12,6 +12,12 @@ import { UUIDParamDto } from 'src/validation'; export class SessionController { constructor(private service: SessionService) {} + @Post() + @Authenticated({ permission: Permission.SESSION_CREATE }) + createSession(@Auth() auth: AuthDto, @Body() dto: SessionCreateDto): Promise { + return this.service.create(auth, dto); + } + @Get() @Authenticated({ permission: Permission.SESSION_READ }) getSessions(@Auth() auth: AuthDto): Promise { diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 1fd7fdc22b..6efbd5f7d7 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -343,6 +343,8 @@ export interface Sessions { deviceOS: Generated; deviceType: Generated; id: Generated; + parentId: string | null; + expiredAt: Date | null; token: string; updatedAt: Generated; updateId: Generated; diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index b54264a5b4..f109e44fa0 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -1,4 +1,24 @@ +import { IsInt, IsPositive, IsString } from 'class-validator'; import { Session } from 'src/database'; +import { Optional } from 'src/validation'; + +export class SessionCreateDto { + /** + * session duration, in seconds + */ + @IsInt() + @IsPositive() + @Optional() + duration?: number; + + @IsString() + @Optional() + deviceType?: string; + + @IsString() + @Optional() + deviceOS?: string; +} export class SessionResponseDto { id!: string; @@ -9,6 +29,10 @@ export class SessionResponseDto { deviceOS!: string; } +export class SessionCreateResponseDto extends SessionResponseDto { + token!: string; +} + export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({ id: entity.id, createdAt: entity.createdAt.toISOString(), diff --git a/server/src/enum.ts b/server/src/enum.ts index fedfaa6b79..c6feb27dcc 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -144,6 +144,7 @@ export enum Permission { PERSON_MERGE = 'person.merge', PERSON_REASSIGN = 'person.reassign', + SESSION_CREATE = 'session.create', SESSION_READ = 'session.read', SESSION_UPDATE = 'session.update', SESSION_DELETE = 'session.delete', diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index c2daa2a49c..b265380a1f 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -36,6 +36,10 @@ from "sessions" where "sessions"."token" = $1 + and ( + "sessions"."expiredAt" is null + or "sessions"."expiredAt" > $2 + ) -- SessionRepository.getByUserId select diff --git a/server/src/repositories/crypto.repository.ts b/server/src/repositories/crypto.repository.ts index e471ccb031..c3136db456 100644 --- a/server/src/repositories/crypto.repository.ts +++ b/server/src/repositories/crypto.repository.ts @@ -54,7 +54,7 @@ export class CryptoRepository { }); } - newPassword(bytes: number) { + randomBytesAsText(bytes: number) { return randomBytes(bytes).toString('base64').replaceAll(/\W/g, ''); } } diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 742807dc9c..ce819470c7 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { DateTime } from 'luxon'; import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; import { DB, Sessions } from 'src/db'; @@ -13,6 +14,19 @@ export type SessionSearchOptions = { updatedBefore: Date }; export class SessionRepository { constructor(@InjectKysely() private db: Kysely) {} + cleanup() { + return this.db + .deleteFrom('sessions') + .where((eb) => + eb.or([ + eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()), + eb.and([eb('expiredAt', 'is not', null), eb('expiredAt', '<=', DateTime.now().toJSDate())]), + ]), + ) + .returning(['id', 'deviceOS', 'deviceType']) + .execute(); + } + @GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] }) search(options: SessionSearchOptions) { return this.db @@ -37,6 +51,9 @@ export class SessionRepository { ).as('user'), ]) .where('sessions.token', '=', token) + .where((eb) => + eb.or([eb('sessions.expiredAt', 'is', null), eb('sessions.expiredAt', '>', DateTime.now().toJSDate())]), + ) .executeTakeFirst(); } diff --git a/server/src/schema/migrations/1747329504572-AddNewSessionColumns.ts b/server/src/schema/migrations/1747329504572-AddNewSessionColumns.ts new file mode 100644 index 0000000000..d3cf8de173 --- /dev/null +++ b/server/src/schema/migrations/1747329504572-AddNewSessionColumns.ts @@ -0,0 +1,15 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" ADD "expiredAt" timestamp with time zone;`.execute(db); + await sql`ALTER TABLE "sessions" ADD "parentId" uuid;`.execute(db); + await sql`ALTER TABLE "sessions" ADD CONSTRAINT "FK_afbbabbd7daf5b91de4dca84de8" FOREIGN KEY ("parentId") REFERENCES "sessions" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); + await sql`CREATE INDEX "IDX_afbbabbd7daf5b91de4dca84de" ON "sessions" ("parentId")`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "IDX_afbbabbd7daf5b91de4dca84de";`.execute(db); + await sql`ALTER TABLE "sessions" DROP CONSTRAINT "FK_afbbabbd7daf5b91de4dca84de8";`.execute(db); + await sql`ALTER TABLE "sessions" DROP COLUMN "expiredAt";`.execute(db); + await sql`ALTER TABLE "sessions" DROP COLUMN "parentId";`.execute(db); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 090b469b54..9cc41c5bba 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -25,9 +25,15 @@ export class SessionTable { @UpdateDateColumn() updatedAt!: Date; + @Column({ type: 'timestamp with time zone', nullable: true }) + expiredAt!: Date | null; + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) userId!: string; + @ForeignKeyColumn(() => SessionTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', nullable: true }) + parentId!: string | null; + @Column({ default: '' }) deviceType!: string; diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 680cd38f1e..784c944146 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -18,7 +18,7 @@ describe(ApiKeyService.name, () => { const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.ALL] }); const key = 'super-secret'; - mocks.crypto.newPassword.mockReturnValue(key); + mocks.crypto.randomBytesAsText.mockReturnValue(key); mocks.apiKey.create.mockResolvedValue(apiKey); await sut.create(auth, { name: apiKey.name, permissions: apiKey.permissions }); @@ -29,7 +29,7 @@ describe(ApiKeyService.name, () => { permissions: apiKey.permissions, userId: apiKey.userId, }); - expect(mocks.crypto.newPassword).toHaveBeenCalled(); + expect(mocks.crypto.randomBytesAsText).toHaveBeenCalled(); expect(mocks.crypto.hashSha256).toHaveBeenCalled(); }); @@ -38,7 +38,7 @@ describe(ApiKeyService.name, () => { const apiKey = factory.apiKey({ userId: auth.user.id }); const key = 'super-secret'; - mocks.crypto.newPassword.mockReturnValue(key); + mocks.crypto.randomBytesAsText.mockReturnValue(key); mocks.apiKey.create.mockResolvedValue(apiKey); await sut.create(auth, { permissions: [Permission.ALL] }); @@ -49,7 +49,7 @@ describe(ApiKeyService.name, () => { permissions: [Permission.ALL], userId: auth.user.id, }); - expect(mocks.crypto.newPassword).toHaveBeenCalled(); + expect(mocks.crypto.randomBytesAsText).toHaveBeenCalled(); expect(mocks.crypto.hashSha256).toHaveBeenCalled(); }); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 33861d82cd..49d4183b01 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -9,20 +9,21 @@ import { isGranted } from 'src/utils/access'; @Injectable() export class ApiKeyService extends BaseService { async create(auth: AuthDto, dto: APIKeyCreateDto): Promise { - const secret = this.cryptoRepository.newPassword(32); + const token = this.cryptoRepository.randomBytesAsText(32); + const tokenHashed = this.cryptoRepository.hashSha256(token); if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) { throw new BadRequestException('Cannot grant permissions you do not have'); } const entity = await this.apiKeyRepository.create({ - key: this.cryptoRepository.hashSha256(secret), + key: tokenHashed, name: dto.name || 'API Key', userId: auth.user.id, permissions: dto.permissions, }); - return { secret, apiKey: this.map(entity) }; + return { secret: token, apiKey: this.map(entity) }; } async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 496c252643..7bda2eeb98 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -492,17 +492,17 @@ export class AuthService extends BaseService { } private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) { - const key = this.cryptoRepository.newPassword(32); - const token = this.cryptoRepository.hashSha256(key); + const token = this.cryptoRepository.randomBytesAsText(32); + const tokenHashed = this.cryptoRepository.hashSha256(token); await this.sessionRepository.create({ - token, + token: tokenHashed, deviceOS: loginDetails.deviceOS, deviceType: loginDetails.deviceType, userId: user.id, }); - return mapLoginResponse(user, key); + return mapLoginResponse(user, token); } private getClaim(profile: OAuthProfile, options: ClaimOptions): T { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 87e004845d..f6173c69f7 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -17,7 +17,7 @@ export class CliService extends BaseService { } const providedPassword = await ask(mapUserAdmin(admin)); - const password = providedPassword || this.cryptoRepository.newPassword(24); + const password = providedPassword || this.cryptoRepository.randomBytesAsText(24); const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS); await this.userRepository.update(admin.id, { password: hashedPassword }); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 6e26b26407..7ac338da80 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -17,30 +17,9 @@ describe('SessionService', () => { }); describe('handleCleanup', () => { - it('should return skipped if nothing is to be deleted', async () => { - mocks.session.search.mockResolvedValue([]); - await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SKIPPED); - expect(mocks.session.search).toHaveBeenCalled(); - }); - - it('should delete sessions', async () => { - mocks.session.search.mockResolvedValue([ - { - createdAt: new Date('1970-01-01T00:00:00.00Z'), - updatedAt: new Date('1970-01-02T00:00:00.00Z'), - deviceOS: '', - deviceType: '', - id: '123', - token: '420', - userId: '42', - updateId: 'uuid-v7', - pinExpiresAt: null, - }, - ]); - mocks.session.delete.mockResolvedValue(); - + it('should clean sessions', async () => { + mocks.session.cleanup.mockResolvedValue([]); await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS); - expect(mocks.session.delete).toHaveBeenCalledWith('123'); }); }); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 6b0632cd44..9f49cda07f 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; +import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; @@ -10,16 +10,8 @@ import { BaseService } from 'src/services/base.service'; export class SessionService extends BaseService { @OnJob({ name: JobName.CLEAN_OLD_SESSION_TOKENS, queue: QueueName.BACKGROUND_TASK }) async handleCleanup(): Promise { - const sessions = await this.sessionRepository.search({ - updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(), - }); - - if (sessions.length === 0) { - return JobStatus.SKIPPED; - } - + const sessions = await this.sessionRepository.cleanup(); for (const session of sessions) { - await this.sessionRepository.delete(session.id); this.logger.verbose(`Deleted expired session token: ${session.deviceOS}/${session.deviceType}`); } @@ -28,6 +20,25 @@ export class SessionService extends BaseService { return JobStatus.SUCCESS; } + async create(auth: AuthDto, dto: SessionCreateDto): Promise { + if (!auth.session) { + throw new BadRequestException('This endpoint can only be used with a session token'); + } + + const token = this.cryptoRepository.randomBytesAsText(32); + const tokenHashed = this.cryptoRepository.hashSha256(token); + const session = await this.sessionRepository.create({ + parentId: auth.session.id, + userId: auth.user.id, + expiredAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null, + deviceType: dto.deviceType, + deviceOS: dto.deviceOS, + token: tokenHashed, + }); + + return { ...mapSession(session), token }; + } + async getAll(auth: AuthDto): Promise { const sessions = await this.sessionRepository.getByUserId(auth.user.id); return sessions.map((session) => mapSession(session, auth.session?.id)); diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts index 9d32a88987..1167923c0c 100644 --- a/server/test/repositories/crypto.repository.mock.ts +++ b/server/test/repositories/crypto.repository.mock.ts @@ -12,6 +12,6 @@ export const newCryptoRepositoryMock = (): Mocked true), hashSha1: vitest.fn().mockImplementation((input) => Buffer.from(`${input.toString()} (hashed)`)), hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`), - newPassword: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')), + randomBytesAsText: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')), }; }; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 01091854fa..231deeba83 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -126,6 +126,8 @@ const sessionFactory = (session: Partial = {}) => ({ deviceOS: 'android', deviceType: 'mobile', token: 'abc123', + parentId: null, + expiredAt: null, userId: newUuid(), pinExpiresAt: newDate(), ...session, diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index ad0b621921..47636fe4bf 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -7,6 +7,7 @@ mdiAndroid, mdiApple, mdiAppleSafari, + mdiCast, mdiGoogleChrome, mdiHelp, mdiLinux, @@ -46,6 +47,8 @@ {:else if device.deviceOS === 'Chrome OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium' || device.deviceType === 'Mobile Chrome'} + {:else if device.deviceOS === 'Google Cast'} + {:else} {/if} From c046651f234d09bac3fa0369eb4c06f3598638a6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 May 2025 14:45:23 -0400 Subject: [PATCH 44/48] feat(web): continue after login (#18302) --- web/src/lib/utils/auth.ts | 4 ++-- web/src/routes/(user)/albums/+page.ts | 4 ++-- .../[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- .../(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/buy/+page.ts | 2 +- web/src/routes/(user)/explore/+page.ts | 4 ++-- .../favorites/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- .../(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts | 2 +- .../(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- .../(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- .../[userId]/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/people/+page.ts | 4 ++-- .../[personId]/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/photos/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/places/+page.ts | 4 ++-- .../(user)/search/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- .../share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/shared-links/[[id=id]]/+page.ts | 4 ++-- web/src/routes/(user)/sharing/+page.ts | 4 ++-- .../(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts | 2 +- .../(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/user-settings/+page.ts | 4 ++-- web/src/routes/(user)/utilities/+page.ts | 4 ++-- .../duplicates/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/admin/jobs-status/+page.ts | 4 ++-- web/src/routes/admin/library-management/+page.ts | 4 ++-- web/src/routes/admin/server-status/+page.ts | 4 ++-- web/src/routes/admin/system-settings/+page.ts | 4 ++-- web/src/routes/admin/users/+page.ts | 4 ++-- web/src/routes/admin/users/[id]/+page.ts | 4 ++-- web/src/routes/auth/change-password/+page.ts | 4 ++-- web/src/routes/auth/login/+page.svelte | 3 ++- web/src/routes/auth/login/+page.ts | 3 ++- web/src/routes/auth/onboarding/+page.ts | 4 ++-- 34 files changed, 65 insertions(+), 63 deletions(-) diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index 9b78c345e2..c22b706631 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -50,7 +50,7 @@ const hasAuthCookie = (): boolean => { return false; }; -export const authenticate = async (options?: AuthOptions) => { +export const authenticate = async (url: URL, options?: AuthOptions) => { const { public: publicRoute, admin: adminRoute } = options || {}; const user = await loadUser(); @@ -59,7 +59,7 @@ export const authenticate = async (options?: AuthOptions) => { } if (!user) { - redirect(302, AppRoute.AUTH_LOGIN); + redirect(302, `${AppRoute.AUTH_LOGIN}?continue=${encodeURIComponent(url.pathname + url.search)}`); } if (adminRoute && !user.isAdmin) { diff --git a/web/src/routes/(user)/albums/+page.ts b/web/src/routes/(user)/albums/+page.ts index e56d0f06b7..f4527d56d2 100644 --- a/web/src/routes/(user)/albums/+page.ts +++ b/web/src/routes/(user)/albums/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAllAlbums } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const sharedAlbums = await getAllAlbums({ shared: true }); const albums = await getAllAlbums({}); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.ts index 0143390974..f8691b5fd1 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getAlbumInfo } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const [album, asset] = await Promise.all([ getAlbumInfo({ id: params.albumId, withoutAssets: true }), getAssetInfoFromParam(params), diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.ts index c44ba64d5b..f5d4560505 100644 --- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/buy/+page.ts b/web/src/routes/(user)/buy/+page.ts index ba55948b1e..d0180b39ff 100644 --- a/web/src/routes/(user)/buy/+page.ts +++ b/web/src/routes/(user)/buy/+page.ts @@ -5,7 +5,7 @@ import { activateProduct, getActivationKey } from '$lib/utils/license-utils'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { - await authenticate(); + await authenticate(url); const $t = await getFormatter(); const licenseKey = url.searchParams.get('licenseKey'); diff --git a/web/src/routes/(user)/explore/+page.ts b/web/src/routes/(user)/explore/+page.ts index 84ec944efe..9005f7dced 100644 --- a/web/src/routes/(user)/explore/+page.ts +++ b/web/src/routes/(user)/explore/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAllPeople, getExploreData } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const [items, response] = await Promise.all([getExploreData(), getAllPeople({ withHidden: false })]); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.ts index be828b69dd..0d9fe7a203 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts index d00ba238ef..7fd0a749c0 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -7,7 +7,7 @@ import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import type { PageLoad } from './$types'; export const load = (async ({ params, url }) => { - await authenticate(); + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts index 490e1430e6..add9882bcd 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts index e323fca182..5c030da72f 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - const user = await authenticate(); +export const load = (async ({ params, url }) => { + const user = await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.ts index 1395a3e8d3..1977d9a095 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -4,8 +4,8 @@ import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getUser } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const partner = await getUser({ id: params.userId }); const asset = await getAssetInfoFromParam(params); diff --git a/web/src/routes/(user)/people/+page.ts b/web/src/routes/(user)/people/+page.ts index 305ba31da6..35ed6c06c4 100644 --- a/web/src/routes/(user)/people/+page.ts +++ b/web/src/routes/(user)/people/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAllPeople } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const people = await getAllPeople({ withHidden: true }); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.ts index 88e223640f..92371bd34e 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -4,8 +4,8 @@ import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getPerson, getPersonStatistics } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const [person, statistics, asset] = await Promise.all([ getPerson({ id: params.personId }), diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts b/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts index 6e9384f853..209b5483a8 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/places/+page.ts b/web/src/routes/(user)/places/+page.ts index a0c421ef3a..9449f416be 100644 --- a/web/src/routes/(user)/places/+page.ts +++ b/web/src/routes/(user)/places/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetsByCity } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const items = await getAssetsByCity(); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.ts index 23871d8bdf..82dd18acaa 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts index 66fc3552c7..c0edb5e669 100644 --- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -5,9 +5,9 @@ import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getMySharedLink, isHttpError } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { +export const load = (async ({ params, url }) => { const { key } = params; - await authenticate({ public: true }); + await authenticate(url, { public: true }); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts b/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts index 920e5bdba4..f61484a910 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts @@ -2,8 +2,8 @@ import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const $t = await getFormatter(); return { diff --git a/web/src/routes/(user)/sharing/+page.ts b/web/src/routes/(user)/sharing/+page.ts index b1872ca9f2..2bf737dfc7 100644 --- a/web/src/routes/(user)/sharing/+page.ts +++ b/web/src/routes/(user)/sharing/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { PartnerDirection, getAllAlbums, getPartners } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const sharedAlbums = await getAllAlbums({ shared: true }); const partners = await getPartners({ direction: PartnerDirection.SharedWith }); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts index 23846e57c4..6e92eda7d3 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -7,7 +7,7 @@ import { getAllTags } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async ({ params, url }) => { - await authenticate(); + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts index 926af322ca..79c41892c7 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/user-settings/+page.ts b/web/src/routes/(user)/user-settings/+page.ts index 15b8d8125c..bf36eeefb5 100644 --- a/web/src/routes/(user)/user-settings/+page.ts +++ b/web/src/routes/(user)/user-settings/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getApiKeys, getSessions } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const keys = await getApiKeys(); const sessions = await getSessions(); diff --git a/web/src/routes/(user)/utilities/+page.ts b/web/src/routes/(user)/utilities/+page.ts index a0420a575b..af241d0fd7 100644 --- a/web/src/routes/(user)/utilities/+page.ts +++ b/web/src/routes/(user)/utilities/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.ts index a7faaed3c3..978f50830e 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -4,8 +4,8 @@ import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getAssetDuplicates } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const duplicates = await getAssetDuplicates(); const $t = await getFormatter(); diff --git a/web/src/routes/admin/jobs-status/+page.ts b/web/src/routes/admin/jobs-status/+page.ts index 8044b61861..0d4ec8b41f 100644 --- a/web/src/routes/admin/jobs-status/+page.ts +++ b/web/src/routes/admin/jobs-status/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAllJobsStatus } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); const jobs = await getAllJobsStatus(); const $t = await getFormatter(); diff --git a/web/src/routes/admin/library-management/+page.ts b/web/src/routes/admin/library-management/+page.ts index 71bc835a6f..735c7fac92 100644 --- a/web/src/routes/admin/library-management/+page.ts +++ b/web/src/routes/admin/library-management/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { searchUsersAdmin } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); await requestServerInfo(); const allUsers = await searchUsersAdmin({ withDeleted: false }); const $t = await getFormatter(); diff --git a/web/src/routes/admin/server-status/+page.ts b/web/src/routes/admin/server-status/+page.ts index 39ce96ae41..7450550737 100644 --- a/web/src/routes/admin/server-status/+page.ts +++ b/web/src/routes/admin/server-status/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getServerStatistics } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); const stats = await getServerStatistics(); const $t = await getFormatter(); diff --git a/web/src/routes/admin/system-settings/+page.ts b/web/src/routes/admin/system-settings/+page.ts index 555835e017..294096a4be 100644 --- a/web/src/routes/admin/system-settings/+page.ts +++ b/web/src/routes/admin/system-settings/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getConfig } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); const configs = await getConfig(); const $t = await getFormatter(); diff --git a/web/src/routes/admin/users/+page.ts b/web/src/routes/admin/users/+page.ts index 0a6af40c69..521f8573e1 100644 --- a/web/src/routes/admin/users/+page.ts +++ b/web/src/routes/admin/users/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { searchUsersAdmin } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); await requestServerInfo(); const allUsers = await searchUsersAdmin({ withDeleted: true }); const $t = await getFormatter(); diff --git a/web/src/routes/admin/users/[id]/+page.ts b/web/src/routes/admin/users/[id]/+page.ts index 7e2930c46a..c6e918d648 100644 --- a/web/src/routes/admin/users/[id]/+page.ts +++ b/web/src/routes/admin/users/[id]/+page.ts @@ -5,8 +5,8 @@ import { getUserPreferencesAdmin, getUserStatisticsAdmin, searchUsersAdmin } fro import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate({ admin: true }); +export const load = (async ({ params, url }) => { + await authenticate(url, { admin: true }); await requestServerInfo(); const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []); if (!user) { diff --git a/web/src/routes/auth/change-password/+page.ts b/web/src/routes/auth/change-password/+page.ts index 19abb2e832..c4331b73cc 100644 --- a/web/src/routes/auth/change-password/+page.ts +++ b/web/src/routes/auth/change-password/+page.ts @@ -6,8 +6,8 @@ import { redirect } from '@sveltejs/kit'; import { get } from 'svelte/store'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); if (!get(user).shouldChangePassword) { redirect(302, AppRoute.PHOTOS); } diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index fdad88e1ff..5cce88ae2c 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -26,7 +26,8 @@ let oauthLoading = $state(true); const onSuccess = async (user: LoginResponseDto) => { - await goto(AppRoute.PHOTOS, { invalidateAll: true }); + console.log(data.continueUrl); + await goto(data.continueUrl, { invalidateAll: true }); eventManager.emit('auth.login', user); }; diff --git a/web/src/routes/auth/login/+page.ts b/web/src/routes/auth/login/+page.ts index 847992ab20..54c5da716a 100644 --- a/web/src/routes/auth/login/+page.ts +++ b/web/src/routes/auth/login/+page.ts @@ -6,7 +6,7 @@ import { redirect } from '@sveltejs/kit'; import { get } from 'svelte/store'; import type { PageLoad } from './$types'; -export const load = (async ({ parent }) => { +export const load = (async ({ parent, url }) => { await parent(); const { isInitialized } = get(serverConfig); @@ -20,5 +20,6 @@ export const load = (async ({ parent }) => { meta: { title: $t('login'), }, + continueUrl: url.searchParams.get('continue') || AppRoute.PHOTOS, }; }) satisfies PageLoad; diff --git a/web/src/routes/auth/onboarding/+page.ts b/web/src/routes/auth/onboarding/+page.ts index db16c8e514..86c19c10a8 100644 --- a/web/src/routes/auth/onboarding/+page.ts +++ b/web/src/routes/auth/onboarding/+page.ts @@ -2,8 +2,8 @@ import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); const $t = await getFormatter(); From ecb66fdb2c3aa0858dc92fadbff2946b3574c091 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 May 2025 17:55:16 -0400 Subject: [PATCH 45/48] fix: check i18n are sorted (#18324) --- .github/workflows/test.yml | 43 +++++++++++ i18n/en.json | 72 +++++++++---------- web/package.json | 3 +- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 2 +- web/src/routes/auth/pin-prompt/+page.ts | 2 +- 5 files changed, 83 insertions(+), 39 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f36b01518e..91f4ffce4f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,7 @@ jobs: permissions: contents: read outputs: + should_run_i18n: ${{ steps.found_paths.outputs.i18n == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_cli: ${{ steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }} @@ -36,6 +37,8 @@ jobs: uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: filters: | + i18n: + - 'i18n/**' web: - 'web/**' - 'i18n/**' @@ -262,6 +265,46 @@ jobs: run: npm run test:cov if: ${{ !cancelled() }} + i18n-tests: + name: Test i18n + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_i18n == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: './web/.nvmrc' + + - name: Install dependencies + run: npm --prefix=web ci + + - name: Format + run: npm --prefix=web run format:i18n + + - name: Find file changes + uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 + id: verify-changed-files + with: + files: | + i18n/** + + - name: Verify files have not changed + if: steps.verify-changed-files.outputs.files_changed == 'true' + env: + CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }} + run: | + echo "ERROR: i18n files not up to date!" + echo "Changed files: ${CHANGED_FILES}" + exit 1 + e2e-tests-lint: name: End-to-End Lint needs: pre-job diff --git a/i18n/en.json b/i18n/en.json index 05b236b33a..e4fc825cda 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,32 +1,4 @@ { - "new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page", - "enter_your_pin_code": "Enter your PIN code", - "enter_your_pin_code_subtitle": "Enter your PIN code to access the locked folder", - "pin_verification": "PIN code verification", - "wrong_pin_code": "Wrong PIN code", - "nothing_here_yet": "Nothing here yet", - "move_to_locked_folder": "Move to Locked Folder", - "remove_from_locked_folder": "Remove from Locked Folder", - "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the Locked Folder", - "remove_from_locked_folder_confirmation": "Are you sure you want to move these photos and videos out of Locked Folder? They will be visible in your library", - "move": "Move", - "no_locked_photos_message": "Photos and videos in Locked Folder are hidden and won't show up as you browser your library.", - "locked_folder": "Locked Folder", - "add_to_locked_folder": "Add to Locked Folder", - "move_off_locked_folder": "Move out of Locked Folder", - "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", - "change_pin_code": "Change 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", @@ -54,6 +26,7 @@ "add_to_album": "Add to album", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "add_to_locked_folder": "Add to Locked Folder", "add_to_shared_album": "Add to shared album", "add_url": "Add URL", "added_to_archive": "Added to archive", @@ -640,6 +613,7 @@ "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", + "change_pin_code": "Change PIN code", "change_your_password": "Change your password", "changed_visibility_successfully": "Changed visibility successfully", "check_all": "Check All", @@ -680,6 +654,7 @@ "confirm_delete_face": "Are you sure you want to delete {name} face from the asset?", "confirm_delete_shared_link": "Are you sure you want to delete this shared link?", "confirm_keep_this_delete_others": "All other assets in the stack will be deleted except for this asset. Are you sure you want to continue?", + "confirm_new_pin_code": "Confirm new PIN code", "confirm_password": "Confirm password", "contain": "Contain", "context": "Context", @@ -722,9 +697,11 @@ "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.", "create_user": "Create user", "created": "Created", + "created_at": "Created", "crop": "Crop", "curated_object_page_title": "Things", "current_device": "Current device", + "current_pin_code": "Current PIN code", "current_server_address": "Current server address", "custom_locale": "Custom Locale", "custom_locale_description": "Format dates and numbers based on the language and the region", @@ -837,6 +814,7 @@ "editor_crop_tool_h2_aspect_ratios": "Aspect ratios", "editor_crop_tool_h2_rotation": "Rotation", "email": "Email", + "email_notifications": "Email notifications", "empty_folder": "This folder is empty", "empty_trash": "Empty trash", "empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!", @@ -845,6 +823,8 @@ "end_date": "End date", "enqueued": "Enqueued", "enter_wifi_name": "Enter Wi-Fi name", + "enter_your_pin_code": "Enter your PIN code", + "enter_your_pin_code_subtitle": "Enter your PIN code to access the locked folder", "error": "Error", "error_change_sort_album": "Failed to change album sort order", "error_delete_face": "Error deleting face from asset", @@ -852,7 +832,6 @@ "error_saving_image": "Error: {error}", "error_title": "Error - Something went wrong", "errors": { - "unable_to_move_to_locked_folder": "Unable to move to locked folder", "cannot_navigate_next_asset": "Cannot navigate to the next asset", "cannot_navigate_previous_asset": "Cannot navigate to previous asset", "cant_apply_changes": "Can't apply changes", @@ -940,6 +919,7 @@ "unable_to_log_out_all_devices": "Unable to log out all devices", "unable_to_log_out_device": "Unable to log out device", "unable_to_login_with_oauth": "Unable to login with OAuth", + "unable_to_move_to_locked_folder": "Unable to move to locked folder", "unable_to_play_video": "Unable to play video", "unable_to_reassign_assets_existing_person": "Unable to reassign assets to {name, select, null {an existing person} other {{name}}}", "unable_to_reassign_assets_new_person": "Unable to reassign assets to a new person", @@ -1080,6 +1060,7 @@ "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", "host": "Host", "hour": "Hour", + "id": "ID", "ignore_icloud_photos": "Ignore iCloud photos", "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image": "Image", @@ -1161,6 +1142,7 @@ "location_picker_latitude_hint": "Enter your latitude here", "location_picker_longitude_error": "Enter a valid longitude", "location_picker_longitude_hint": "Enter your longitude here", + "locked_folder": "Locked Folder", "log_out": "Log out", "log_out_all_devices": "Log Out All Devices", "logged_out_all_devices": "Logged out all devices", @@ -1229,8 +1211,8 @@ "map_settings_only_show_favorites": "Show Favorite Only", "map_settings_theme_settings": "Map Theme", "map_zoom_to_see_photos": "Zoom out to see photos", - "mark_as_read": "Mark as read", "mark_all_as_read": "Mark all as read", + "mark_as_read": "Mark as read", "marked_all_as_read": "Marked all as read", "matches": "Matches", "media_type": "Media type", @@ -1258,6 +1240,10 @@ "month": "Month", "monthly_title_text_date_format": "MMMM y", "more": "More", + "move": "Move", + "move_off_locked_folder": "Move out of Locked Folder", + "move_to_locked_folder": "Move to Locked Folder", + "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the Locked Folder", "moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive", "moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library", "moved_to_trash": "Moved to trash", @@ -1274,6 +1260,8 @@ "new_api_key": "New API Key", "new_password": "New password", "new_person": "New person", + "new_pin_code": "New PIN code", + "new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page", "new_user_created": "New user created", "new_version_available": "NEW VERSION AVAILABLE", "newest_first": "Newest first", @@ -1291,23 +1279,24 @@ "no_explore_results_message": "Upload more photos to explore your collection.", "no_favorites_message": "Add favorites to quickly find your best pictures and videos", "no_libraries_message": "Create an external library to view your photos and videos", + "no_locked_photos_message": "Photos and videos in Locked Folder are hidden and won't show up as you browser your library.", "no_name": "No Name", + "no_notifications": "No notifications", "no_people_found": "No matching people found", "no_places": "No places", "no_results": "No results", "no_results_description": "Try a synonym or more general keyword", - "no_notifications": "No notifications", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", "not_in_any_album": "Not in any album", "not_selected": "Not selected", "note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the", "notes": "Notes", + "nothing_here_yet": "Nothing here yet", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", "notification_toggle_setting_description": "Enable email notifications", - "email_notifications": "Email notifications", "notifications": "Notifications", "notifications_setting_description": "Manage notifications", "oauth": "OAuth", @@ -1395,6 +1384,10 @@ "photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}", "photos_from_previous_years": "Photos from previous years", "pick_a_location": "Pick a location", + "pin_code_changed_successfully": "Successfully changed PIN code", + "pin_code_reset_successfully": "Successfully reset PIN code", + "pin_code_setup_successfully": "Successfully setup a PIN code", + "pin_verification": "PIN code verification", "place": "Place", "places": "Places", "places_count": "{count, plural, one {{count, number} Place} other {{count, number} Places}}", @@ -1492,6 +1485,8 @@ "remove_deleted_assets": "Remove Deleted Assets", "remove_from_album": "Remove from album", "remove_from_favorites": "Remove from favorites", + "remove_from_locked_folder": "Remove from Locked Folder", + "remove_from_locked_folder_confirmation": "Are you sure you want to move these photos and videos out of Locked Folder? They will be visible in your library", "remove_from_shared_link": "Remove from shared link", "remove_memory": "Remove memory", "remove_photo_from_memory": "Remove photo from this memory", @@ -1515,6 +1510,7 @@ "reset": "Reset", "reset_password": "Reset password", "reset_people_visibility": "Reset people visibility", + "reset_pin_code": "Reset PIN code", "reset_to_default": "Reset to default", "resolve_duplicates": "Resolve duplicates", "resolved_all_duplicates": "Resolved all duplicates", @@ -1655,6 +1651,7 @@ "settings": "Settings", "settings_require_restart": "Please restart Immich to apply this setting", "settings_saved": "Settings saved", + "setup_pin_code": "Setup a PIN code", "share": "Share", "share_add_photos": "Add photos", "share_assets_selected": "{count} selected", @@ -1771,8 +1768,8 @@ "stop_sharing_photos_with_user": "Stop sharing your photos with this user", "storage": "Storage space", "storage_label": "Storage label", - "storage_usage": "{used} of {available} used", "storage_quota": "Storage Quota", + "storage_usage": "{used} of {available} used", "submit": "Submit", "suggestions": "Suggestions", "sunrise_on_the_beach": "Sunrise on the beach", @@ -1840,6 +1837,8 @@ "trash_page_title": "Trash ({count})", "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.", "type": "Type", + "unable_to_change_pin_code": "Unable to change PIN code", + "unable_to_setup_pin_code": "Unable to setup PIN code", "unarchive": "Unarchive", "unarchived_count": "{count, plural, other {Unarchived #}}", "unfavorite": "Unfavorite", @@ -1863,6 +1862,7 @@ "untracked_files": "Untracked files", "untracked_files_decription": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug", "up_next": "Up next", + "updated_at": "Updated", "updated_password": "Updated password", "upload": "Upload", "upload_concurrency": "Upload concurrency", @@ -1877,7 +1877,6 @@ "upload_success": "Upload success, refresh the page to see new upload assets.", "upload_to_immich": "Upload to Immich ({count})", "uploading": "Uploading", - "id": "ID", "url": "URL", "usage": "Usage", "use_current_connection": "use current connection", @@ -1886,8 +1885,8 @@ "user_has_been_deleted": "This user has been deleted.", "user_id": "User ID", "user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", - "created_at": "Created", - "updated_at": "Updated", + "user_pin_code_settings": "PIN Code", + "user_pin_code_settings_description": "Manage your PIN code", "user_purchase_settings": "Purchase", "user_purchase_settings_description": "Manage your purchase", "user_role_set": "Set {user} as {role}", @@ -1937,6 +1936,7 @@ "welcome": "Welcome", "welcome_to_immich": "Welcome to Immich", "wifi_name": "Wi-Fi Name", + "wrong_pin_code": "Wrong PIN code", "year": "Year", "years_ago": "{years, plural, one {# year} other {# years}} ago", "yes": "Yes", diff --git a/web/package.json b/web/package.json index 7bf5e36189..94f48a7d97 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,8 @@ "lint:p": "eslint-p . --max-warnings 0 --concurrency=4", "lint:fix": "npm run lint -- --fix", "format": "prettier --check .", - "format:fix": "prettier --write .", + "format:fix": "prettier --write . && npm run format:i18n", + "format:i18n": "npx --yes sort-json ../i18n/*.json", "test": "vitest --run", "test:cov": "vitest --coverage", "test:watch": "vitest dev", diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts index 9b9d86a4b3..445917f0d0 100644 --- a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -7,7 +7,7 @@ import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; export const load = (async ({ params, url }) => { - await authenticate(); + await authenticate(url); const { isElevated, pinCode } = await getAuthStatus(); if (!isElevated || !pinCode) { diff --git a/web/src/routes/auth/pin-prompt/+page.ts b/web/src/routes/auth/pin-prompt/+page.ts index e2b79605d8..b0d248ebe6 100644 --- a/web/src/routes/auth/pin-prompt/+page.ts +++ b/web/src/routes/auth/pin-prompt/+page.ts @@ -4,7 +4,7 @@ import { getAuthStatus } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { - await authenticate(); + await authenticate(url); const { pinCode } = await getAuthStatus(); From c1150fe7e3cbf8fa2c2a7e41613dbeffac1cae9c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 May 2025 18:08:31 -0400 Subject: [PATCH 46/48] feat: lock auth session (#18322) --- i18n/en.json | 1 + mobile/openapi/README.md | 6 +- mobile/openapi/lib/api.dart | 2 + .../openapi/lib/api/authentication_api.dart | 123 ++++++++++------- mobile/openapi/lib/api/sessions_api.dart | 40 ++++++ mobile/openapi/lib/api_client.dart | 4 + .../lib/model/auth_status_response_dto.dart | 40 +++++- mobile/openapi/lib/model/permission.dart | 3 + .../openapi/lib/model/pin_code_reset_dto.dart | 125 ++++++++++++++++++ .../model/session_create_response_dto.dart | 19 ++- .../lib/model/session_response_dto.dart | 19 ++- .../openapi/lib/model/session_unlock_dto.dart | 125 ++++++++++++++++++ open-api/immich-openapi-specs.json | 105 ++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 45 +++++-- server/src/controllers/auth.controller.ts | 17 ++- server/src/controllers/session.controller.ts | 7 + server/src/database.ts | 1 + server/src/db.d.ts | 2 +- server/src/dtos/auth.dto.ts | 4 + server/src/dtos/session.dto.ts | 2 + server/src/enum.ts | 1 + server/src/queries/access.repository.sql | 9 ++ server/src/queries/session.repository.sql | 23 +++- server/src/repositories/access.repository.ts | 21 +++ server/src/repositories/session.repository.ts | 22 ++- .../migrations/1747338664832-SessionRename.ts | 9 ++ server/src/schema/tables/session.table.ts | 2 +- server/src/services/auth.service.spec.ts | 4 +- server/src/services/auth.service.ts | 40 +++--- server/src/services/session.service.ts | 7 +- server/src/utils/access.ts | 7 + .../repositories/access.repository.mock.ts | 4 + server/test/small.factory.ts | 2 +- .../[[assetId=id]]/+page.svelte | 19 ++- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 8 +- web/src/routes/auth/pin-prompt/+page.svelte | 15 +-- web/src/routes/auth/pin-prompt/+page.ts | 5 +- 37 files changed, 765 insertions(+), 123 deletions(-) create mode 100644 mobile/openapi/lib/model/pin_code_reset_dto.dart create mode 100644 mobile/openapi/lib/model/session_unlock_dto.dart create mode 100644 server/src/schema/migrations/1747338664832-SessionRename.ts diff --git a/i18n/en.json b/i18n/en.json index e4fc825cda..578fe9a115 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1142,6 +1142,7 @@ "location_picker_latitude_hint": "Enter your latitude here", "location_picker_longitude_error": "Enter a valid longitude", "location_picker_longitude_hint": "Enter your longitude here", + "lock": "Lock", "locked_folder": "Locked Folder", "log_out": "Log out", "log_out_all_devices": "Log Out All Devices", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9544b2ddab..620fc97664 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -111,13 +111,14 @@ Class | Method | HTTP request | Description *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* | [**lockAuthSession**](doc//AuthenticationApi.md#lockauthsession) | **POST** /auth/session/lock | *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* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | -*AuthenticationApi* | [**verifyPinCode**](doc//AuthenticationApi.md#verifypincode) | **POST** /auth/pin-code/verify | *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | @@ -198,6 +199,7 @@ Class | Method | HTTP request | Description *SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | *SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | *SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | +*SessionsApi* | [**lockSession**](doc//SessionsApi.md#locksession) | **POST** /sessions/{id}/lock | *SharedLinksApi* | [**addSharedLinkAssets**](doc//SharedLinksApi.md#addsharedlinkassets) | **PUT** /shared-links/{id}/assets | *SharedLinksApi* | [**createSharedLink**](doc//SharedLinksApi.md#createsharedlink) | **POST** /shared-links | *SharedLinksApi* | [**getAllSharedLinks**](doc//SharedLinksApi.md#getallsharedlinks) | **GET** /shared-links | @@ -392,6 +394,7 @@ Class | Method | HTTP request | Description - [PersonUpdateDto](doc//PersonUpdateDto.md) - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md) - [PinCodeChangeDto](doc//PinCodeChangeDto.md) + - [PinCodeResetDto](doc//PinCodeResetDto.md) - [PinCodeSetupDto](doc//PinCodeSetupDto.md) - [PlacesResponseDto](doc//PlacesResponseDto.md) - [PurchaseResponse](doc//PurchaseResponse.md) @@ -424,6 +427,7 @@ Class | Method | HTTP request | Description - [SessionCreateDto](doc//SessionCreateDto.md) - [SessionCreateResponseDto](doc//SessionCreateResponseDto.md) - [SessionResponseDto](doc//SessionResponseDto.md) + - [SessionUnlockDto](doc//SessionUnlockDto.md) - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) - [SharedLinkEditDto](doc//SharedLinkEditDto.md) - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d0e39e0965..8710298d7d 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -189,6 +189,7 @@ 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_reset_dto.dart'; part 'model/pin_code_setup_dto.dart'; part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; @@ -221,6 +222,7 @@ part 'model/server_version_response_dto.dart'; part 'model/session_create_dto.dart'; part 'model/session_create_response_dto.dart'; part 'model/session_response_dto.dart'; +part 'model/session_unlock_dto.dart'; part 'model/shared_link_create_dto.dart'; part 'model/shared_link_edit_dto.dart'; part 'model/shared_link_response_dto.dart'; diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index 446a0616ed..5482a9fc51 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -143,6 +143,39 @@ class AuthenticationApi { return null; } + /// Performs an HTTP 'POST /auth/session/lock' operation and returns the [Response]. + Future lockAuthSessionWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/session/lock'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future lockAuthSession() async { + final response = await lockAuthSessionWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'POST /auth/login' operation and returns the [Response]. /// Parameters: /// @@ -234,13 +267,13 @@ class AuthenticationApi { /// Performs an HTTP 'DELETE /auth/pin-code' operation and returns the [Response]. /// Parameters: /// - /// * [PinCodeChangeDto] pinCodeChangeDto (required): - Future resetPinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async { + /// * [PinCodeResetDto] pinCodeResetDto (required): + Future resetPinCodeWithHttpInfo(PinCodeResetDto pinCodeResetDto,) async { // ignore: prefer_const_declarations final apiPath = r'/auth/pin-code'; // ignore: prefer_final_locals - Object? postBody = pinCodeChangeDto; + Object? postBody = pinCodeResetDto; final queryParams = []; final headerParams = {}; @@ -262,9 +295,9 @@ class AuthenticationApi { /// Parameters: /// - /// * [PinCodeChangeDto] pinCodeChangeDto (required): - Future resetPinCode(PinCodeChangeDto pinCodeChangeDto,) async { - final response = await resetPinCodeWithHttpInfo(pinCodeChangeDto,); + /// * [PinCodeResetDto] pinCodeResetDto (required): + Future resetPinCode(PinCodeResetDto pinCodeResetDto,) async { + final response = await resetPinCodeWithHttpInfo(pinCodeResetDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -356,6 +389,45 @@ class AuthenticationApi { return null; } + /// Performs an HTTP 'POST /auth/session/unlock' operation and returns the [Response]. + /// Parameters: + /// + /// * [SessionUnlockDto] sessionUnlockDto (required): + Future unlockAuthSessionWithHttpInfo(SessionUnlockDto sessionUnlockDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/session/unlock'; + + // ignore: prefer_final_locals + Object? postBody = sessionUnlockDto; + + 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: + /// + /// * [SessionUnlockDto] sessionUnlockDto (required): + Future unlockAuthSession(SessionUnlockDto sessionUnlockDto,) async { + final response = await unlockAuthSessionWithHttpInfo(sessionUnlockDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'POST /auth/validateToken' operation and returns the [Response]. Future validateAccessTokenWithHttpInfo() async { // ignore: prefer_const_declarations @@ -396,43 +468,4 @@ class AuthenticationApi { } return null; } - - /// Performs an HTTP 'POST /auth/pin-code/verify' operation and returns the [Response]. - /// Parameters: - /// - /// * [PinCodeSetupDto] pinCodeSetupDto (required): - Future verifyPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async { - // ignore: prefer_const_declarations - final apiPath = r'/auth/pin-code/verify'; - - // 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 verifyPinCode(PinCodeSetupDto pinCodeSetupDto,) async { - final response = await verifyPinCodeWithHttpInfo(pinCodeSetupDto,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } } diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index 9f850fb4c8..3228d31e91 100644 --- a/mobile/openapi/lib/api/sessions_api.dart +++ b/mobile/openapi/lib/api/sessions_api.dart @@ -179,4 +179,44 @@ class SessionsApi { } return null; } + + /// Performs an HTTP 'POST /sessions/{id}/lock' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future lockSessionWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/sessions/{id}/lock' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future lockSession(String id,) async { + final response = await lockSessionWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index f40d09ecc3..a3b1c41ca6 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -434,6 +434,8 @@ class ApiClient { return PersonWithFacesResponseDto.fromJson(value); case 'PinCodeChangeDto': return PinCodeChangeDto.fromJson(value); + case 'PinCodeResetDto': + return PinCodeResetDto.fromJson(value); case 'PinCodeSetupDto': return PinCodeSetupDto.fromJson(value); case 'PlacesResponseDto': @@ -498,6 +500,8 @@ class ApiClient { return SessionCreateResponseDto.fromJson(value); case 'SessionResponseDto': return SessionResponseDto.fromJson(value); + case 'SessionUnlockDto': + return SessionUnlockDto.fromJson(value); case 'SharedLinkCreateDto': return SharedLinkCreateDto.fromJson(value); case 'SharedLinkEditDto': diff --git a/mobile/openapi/lib/model/auth_status_response_dto.dart b/mobile/openapi/lib/model/auth_status_response_dto.dart index 0ccd87114e..4e823506ee 100644 --- a/mobile/openapi/lib/model/auth_status_response_dto.dart +++ b/mobile/openapi/lib/model/auth_status_response_dto.dart @@ -13,38 +13,70 @@ part of openapi.api; class AuthStatusResponseDto { /// Returns a new [AuthStatusResponseDto] instance. AuthStatusResponseDto({ + this.expiresAt, required this.isElevated, required this.password, required this.pinCode, + this.pinExpiresAt, }); + /// + /// 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? expiresAt; + bool isElevated; bool password; bool pinCode; + /// + /// 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? pinExpiresAt; + @override bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto && + other.expiresAt == expiresAt && other.isElevated == isElevated && other.password == password && - other.pinCode == pinCode; + other.pinCode == pinCode && + other.pinExpiresAt == pinExpiresAt; @override int get hashCode => // ignore: unnecessary_parenthesis + (expiresAt == null ? 0 : expiresAt!.hashCode) + (isElevated.hashCode) + (password.hashCode) + - (pinCode.hashCode); + (pinCode.hashCode) + + (pinExpiresAt == null ? 0 : pinExpiresAt!.hashCode); @override - String toString() => 'AuthStatusResponseDto[isElevated=$isElevated, password=$password, pinCode=$pinCode]'; + String toString() => 'AuthStatusResponseDto[expiresAt=$expiresAt, isElevated=$isElevated, password=$password, pinCode=$pinCode, pinExpiresAt=$pinExpiresAt]'; Map toJson() { final json = {}; + if (this.expiresAt != null) { + json[r'expiresAt'] = this.expiresAt; + } else { + // json[r'expiresAt'] = null; + } json[r'isElevated'] = this.isElevated; json[r'password'] = this.password; json[r'pinCode'] = this.pinCode; + if (this.pinExpiresAt != null) { + json[r'pinExpiresAt'] = this.pinExpiresAt; + } else { + // json[r'pinExpiresAt'] = null; + } return json; } @@ -57,9 +89,11 @@ class AuthStatusResponseDto { final json = value.cast(); return AuthStatusResponseDto( + expiresAt: mapValueOfType(json, r'expiresAt'), isElevated: mapValueOfType(json, r'isElevated')!, password: mapValueOfType(json, r'password')!, pinCode: mapValueOfType(json, r'pinCode')!, + pinExpiresAt: mapValueOfType(json, r'pinExpiresAt'), ); } return null; diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 73ecbd5868..a85b5002bf 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -85,6 +85,7 @@ class Permission { static const sessionPeriodRead = Permission._(r'session.read'); static const sessionPeriodUpdate = Permission._(r'session.update'); static const sessionPeriodDelete = Permission._(r'session.delete'); + static const sessionPeriodLock = Permission._(r'session.lock'); static const sharedLinkPeriodCreate = Permission._(r'sharedLink.create'); static const sharedLinkPeriodRead = Permission._(r'sharedLink.read'); static const sharedLinkPeriodUpdate = Permission._(r'sharedLink.update'); @@ -171,6 +172,7 @@ class Permission { sessionPeriodRead, sessionPeriodUpdate, sessionPeriodDelete, + sessionPeriodLock, sharedLinkPeriodCreate, sharedLinkPeriodRead, sharedLinkPeriodUpdate, @@ -292,6 +294,7 @@ class PermissionTypeTransformer { case r'session.read': return Permission.sessionPeriodRead; case r'session.update': return Permission.sessionPeriodUpdate; case r'session.delete': return Permission.sessionPeriodDelete; + case r'session.lock': return Permission.sessionPeriodLock; case r'sharedLink.create': return Permission.sharedLinkPeriodCreate; case r'sharedLink.read': return Permission.sharedLinkPeriodRead; case r'sharedLink.update': return Permission.sharedLinkPeriodUpdate; diff --git a/mobile/openapi/lib/model/pin_code_reset_dto.dart b/mobile/openapi/lib/model/pin_code_reset_dto.dart new file mode 100644 index 0000000000..3585348675 --- /dev/null +++ b/mobile/openapi/lib/model/pin_code_reset_dto.dart @@ -0,0 +1,125 @@ +// +// 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 PinCodeResetDto { + /// Returns a new [PinCodeResetDto] instance. + PinCodeResetDto({ + this.password, + this.pinCode, + }); + + /// + /// 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 PinCodeResetDto && + other.password == password && + other.pinCode == pinCode; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (password == null ? 0 : password!.hashCode) + + (pinCode == null ? 0 : pinCode!.hashCode); + + @override + String toString() => 'PinCodeResetDto[password=$password, pinCode=$pinCode]'; + + Map toJson() { + final json = {}; + 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 [PinCodeResetDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PinCodeResetDto? fromJson(dynamic value) { + upgradeDto(value, "PinCodeResetDto"); + if (value is Map) { + final json = value.cast(); + + return PinCodeResetDto( + 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 = PinCodeResetDto.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 = PinCodeResetDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PinCodeResetDto-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] = PinCodeResetDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart index 1ef346c96a..ab1c4ca2d8 100644 --- a/mobile/openapi/lib/model/session_create_response_dto.dart +++ b/mobile/openapi/lib/model/session_create_response_dto.dart @@ -17,6 +17,7 @@ class SessionCreateResponseDto { required this.current, required this.deviceOS, required this.deviceType, + this.expiresAt, required this.id, required this.token, required this.updatedAt, @@ -30,6 +31,14 @@ class SessionCreateResponseDto { String deviceType; + /// + /// 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? expiresAt; + String id; String token; @@ -42,6 +51,7 @@ class SessionCreateResponseDto { other.current == current && other.deviceOS == deviceOS && other.deviceType == deviceType && + other.expiresAt == expiresAt && other.id == id && other.token == token && other.updatedAt == updatedAt; @@ -53,12 +63,13 @@ class SessionCreateResponseDto { (current.hashCode) + (deviceOS.hashCode) + (deviceType.hashCode) + + (expiresAt == null ? 0 : expiresAt!.hashCode) + (id.hashCode) + (token.hashCode) + (updatedAt.hashCode); @override - String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, id=$id, token=$token, updatedAt=$updatedAt]'; + String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, token=$token, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -66,6 +77,11 @@ class SessionCreateResponseDto { json[r'current'] = this.current; json[r'deviceOS'] = this.deviceOS; json[r'deviceType'] = this.deviceType; + if (this.expiresAt != null) { + json[r'expiresAt'] = this.expiresAt; + } else { + // json[r'expiresAt'] = null; + } json[r'id'] = this.id; json[r'token'] = this.token; json[r'updatedAt'] = this.updatedAt; @@ -85,6 +101,7 @@ class SessionCreateResponseDto { current: mapValueOfType(json, r'current')!, deviceOS: mapValueOfType(json, r'deviceOS')!, deviceType: mapValueOfType(json, r'deviceType')!, + expiresAt: mapValueOfType(json, r'expiresAt'), id: mapValueOfType(json, r'id')!, token: mapValueOfType(json, r'token')!, updatedAt: mapValueOfType(json, r'updatedAt')!, diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index 92e2dc6067..cf9eb08a78 100644 --- a/mobile/openapi/lib/model/session_response_dto.dart +++ b/mobile/openapi/lib/model/session_response_dto.dart @@ -17,6 +17,7 @@ class SessionResponseDto { required this.current, required this.deviceOS, required this.deviceType, + this.expiresAt, required this.id, required this.updatedAt, }); @@ -29,6 +30,14 @@ class SessionResponseDto { String deviceType; + /// + /// 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? expiresAt; + String id; String updatedAt; @@ -39,6 +48,7 @@ class SessionResponseDto { other.current == current && other.deviceOS == deviceOS && other.deviceType == deviceType && + other.expiresAt == expiresAt && other.id == id && other.updatedAt == updatedAt; @@ -49,11 +59,12 @@ class SessionResponseDto { (current.hashCode) + (deviceOS.hashCode) + (deviceType.hashCode) + + (expiresAt == null ? 0 : expiresAt!.hashCode) + (id.hashCode) + (updatedAt.hashCode); @override - String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, id=$id, updatedAt=$updatedAt]'; + String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -61,6 +72,11 @@ class SessionResponseDto { json[r'current'] = this.current; json[r'deviceOS'] = this.deviceOS; json[r'deviceType'] = this.deviceType; + if (this.expiresAt != null) { + json[r'expiresAt'] = this.expiresAt; + } else { + // json[r'expiresAt'] = null; + } json[r'id'] = this.id; json[r'updatedAt'] = this.updatedAt; return json; @@ -79,6 +95,7 @@ class SessionResponseDto { current: mapValueOfType(json, r'current')!, deviceOS: mapValueOfType(json, r'deviceOS')!, deviceType: mapValueOfType(json, r'deviceType')!, + expiresAt: mapValueOfType(json, r'expiresAt'), id: mapValueOfType(json, r'id')!, updatedAt: mapValueOfType(json, r'updatedAt')!, ); diff --git a/mobile/openapi/lib/model/session_unlock_dto.dart b/mobile/openapi/lib/model/session_unlock_dto.dart new file mode 100644 index 0000000000..4cfeb14385 --- /dev/null +++ b/mobile/openapi/lib/model/session_unlock_dto.dart @@ -0,0 +1,125 @@ +// +// 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 SessionUnlockDto { + /// Returns a new [SessionUnlockDto] instance. + SessionUnlockDto({ + this.password, + this.pinCode, + }); + + /// + /// 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 SessionUnlockDto && + other.password == password && + other.pinCode == pinCode; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (password == null ? 0 : password!.hashCode) + + (pinCode == null ? 0 : pinCode!.hashCode); + + @override + String toString() => 'SessionUnlockDto[password=$password, pinCode=$pinCode]'; + + Map toJson() { + final json = {}; + 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 [SessionUnlockDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SessionUnlockDto? fromJson(dynamic value) { + upgradeDto(value, "SessionUnlockDto"); + if (value is Map) { + final json = value.cast(); + + return SessionUnlockDto( + 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 = SessionUnlockDto.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 = SessionUnlockDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SessionUnlockDto-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] = SessionUnlockDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d4a1e219c9..89bdfef45e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2377,7 +2377,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PinCodeChangeDto" + "$ref": "#/components/schemas/PinCodeResetDto" } } }, @@ -2470,15 +2470,40 @@ ] } }, - "/auth/pin-code/verify": { + "/auth/session/lock": { "post": { - "operationId": "verifyPinCode", + "operationId": "lockAuthSession", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Authentication" + ] + } + }, + "/auth/session/unlock": { + "post": { + "operationId": "unlockAuthSession", "parameters": [], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PinCodeSetupDto" + "$ref": "#/components/schemas/SessionUnlockDto" } } }, @@ -5695,6 +5720,41 @@ ] } }, + "/sessions/{id}/lock": { + "post": { + "operationId": "lockSession", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] + } + }, "/shared-links": { "get": { "operationId": "getAllSharedLinks", @@ -9327,6 +9387,9 @@ }, "AuthStatusResponseDto": { "properties": { + "expiresAt": { + "type": "string" + }, "isElevated": { "type": "boolean" }, @@ -9335,6 +9398,9 @@ }, "pinCode": { "type": "boolean" + }, + "pinExpiresAt": { + "type": "string" } }, "required": [ @@ -11096,6 +11162,7 @@ "session.read", "session.update", "session.delete", + "session.lock", "sharedLink.create", "sharedLink.read", "sharedLink.update", @@ -11297,6 +11364,18 @@ ], "type": "object" }, + "PinCodeResetDto": { + "properties": { + "password": { + "type": "string" + }, + "pinCode": { + "example": "123456", + "type": "string" + } + }, + "type": "object" + }, "PinCodeSetupDto": { "properties": { "pinCode": { @@ -12109,6 +12188,9 @@ "deviceType": { "type": "string" }, + "expiresAt": { + "type": "string" + }, "id": { "type": "string" }, @@ -12144,6 +12226,9 @@ "deviceType": { "type": "string" }, + "expiresAt": { + "type": "string" + }, "id": { "type": "string" }, @@ -12161,6 +12246,18 @@ ], "type": "object" }, + "SessionUnlockDto": { + "properties": { + "password": { + "type": "string" + }, + "pinCode": { + "example": "123456", + "type": "string" + } + }, + "type": "object" + }, "SharedLinkCreateDto": { "properties": { "albumId": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index de0a723ffa..1d3a04da44 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -512,18 +512,28 @@ export type LogoutResponseDto = { redirectUri: string; successful: boolean; }; -export type PinCodeChangeDto = { - newPinCode: string; +export type PinCodeResetDto = { password?: string; pinCode?: string; }; export type PinCodeSetupDto = { pinCode: string; }; +export type PinCodeChangeDto = { + newPinCode: string; + password?: string; + pinCode?: string; +}; +export type SessionUnlockDto = { + password?: string; + pinCode?: string; +}; export type AuthStatusResponseDto = { + expiresAt?: string; isElevated: boolean; password: boolean; pinCode: boolean; + pinExpiresAt?: string; }; export type ValidateAccessTokenResponseDto = { authStatus: boolean; @@ -1075,6 +1085,7 @@ export type SessionResponseDto = { current: boolean; deviceOS: string; deviceType: string; + expiresAt?: string; id: string; updatedAt: string; }; @@ -1089,6 +1100,7 @@ export type SessionCreateResponseDto = { current: boolean; deviceOS: string; deviceType: string; + expiresAt?: string; id: string; token: string; updatedAt: string; @@ -2066,13 +2078,13 @@ export function logout(opts?: Oazapfts.RequestOpts) { method: "POST" })); } -export function resetPinCode({ pinCodeChangeDto }: { - pinCodeChangeDto: PinCodeChangeDto; +export function resetPinCode({ pinCodeResetDto }: { + pinCodeResetDto: PinCodeResetDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({ ...opts, method: "DELETE", - body: pinCodeChangeDto + body: pinCodeResetDto }))); } export function setupPinCode({ pinCodeSetupDto }: { @@ -2093,13 +2105,19 @@ export function changePinCode({ pinCodeChangeDto }: { body: pinCodeChangeDto }))); } -export function verifyPinCode({ pinCodeSetupDto }: { - pinCodeSetupDto: PinCodeSetupDto; +export function lockAuthSession(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/auth/session/lock", { + ...opts, + method: "POST" + })); +} +export function unlockAuthSession({ sessionUnlockDto }: { + sessionUnlockDto: SessionUnlockDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/auth/pin-code/verify", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/auth/session/unlock", oazapfts.json({ ...opts, method: "POST", - body: pinCodeSetupDto + body: sessionUnlockDto }))); } export function getAuthStatus(opts?: Oazapfts.RequestOpts) { @@ -2952,6 +2970,14 @@ export function deleteSession({ id }: { method: "DELETE" })); } +export function lockSession({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/sessions/${encodeURIComponent(id)}/lock`, { + ...opts, + method: "POST" + })); +} export function getAllSharedLinks({ albumId }: { albumId?: string; }, opts?: Oazapfts.RequestOpts) { @@ -3709,6 +3735,7 @@ export enum Permission { SessionRead = "session.read", SessionUpdate = "session.update", SessionDelete = "session.delete", + SessionLock = "session.lock", SharedLinkCreate = "sharedLink.create", SharedLinkRead = "sharedLink.read", SharedLinkUpdate = "sharedLink.update", diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 5d3ba8be95..78c611d761 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -9,7 +9,9 @@ import { LoginResponseDto, LogoutResponseDto, PinCodeChangeDto, + PinCodeResetDto, PinCodeSetupDto, + SessionUnlockDto, SignUpDto, ValidateAccessTokenResponseDto, } from 'src/dtos/auth.dto'; @@ -98,14 +100,21 @@ export class AuthController { @Delete('pin-code') @Authenticated() - async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise { + async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeResetDto): Promise { return this.service.resetPinCode(auth, dto); } - @Post('pin-code/verify') + @Post('session/unlock') @HttpCode(HttpStatus.OK) @Authenticated() - async verifyPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise { - return this.service.verifyPinCode(auth, dto); + async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise { + return this.service.unlockSession(auth, dto); + } + + @Post('session/lock') + @HttpCode(HttpStatus.OK) + @Authenticated() + async lockAuthSession(@Auth() auth: AuthDto): Promise { + return this.service.lockSession(auth); } } diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index addcfd8fe9..3838d5af80 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -37,4 +37,11 @@ export class SessionController { deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } + + @Post(':id/lock') + @Authenticated({ permission: Permission.SESSION_LOCK }) + @HttpCode(HttpStatus.NO_CONTENT) + lockSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.lock(auth, id); + } } diff --git a/server/src/database.ts b/server/src/database.ts index 29c746aa1f..cfccd70b75 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -232,6 +232,7 @@ export type Session = { id: string; createdAt: Date; updatedAt: Date; + expiresAt: Date | null; deviceOS: string; deviceType: string; pinExpiresAt: Date | null; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 6efbd5f7d7..943c9ddfa0 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -344,7 +344,7 @@ export interface Sessions { deviceType: Generated; id: Generated; parentId: string | null; - expiredAt: Date | null; + expiresAt: Date | null; token: string; updatedAt: Generated; updateId: Generated; diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 8644426ab2..2f3ae5c14b 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -93,6 +93,8 @@ export class PinCodeResetDto { password?: string; } +export class SessionUnlockDto extends PinCodeResetDto {} + export class PinCodeChangeDto extends PinCodeResetDto { @PinCode() newPinCode!: string; @@ -139,4 +141,6 @@ export class AuthStatusResponseDto { pinCode!: boolean; password!: boolean; isElevated!: boolean; + expiresAt?: string; + pinExpiresAt?: string; } diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index f109e44fa0..f15166fbf5 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -24,6 +24,7 @@ export class SessionResponseDto { id!: string; createdAt!: string; updatedAt!: string; + expiresAt?: string; current!: boolean; deviceType!: string; deviceOS!: string; @@ -37,6 +38,7 @@ export const mapSession = (entity: Session, currentId?: string): SessionResponse id: entity.id, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), + expiresAt: entity.expiresAt?.toISOString(), current: currentId === entity.id, deviceOS: entity.deviceOS, deviceType: entity.deviceType, diff --git a/server/src/enum.ts b/server/src/enum.ts index c6feb27dcc..a4d2d21274 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -148,6 +148,7 @@ export enum Permission { SESSION_READ = 'session.read', SESSION_UPDATE = 'session.update', SESSION_DELETE = 'session.delete', + SESSION_LOCK = 'session.lock', SHARED_LINK_CREATE = 'sharedLink.create', SHARED_LINK_READ = 'sharedLink.read', diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index c73f44c19d..402bbdcfaf 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -199,6 +199,15 @@ where "partners"."sharedById" in ($1) and "partners"."sharedWithId" = $2 +-- AccessRepository.session.checkOwnerAccess +select + "sessions"."id" +from + "sessions" +where + "sessions"."id" in ($1) + and "sessions"."userId" = $2 + -- AccessRepository.stack.checkOwnerAccess select "stacks"."id" diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index b265380a1f..6a9b69c2e3 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -1,12 +1,14 @@ -- NOTE: This file is auto generated by ./sql-generator --- SessionRepository.search +-- SessionRepository.get select - * + "id", + "expiresAt", + "pinExpiresAt" from "sessions" where - "sessions"."updatedAt" <= $1 + "id" = $1 -- SessionRepository.getByToken select @@ -37,8 +39,8 @@ from where "sessions"."token" = $1 and ( - "sessions"."expiredAt" is null - or "sessions"."expiredAt" > $2 + "sessions"."expiresAt" is null + or "sessions"."expiresAt" > $2 ) -- SessionRepository.getByUserId @@ -50,6 +52,10 @@ from and "users"."deletedAt" is null where "sessions"."userId" = $1 + and ( + "sessions"."expiresAt" is null + or "sessions"."expiresAt" > $2 + ) order by "sessions"."updatedAt" desc, "sessions"."createdAt" desc @@ -58,3 +64,10 @@ order by delete from "sessions" where "id" = $1::uuid + +-- SessionRepository.lockAll +update "sessions" +set + "pinExpiresAt" = $1 +where + "userId" = $2 diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index b25007c4ea..17f69c0e52 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -306,6 +306,25 @@ class NotificationAccess { } } +class SessionAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, sessionIds: Set) { + if (sessionIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('sessions') + .select('sessions.id') + .where('sessions.id', 'in', [...sessionIds]) + .where('sessions.userId', '=', userId) + .execute() + .then((sessions) => new Set(sessions.map((session) => session.id))); + } +} class StackAccess { constructor(private db: Kysely) {} @@ -456,6 +475,7 @@ export class AccessRepository { notification: NotificationAccess; person: PersonAccess; partner: PartnerAccess; + session: SessionAccess; stack: StackAccess; tag: TagAccess; timeline: TimelineAccess; @@ -469,6 +489,7 @@ export class AccessRepository { this.notification = new NotificationAccess(db); this.person = new PersonAccess(db); this.partner = new PartnerAccess(db); + this.session = new SessionAccess(db); this.stack = new StackAccess(db); this.tag = new TagAccess(db); this.timeline = new TimelineAccess(db); diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index ce819470c7..6c3d10cb9a 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -20,20 +20,20 @@ export class SessionRepository { .where((eb) => eb.or([ eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()), - eb.and([eb('expiredAt', 'is not', null), eb('expiredAt', '<=', DateTime.now().toJSDate())]), + eb.and([eb('expiresAt', 'is not', null), eb('expiresAt', '<=', DateTime.now().toJSDate())]), ]), ) .returning(['id', 'deviceOS', 'deviceType']) .execute(); } - @GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] }) - search(options: SessionSearchOptions) { + @GenerateSql({ params: [DummyValue.UUID] }) + get(id: string) { return this.db .selectFrom('sessions') - .selectAll() - .where('sessions.updatedAt', '<=', options.updatedBefore) - .execute(); + .select(['id', 'expiresAt', 'pinExpiresAt']) + .where('id', '=', id) + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.STRING] }) @@ -52,7 +52,7 @@ export class SessionRepository { ]) .where('sessions.token', '=', token) .where((eb) => - eb.or([eb('sessions.expiredAt', 'is', null), eb('sessions.expiredAt', '>', DateTime.now().toJSDate())]), + eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]), ) .executeTakeFirst(); } @@ -64,6 +64,9 @@ export class SessionRepository { .innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null)) .selectAll('sessions') .where('sessions.userId', '=', userId) + .where((eb) => + eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]), + ) .orderBy('sessions.updatedAt', 'desc') .orderBy('sessions.createdAt', 'desc') .execute(); @@ -86,4 +89,9 @@ export class SessionRepository { async delete(id: string) { await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute(); } + + @GenerateSql({ params: [DummyValue.UUID] }) + async lockAll(userId: string) { + await this.db.updateTable('sessions').set({ pinExpiresAt: null }).where('userId', '=', userId).execute(); + } } diff --git a/server/src/schema/migrations/1747338664832-SessionRename.ts b/server/src/schema/migrations/1747338664832-SessionRename.ts new file mode 100644 index 0000000000..5ba532d136 --- /dev/null +++ b/server/src/schema/migrations/1747338664832-SessionRename.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" RENAME "expiredAt" TO "expiresAt";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" RENAME "expiresAt" TO "expiredAt";`.execute(db); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 9cc41c5bba..6bd5d84cb2 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -26,7 +26,7 @@ export class SessionTable { updatedAt!: Date; @Column({ type: 'timestamp with time zone', nullable: true }) - expiredAt!: Date | null; + expiresAt!: Date | null; @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) userId!: string; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index fb1a5ae042..4bc5f1ce0b 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -924,13 +924,13 @@ describe(AuthService.name, () => { const user = factory.userAdmin(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); - mocks.session.getByUserId.mockResolvedValue([currentSession]); + mocks.session.lockAll.mockResolvedValue(void 0); mocks.session.update.mockResolvedValue(currentSession); await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); - expect(mocks.session.update).toHaveBeenCalledWith(currentSession.id, { pinExpiresAt: null }); + expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id); }); it('should throw if the PIN code does not match', async () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 7bda2eeb98..e6c541a624 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -18,6 +18,7 @@ import { PinCodeChangeDto, PinCodeResetDto, PinCodeSetupDto, + SessionUnlockDto, SignUpDto, mapLoginResponse, } from 'src/dtos/auth.dto'; @@ -123,24 +124,21 @@ export class AuthService extends BaseService { async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) { const user = await this.userRepository.getForPinCode(auth.user.id); - this.resetPinChecks(user, dto); + this.validatePinCode(user, dto); await this.userRepository.update(auth.user.id, { pinCode: null }); - const sessions = await this.sessionRepository.getByUserId(auth.user.id); - for (const session of sessions) { - await this.sessionRepository.update(session.id, { pinExpiresAt: null }); - } + await this.sessionRepository.lockAll(auth.user.id); } async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) { const user = await this.userRepository.getForPinCode(auth.user.id); - this.resetPinChecks(user, dto); + this.validatePinCode(user, dto); const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS); await this.userRepository.update(auth.user.id, { pinCode: hashed }); } - private resetPinChecks( + private validatePinCode( user: { pinCode: string | null; password: string | null }, dto: { pinCode?: string; password?: string }, ) { @@ -474,23 +472,27 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Invalid user token'); } - async verifyPinCode(auth: AuthDto, dto: PinCodeSetupDto): Promise { - const user = await this.userRepository.getForPinCode(auth.user.id); - if (!user) { - throw new UnauthorizedException(); - } - - this.resetPinChecks(user, { pinCode: dto.pinCode }); - + async unlockSession(auth: AuthDto, dto: SessionUnlockDto): Promise { if (!auth.session) { - throw new BadRequestException('Session is missing'); + throw new BadRequestException('This endpoint can only be used with a session token'); } + const user = await this.userRepository.getForPinCode(auth.user.id); + this.validatePinCode(user, { pinCode: dto.pinCode }); + await this.sessionRepository.update(auth.session.id, { - pinExpiresAt: new Date(DateTime.now().plus({ minutes: 15 }).toJSDate()), + pinExpiresAt: DateTime.now().plus({ minutes: 15 }).toJSDate(), }); } + async lockSession(auth: AuthDto): Promise { + if (!auth.session) { + throw new BadRequestException('This endpoint can only be used with a session token'); + } + + await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null }); + } + private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) { const token = this.cryptoRepository.randomBytesAsText(32); const tokenHashed = this.cryptoRepository.hashSha256(token); @@ -526,10 +528,14 @@ export class AuthService extends BaseService { throw new UnauthorizedException(); } + const session = auth.session ? await this.sessionRepository.get(auth.session.id) : undefined; + return { pinCode: !!user.pinCode, password: !!user.password, isElevated: !!auth.session?.hasElevatedPermission, + expiresAt: session?.expiresAt?.toISOString(), + pinExpiresAt: session?.pinExpiresAt?.toISOString(), }; } } diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 9f49cda07f..059ff00e16 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -30,7 +30,7 @@ export class SessionService extends BaseService { const session = await this.sessionRepository.create({ parentId: auth.session.id, userId: auth.user.id, - expiredAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null, + expiresAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null, deviceType: dto.deviceType, deviceOS: dto.deviceOS, token: tokenHashed, @@ -49,6 +49,11 @@ export class SessionService extends BaseService { await this.sessionRepository.delete(id); } + async lock(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.SESSION_LOCK, ids: [id] }); + await this.sessionRepository.update(id, { pinExpiresAt: null }); + } + async deleteAll(auth: AuthDto): Promise { const sessions = await this.sessionRepository.getByUserId(auth.user.id); for (const session of sessions) { diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index e2fe7429f3..38697a654b 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -280,6 +280,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.partner.checkUpdateAccess(auth.user.id, ids); } + case Permission.SESSION_READ: + case Permission.SESSION_UPDATE: + case Permission.SESSION_DELETE: + case Permission.SESSION_LOCK: { + return access.session.checkOwnerAccess(auth.user.id, ids); + } + case Permission.STACK_READ: { return access.stack.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 5b98b95e27..50db983cba 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -50,6 +50,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()), }, + session: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + stack: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 231deeba83..75e36c1da2 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -127,7 +127,7 @@ const sessionFactory = (session: Partial = {}) => ({ deviceType: 'mobile', token: 'abc123', parentId: null, - expiredAt: null, + expiresAt: null, userId: newUuid(), pinExpiresAt: newDate(), ...session, diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte index 49b40866dd..9c41a7fe59 100644 --- a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,4 +1,5 @@ @@ -62,6 +69,12 @@ {/if} + {#snippet buttons()} + + {/snippet} + { await authenticate(url); + const { isElevated, pinCode } = await getAuthStatus(); - if (!isElevated || !pinCode) { - const continuePath = encodeURIComponent(url.pathname); - const redirectPath = `${AppRoute.AUTH_PIN_PROMPT}?continue=${continuePath}`; - - redirect(302, redirectPath); + redirect(302, `${AppRoute.AUTH_PIN_PROMPT}?continue=${encodeURIComponent(url.pathname + url.search)}`); } + const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/auth/pin-prompt/+page.svelte b/web/src/routes/auth/pin-prompt/+page.svelte index 91480cd35c..ffed9d5de0 100644 --- a/web/src/routes/auth/pin-prompt/+page.svelte +++ b/web/src/routes/auth/pin-prompt/+page.svelte @@ -3,9 +3,8 @@ import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte'; import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte'; import PincodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte'; - import { AppRoute } from '$lib/constants'; import { handleError } from '$lib/utils/handle-error'; - import { verifyPinCode } from '@immich/sdk'; + import { unlockAuthSession } from '@immich/sdk'; import { Icon } from '@immich/ui'; import { mdiLockOpenVariantOutline, mdiLockOutline, mdiLockSmart } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -23,17 +22,15 @@ let hasPinCode = $derived(data.hasPinCode); let pinCode = $state(''); - const onPinFilled = async (code: string, withDelay = false) => { + const handleUnlockSession = async (code: string) => { try { - await verifyPinCode({ pinCodeSetupDto: { pinCode: code } }); + await unlockAuthSession({ sessionUnlockDto: { pinCode: code } }); isVerified = true; - if (withDelay) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } + await new Promise((resolve) => setTimeout(resolve, 1000)); - void goto(data.continuePath ?? AppRoute.LOCKED); + await goto(data.continueUrl); } catch (error) { handleError(error, $t('wrong_pin_code')); isBadPinCode = true; @@ -64,7 +61,7 @@ bind:value={pinCode} tabindexStart={1} pinLength={6} - onFilled={(pinCode) => onPinFilled(pinCode, true)} + onFilled={handleUnlockSession} />
diff --git a/web/src/routes/auth/pin-prompt/+page.ts b/web/src/routes/auth/pin-prompt/+page.ts index b0d248ebe6..89d59a3127 100644 --- a/web/src/routes/auth/pin-prompt/+page.ts +++ b/web/src/routes/auth/pin-prompt/+page.ts @@ -1,3 +1,4 @@ +import { AppRoute } from '$lib/constants'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { getAuthStatus } from '@immich/sdk'; @@ -8,8 +9,6 @@ export const load = (async ({ url }) => { const { pinCode } = await getAuthStatus(); - const continuePath = url.searchParams.get('continue'); - const $t = await getFormatter(); return { @@ -17,6 +16,6 @@ export const load = (async ({ url }) => { title: $t('pin_verification'), }, hasPinCode: !!pinCode, - continuePath, + continueUrl: url.searchParams.get('continue') || AppRoute.LOCKED, }; }) satisfies PageLoad; From 86d64f34833718dbf24ce67e234dc0b0dc8b2b3b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 May 2025 18:31:33 -0400 Subject: [PATCH 47/48] refactor: buttons (#18317) * refactor: buttons * fix: woopsie --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- .../elements/buttons/__test__/button.spec.ts | 20 --- .../components/elements/buttons/button.svelte | 123 ------------------ .../elements/buttons/skip-link.svelte | 5 +- .../faces-page/edit-name-input.svelte | 8 +- .../manage-people-visibility.svelte | 9 +- .../faces-page/merge-face-selector.svelte | 9 +- .../faces-page/unmerge-face-selector.svelte | 32 ++--- .../forms/library-scan-settings-form.svelte | 21 +-- .../components/forms/tag-asset-form.svelte | 14 +- .../onboarding-page/onboarding-hello.svelte | 10 +- .../onboarding-page/onboarding-privacy.svelte | 20 +-- .../onboarding-storage-template.svelte | 22 ++-- .../onboarding-page/onboarding-theme.svelte | 12 +- .../navigation-bar/account-info-panel.svelte | 21 ++- .../profile-image-cropper.svelte | 10 +- .../individual-purchase-option-card.svelte | 4 +- .../purchase-activation-success.svelte | 4 +- .../purchasing/purchase-content.svelte | 5 +- .../server-purchase-option-card.svelte | 4 +- .../search-bar/search-people-section.svelte | 17 +-- .../version-announcement-box.svelte | 8 +- .../lib/components/slideshow-settings.svelte | 8 +- .../duplicates-compare-control.svelte | 22 +++- .../modals/PersonEditBirthDateModal.svelte | 11 +- .../[[assetId=id]]/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 2 +- 26 files changed, 148 insertions(+), 279 deletions(-) delete mode 100644 web/src/lib/components/elements/buttons/__test__/button.spec.ts delete mode 100644 web/src/lib/components/elements/buttons/button.svelte diff --git a/web/src/lib/components/elements/buttons/__test__/button.spec.ts b/web/src/lib/components/elements/buttons/__test__/button.spec.ts deleted file mode 100644 index 0539315c57..0000000000 --- a/web/src/lib/components/elements/buttons/__test__/button.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Button from '$lib/components/elements/buttons/button.svelte'; -import { render, screen } from '@testing-library/svelte'; - -describe('Button component', () => { - it('should render as a button', () => { - render(Button); - const button = screen.getByRole('button'); - expect(button).toBeInTheDocument(); - expect(button).toHaveAttribute('type', 'button'); - expect(button).not.toHaveAttribute('href'); - }); - - it('should render as a link if href prop is set', () => { - render(Button, { props: { href: '/test' } }); - const link = screen.getByRole('link'); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute('href', '/test'); - expect(link).not.toHaveAttribute('type'); - }); -}); diff --git a/web/src/lib/components/elements/buttons/button.svelte b/web/src/lib/components/elements/buttons/button.svelte deleted file mode 100644 index ac7d9808f3..0000000000 --- a/web/src/lib/components/elements/buttons/button.svelte +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - {@render children?.()} - diff --git a/web/src/lib/components/elements/buttons/skip-link.svelte b/web/src/lib/components/elements/buttons/skip-link.svelte index b8f8fcd483..65e5001f8a 100644 --- a/web/src/lib/components/elements/buttons/skip-link.svelte +++ b/web/src/lib/components/elements/buttons/skip-link.svelte @@ -1,7 +1,7 @@ @@ -39,6 +39,6 @@
- + 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 1b1a91d163..387f01395d 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,9 +1,9 @@ @@ -39,6 +39,6 @@ - + diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte index e4b6ae7c3b..270be62527 100644 --- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte @@ -1,13 +1,12 @@
{ @@ -486,19 +486,6 @@ {/key}
-{#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS} - a.id)} - personAssets={person} - onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} - onConfirm={handleUnmerge} - /> -{/if} - -{#if viewMode === PersonPageViewMode.MERGE_PEOPLE} - -{/if} -
{#if assetInteraction.selectionActive} + +{#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS} + a.id)} + personAssets={person} + onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} + onConfirm={handleUnmerge} + /> +{/if} + +{#if viewMode === PersonPageViewMode.MERGE_PEOPLE} + +{/if}