From 16745e77d40a696bf0064ad27f40650370ef9aa6 Mon Sep 17 00:00:00 2001 From: xCJPECKOVERx Date: Tue, 10 Jun 2025 00:49:34 -0400 Subject: [PATCH 01/33] fix(web): Remove tag buttons in search and modal missing tooltip translations (#19087) * - use translations for "remove tag" text on the search-tags-section and the AssetTagModal * include new translations * - fix i18n --- i18n/en.json | 1 + .../shared-components/search-bar/search-tags-section.svelte | 2 +- web/src/lib/modals/AssetTagModal.svelte | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 1be84c5e7a..86de3808ba 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1499,6 +1499,7 @@ "remove_from_shared_link": "Remove from shared link", "remove_memory": "Remove memory", "remove_photo_from_memory": "Remove photo from this memory", + "remove_tag": "Remove tag", "remove_url": "Remove URL", "remove_user": "Remove user", "removed_api_key": "Removed API Key: {name}", diff --git a/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte index 0819022ae2..f1412e570f 100644 --- a/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-tags-section.svelte @@ -67,7 +67,7 @@ - - {/each} - - - - - {#if htmlPreview} - - -
- + > + + {$t('admin.template_email_preview')} +
-
-
- {/if} - - + {/each} + + + + diff --git a/web/src/lib/modals/EmailTemplatePreviewModal.svelte b/web/src/lib/modals/EmailTemplatePreviewModal.svelte new file mode 100644 index 0000000000..3165c654da --- /dev/null +++ b/web/src/lib/modals/EmailTemplatePreviewModal.svelte @@ -0,0 +1,23 @@ + + + + +
+ +
+
+
From 38ad15af4c3d14ebc2e0a698cb9acc12d9695a67 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 11 Jun 2025 15:08:11 -0400 Subject: [PATCH 11/33] refactor: user avatar (#19121) --- .../asset-viewer/activity-viewer.svelte | 2 +- .../navigation-bar/navigation-bar.svelte | 2 +- .../shared-components/user-avatar.svelte | 25 +++++-------------- web/src/lib/modals/AvatarEditModal.svelte | 6 ++--- 4 files changed, 11 insertions(+), 24 deletions(-) diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index c4aa7098d5..c368a1a8fe 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -247,7 +247,7 @@
- +
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 c886ff1cc4..f1140eaab0 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 @@ -155,7 +155,7 @@ title={`${$user.name} (${$user.email})`} > {#key $user} - + {/key} diff --git a/web/src/lib/components/shared-components/user-avatar.svelte b/web/src/lib/components/shared-components/user-avatar.svelte index 938f569508..4306c3cf38 100644 --- a/web/src/lib/components/shared-components/user-avatar.svelte +++ b/web/src/lib/components/shared-components/user-avatar.svelte @@ -18,25 +18,13 @@ interface Props { user: User; - color?: UserAvatarColor | undefined; size?: Size; - rounded?: boolean; interactive?: boolean; - showTitle?: boolean; - showProfileImage?: boolean; + noTitle?: boolean; label?: string | undefined; } - let { - user, - color = undefined, - size = 'full', - rounded = true, - interactive = false, - showTitle = true, - showProfileImage = true, - label = undefined, - }: Props = $props(); + let { user, size = 'full', interactive = false, noTitle = false, label = undefined }: Props = $props(); let img: HTMLImageElement | undefined = $state(); let showFallback = $state(true); @@ -79,7 +67,7 @@ } }); - let colorClass = $derived(colorClasses[color || user.avatarColor]); + let colorClass = $derived(colorClasses[user.avatarColor]); let sizeClass = $derived(sizeClasses[size]); let title = $derived(label ?? `${user.name} (${user.email})`); let interactiveClass = $derived( @@ -90,11 +78,10 @@
- {#if showProfileImage && user.profileImagePath} + {#if user.profileImagePath} - + -
+
{#each colors as color (color)} {/each}
From 4c5cd1427028b44ccf5b694d8845801034181eed Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 11 Jun 2025 21:08:36 +0200 Subject: [PATCH 12/33] refactor: map modal (#19120) --- .../components/album-page/album-map.svelte | 94 ++++++------------- web/src/lib/modals/MapModal.svelte | 42 +++++++++ 2 files changed, 70 insertions(+), 66 deletions(-) create mode 100644 web/src/lib/modals/MapModal.svelte diff --git a/web/src/lib/components/album-page/album-map.svelte b/web/src/lib/components/album-page/album-map.svelte index a3ccc15d4d..83a66db3af 100644 --- a/web/src/lib/components/album-page/album-map.svelte +++ b/web/src/lib/components/album-page/album-map.svelte @@ -1,13 +1,12 @@ + + + +
+
+ {#await import('../components/shared-components/map/map.svelte')} + {#await delay(timeToLoadTheMap) then} + +
+ +
+ {/await} + {:then { default: Map }} + + {/await} +
+
+
+
From 5179c5badffcd6162cde401fd0907ad25f22dcb1 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 12 Jun 2025 00:25:36 +0200 Subject: [PATCH 13/33] fix: required argument in onClose modal function (#19122) --- .../search-bar/search-bar.svelte | 4 ++-- web/src/lib/managers/modal-manager.svelte.ts | 17 +++++++++++------ web/src/lib/modals/ConfirmModal.svelte | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 262c10adfd..9f36431f19 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -10,11 +10,11 @@ import { generateId } from '$lib/utils/generate-id'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; + import { IconButton } from '@immich/ui'; import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js'; import { onDestroy, tick } from 'svelte'; import { t } from 'svelte-i18n'; import SearchHistoryBox from './search-history-box.svelte'; - import { IconButton } from '@immich/ui'; interface Props { value?: string; @@ -93,7 +93,7 @@ } const result = modalManager.open(SearchFilterModal, { searchQuery }); - close = result.close; + close = () => result.close(undefined); closeDropdown(); const searchResult = await result.onClose; diff --git a/web/src/lib/managers/modal-manager.svelte.ts b/web/src/lib/managers/modal-manager.svelte.ts index 7b658d63e5..46363a105b 100644 --- a/web/src/lib/managers/modal-manager.svelte.ts +++ b/web/src/lib/managers/modal-manager.svelte.ts @@ -1,8 +1,13 @@ import ConfirmModal from '$lib/modals/ConfirmModal.svelte'; import { mount, unmount, type Component, type ComponentProps } from 'svelte'; -type OnCloseData = T extends { onClose: (data?: infer R) => void } ? R : never; +type OnCloseData = T extends { onClose: (data?: infer R) => void } + ? R | undefined + : T extends { onClose: (data: infer R) => void } + ? R + : never; type ExtendsEmptyObject = keyof T extends never ? Record : T; +type StripValueIfOptional = T extends undefined ? undefined : T; class ModalManager { show(Component: Component, props: ExtendsEmptyObject>) { @@ -11,12 +16,12 @@ class ModalManager { open>(Component: Component, props: ExtendsEmptyObject>) { let modal: object = {}; - let onClose: () => Promise; + let onClose: (...args: [StripValueIfOptional]) => Promise; - const deferred = new Promise((resolve) => { - onClose = async (data?: K) => { + const deferred = new Promise>((resolve) => { + onClose = async (...args: [StripValueIfOptional]) => { await unmount(modal); - resolve(data); + resolve(args?.[0]); }; modal = mount(Component, { @@ -30,7 +35,7 @@ class ModalManager { return { onClose: deferred, - close: () => onClose(), + close: (...args: [StripValueIfOptional]) => onClose(args[0]), }; } diff --git a/web/src/lib/modals/ConfirmModal.svelte b/web/src/lib/modals/ConfirmModal.svelte index 327d13c355..1a73b58793 100644 --- a/web/src/lib/modals/ConfirmModal.svelte +++ b/web/src/lib/modals/ConfirmModal.svelte @@ -10,7 +10,7 @@ confirmColor?: Color; disabled?: boolean; size?: 'small' | 'medium'; - onClose: (confirmed?: boolean) => void; + onClose: (confirmed: boolean) => void; promptSnippet?: Snippet; } From 22eef5f3c5d531758514c17a903e13a21a8bd2df Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 12 Jun 2025 00:32:49 +0200 Subject: [PATCH 14/33] chore: more flexible modal manager types (#19123) * fix: required argument in onClose modal function * chore: more flexible modal manager types --- .../settings/auth/auth-settings.svelte | 2 +- .../components/asset-viewer/slideshow-bar.svelte | 2 +- .../actions/change-description-action.svelte | 2 +- .../lib/components/photos-page/asset-grid.svelte | 2 +- .../gallery-viewer/gallery-viewer.svelte | 3 +-- .../navigation-bar/account-info-panel.svelte | 2 +- .../side-bar/purchase-info.svelte | 2 +- web/src/lib/managers/modal-manager.svelte.ts | 16 +++++++++++----- .../[[assetId=id]]/+page.svelte | 2 +- .../routes/admin/library-management/+page.svelte | 2 +- web/src/routes/admin/users/+page.svelte | 2 +- 11 files changed, 21 insertions(+), 16 deletions(-) diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index d7af26688a..a5384e9b41 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -36,7 +36,7 @@ const handleSave = async (skipConfirm: boolean) => { const allMethodsDisabled = !config.oauth.enabled && !config.passwordLogin.enabled; if (allMethodsDisabled && !skipConfirm) { - const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal, {}); + const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal); if (!isConfirmed) { return; } diff --git a/web/src/lib/components/asset-viewer/slideshow-bar.svelte b/web/src/lib/components/asset-viewer/slideshow-bar.svelte index 135da12d26..0bb81ea44b 100644 --- a/web/src/lib/components/asset-viewer/slideshow-bar.svelte +++ b/web/src/lib/components/asset-viewer/slideshow-bar.svelte @@ -104,7 +104,7 @@ if (document.fullscreenElement) { await document.exitFullscreen(); } - await modalManager.show(SlideshowSettingsModal, {}); + await modalManager.show(SlideshowSettingsModal); }; diff --git a/web/src/lib/components/photos-page/actions/change-description-action.svelte b/web/src/lib/components/photos-page/actions/change-description-action.svelte index 129d327fb9..740bed78ef 100644 --- a/web/src/lib/components/photos-page/actions/change-description-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-description-action.svelte @@ -18,7 +18,7 @@ const { clearSelect, getOwnedAssets } = getAssetControlContext(); const handleUpdateDescription = async () => { - const description = await modalManager.show(AssetUpdateDecriptionConfirmModal, {}); + const description = await modalManager.show(AssetUpdateDecriptionConfirmModal); if (description) { const ids = getSelectedAssets(getOwnedAssets(), $user); diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 2dd9ef3ac7..ce2b1ca096 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -703,7 +703,7 @@ } isShortcutModalOpen = true; - await modalManager.show(ShortcutsModal, {}); + await modalManager.show(ShortcutsModal); isShortcutModalOpen = false; }; diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 1c4d888cb8..09998ed060 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -5,11 +5,10 @@ import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import { AppRoute, AssetAction } from '$lib/constants'; import { modalManager } from '$lib/managers/modal-manager.svelte'; + import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; - import type { Viewport } from '$lib/managers/timeline-manager/types'; import { showDeleteModal } from '$lib/stores/preferences.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { handlePromiseError } from '$lib/utils'; 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 40ae672e0c..b079ecb241 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 @@ -51,7 +51,7 @@ shape="round" onclick={async () => { onClose(); - await modalManager.show(AvatarEditModal, {}); + await modalManager.show(AvatarEditModal); }} />
diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte index 665c620563..5a984e94be 100644 --- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte +++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte @@ -27,7 +27,7 @@ const { isPurchased } = purchaseStore; const openPurchaseModal = async () => { - await modalManager.show(PurchaseModal, {}); + await modalManager.show(PurchaseModal); showMessage = false; }; diff --git a/web/src/lib/managers/modal-manager.svelte.ts b/web/src/lib/managers/modal-manager.svelte.ts index 46363a105b..f5612d072d 100644 --- a/web/src/lib/managers/modal-manager.svelte.ts +++ b/web/src/lib/managers/modal-manager.svelte.ts @@ -6,15 +6,21 @@ type OnCloseData = T extends { onClose: (data?: infer R) => void } : T extends { onClose: (data: infer R) => void } ? R : never; -type ExtendsEmptyObject = keyof T extends never ? Record : T; +type ExtendsEmptyObject = keyof T extends never ? never : T; type StripValueIfOptional = T extends undefined ? undefined : T; +// if the modal does not expect any props, makes the props param optional but also allows passing `{}` and `undefined` +type OptionalParamIfEmpty = ExtendsEmptyObject extends never ? [] | [Record | undefined] : [T]; + class ModalManager { - show(Component: Component, props: ExtendsEmptyObject>) { - return this.open(Component, props).onClose; + show(Component: Component, ...props: OptionalParamIfEmpty>) { + return this.open(Component, ...props).onClose; } - open>(Component: Component, props: ExtendsEmptyObject>) { + open>( + Component: Component, + ...props: OptionalParamIfEmpty> + ) { let modal: object = {}; let onClose: (...args: [StripValueIfOptional]) => Promise; @@ -27,7 +33,7 @@ class ModalManager { modal = mount(Component, { target: document.body, props: { - ...(props as T), + ...((props?.[0] ?? {}) as T), onClose, }, }); 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 22fc8dac01..42da091997 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 @@ -200,7 +200,7 @@ icon={mdiInformationOutline} aria-label={$t('deduplication_info')} size="small" - onclick={() => modalManager.show(DuplicatesInformationModal, {})} + onclick={() => modalManager.show(DuplicatesInformationModal)} />
diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 450e0d8c8d..3d8792749c 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -207,7 +207,7 @@ }; const onCreateNewLibraryClicked = async () => { - const result = await modalManager.show(LibraryUserPickerModal, {}); + const result = await modalManager.show(LibraryUserPickerModal); if (result) { await handleCreate(result); } diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte index 87846b9542..3ebc67dabf 100644 --- a/web/src/routes/admin/users/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -58,7 +58,7 @@ }; const handleCreate = async () => { - await modalManager.show(UserCreateModal, {}); + await modalManager.show(UserCreateModal); await refresh(); }; From e5219f1f311a04850007993ef36d24b26491e1cf Mon Sep 17 00:00:00 2001 From: nosajthenitram Date: Wed, 11 Jun 2025 21:11:13 -0500 Subject: [PATCH 15/33] feat(web): Added admin user config to user settings (#15380) * feat(web): Added admin user config to user settings * feat (web) - cleaned up the files and added tests * feat (web) - added missing files * feat (web) - updated per review comments * feat (web) - e2e admin command test failures --- e2e/src/api/specs/user-admin.e2e-spec.ts | 11 +-- .../specs/immich-admin.e2e-spec.ts | 38 ++++++++ e2e/src/web/specs/user-admin.e2e-spec.ts | 89 +++++++++++++++++++ i18n/en.json | 1 + .../lib/model/user_admin_create_dto.dart | 19 +++- .../lib/model/user_admin_update_dto.dart | 19 +++- open-api/immich-openapi-specs.json | 6 ++ open-api/typescript-sdk/src/fetch-client.ts | 2 + server/src/commands/grant-admin.ts | 67 ++++++++++++++ server/src/commands/index.ts | 4 + server/src/dtos/user.dto.ts | 8 ++ server/src/services/cli.service.ts | 18 ++++ .../src/services/user-admin.service.spec.ts | 10 ++- server/src/services/user-admin.service.ts | 10 ++- web/src/lib/modals/UserEditModal.svelte | 26 ++++-- 15 files changed, 308 insertions(+), 20 deletions(-) create mode 100644 e2e/src/web/specs/user-admin.e2e-spec.ts create mode 100644 server/src/commands/grant-admin.ts diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index 1fbee84c3f..b0696dcada 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -118,7 +118,7 @@ describe('/admin/users', () => { }); } - it('should ignore `isAdmin`', async () => { + it('should accept `isAdmin`', async () => { const { status, body } = await request(app) .post(`/admin/users`) .send({ @@ -130,7 +130,7 @@ describe('/admin/users', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toMatchObject({ email: 'user5@immich.cloud', - isAdmin: false, + isAdmin: true, shouldChangePassword: true, }); expect(status).toBe(201); @@ -163,14 +163,15 @@ describe('/admin/users', () => { }); } - it('should not allow a non-admin to become an admin', async () => { + it('should allow a non-admin to become an admin', async () => { + const user = await utils.userSetup(admin.accessToken, createUserDto.create('admin2')); const { status, body } = await request(app) - .put(`/admin/users/${nonAdmin.userId}`) + .put(`/admin/users/${user.userId}`) .send({ isAdmin: true }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toMatchObject({ isAdmin: false }); + expect(body).toMatchObject({ isAdmin: true }); }); it('ignores updates to profileImagePath', async () => { diff --git a/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts b/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts index cf0558883a..24699cda30 100644 --- a/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts +++ b/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts @@ -7,6 +7,44 @@ describe(`immich-admin`, () => { await utils.adminSetup(); }); + describe('revoke-admin', () => { + it('should revoke admin privileges from a user', async () => { + const { child, promise } = immichAdmin(['revoke-admin']); + + let data = ''; + child.stdout.on('data', (chunk) => { + data += chunk; + if (data.includes('Please enter the user email:')) { + child.stdin.end('admin@immich.cloud\n'); + } + }); + + const { stdout, exitCode } = await promise; + expect(exitCode).toBe(0); + + expect(stdout).toContain('Admin access has been revoked from'); + }); + }); + + describe('grant-admin', () => { + it('should grant admin privileges to a user', async () => { + const { child, promise } = immichAdmin(['grant-admin']); + + let data = ''; + child.stdout.on('data', (chunk) => { + data += chunk; + if (data.includes('Please enter the user email:')) { + child.stdin.end('admin@immich.cloud\n'); + } + }); + + const { stdout, exitCode } = await promise; + expect(exitCode).toBe(0); + + expect(stdout).toContain('Admin access has been granted to'); + }); + }); + describe('list-users', () => { it('should list the admin user', async () => { const { stdout, exitCode } = await immichAdmin(['list-users']).promise; diff --git a/e2e/src/web/specs/user-admin.e2e-spec.ts b/e2e/src/web/specs/user-admin.e2e-spec.ts new file mode 100644 index 0000000000..3d64e47aef --- /dev/null +++ b/e2e/src/web/specs/user-admin.e2e-spec.ts @@ -0,0 +1,89 @@ +import { getUserAdmin } from '@immich/sdk'; +import { expect, test } from '@playwright/test'; +import { asBearerAuth, utils } from 'src/utils'; + +test.describe('User Administration', () => { + test.beforeAll(() => { + utils.initSdk(); + }); + + test.beforeEach(async () => { + await utils.resetDatabase(); + }); + + test('validate admin/users link', async ({ context, page }) => { + const admin = await utils.adminSetup(); + await utils.setAuthCookies(context, admin.accessToken); + + // Navigate to user management page and verify title and header + await page.goto(`/admin/users`); + await expect(page).toHaveTitle(/User Management/); + await expect(page.getByText('User Management')).toBeVisible(); + }); + + test('create user', async ({ context, page }) => { + const admin = await utils.adminSetup(); + await utils.setAuthCookies(context, admin.accessToken); + + // Create a new user + await page.goto('/admin/users'); + await page.getByRole('button', { name: 'Create user' }).click(); + await page.getByLabel('Email').fill('user@immich.cloud'); + await page.getByLabel('Password', { exact: true }).fill('password'); + await page.getByLabel('Confirm Password').fill('password'); + await page.getByLabel('Name').fill('Immich User'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + + // Verify the user exists in the user list + await page.getByRole('row', { name: 'user@immich.cloud' }); + }); + + test('promote to admin', async ({ context, page }) => { + const admin = await utils.adminSetup(); + await utils.setAuthCookies(context, admin.accessToken); + + const user = await utils.userSetup(admin.accessToken, { + name: 'Admin 2', + email: 'admin2@immich.cloud', + password: 'password', + }); + + expect(user.isAdmin).toBe(false); + + await page.goto(`/admin/users/${user.userId}`); + + await page.getByRole('button', { name: 'Edit user' }).click(); + await expect(page.getByLabel('Admin User')).not.toBeChecked(); + await page.getByText('Admin User').click(); + await expect(page.getByLabel('Admin User')).toBeChecked(); + await page.getByRole('button', { name: 'Confirm' }).click(); + + const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) }); + expect(updated.isAdmin).toBe(true); + }); + + test('revoke admin access', async ({ context, page }) => { + const admin = await utils.adminSetup(); + await utils.setAuthCookies(context, admin.accessToken); + + const user = await utils.userSetup(admin.accessToken, { + name: 'Admin 2', + email: 'admin2@immich.cloud', + password: 'password', + isAdmin: true, + }); + + expect(user.isAdmin).toBe(true); + + await page.goto(`/admin/users/${user.userId}`); + + await page.getByRole('button', { name: 'Edit user' }).click(); + await expect(page.getByLabel('Admin User')).toBeChecked(); + await page.getByText('Admin User').click(); + await expect(page.getByLabel('Admin User')).not.toBeChecked(); + await page.getByRole('button', { name: 'Confirm' }).click(); + + const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) }); + expect(updated.isAdmin).toBe(false); + }); +}); diff --git a/i18n/en.json b/i18n/en.json index 86de3808ba..2735393d33 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -34,6 +34,7 @@ "added_to_favorites_count": "Added {count, number} to favorites", "admin": { "add_exclusion_pattern_description": "Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named \"Raw\", use \"**/Raw/**\". To ignore all files ending in \".tif\", use \"**/*.tif\". To ignore an absolute path, use \"/path/to/ignore/**\".", + "admin_user": "Admin User", "asset_offline_description": "This external library asset is no longer found on disk and has been moved to trash. If the file was moved within the library, check your timeline for the new corresponding asset. To restore this asset, please ensure that the file path below can be accessed by Immich and scan the library.", "authentication_settings": "Authentication Settings", "authentication_settings_description": "Manage password, OAuth, and other authentication settings", diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index 1477c82ca1..8c8b70fbce 100644 --- a/mobile/openapi/lib/model/user_admin_create_dto.dart +++ b/mobile/openapi/lib/model/user_admin_create_dto.dart @@ -15,6 +15,7 @@ class UserAdminCreateDto { UserAdminCreateDto({ this.avatarColor, required this.email, + this.isAdmin, required this.name, this.notify, required this.password, @@ -27,6 +28,14 @@ class UserAdminCreateDto { String email; + /// + /// 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. + /// + bool? isAdmin; + String name; /// @@ -56,6 +65,7 @@ class UserAdminCreateDto { bool operator ==(Object other) => identical(this, other) || other is UserAdminCreateDto && other.avatarColor == avatarColor && other.email == email && + other.isAdmin == isAdmin && other.name == name && other.notify == notify && other.password == password && @@ -68,6 +78,7 @@ class UserAdminCreateDto { // ignore: unnecessary_parenthesis (avatarColor == null ? 0 : avatarColor!.hashCode) + (email.hashCode) + + (isAdmin == null ? 0 : isAdmin!.hashCode) + (name.hashCode) + (notify == null ? 0 : notify!.hashCode) + (password.hashCode) + @@ -76,7 +87,7 @@ class UserAdminCreateDto { (storageLabel == null ? 0 : storageLabel!.hashCode); @override - String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; + String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, isAdmin=$isAdmin, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; Map toJson() { final json = {}; @@ -86,6 +97,11 @@ class UserAdminCreateDto { // json[r'avatarColor'] = null; } json[r'email'] = this.email; + if (this.isAdmin != null) { + json[r'isAdmin'] = this.isAdmin; + } else { + // json[r'isAdmin'] = null; + } json[r'name'] = this.name; if (this.notify != null) { json[r'notify'] = this.notify; @@ -122,6 +138,7 @@ class UserAdminCreateDto { return UserAdminCreateDto( avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), email: mapValueOfType(json, r'email')!, + isAdmin: mapValueOfType(json, r'isAdmin'), name: mapValueOfType(json, r'name')!, notify: mapValueOfType(json, r'notify'), password: mapValueOfType(json, r'password')!, diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index ee5c006840..9605552d20 100644 --- a/mobile/openapi/lib/model/user_admin_update_dto.dart +++ b/mobile/openapi/lib/model/user_admin_update_dto.dart @@ -15,6 +15,7 @@ class UserAdminUpdateDto { UserAdminUpdateDto({ this.avatarColor, this.email, + this.isAdmin, this.name, this.password, this.pinCode, @@ -33,6 +34,14 @@ class UserAdminUpdateDto { /// String? email; + /// + /// 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. + /// + bool? isAdmin; + /// /// 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 @@ -68,6 +77,7 @@ class UserAdminUpdateDto { bool operator ==(Object other) => identical(this, other) || other is UserAdminUpdateDto && other.avatarColor == avatarColor && other.email == email && + other.isAdmin == isAdmin && other.name == name && other.password == password && other.pinCode == pinCode && @@ -80,6 +90,7 @@ class UserAdminUpdateDto { // ignore: unnecessary_parenthesis (avatarColor == null ? 0 : avatarColor!.hashCode) + (email == null ? 0 : email!.hashCode) + + (isAdmin == null ? 0 : isAdmin!.hashCode) + (name == null ? 0 : name!.hashCode) + (password == null ? 0 : password!.hashCode) + (pinCode == null ? 0 : pinCode!.hashCode) + @@ -88,7 +99,7 @@ class UserAdminUpdateDto { (storageLabel == null ? 0 : storageLabel!.hashCode); @override - String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, pinCode=$pinCode, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; + String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, isAdmin=$isAdmin, name=$name, password=$password, pinCode=$pinCode, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; Map toJson() { final json = {}; @@ -102,6 +113,11 @@ class UserAdminUpdateDto { } else { // json[r'email'] = null; } + if (this.isAdmin != null) { + json[r'isAdmin'] = this.isAdmin; + } else { + // json[r'isAdmin'] = null; + } if (this.name != null) { json[r'name'] = this.name; } else { @@ -146,6 +162,7 @@ class UserAdminUpdateDto { return UserAdminUpdateDto( avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), email: mapValueOfType(json, r'email'), + isAdmin: mapValueOfType(json, r'isAdmin'), name: mapValueOfType(json, r'name'), password: mapValueOfType(json, r'password'), pinCode: mapValueOfType(json, r'pinCode'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4f0fa44fa4..8318756295 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -15131,6 +15131,9 @@ "format": "email", "type": "string" }, + "isAdmin": { + "type": "boolean" + }, "name": { "type": "string" }, @@ -15281,6 +15284,9 @@ "format": "email", "type": "string" }, + "isAdmin": { + "type": "boolean" + }, "name": { "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 417055e484..8dced80394 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -109,6 +109,7 @@ export type UserAdminResponseDto = { export type UserAdminCreateDto = { avatarColor?: (UserAvatarColor) | null; email: string; + isAdmin?: boolean; name: string; notify?: boolean; password: string; @@ -122,6 +123,7 @@ export type UserAdminDeleteDto = { export type UserAdminUpdateDto = { avatarColor?: (UserAvatarColor) | null; email?: string; + isAdmin?: boolean; name?: string; password?: string; pinCode?: string | null; diff --git a/server/src/commands/grant-admin.ts b/server/src/commands/grant-admin.ts new file mode 100644 index 0000000000..41fd4f2340 --- /dev/null +++ b/server/src/commands/grant-admin.ts @@ -0,0 +1,67 @@ +import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander'; +import { CliService } from 'src/services/cli.service'; + +const prompt = (inquirer: InquirerService) => { + return function ask(): Promise { + return inquirer.ask<{ email: string }>('prompt-email', {}).then(({ email }: { email: string }) => email); + }; +}; + +@Command({ + name: 'grant-admin', + description: 'Grant admin privileges to a user (by email)', +}) +export class GrantAdminCommand extends CommandRunner { + constructor( + private service: CliService, + private inquirer: InquirerService, + ) { + super(); + } + + async run(): Promise { + try { + const email = await prompt(this.inquirer)(); + await this.service.grantAdminAccess(email); + console.debug('Admin access has been granted to', email); + } catch (error) { + console.error(error); + console.error('Unable to grant admin access to user'); + } + } +} + +@Command({ + name: 'revoke-admin', + description: 'Revoke admin privileges from a user (by email)', +}) +export class RevokeAdminCommand extends CommandRunner { + constructor( + private service: CliService, + private inquirer: InquirerService, + ) { + super(); + } + + async run(): Promise { + try { + const email = await prompt(this.inquirer)(); + await this.service.revokeAdminAccess(email); + console.debug('Admin access has been revoked from', email); + } catch (error) { + console.error(error); + console.error('Unable to revoke admin access from user'); + } + } +} + +@QuestionSet({ name: 'prompt-email' }) +export class PromptEmailQuestion { + @Question({ + message: 'Please enter the user email: ', + name: 'email', + }) + parseEmail(value: string) { + return value; + } +} diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts index 59846628bf..ce085f6e34 100644 --- a/server/src/commands/index.ts +++ b/server/src/commands/index.ts @@ -1,3 +1,4 @@ +import { GrantAdminCommand, PromptEmailQuestion, RevokeAdminCommand } from 'src/commands/grant-admin'; import { ListUsersCommand } from 'src/commands/list-users.command'; import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; @@ -7,10 +8,13 @@ import { VersionCommand } from 'src/commands/version.command'; export const commands = [ ResetAdminPasswordCommand, PromptPasswordQuestions, + PromptEmailQuestion, EnablePasswordLoginCommand, DisablePasswordLoginCommand, EnableOAuthLogin, DisableOAuthLogin, ListUsersCommand, VersionCommand, + GrantAdminCommand, + RevokeAdminCommand, ]; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 9d43e53f89..ed08f7534d 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -106,6 +106,10 @@ export class UserAdminCreateDto { @Optional() @IsBoolean() notify?: boolean; + + @Optional() + @IsBoolean() + isAdmin?: boolean; } export class UserAdminUpdateDto { @@ -145,6 +149,10 @@ export class UserAdminUpdateDto { @Min(0) @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes?: number | null; + + @Optional() + @IsBoolean() + isAdmin?: boolean; } export class UserAdminDeleteDto { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index f6173c69f7..021a5240f6 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -37,6 +37,24 @@ export class CliService extends BaseService { await this.updateConfig(config); } + async grantAdminAccess(email: string): Promise { + const user = await this.userRepository.getByEmail(email); + if (!user) { + throw new Error('User does not exist'); + } + + await this.userRepository.update(user.id, { isAdmin: true }); + } + + async revokeAdminAccess(email: string): Promise { + const user = await this.userRepository.getByEmail(email); + if (!user) { + throw new Error('User does not exist'); + } + + await this.userRepository.update(user.id, { isAdmin: false }); + } + async disableOAuthLogin(): Promise { const config = await this.getConfig({ withCache: false }); config.oauth.enabled = false; diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 3e613bc485..85cbb8238a 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -4,6 +4,7 @@ import { JobName, UserStatus } from 'src/enum'; import { UserAdminService } from 'src/services/user-admin.service'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { describe } from 'vitest'; @@ -116,7 +117,7 @@ describe(UserAdminService.name, () => { it('should throw error if user could not be found', async () => { mocks.user.get.mockResolvedValue(void 0); - await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException); + await expect(sut.delete(authStub.admin, 'not-found', {})).rejects.toThrowError(BadRequestException); expect(mocks.user.delete).not.toHaveBeenCalled(); }); @@ -124,8 +125,11 @@ describe(UserAdminService.name, () => { await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException); }); - it('should require the auth user be an admin', async () => { - await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException); + it('should not allow deleting own account', async () => { + const user = factory.userAdmin({ isAdmin: false }); + const auth = factory.auth({ user }); + mocks.user.get.mockResolvedValue(user); + await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException); expect(mocks.user.delete).not.toHaveBeenCalled(); }); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index dcd415174d..332496a95e 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -52,6 +52,10 @@ export class UserAdminService extends BaseService { async update(auth: AuthDto, id: string, dto: UserAdminUpdateDto): Promise { const user = await this.findOrFail(id, {}); + if (dto.isAdmin !== undefined && dto.isAdmin !== auth.user.isAdmin && auth.user.id === id) { + throw new BadRequestException('Admin status can only be changed by another admin'); + } + if (dto.quotaSizeInBytes && user.quotaSizeInBytes !== dto.quotaSizeInBytes) { await this.userRepository.syncUsage(id); } @@ -89,9 +93,9 @@ export class UserAdminService extends BaseService { async delete(auth: AuthDto, id: string, dto: UserAdminDeleteDto): Promise { const { force } = dto; - const { isAdmin } = await this.findOrFail(id, {}); - if (isAdmin) { - throw new ForbiddenException('Cannot delete admin user'); + await this.findOrFail(id, {}); + if (auth.user.id === id) { + throw new ForbiddenException('Cannot delete your own account'); } await this.albumRepository.softDeleteAll(id); diff --git a/web/src/lib/modals/UserEditModal.svelte b/web/src/lib/modals/UserEditModal.svelte index 0bb018721b..aa0d6c3e87 100644 --- a/web/src/lib/modals/UserEditModal.svelte +++ b/web/src/lib/modals/UserEditModal.svelte @@ -1,10 +1,11 @@ - diff --git a/web/src/lib/components/forms/library-import-paths-form.svelte b/web/src/lib/components/forms/library-import-paths-form.svelte index c6042d5ce5..f83057bf67 100644 --- a/web/src/lib/components/forms/library-import-paths-form.svelte +++ b/web/src/lib/components/forms/library-import-paths-form.svelte @@ -169,19 +169,9 @@ > {#if validatedPath.isValid} - + {:else} - + {/if} diff --git a/web/src/lib/components/shared-components/notification/notification-card.svelte b/web/src/lib/components/shared-components/notification/notification-card.svelte index d4fc4023d3..e5755fc016 100644 --- a/web/src/lib/components/shared-components/notification/notification-card.svelte +++ b/web/src/lib/components/shared-components/notification/notification-card.svelte @@ -7,7 +7,7 @@ type ComponentNotification, type Notification, } from '$lib/components/shared-components/notification/notification'; - import { IconButton } from '@immich/ui'; + import { Button, IconButton, type Color } from '@immich/ui'; import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -40,10 +40,10 @@ [NotificationType.Warning]: '#D08613', }; - const buttonStyle: Record = { - [NotificationType.Info]: 'text-white bg-immich-primary hover:bg-immich-primary/75', - [NotificationType.Error]: 'text-white bg-immich-error hover:bg-immich-error/75', - [NotificationType.Warning]: 'text-white bg-immich-warning hover:bg-immich-warning/75', + const colors: Record = { + [NotificationType.Info]: 'primary', + [NotificationType.Error]: 'danger', + [NotificationType.Warning]: 'warning', }; onMount(() => { @@ -111,16 +111,16 @@

{#if notification.button} -

- +

{/if}
diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte index c65dbc258b..17e39f1739 100644 --- a/web/src/lib/components/shared-components/scrubber/scrubber.svelte +++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte @@ -505,10 +505,7 @@ {/if} {#if !usingMobileDevice && !isDragging} -
+
{#if timelineManager.scrolling && scrollHoverLabel && !isHover}

- import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; - import { getAllTags, type TagResponseDto } from '@immich/sdk'; - import { t } from 'svelte-i18n'; - import { onMount } from 'svelte'; - import { SvelteSet } from 'svelte/reactivity'; import Icon from '$lib/components/elements/icon.svelte'; - import { mdiClose } from '@mdi/js'; + import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; import { preferences } from '$lib/stores/user.store'; + import { getAllTags, type TagResponseDto } from '@immich/sdk'; + import { mdiClose } from '@mdi/js'; + import { onMount } from 'svelte'; + import { t } from 'svelte-i18n'; + import { SvelteSet } from 'svelte/reactivity'; interface Props { selectedTags: SvelteSet; @@ -57,7 +57,7 @@ {#if tag}

{tag.value} diff --git a/web/src/lib/components/shared-components/side-bar/storage-space.svelte b/web/src/lib/components/shared-components/side-bar/storage-space.svelte index 4a40cbc1e7..fdcec683e7 100644 --- a/web/src/lib/components/shared-components/side-bar/storage-space.svelte +++ b/web/src/lib/components/shared-components/side-bar/storage-space.svelte @@ -1,12 +1,12 @@