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 | 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;