diff --git a/e2e/src/web/specs/user-admin.e2e-spec.ts b/e2e/src/web/specs/user-admin.e2e-spec.ts index 3d64e47aef..611a1b3dec 100644 --- a/e2e/src/web/specs/user-admin.e2e-spec.ts +++ b/e2e/src/web/specs/user-admin.e2e-spec.ts @@ -52,7 +52,7 @@ test.describe('User Administration', () => { await page.goto(`/admin/users/${user.userId}`); - await page.getByRole('button', { name: 'Edit user' }).click(); + await page.getByRole('button', { name: 'Edit' }).click(); await expect(page.getByLabel('Admin User')).not.toBeChecked(); await page.getByText('Admin User').click(); await expect(page.getByLabel('Admin User')).toBeChecked(); @@ -77,7 +77,7 @@ test.describe('User Administration', () => { await page.goto(`/admin/users/${user.userId}`); - await page.getByRole('button', { name: 'Edit user' }).click(); + await page.getByRole('button', { name: 'Edit' }).click(); await expect(page.getByLabel('Admin User')).toBeChecked(); await page.getByText('Admin User').click(); await expect(page.getByLabel('Admin User')).not.toBeChecked(); diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte index 1bbbf642e0..37bc09fb73 100644 --- a/web/src/lib/components/ActionButton.svelte +++ b/web/src/lib/components/ActionButton.svelte @@ -1,19 +1,16 @@ - onSelect?.({ event, item: action })} -/> +{#if action.$if?.() ?? true} + +{/if} diff --git a/web/src/lib/components/HeaderButton.svelte b/web/src/lib/components/HeaderButton.svelte new file mode 100644 index 0000000000..9021d2d1cb --- /dev/null +++ b/web/src/lib/components/HeaderButton.svelte @@ -0,0 +1,18 @@ + + +{#if action.$if?.() ?? true} + +{/if} diff --git a/web/src/lib/components/TableButton.svelte b/web/src/lib/components/TableButton.svelte new file mode 100644 index 0000000000..4bd82e4dd9 --- /dev/null +++ b/web/src/lib/components/TableButton.svelte @@ -0,0 +1,16 @@ + + +{#if action.$if?.() ?? true} + +{/if} diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 1bd437b4cc..733fb8bc71 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -1,5 +1,5 @@ import type { ThemeSetting } from '$lib/managers/theme-manager.svelte'; -import type { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@immich/sdk'; +import type { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto, UserAdminResponseDto } from '@immich/sdk'; export type Events = { AppInit: []; @@ -14,6 +14,11 @@ export type Events = { SharedLinkCreate: [SharedLinkResponseDto]; SharedLinkUpdate: [SharedLinkResponseDto]; SharedLinkDelete: [SharedLinkResponseDto]; + + UserAdminCreate: [UserAdminResponseDto]; + UserAdminUpdate: [UserAdminResponseDto]; + UserAdminDelete: [UserAdminResponseDto]; + UserAdminRestore: [UserAdminResponseDto]; }; type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void; diff --git a/web/src/lib/modals/UserCreateModal.svelte b/web/src/lib/modals/UserCreateModal.svelte index 1a1f46d1d5..38d6ed54b2 100644 --- a/web/src/lib/modals/UserCreateModal.svelte +++ b/web/src/lib/modals/UserCreateModal.svelte @@ -1,11 +1,9 @@
- {#if error} - - {/if} - {#if success}

{$t('new_user_created')}

{/if} diff --git a/web/src/lib/modals/UserDeleteConfirmModal.svelte b/web/src/lib/modals/UserDeleteConfirmModal.svelte index 9c9223707e..46e02d54c4 100644 --- a/web/src/lib/modals/UserDeleteConfirmModal.svelte +++ b/web/src/lib/modals/UserDeleteConfirmModal.svelte @@ -1,14 +1,15 @@ import { AppRoute } from '$lib/constants'; + import { handleUpdateUserAdmin } from '$lib/services/user-admin.service'; import { user as authUser } from '$lib/stores/user.store'; import { userInteraction } from '$lib/stores/user.svelte'; import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; - import { handleError } from '$lib/utils/handle-error'; - import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; + import { type UserAdminResponseDto } from '@immich/sdk'; import { Button, Field, @@ -23,7 +23,7 @@ interface Props { user: UserAdminResponseDto; - onClose: (data?: UserAdminResponseDto) => void; + onClose: () => void; } let { user, onClose }: Props = $props(); @@ -48,28 +48,20 @@ quotaSizeBytes > userInteraction.serverInfo.diskSizeRaw, ); - const handleEditUser = async () => { - try { - const newUser = await updateUserAdmin({ - id: user.id, - userAdminUpdateDto: { - email, - name, - storageLabel, - quotaSizeInBytes: typeof quotaSize === 'number' ? convertToBytes(quotaSize, ByteUnit.GiB) : null, - isAdmin, - }, - }); - - onClose(newUser); - } catch (error) { - handleError(error, $t('errors.unable_to_update_user')); - } - }; - const onSubmit = async (event: Event) => { event.preventDefault(); - await handleEditUser(); + + const success = await handleUpdateUserAdmin(user, { + email, + name, + storageLabel, + quotaSizeInBytes: typeof quotaSize === 'number' ? convertToBytes(quotaSize, ByteUnit.GiB) : null, + isAdmin, + }); + + if (success) { + onClose(); + } }; diff --git a/web/src/lib/modals/UserRestoreConfirmModal.svelte b/web/src/lib/modals/UserRestoreConfirmModal.svelte index 03c36e27cd..0a01f846b9 100644 --- a/web/src/lib/modals/UserRestoreConfirmModal.svelte +++ b/web/src/lib/modals/UserRestoreConfirmModal.svelte @@ -1,30 +1,39 @@ - - + + {#snippet promptSnippet()}

{#snippet children({ message })} @@ -32,16 +41,5 @@ {/snippet}

-
- - - - - - - -
+ {/snippet} +
diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index 6e5583495f..702f84d6f9 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -15,7 +15,6 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro ? $t('album_delete_confirmation', { values: { album: album.albumName } }) : $t('unnamed_album_delete_confirmation'); const description = $t('album_delete_confirmation_description'); - const success = await modalManager.showDialog({ prompt: `${confirmation} ${description}` }); if (!success) { return false; @@ -24,13 +23,10 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro try { await deleteAlbum({ id: album.id }); - eventManager.emit('AlbumDelete', album); - if (notify) { toastManager.success(); } - return true; } catch (error) { handleError(error, $t('errors.unable_to_delete_album')); diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index 97a7f5851e..9ac1c47a94 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -103,24 +103,19 @@ export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto, export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto): Promise => { const $t = await getFormatter(); - const success = await modalManager.showDialog({ title: $t('delete_shared_link'), prompt: $t('confirm_delete_shared_link'), confirmText: $t('delete'), }); - if (!success) { return false; } try { await removeSharedLink({ id: sharedLink.id }); - eventManager.emit('SharedLinkDelete', sharedLink); - toastManager.success($t('deleted_shared_link')); - return true; } catch (error) { handleError(error, $t('errors.unable_to_delete_shared_link')); @@ -130,13 +125,11 @@ export const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto): export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkResponseDto, assetIds: string[]) => { const $t = await getFormatter(); - const success = await modalManager.showDialog({ title: $t('remove_assets_title'), prompt: $t('remove_assets_shared_link_confirmation', { values: { count: assetIds.length } }), confirmText: $t('remove'), }); - if (!success) { return false; } diff --git a/web/src/lib/services/user-admin.service.ts b/web/src/lib/services/user-admin.service.ts new file mode 100644 index 0000000000..9d3e21bc9e --- /dev/null +++ b/web/src/lib/services/user-admin.service.ts @@ -0,0 +1,232 @@ +import { goto } from '$app/navigation'; +import { eventManager } from '$lib/managers/event-manager.svelte'; +import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte'; +import UserCreateModal from '$lib/modals/UserCreateModal.svelte'; +import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte'; +import UserEditModal from '$lib/modals/UserEditModal.svelte'; +import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte'; +import { serverConfig } from '$lib/stores/server-config.store'; +import { user as authUser } from '$lib/stores/user.store'; +import type { ActionItem } from '$lib/types'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { + createUserAdmin, + deleteUserAdmin, + restoreUserAdmin, + updateUserAdmin, + UserStatus, + type UserAdminCreateDto, + type UserAdminDeleteDto, + type UserAdminResponseDto, + type UserAdminUpdateDto, +} from '@immich/sdk'; +import { MenuItemType, menuManager, modalManager, toastManager } from '@immich/ui'; +import { + mdiDeleteRestore, + mdiDotsVertical, + mdiEyeOutline, + mdiLockReset, + mdiLockSmart, + mdiPencilOutline, + mdiPlusBoxOutline, + mdiTrashCanOutline, +} from '@mdi/js'; +import { DateTime } from 'luxon'; +import type { MessageFormatter } from 'svelte-i18n'; +import { get } from 'svelte/store'; + +const getDeleteDate = (deletedAt: string): Date => + DateTime.fromISO(deletedAt) + .plus({ days: get(serverConfig).userDeleteDelay }) + .toJSDate(); + +export const getUserAdminsActions = ($t: MessageFormatter) => { + const Create: ActionItem = { + title: $t('create_user'), + icon: mdiPlusBoxOutline, + onSelect: () => void modalManager.show(UserCreateModal, {}), + }; + + return { Create }; +}; + +export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => { + const View: ActionItem = { + icon: mdiEyeOutline, + title: $t('view'), + onSelect: () => void goto(`/admin/users/${user.id}`), + }; + + const Update: ActionItem = { + icon: mdiPencilOutline, + title: $t('edit'), + onSelect: () => void modalManager.show(UserEditModal, { user }), + }; + + const Delete: ActionItem = { + icon: mdiTrashCanOutline, + title: $t('delete'), + color: 'danger', + $if: () => get(authUser).id !== user.id && !user.deletedAt, + onSelect: () => void modalManager.show(UserDeleteConfirmModal, { user }), + }; + + const Restore: ActionItem = { + icon: mdiDeleteRestore, + title: $t('restore'), + color: 'primary', + $if: () => !!user.deletedAt && user.status === UserStatus.Deleted, + onSelect: () => void modalManager.show(UserRestoreConfirmModal, { user }), + props: { + title: $t('admin.user_restore_scheduled_removal', { + values: { date: getDeleteDate(user.deletedAt!) }, + }), + }, + }; + + const ResetPassword: ActionItem = { + icon: mdiLockReset, + title: $t('reset_password'), + $if: () => get(authUser).id !== user.id, + onSelect: () => void handleResetPasswordUserAdmin(user), + }; + + const ResetPinCode: ActionItem = { + icon: mdiLockSmart, + title: $t('reset_pin_code'), + onSelect: () => void handleResetPinCodeUserAdmin(user), + }; + + const ContextMenu: ActionItem = { + icon: mdiDotsVertical, + title: $t('actions'), + onSelect: ({ event }) => + void menuManager.show({ + target: event.currentTarget as HTMLElement, + position: 'top-right', + items: [ + View, + Update, + ResetPassword, + ResetPinCode, + get(authUser).id === user.id ? undefined : MenuItemType.Divider, + Restore, + Delete, + ].filter(Boolean), + }), + }; + + return { View, Update, Delete, Restore, ResetPassword, ResetPinCode, ContextMenu }; +}; + +export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => { + const $t = await getFormatter(); + + try { + const response = await createUserAdmin({ userAdminCreateDto: dto }); + eventManager.emit('UserAdminCreate', response); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_create_user')); + } +}; + +export const handleUpdateUserAdmin = async (user: UserAdminResponseDto, dto: UserAdminUpdateDto) => { + const $t = await getFormatter(); + + try { + const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto }); + eventManager.emit('UserAdminUpdate', response); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_update_user')); + return false; + } +}; + +export const handleDeleteUserAdmin = async (user: UserAdminResponseDto, dto: UserAdminDeleteDto) => { + const $t = await getFormatter(); + + try { + const result = await deleteUserAdmin({ id: user.id, userAdminDeleteDto: dto }); + eventManager.emit('UserAdminDelete', result); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_delete_user')); + } +}; + +export const handleRestoreUserAdmin = async (user: UserAdminResponseDto) => { + const $t = await getFormatter(); + + try { + const response = await restoreUserAdmin({ id: user.id }); + eventManager.emit('UserAdminRestore', response); + toastManager.success(); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_restore_user')); + return false; + } +}; + +// TODO move password reset server-side +const generatePassword = (length: number = 16) => { + let generatedPassword = ''; + + const characterSet = '0123456789' + 'abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + ',.-{}+!#$%/()=?'; + + for (let i = 0; i < length; i++) { + let randomNumber = crypto.getRandomValues(new Uint32Array(1))[0]; + randomNumber = randomNumber / 2 ** 32; + randomNumber = Math.floor(randomNumber * characterSet.length); + + generatedPassword += characterSet[randomNumber]; + } + + return generatedPassword; +}; + +export const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => { + const $t = await getFormatter(); + const prompt = $t('admin.confirm_user_password_reset', { values: { user: user.name } }); + const success = await modalManager.showDialog({ prompt }); + if (!success) { + return false; + } + + try { + const dto = { password: generatePassword(), shouldChangePassword: true }; + const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto }); + eventManager.emit('UserAdminUpdate', response); + toastManager.success(); + await modalManager.show(PasswordResetSuccessModal, { newPassword: dto.password }); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_reset_password')); + return false; + } +}; + +export const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => { + const $t = await getFormatter(); + const prompt = $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }); + const success = await modalManager.showDialog({ prompt }); + if (!success) { + return false; + } + + try { + const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } }); + eventManager.emit('UserAdminUpdate', response); + toastManager.success($t('pin_code_reset_successfully')); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_reset_pin_code')); + return false; + } +}; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000000..960158a0f7 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,4 @@ +import type { MenuItem } from '@immich/ui'; +import type { HTMLAttributes } from 'svelte/elements'; + +export type ActionItem = MenuItem & { props?: Omit, 'color'> }; diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte index 129862a62c..c4c1012774 100644 --- a/web/src/routes/admin/users/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -1,19 +1,16 @@ + + {#snippet buttons()} - + {/snippet}
@@ -93,20 +78,21 @@ {#if allUsers} - {#each allUsers as immichUser (immichUser.id)} + {#each allUsers as user (user.id)} + {@const UserAdminActions = getUserAdminActions($t, user)} - {immichUser.email} + {user.email} - {immichUser.name} + {user.name}
- {#if immichUser.quotaSizeInBytes !== null && immichUser.quotaSizeInBytes >= 0} - {getByteUnitString(immichUser.quotaSizeInBytes, $locale)} + {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} + {getByteUnitString(user.quotaSizeInBytes, $locale)} {:else} {/if} @@ -115,38 +101,8 @@ - {#if !immichUser.deletedAt} - - {#if immichUser.id !== $user.id} - handleDelete(immichUser)} - aria-label={$t('delete_user')} - /> - {/if} - {/if} - {#if immichUser.deletedAt && immichUser.status === UserStatus.Deleted} - handleRestore(immichUser)} - aria-label={$t('admin.user_restore_scheduled_removal')} - /> - {/if} + + {/each} diff --git a/web/src/routes/admin/users/[id]/+page.svelte b/web/src/routes/admin/users/[id]/+page.svelte index 79c663af39..49cfb4715a 100644 --- a/web/src/routes/admin/users/[id]/+page.svelte +++ b/web/src/routes/admin/users/[id]/+page.svelte @@ -1,22 +1,18 @@ + + {#snippet buttons()} - {#if canResetPassword} - - {/if} - - - - {#if user.deletedAt} - - {:else} - - {/if} + + + + + {/snippet}