mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 20:07:41 +09:00 
			
		
		
		
	| @@ -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": "<b>{user}</b>'s account and assets will be queued for permanent deletion <b>immediately</b>.", | ||||
|     "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}", | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -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} |  | ||||
|   | ||||
							
								
								
									
										83
									
								
								mobile/openapi/lib/api/users_admin_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										83
									
								
								mobile/openapi/lib/api/users_admin_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -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<Response> 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 = <QueryParam>[]; | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|     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 = <String>[]; | ||||
| 
 | ||||
| 
 | ||||
|     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<AssetStatsResponseDto?> 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<Response> searchUsersAdminWithHttpInfo({ bool? withDeleted, }) async { | ||||
|   Future<Response> searchUsersAdminWithHttpInfo({ String? id, bool? withDeleted, }) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final apiPath = r'/admin/users'; | ||||
| 
 | ||||
| @@ -274,6 +346,9 @@ class UsersAdminApi { | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|     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<List<UserAdminResponseDto>?> searchUsersAdmin({ bool? withDeleted, }) async { | ||||
|     final response = await searchUsersAdminWithHttpInfo( withDeleted: withDeleted, ); | ||||
|   Future<List<UserAdminResponseDto>?> 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)); | ||||
|     } | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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<AssetStatsResponseDto> { | ||||
|     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<UserPreferencesResponseDto> { | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -14,6 +14,7 @@ import { asUuid } from 'src/utils/database'; | ||||
| type Upsert = Insertable<DbUserMetadata>; | ||||
|  | ||||
| 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(); | ||||
|   } | ||||
|   | ||||
| @@ -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<UserAdminResponseDto[]> { | ||||
|     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<AssetStatsResponseDto> { | ||||
|     const stats = await this.assetRepository.getStatistics(auth.user.id, dto); | ||||
|     return mapStats(stats); | ||||
|   } | ||||
|  | ||||
|   async getPreferences(auth: AuthDto, id: string): Promise<UserPreferencesResponseDto> { | ||||
|     await this.findOrFail(id, { withDeleted: true }); | ||||
|     const metadata = await this.userRepository.getMetadata(id); | ||||
|   | ||||
| @@ -44,7 +44,7 @@ | ||||
|   </ModalBody> | ||||
|  | ||||
|   <ModalFooter> | ||||
|     <div class="flex gap-3 w-full my-3"> | ||||
|     <div class="flex gap-3 w-full"> | ||||
|       {#if !hideCancelButton} | ||||
|         <Button shape="round" color={cancelColor} fullWidth onclick={() => onClose(false)}> | ||||
|           {cancelText} | ||||
|   | ||||
| @@ -61,7 +61,7 @@ | ||||
|       </Button> | ||||
|       {#if $user.isAdmin} | ||||
|         <Button | ||||
|           href={AppRoute.ADMIN_USER_MANAGEMENT} | ||||
|           href={AppRoute.ADMIN_USERS} | ||||
|           onclick={onClose} | ||||
|           color="dark-gray" | ||||
|           size="sm" | ||||
|   | ||||
| @@ -8,7 +8,7 @@ | ||||
| </script> | ||||
|  | ||||
| <SideBarSection ariaLabel={$t('primary')}> | ||||
|   <SideBarLink title={$t('users')} routeId={AppRoute.ADMIN_USER_MANAGEMENT} icon={mdiAccountMultipleOutline} /> | ||||
|   <SideBarLink title={$t('users')} routeId={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} /> | ||||
|   <SideBarLink title={$t('jobs')} routeId={AppRoute.ADMIN_JOBS} icon={mdiSync} /> | ||||
|   <SideBarLink title={$t('settings')} routeId={AppRoute.ADMIN_SETTINGS} icon={mdiCog} /> | ||||
|   <SideBarLink title={$t('external_libraries')} routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} /> | ||||
|   | ||||
| @@ -13,7 +13,7 @@ export enum AssetAction { | ||||
| } | ||||
|  | ||||
| export enum AppRoute { | ||||
|   ADMIN_USER_MANAGEMENT = '/admin/user-management', | ||||
|   ADMIN_USERS = '/admin/users', | ||||
|   ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management', | ||||
|   ADMIN_SETTINGS = '/admin/system-settings', | ||||
|   ADMIN_STATS = '/admin/server-status', | ||||
|   | ||||
| @@ -1,40 +1,32 @@ | ||||
| <script lang="ts"> | ||||
|   import { AppRoute } from '$lib/constants'; | ||||
|   import { modalManager } from '$lib/managers/modal-manager.svelte'; | ||||
|   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 { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; | ||||
|   import { mdiAccountEditOutline, mdiLockSmart, mdiOnepassword } from '@mdi/js'; | ||||
|   import { mdiAccountEditOutline } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|  | ||||
|   interface Props { | ||||
|     user: UserAdminResponseDto; | ||||
|     canResetPassword?: boolean; | ||||
|     onClose: ( | ||||
|       data?: | ||||
|         | { action: 'update'; data: UserAdminResponseDto } | ||||
|         | { action: 'resetPassword'; data: string } | ||||
|         | { action: 'resetPinCode' }, | ||||
|     ) => void; | ||||
|     onClose: (data?: UserAdminResponseDto) => void; | ||||
|   } | ||||
|  | ||||
|   let { user, canResetPassword = true, onClose }: Props = $props(); | ||||
|   let { user, onClose }: Props = $props(); | ||||
|  | ||||
|   let quotaSize = $state(user.quotaSizeInBytes === null ? null : convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB)); | ||||
|   let newPassword = $state<string>(''); | ||||
|  | ||||
|   const previousQutoa = user.quotaSizeInBytes; | ||||
|   const previousQuota = user.quotaSizeInBytes; | ||||
|  | ||||
|   let quotaSizeWarning = $derived( | ||||
|     previousQutoa !== convertToBytes(Number(quotaSize), ByteUnit.GiB) && | ||||
|     previousQuota !== convertToBytes(Number(quotaSize), ByteUnit.GiB) && | ||||
|       !!quotaSize && | ||||
|       userInteraction.serverInfo && | ||||
|       convertToBytes(Number(quotaSize), ByteUnit.GiB) > userInteraction.serverInfo.diskSizeRaw, | ||||
|   ); | ||||
|  | ||||
|   const editUser = async () => { | ||||
|   const handleEditUser = async () => { | ||||
|     try { | ||||
|       const { id, email, name, storageLabel } = user; | ||||
|       const newUser = await updateUserAdmin({ | ||||
| @@ -47,76 +39,15 @@ | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       onClose({ action: 'update', data: newUser }); | ||||
|       onClose(newUser); | ||||
|     } catch (error) { | ||||
|       handleError(error, $t('errors.unable_to_update_user')); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const resetPassword = async () => { | ||||
|     const isConfirmed = await modalManager.openDialog({ | ||||
|       prompt: $t('admin.confirm_user_password_reset', { values: { user: user.name } }), | ||||
|     }); | ||||
|  | ||||
|     if (!isConfirmed) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       newPassword = generatePassword(); | ||||
|  | ||||
|       await updateUserAdmin({ | ||||
|         id: user.id, | ||||
|         userAdminUpdateDto: { | ||||
|           password: newPassword, | ||||
|           shouldChangePassword: true, | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       onClose({ action: 'resetPassword', data: newPassword }); | ||||
|     } catch (error) { | ||||
|       handleError(error, $t('errors.unable_to_reset_password')); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const resetUserPincode = async () => { | ||||
|     const isConfirmed = await modalManager.openDialog({ | ||||
|       prompt: $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }), | ||||
|     }); | ||||
|  | ||||
|     if (!isConfirmed) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } }); | ||||
|  | ||||
|       onClose({ action: 'resetPinCode' }); | ||||
|     } catch (error) { | ||||
|       handleError(error, $t('errors.unable_to_reset_pin_code')); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // TODO move password reset server-side | ||||
|   function 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; | ||||
|   } | ||||
|  | ||||
|   const onSubmit = async (event: Event) => { | ||||
|     event.preventDefault(); | ||||
|     await editUser(); | ||||
|     await handleEditUser(); | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| @@ -172,34 +103,11 @@ | ||||
|   </ModalBody> | ||||
|  | ||||
|   <ModalFooter> | ||||
|     <div class="w-full"> | ||||
|     <div class="flex gap-3 w-full"> | ||||
|         {#if canResetPassword} | ||||
|           <Button | ||||
|             shape="round" | ||||
|             color="warning" | ||||
|             variant="filled" | ||||
|             fullWidth | ||||
|             onclick={resetPassword} | ||||
|             leadingIcon={mdiOnepassword} | ||||
|       <Button shape="round" color="secondary" fullWidth form="edit-user-form" onclick={() => onClose()} | ||||
|         >{$t('cancel')}</Button | ||||
|       > | ||||
|             {$t('reset_password')}</Button | ||||
|           > | ||||
|         {/if} | ||||
|  | ||||
|         <Button | ||||
|           shape="round" | ||||
|           color="warning" | ||||
|           variant="filled" | ||||
|           fullWidth | ||||
|           onclick={resetUserPincode} | ||||
|           leadingIcon={mdiLockSmart}>{$t('reset_pin_code')}</Button | ||||
|         > | ||||
|       </div> | ||||
|  | ||||
|       <div class="w-full mt-4"> | ||||
|       <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button> | ||||
|     </div> | ||||
|     </div> | ||||
|   </ModalFooter> | ||||
| </Modal> | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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': { | ||||
|     const result = await modalManager.show(UserEditModal, { user: dto }); | ||||
|     if (result) { | ||||
|       await refresh(); | ||||
|         break; | ||||
|       } | ||||
|       case 'resetPinCode': { | ||||
|         notificationController.show({ type: NotificationType.Info, message: $t('pin_code_reset_successfully') }); | ||||
|         break; | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
| @@ -123,7 +112,7 @@ | ||||
|                     : 'bg-immich-bg dark:bg-immich-dark-gray/50'}" | ||||
|               > | ||||
|                 <td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm" | ||||
|                   >{immichUser.email}</td | ||||
|                   ><Link href="{AppRoute.ADMIN_USERS}/{immichUser.id}">{immichUser.email}</Link></td | ||||
|                 > | ||||
|                 <td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{immichUser.name}</td> | ||||
|                 <td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm"> | ||||
							
								
								
									
										18
									
								
								web/src/routes/admin/users/+page.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								web/src/routes/admin/users/+page.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
							
								
								
									
										343
									
								
								web/src/routes/admin/users/[id]/+page.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										343
									
								
								web/src/routes/admin/users/[id]/+page.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,343 @@ | ||||
| <script lang="ts"> | ||||
|   import { goto } from '$app/navigation'; | ||||
|   import StatsCard from '$lib/components/admin-page/server-stats/stats-card.svelte'; | ||||
|   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; | ||||
|   import { | ||||
|     notificationController, | ||||
|     NotificationType, | ||||
|   } from '$lib/components/shared-components/notification/notification'; | ||||
|   import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; | ||||
|   import { AppRoute } from '$lib/constants'; | ||||
|   import PasswordResetSuccess from '$lib/forms/password-reset-success.svelte'; | ||||
|   import { modalManager } from '$lib/managers/modal-manager.svelte'; | ||||
|   import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte'; | ||||
|   import UserEditModal from '$lib/modals/UserEditModal.svelte'; | ||||
|   import { locale } from '$lib/stores/preferences.store'; | ||||
|   import { user as authUser } from '$lib/stores/user.store'; | ||||
|   import { getBytesWithUnit } from '$lib/utils/byte-units'; | ||||
|   import { handleError } from '$lib/utils/handle-error'; | ||||
|   import { updateUserAdmin } from '@immich/sdk'; | ||||
|   import { | ||||
|     Button, | ||||
|     Card, | ||||
|     CardBody, | ||||
|     CardHeader, | ||||
|     CardTitle, | ||||
|     Code, | ||||
|     Container, | ||||
|     Field, | ||||
|     getByteUnitString, | ||||
|     Heading, | ||||
|     HStack, | ||||
|     Icon, | ||||
|     Stack, | ||||
|     Switch, | ||||
|     Text, | ||||
|   } from '@immich/ui'; | ||||
|   import { | ||||
|     mdiAccountOutline, | ||||
|     mdiCameraIris, | ||||
|     mdiChartPie, | ||||
|     mdiChartPieOutline, | ||||
|     mdiCheckCircle, | ||||
|     mdiFeatureSearchOutline, | ||||
|     mdiLockSmart, | ||||
|     mdiOnepassword, | ||||
|     mdiPencilOutline, | ||||
|     mdiPlayCircle, | ||||
|     mdiTrashCanOutline, | ||||
|   } from '@mdi/js'; | ||||
|   import { t } from 'svelte-i18n'; | ||||
|   import type { PageData } from './$types'; | ||||
|  | ||||
|   interface Props { | ||||
|     data: PageData; | ||||
|   } | ||||
|  | ||||
|   let { data }: Props = $props(); | ||||
|  | ||||
|   let user = $derived(data.user); | ||||
|   const userPreferences = $derived(data.userPreferences); | ||||
|   const userStatistics = $derived(data.userStatistics); | ||||
|  | ||||
|   const TiB = 1024 ** 4; | ||||
|   const usage = $derived(user.quotaUsageInBytes ?? 0); | ||||
|   let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0)); | ||||
|  | ||||
|   const usedBytes = $derived(user.quotaUsageInBytes ?? 0); | ||||
|   const availableBytes = $derived(user.quotaSizeInBytes ?? 1); | ||||
|   let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100)); | ||||
|   let canResetPassword = $derived($authUser.id !== user.id); | ||||
|   let newPassword = $state<string>(''); | ||||
|  | ||||
|   const handleEdit = async () => { | ||||
|     const result = await modalManager.show(UserEditModal, { user: { ...user } }); | ||||
|     if (result) { | ||||
|       user = result; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleDelete = async () => { | ||||
|     const result = await modalManager.show(UserDeleteConfirmModal, { user }); | ||||
|     if (result) { | ||||
|       await goto(AppRoute.ADMIN_USERS); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const getUsageClass = () => { | ||||
|     if (usedPercentage >= 95) { | ||||
|       return 'bg-red-500'; | ||||
|     } | ||||
|  | ||||
|     if (usedPercentage > 80) { | ||||
|       return 'bg-yellow-500'; | ||||
|     } | ||||
|  | ||||
|     return 'bg-immich-primary dark:bg-immich-dark-primary'; | ||||
|   }; | ||||
|  | ||||
|   const handleResetPassword = async () => { | ||||
|     const isConfirmed = await modalManager.openDialog({ | ||||
|       prompt: $t('admin.confirm_user_password_reset', { values: { user: user.name } }), | ||||
|     }); | ||||
|  | ||||
|     if (!isConfirmed) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       newPassword = generatePassword(); | ||||
|  | ||||
|       await updateUserAdmin({ | ||||
|         id: user.id, | ||||
|         userAdminUpdateDto: { | ||||
|           password: newPassword, | ||||
|           shouldChangePassword: true, | ||||
|         }, | ||||
|       }); | ||||
|  | ||||
|       await modalManager.show(PasswordResetSuccess, { newPassword }); | ||||
|     } catch (error) { | ||||
|       handleError(error, $t('errors.unable_to_reset_password')); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleResetUserPinCode = async () => { | ||||
|     const isConfirmed = await modalManager.openDialog({ | ||||
|       prompt: $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }), | ||||
|     }); | ||||
|  | ||||
|     if (!isConfirmed) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } }); | ||||
|  | ||||
|       notificationController.show({ type: NotificationType.Info, message: $t('pin_code_reset_successfully') }); | ||||
|     } catch (error) { | ||||
|       handleError(error, $t('errors.unable_to_reset_pin_code')); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // TODO move password reset server-side | ||||
|   function 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; | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <UserPageLayout title={data.meta.title} admin> | ||||
|   {#snippet buttons()} | ||||
|     <HStack gap={0}> | ||||
|       {#if canResetPassword} | ||||
|         <Button | ||||
|           color="secondary" | ||||
|           size="small" | ||||
|           variant="ghost" | ||||
|           leadingIcon={mdiOnepassword} | ||||
|           onclick={handleResetPassword} | ||||
|         > | ||||
|           <Text class="hidden md:block">{$t('reset_password')}</Text> | ||||
|         </Button> | ||||
|       {/if} | ||||
|  | ||||
|       <Button | ||||
|         color="secondary" | ||||
|         size="small" | ||||
|         variant="ghost" | ||||
|         leadingIcon={mdiLockSmart} | ||||
|         onclick={handleResetUserPinCode} | ||||
|       > | ||||
|         <Text class="hidden md:block">{$t('reset_pin_code')}</Text> | ||||
|       </Button> | ||||
|       <Button | ||||
|         color="secondary" | ||||
|         size="small" | ||||
|         variant="ghost" | ||||
|         leadingIcon={mdiPencilOutline} | ||||
|         onclick={() => handleEdit()} | ||||
|       > | ||||
|         <Text class="hidden md:block">{$t('edit_user')}</Text> | ||||
|       </Button> | ||||
|       <Button | ||||
|         color="danger" | ||||
|         size="small" | ||||
|         variant="ghost" | ||||
|         leadingIcon={mdiTrashCanOutline} | ||||
|         onclick={() => handleDelete()} | ||||
|       > | ||||
|         <Text class="hidden md:block">{$t('delete_user')}</Text> | ||||
|       </Button> | ||||
|     </HStack> | ||||
|   {/snippet} | ||||
|   <div> | ||||
|     <Container size="large" center> | ||||
|       <div class="grid gap-4 grod-cols-1 lg:grid-cols-2 w-full"> | ||||
|         <div class="col-span-full flex gap-4 items-center my-4"> | ||||
|           <UserAvatar {user} size="md" /> | ||||
|           <Heading tag="h1" size="large">{user.name}</Heading> | ||||
|         </div> | ||||
|         <div class="col-span-full"> | ||||
|           <div class="flex flex-col lg:flex-row gap-4 w-full"> | ||||
|             <StatsCard icon={mdiCameraIris} title={$t('photos').toUpperCase()} value={userStatistics.images} /> | ||||
|             <StatsCard icon={mdiPlayCircle} title={$t('videos').toUpperCase()} value={userStatistics.videos} /> | ||||
|             <StatsCard | ||||
|               icon={mdiChartPie} | ||||
|               title={$t('storage').toUpperCase()} | ||||
|               value={statsUsage} | ||||
|               unit={statsUsageUnit} | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div> | ||||
|           <Card color="secondary"> | ||||
|             <CardHeader> | ||||
|               <div class="flex items-center gap-2"> | ||||
|                 <Icon icon={mdiAccountOutline} size="1.5rem" /> | ||||
|                 <CardTitle>{$t('profile')}</CardTitle> | ||||
|               </div> | ||||
|             </CardHeader> | ||||
|             <CardBody> | ||||
|               <Stack gap={2}> | ||||
|                 <div> | ||||
|                   <Heading tag="h3" size="tiny">{$t('name')}</Heading> | ||||
|                   <Text>{user.name}</Text> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                   <Heading tag="h3" size="tiny">{$t('email')}</Heading> | ||||
|                   <Text>{user.email}</Text> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                   <Heading tag="h3" size="tiny">{$t('created_at')}</Heading> | ||||
|                   <Text>{user.createdAt}</Text> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                   <Heading tag="h3" size="tiny">{$t('updated_at')}</Heading> | ||||
|                   <Text>{user.updatedAt}</Text> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                   <Heading tag="h3" size="tiny">{$t('id')}</Heading> | ||||
|                   <Code>{user.id}</Code> | ||||
|                 </div> | ||||
|               </Stack> | ||||
|             </CardBody> | ||||
|           </Card> | ||||
|         </div> | ||||
|         <Card color="secondary"> | ||||
|           <CardHeader> | ||||
|             <div class="flex items-center gap-2"> | ||||
|               <Icon icon={mdiFeatureSearchOutline} size="1.5rem" /> | ||||
|               <CardTitle>{$t('features')}</CardTitle> | ||||
|             </div> | ||||
|           </CardHeader> | ||||
|           <CardBody> | ||||
|             <div> | ||||
|               <Stack gap={2}> | ||||
|                 <Field readOnly label={$t('email_notifications')}> | ||||
|                   <Switch checked={userPreferences.emailNotifications.enabled} color="primary" /> | ||||
|                 </Field> | ||||
|                 <Field readOnly label={$t('folders')}> | ||||
|                   <Switch checked={userPreferences.folders.enabled} color="primary" /> | ||||
|                 </Field> | ||||
|                 <Field readOnly label={$t('memories')}> | ||||
|                   <Switch checked={userPreferences.memories.enabled} color="primary" /> | ||||
|                 </Field> | ||||
|                 <Field readOnly label={$t('people')}> | ||||
|                   <Switch checked={userPreferences.people.enabled} color="primary" /> | ||||
|                 </Field> | ||||
|                 <Field readOnly label={$t('rating')}> | ||||
|                   <Switch checked={userPreferences.ratings.enabled} color="primary" /> | ||||
|                 </Field> | ||||
|                 <Field readOnly label={$t('shared_links')}> | ||||
|                   <Switch checked={userPreferences.sharedLinks.enabled} color="primary" /> | ||||
|                 </Field> | ||||
|                 <Field readOnly label={$t('show_supporter_badge')}> | ||||
|                   <Switch checked={userPreferences.purchase.showSupportBadge} color="primary" /> | ||||
|                 </Field> | ||||
|                 <Field readOnly label={$t('tags')}> | ||||
|                   <Switch checked={userPreferences.tags.enabled} color="primary" /> | ||||
|                 </Field> | ||||
|               </Stack> | ||||
|             </div> | ||||
|           </CardBody> | ||||
|         </Card> | ||||
|         <Card color="secondary"> | ||||
|           <CardHeader> | ||||
|             <div class="flex items-center gap-2"> | ||||
|               <Icon icon={mdiChartPieOutline} size="1.5rem" /> | ||||
|               <CardTitle>{$t('storage_quota')}</CardTitle> | ||||
|             </div> | ||||
|           </CardHeader> | ||||
|           <CardBody> | ||||
|             <div> | ||||
|               {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} | ||||
|                 <Text> | ||||
|                   {$t('storage_usage', { | ||||
|                     values: { | ||||
|                       used: getByteUnitString(usedBytes, $locale, 3), | ||||
|                       available: getByteUnitString(availableBytes, $locale, 3), | ||||
|                     }, | ||||
|                   })} | ||||
|                 </Text> | ||||
|               {:else} | ||||
|                 <Text class="flex items-center gap-1"> | ||||
|                   <Icon icon={mdiCheckCircle} size="1.25rem" class="text-success" /> | ||||
|                   {$t('unlimited')} | ||||
|                 </Text> | ||||
|               {/if} | ||||
|             </div> | ||||
|  | ||||
|             {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} | ||||
|               <div | ||||
|                 class="storage-status p-4 mt-4 bg-gray-100 dark:bg-immich-dark-primary/10 rounded-lg text-sm w-full" | ||||
|                 title={$t('storage_usage', { | ||||
|                   values: { | ||||
|                     used: getByteUnitString(usedBytes, $locale, 3), | ||||
|                     available: getByteUnitString(availableBytes, $locale, 3), | ||||
|                   }, | ||||
|                 })} | ||||
|               > | ||||
|                 <p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p> | ||||
|                 <div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700"> | ||||
|                   <div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div> | ||||
|                 </div> | ||||
|               </div> | ||||
|             {/if} | ||||
|           </CardBody> | ||||
|         </Card> | ||||
|       </div> | ||||
|     </Container> | ||||
|   </div> | ||||
| </UserPageLayout> | ||||
							
								
								
									
										31
									
								
								web/src/routes/admin/users/[id]/+page.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								web/src/routes/admin/users/[id]/+page.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
		Reference in New Issue
	
	Block a user