From 3066c8198cb23092bb0cb15af1b6702e7c2c8ab2 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 12 May 2025 16:50:26 -0400 Subject: [PATCH] feat(web): user detail page (#18230) feat: user detail page --- i18n/en.json | 7 + mobile/openapi/README.md | Bin 34876 -> 35011 bytes mobile/openapi/lib/api/users_admin_api.dart | Bin 14330 -> 16852 bytes 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, 559 insertions(+), 157 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 2db9976fa..fde78a34a 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 a141d465d126884479e8dd5631c79b7659f5fa2e..9a3055911da53de63f037836bf15b49eace1ca4f 100644 GIT binary patch delta 60 zcmdlpf$8u>rVXzGCMN{2a|V|rmSh%}WF}883{dARhV!Ne%1u5Hz=jand?6sm902ht B7>@t| delta 14 WcmX>+k!jBarVXzGHWvm4nF9bc$_9-9 diff --git a/mobile/openapi/lib/api/users_admin_api.dart b/mobile/openapi/lib/api/users_admin_api.dart index b4508d7dcd61cf18ef93c1dd42c424416154c161..58263504ce8de71a4998bd1763a9626c04e1939f 100644 GIT binary patch delta 825 zcmbtSPcH*O6wmCTwUtt8>tMx1x3sd-3W6eyL`obu_!H6e(Arg#RJYj~O+<>s)$M%( z-$1fQCwB=4iJOCu!NqP{(@D9A%jC`M{@(A;dmq-zHM)~kSQ-t1mGa4y6&Rt69KvO) z8Y)^=Igbg=7HXDFm8)p;O;DxZZFvYDlFfBblA%abpN>Jy*+TS=1(6r_}4|XBQ%mjbUc!LxF zC1da3>?u37&80?SUepxs`js3Hovs)sq!Qm|HmQ^_G4y{A0N!HpzLLpyscE-q0ELxx zEaaRPYA^rq6>67aRB0hL9XlwPkYsKNZim7FextD2n{{OM#s^h3;4bJS97qAUA06p# zx^#ET(BUx_g(r0lKBYyth~6Lq1KNCu7~WtFJ{bpTSE$t|-+|xv6T@|3=uUL(weJgP CnHb0b delta 59 zcmcc8%=jyR1E(`/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 4dfeae949..83d7caef0 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 9efb531bc..9d43e53f8 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 f8710746a..6972479df 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 38c0106f4..d1fe5ce67 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 3d6582d65..75c07aebc 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 0d53c4ef2..bab3c1ea6 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 0a6af40c6..6ff068a1f 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 5b6246be8..75b35491f 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 000000000..0a6af40c6 --- /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 000000000..b0a9327fc --- /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 000000000..ddf3ddbef --- /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;