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_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": "<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_delete_immediately_checkbox": "Queue user and assets for immediate deletion", | ||||||
|  |     "user_details": "User Details", | ||||||
|     "user_management": "User Management", |     "user_management": "User Management", | ||||||
|     "user_password_has_been_reset": "The user's password has been reset:", |     "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.", |     "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_enable_button": "Enable Notifications", | ||||||
|   "notification_permission_list_tile_title": "Notification Permission", |   "notification_permission_list_tile_title": "Notification Permission", | ||||||
|   "notification_toggle_setting_description": "Enable email notifications", |   "notification_toggle_setting_description": "Enable email notifications", | ||||||
|  |   "email_notifications": "Email notifications", | ||||||
|   "notifications": "Notifications", |   "notifications": "Notifications", | ||||||
|   "notifications_setting_description": "Manage notifications", |   "notifications_setting_description": "Manage notifications", | ||||||
|   "oauth": "OAuth", |   "oauth": "OAuth", | ||||||
| @@ -1394,6 +1396,7 @@ | |||||||
|   "previous_or_next_photo": "Previous or next photo", |   "previous_or_next_photo": "Previous or next photo", | ||||||
|   "primary": "Primary", |   "primary": "Primary", | ||||||
|   "privacy": "Privacy", |   "privacy": "Privacy", | ||||||
|  |   "profile": "Profile", | ||||||
|   "profile_drawer_app_logs": "Logs", |   "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_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.", |   "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": "Storage space", | ||||||
|   "storage_label": "Storage label", |   "storage_label": "Storage label", | ||||||
|   "storage_usage": "{used} of {available} used", |   "storage_usage": "{used} of {available} used", | ||||||
|  |   "storage_quota": "Storage Quota", | ||||||
|   "submit": "Submit", |   "submit": "Submit", | ||||||
|   "suggestions": "Suggestions", |   "suggestions": "Suggestions", | ||||||
|   "sunrise_on_the_beach": "Sunrise on the beach", |   "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_success": "Upload success, refresh the page to see new upload assets.", | ||||||
|   "upload_to_immich": "Upload to Immich ({count})", |   "upload_to_immich": "Upload to Immich ({count})", | ||||||
|   "uploading": "Uploading", |   "uploading": "Uploading", | ||||||
|  |   "id": "ID", | ||||||
|   "url": "URL", |   "url": "URL", | ||||||
|   "usage": "Usage", |   "usage": "Usage", | ||||||
|   "use_current_connection": "use current connection", |   "use_current_connection": "use current connection", | ||||||
| @@ -1864,6 +1869,8 @@ | |||||||
|   "user": "User", |   "user": "User", | ||||||
|   "user_id": "User ID", |   "user_id": "User ID", | ||||||
|   "user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", |   "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": "Purchase", | ||||||
|   "user_purchase_settings_description": "Manage your purchase", |   "user_purchase_settings_description": "Manage your purchase", | ||||||
|   "user_role_set": "Set {user} as {role}", |   "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* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} |  | ||||||
| *UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /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* | [**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* | [**restoreUserAdmin**](doc//UsersAdminApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore |  | ||||||
| *UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users |  | *UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users |  | ||||||
| *UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} |  | *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; |     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]. |   /// Performs an HTTP 'POST /admin/users/{id}/restore' operation and returns the [Response]. | ||||||
|   /// Parameters: |   /// Parameters: | ||||||
|   /// |   /// | ||||||
| @@ -262,8 +332,10 @@ class UsersAdminApi { | |||||||
|   /// Performs an HTTP 'GET /admin/users' operation and returns the [Response]. |   /// Performs an HTTP 'GET /admin/users' operation and returns the [Response]. | ||||||
|   /// Parameters: |   /// Parameters: | ||||||
|   /// |   /// | ||||||
|  |   /// * [String] id: | ||||||
|  |   /// | ||||||
|   /// * [bool] withDeleted: |   /// * [bool] withDeleted: | ||||||
|   Future<Response> searchUsersAdminWithHttpInfo({ bool? withDeleted, }) async { |   Future<Response> searchUsersAdminWithHttpInfo({ String? id, bool? withDeleted, }) async { | ||||||
|     // ignore: prefer_const_declarations |     // ignore: prefer_const_declarations | ||||||
|     final apiPath = r'/admin/users'; |     final apiPath = r'/admin/users'; | ||||||
| 
 | 
 | ||||||
| @@ -274,6 +346,9 @@ class UsersAdminApi { | |||||||
|     final headerParams = <String, String>{}; |     final headerParams = <String, String>{}; | ||||||
|     final formParams = <String, String>{}; |     final formParams = <String, String>{}; | ||||||
| 
 | 
 | ||||||
|  |     if (id != null) { | ||||||
|  |       queryParams.addAll(_queryParams('', 'id', id)); | ||||||
|  |     } | ||||||
|     if (withDeleted != null) { |     if (withDeleted != null) { | ||||||
|       queryParams.addAll(_queryParams('', 'withDeleted', withDeleted)); |       queryParams.addAll(_queryParams('', 'withDeleted', withDeleted)); | ||||||
|     } |     } | ||||||
| @@ -294,9 +369,11 @@ class UsersAdminApi { | |||||||
| 
 | 
 | ||||||
|   /// Parameters: |   /// Parameters: | ||||||
|   /// |   /// | ||||||
|  |   /// * [String] id: | ||||||
|  |   /// | ||||||
|   /// * [bool] withDeleted: |   /// * [bool] withDeleted: | ||||||
|   Future<List<UserAdminResponseDto>?> searchUsersAdmin({ bool? withDeleted, }) async { |   Future<List<UserAdminResponseDto>?> searchUsersAdmin({ String? id, bool? withDeleted, }) async { | ||||||
|     final response = await searchUsersAdminWithHttpInfo( withDeleted: withDeleted, ); |     final response = await searchUsersAdminWithHttpInfo( id: id, withDeleted: withDeleted, ); | ||||||
|     if (response.statusCode >= HttpStatus.badRequest) { |     if (response.statusCode >= HttpStatus.badRequest) { | ||||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); |       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -345,6 +345,15 @@ | |||||||
|       "get": { |       "get": { | ||||||
|         "operationId": "searchUsersAdmin", |         "operationId": "searchUsersAdmin", | ||||||
|         "parameters": [ |         "parameters": [ | ||||||
|  |           { | ||||||
|  |             "name": "id", | ||||||
|  |             "required": false, | ||||||
|  |             "in": "query", | ||||||
|  |             "schema": { | ||||||
|  |               "format": "uuid", | ||||||
|  |               "type": "string" | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|           { |           { | ||||||
|             "name": "withDeleted", |             "name": "withDeleted", | ||||||
|             "required": false, |             "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": { |     "/albums": { | ||||||
|       "get": { |       "get": { | ||||||
|         "operationId": "getAllAlbums", |         "operationId": "getAllAlbums", | ||||||
|   | |||||||
| @@ -224,6 +224,11 @@ export type UserPreferencesUpdateDto = { | |||||||
|     sharedLinks?: SharedLinksUpdate; |     sharedLinks?: SharedLinksUpdate; | ||||||
|     tags?: TagsUpdate; |     tags?: TagsUpdate; | ||||||
| }; | }; | ||||||
|  | export type AssetStatsResponseDto = { | ||||||
|  |     images: number; | ||||||
|  |     total: number; | ||||||
|  |     videos: number; | ||||||
|  | }; | ||||||
| export type AlbumUserResponseDto = { | export type AlbumUserResponseDto = { | ||||||
|     role: AlbumUserRole; |     role: AlbumUserRole; | ||||||
|     user: UserResponseDto; |     user: UserResponseDto; | ||||||
| @@ -462,11 +467,6 @@ export type AssetJobsDto = { | |||||||
|     assetIds: string[]; |     assetIds: string[]; | ||||||
|     name: AssetJobName; |     name: AssetJobName; | ||||||
| }; | }; | ||||||
| export type AssetStatsResponseDto = { |  | ||||||
|     images: number; |  | ||||||
|     total: number; |  | ||||||
|     videos: number; |  | ||||||
| }; |  | ||||||
| export type UpdateAssetDto = { | export type UpdateAssetDto = { | ||||||
|     dateTimeOriginal?: string; |     dateTimeOriginal?: string; | ||||||
|     description?: string; |     description?: string; | ||||||
| @@ -1502,13 +1502,15 @@ export function sendTestEmailAdmin({ systemConfigSmtpDto }: { | |||||||
|         body: systemConfigSmtpDto |         body: systemConfigSmtpDto | ||||||
|     }))); |     }))); | ||||||
| } | } | ||||||
| export function searchUsersAdmin({ withDeleted }: { | export function searchUsersAdmin({ id, withDeleted }: { | ||||||
|  |     id?: string; | ||||||
|     withDeleted?: boolean; |     withDeleted?: boolean; | ||||||
| }, opts?: Oazapfts.RequestOpts) { | }, opts?: Oazapfts.RequestOpts) { | ||||||
|     return oazapfts.ok(oazapfts.fetchJson<{ |     return oazapfts.ok(oazapfts.fetchJson<{ | ||||||
|         status: 200; |         status: 200; | ||||||
|         data: UserAdminResponseDto[]; |         data: UserAdminResponseDto[]; | ||||||
|     }>(`/admin/users${QS.query(QS.explode({ |     }>(`/admin/users${QS.query(QS.explode({ | ||||||
|  |         id, | ||||||
|         withDeleted |         withDeleted | ||||||
|     }))}`, { |     }))}`, { | ||||||
|         ...opts |         ...opts | ||||||
| @@ -1596,6 +1598,23 @@ export function restoreUserAdmin({ id }: { | |||||||
|         method: "POST" |         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 }: { | export function getAllAlbums({ assetId, shared }: { | ||||||
|     assetId?: string; |     assetId?: string; | ||||||
|     shared?: boolean; |     shared?: boolean; | ||||||
| @@ -3552,6 +3571,11 @@ export enum UserStatus { | |||||||
|     Removing = "removing", |     Removing = "removing", | ||||||
|     Deleted = "deleted" |     Deleted = "deleted" | ||||||
| } | } | ||||||
|  | export enum AssetVisibility { | ||||||
|  |     Archive = "archive", | ||||||
|  |     Timeline = "timeline", | ||||||
|  |     Hidden = "hidden" | ||||||
|  | } | ||||||
| export enum AlbumUserRole { | export enum AlbumUserRole { | ||||||
|     Editor = "editor", |     Editor = "editor", | ||||||
|     Viewer = "viewer" |     Viewer = "viewer" | ||||||
| @@ -3661,11 +3685,6 @@ export enum Permission { | |||||||
|     AdminUserUpdate = "admin.user.update", |     AdminUserUpdate = "admin.user.update", | ||||||
|     AdminUserDelete = "admin.user.delete" |     AdminUserDelete = "admin.user.delete" | ||||||
| } | } | ||||||
| export enum AssetVisibility { |  | ||||||
|     Archive = "archive", |  | ||||||
|     Timeline = "timeline", |  | ||||||
|     Hidden = "hidden" |  | ||||||
| } |  | ||||||
| export enum AssetMediaStatus { | export enum AssetMediaStatus { | ||||||
|     Created = "created", |     Created = "created", | ||||||
|     Replaced = "replaced", |     Replaced = "replaced", | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; | import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; | ||||||
| import { ApiTags } from '@nestjs/swagger'; | import { ApiTags } from '@nestjs/swagger'; | ||||||
|  | import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto'; | ||||||
| import { AuthDto } from 'src/dtos/auth.dto'; | import { AuthDto } from 'src/dtos/auth.dto'; | ||||||
| import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; | import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; | ||||||
| import { | import { | ||||||
| @@ -57,6 +58,16 @@ export class UserAdminController { | |||||||
|     return this.service.delete(auth, id, dto); |     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') |   @Get(':id/preferences') | ||||||
|   @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) |   @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) | ||||||
|   getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserPreferencesResponseDto> { |   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 { User, UserAdmin } from 'src/database'; | ||||||
| import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; | import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; | ||||||
| import { UserMetadataItem } from 'src/types'; | 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 { | export class UserUpdateMeDto { | ||||||
|   @Optional() |   @Optional() | ||||||
| @@ -67,6 +67,9 @@ export const mapUser = (entity: User | UserAdmin): UserResponseDto => { | |||||||
| export class UserAdminSearchDto { | export class UserAdminSearchDto { | ||||||
|   @ValidateBoolean({ optional: true }) |   @ValidateBoolean({ optional: true }) | ||||||
|   withDeleted?: boolean; |   withDeleted?: boolean; | ||||||
|  |  | ||||||
|  |   @ValidateUUID({ optional: true }) | ||||||
|  |   id?: string; | ||||||
| } | } | ||||||
|  |  | ||||||
| export class UserAdminCreateDto { | export class UserAdminCreateDto { | ||||||
|   | |||||||
| @@ -14,6 +14,7 @@ import { asUuid } from 'src/utils/database'; | |||||||
| type Upsert = Insertable<DbUserMetadata>; | type Upsert = Insertable<DbUserMetadata>; | ||||||
|  |  | ||||||
| export interface UserListFilter { | export interface UserListFilter { | ||||||
|  |   id?: string; | ||||||
|   withDeleted?: boolean; |   withDeleted?: boolean; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -141,12 +142,13 @@ export class UserRepository { | |||||||
|     { name: 'with deleted', params: [{ withDeleted: true }] }, |     { name: 'with deleted', params: [{ withDeleted: true }] }, | ||||||
|     { name: 'without deleted', params: [{ withDeleted: false }] }, |     { name: 'without deleted', params: [{ withDeleted: false }] }, | ||||||
|   ) |   ) | ||||||
|   getList({ withDeleted }: UserListFilter = {}) { |   getList({ id, withDeleted }: UserListFilter = {}) { | ||||||
|     return this.db |     return this.db | ||||||
|       .selectFrom('users') |       .selectFrom('users') | ||||||
|       .select(columns.userAdmin) |       .select(columns.userAdmin) | ||||||
|       .select(withMetadata) |       .select(withMetadata) | ||||||
|       .$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) |       .$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) | ||||||
|  |       .$if(!!id, (eb) => eb.where('users.id', '=', id!)) | ||||||
|       .orderBy('createdAt', 'desc') |       .orderBy('createdAt', 'desc') | ||||||
|       .execute(); |       .execute(); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; | import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; | ||||||
| import { SALT_ROUNDS } from 'src/constants'; | import { SALT_ROUNDS } from 'src/constants'; | ||||||
|  | import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto'; | ||||||
| import { AuthDto } from 'src/dtos/auth.dto'; | import { AuthDto } from 'src/dtos/auth.dto'; | ||||||
| import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; | import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; | ||||||
| import { | import { | ||||||
| @@ -18,7 +19,10 @@ import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/uti | |||||||
| @Injectable() | @Injectable() | ||||||
| export class UserAdminService extends BaseService { | export class UserAdminService extends BaseService { | ||||||
|   async search(auth: AuthDto, dto: UserAdminSearchDto): Promise<UserAdminResponseDto[]> { |   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)); |     return users.map((user) => mapUserAdmin(user)); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -109,6 +113,11 @@ export class UserAdminService extends BaseService { | |||||||
|     return mapUserAdmin(user); |     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> { |   async getPreferences(auth: AuthDto, id: string): Promise<UserPreferencesResponseDto> { | ||||||
|     await this.findOrFail(id, { withDeleted: true }); |     await this.findOrFail(id, { withDeleted: true }); | ||||||
|     const metadata = await this.userRepository.getMetadata(id); |     const metadata = await this.userRepository.getMetadata(id); | ||||||
|   | |||||||
| @@ -44,7 +44,7 @@ | |||||||
|   </ModalBody> |   </ModalBody> | ||||||
|  |  | ||||||
|   <ModalFooter> |   <ModalFooter> | ||||||
|     <div class="flex gap-3 w-full my-3"> |     <div class="flex gap-3 w-full"> | ||||||
|       {#if !hideCancelButton} |       {#if !hideCancelButton} | ||||||
|         <Button shape="round" color={cancelColor} fullWidth onclick={() => onClose(false)}> |         <Button shape="round" color={cancelColor} fullWidth onclick={() => onClose(false)}> | ||||||
|           {cancelText} |           {cancelText} | ||||||
|   | |||||||
| @@ -61,7 +61,7 @@ | |||||||
|       </Button> |       </Button> | ||||||
|       {#if $user.isAdmin} |       {#if $user.isAdmin} | ||||||
|         <Button |         <Button | ||||||
|           href={AppRoute.ADMIN_USER_MANAGEMENT} |           href={AppRoute.ADMIN_USERS} | ||||||
|           onclick={onClose} |           onclick={onClose} | ||||||
|           color="dark-gray" |           color="dark-gray" | ||||||
|           size="sm" |           size="sm" | ||||||
|   | |||||||
| @@ -8,7 +8,7 @@ | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <SideBarSection ariaLabel={$t('primary')}> | <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('jobs')} routeId={AppRoute.ADMIN_JOBS} icon={mdiSync} /> | ||||||
|   <SideBarLink title={$t('settings')} routeId={AppRoute.ADMIN_SETTINGS} icon={mdiCog} /> |   <SideBarLink title={$t('settings')} routeId={AppRoute.ADMIN_SETTINGS} icon={mdiCog} /> | ||||||
|   <SideBarLink title={$t('external_libraries')} routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} /> |   <SideBarLink title={$t('external_libraries')} routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} /> | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ export enum AssetAction { | |||||||
| } | } | ||||||
|  |  | ||||||
| export enum AppRoute { | export enum AppRoute { | ||||||
|   ADMIN_USER_MANAGEMENT = '/admin/user-management', |   ADMIN_USERS = '/admin/users', | ||||||
|   ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management', |   ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management', | ||||||
|   ADMIN_SETTINGS = '/admin/system-settings', |   ADMIN_SETTINGS = '/admin/system-settings', | ||||||
|   ADMIN_STATS = '/admin/server-status', |   ADMIN_STATS = '/admin/server-status', | ||||||
|   | |||||||
| @@ -1,40 +1,32 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|   import { AppRoute } from '$lib/constants'; |   import { AppRoute } from '$lib/constants'; | ||||||
|   import { modalManager } from '$lib/managers/modal-manager.svelte'; |  | ||||||
|   import { userInteraction } from '$lib/stores/user.svelte'; |   import { userInteraction } from '$lib/stores/user.svelte'; | ||||||
|   import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; |   import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; | ||||||
|   import { handleError } from '$lib/utils/handle-error'; |   import { handleError } from '$lib/utils/handle-error'; | ||||||
|   import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; |   import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; | ||||||
|   import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; |   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'; |   import { t } from 'svelte-i18n'; | ||||||
|  |  | ||||||
|   interface Props { |   interface Props { | ||||||
|     user: UserAdminResponseDto; |     user: UserAdminResponseDto; | ||||||
|     canResetPassword?: boolean; |     onClose: (data?: UserAdminResponseDto) => void; | ||||||
|     onClose: ( |  | ||||||
|       data?: |  | ||||||
|         | { action: 'update'; data: UserAdminResponseDto } |  | ||||||
|         | { action: 'resetPassword'; data: string } |  | ||||||
|         | { action: 'resetPinCode' }, |  | ||||||
|     ) => 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 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( |   let quotaSizeWarning = $derived( | ||||||
|     previousQutoa !== convertToBytes(Number(quotaSize), ByteUnit.GiB) && |     previousQuota !== convertToBytes(Number(quotaSize), ByteUnit.GiB) && | ||||||
|       !!quotaSize && |       !!quotaSize && | ||||||
|       userInteraction.serverInfo && |       userInteraction.serverInfo && | ||||||
|       convertToBytes(Number(quotaSize), ByteUnit.GiB) > userInteraction.serverInfo.diskSizeRaw, |       convertToBytes(Number(quotaSize), ByteUnit.GiB) > userInteraction.serverInfo.diskSizeRaw, | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   const editUser = async () => { |   const handleEditUser = async () => { | ||||||
|     try { |     try { | ||||||
|       const { id, email, name, storageLabel } = user; |       const { id, email, name, storageLabel } = user; | ||||||
|       const newUser = await updateUserAdmin({ |       const newUser = await updateUserAdmin({ | ||||||
| @@ -47,76 +39,15 @@ | |||||||
|         }, |         }, | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       onClose({ action: 'update', data: newUser }); |       onClose(newUser); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       handleError(error, $t('errors.unable_to_update_user')); |       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) => { |   const onSubmit = async (event: Event) => { | ||||||
|     event.preventDefault(); |     event.preventDefault(); | ||||||
|     await editUser(); |     await handleEditUser(); | ||||||
|   }; |   }; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| @@ -172,34 +103,11 @@ | |||||||
|   </ModalBody> |   </ModalBody> | ||||||
|  |  | ||||||
|   <ModalFooter> |   <ModalFooter> | ||||||
|     <div class="w-full"> |     <div class="flex gap-3 w-full"> | ||||||
|       <div class="flex gap-3 w-full"> |       <Button shape="round" color="secondary" fullWidth form="edit-user-form" onclick={() => onClose()} | ||||||
|         {#if canResetPassword} |         >{$t('cancel')}</Button | ||||||
|           <Button |       > | ||||||
|             shape="round" |       <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button> | ||||||
|             color="warning" |  | ||||||
|             variant="filled" |  | ||||||
|             fullWidth |  | ||||||
|             onclick={resetPassword} |  | ||||||
|             leadingIcon={mdiOnepassword} |  | ||||||
|           > |  | ||||||
|             {$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> |     </div> | ||||||
|   </ModalFooter> |   </ModalFooter> | ||||||
| </Modal> | </Modal> | ||||||
|   | |||||||
| @@ -3,5 +3,5 @@ import { redirect } from '@sveltejs/kit'; | |||||||
| import type { PageLoad } from './$types'; | import type { PageLoad } from './$types'; | ||||||
|  |  | ||||||
| export const load = (() => { | export const load = (() => { | ||||||
|   redirect(302, AppRoute.ADMIN_USER_MANAGEMENT); |   redirect(302, AppRoute.ADMIN_USERS); | ||||||
| }) satisfies PageLoad; | }) satisfies PageLoad; | ||||||
|   | |||||||
| @@ -1,18 +1,5 @@ | |||||||
| import { authenticate, requestServerInfo } from '$lib/utils/auth'; | import { AppRoute } from '$lib/constants'; | ||||||
| import { getFormatter } from '$lib/utils/i18n'; | import { redirect } from '@sveltejs/kit'; | ||||||
| import { searchUsersAdmin } from '@immich/sdk'; |  | ||||||
| import type { PageLoad } from './$types'; | import type { PageLoad } from './$types'; | ||||||
|  |  | ||||||
| export const load = (async () => { | export const load = (() => redirect(307, AppRoute.ADMIN_USERS)) satisfies PageLoad; | ||||||
|   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; |  | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ | |||||||
|     NotificationType, |     NotificationType, | ||||||
|     notificationController, |     notificationController, | ||||||
|   } from '$lib/components/shared-components/notification/notification'; |   } 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 { modalManager } from '$lib/managers/modal-manager.svelte'; | ||||||
|   import UserCreateModal from '$lib/modals/UserCreateModal.svelte'; |   import UserCreateModal from '$lib/modals/UserCreateModal.svelte'; | ||||||
|   import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte'; |   import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte'; | ||||||
| @@ -18,7 +18,7 @@ | |||||||
|   import { websocketEvents } from '$lib/stores/websocket'; |   import { websocketEvents } from '$lib/stores/websocket'; | ||||||
|   import { getByteUnitString } from '$lib/utils/byte-units'; |   import { getByteUnitString } from '$lib/utils/byte-units'; | ||||||
|   import { UserStatus, searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk'; |   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 { mdiDeleteRestore, mdiInfinity, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; | ||||||
|   import { DateTime } from 'luxon'; |   import { DateTime } from 'luxon'; | ||||||
|   import { onMount } from 'svelte'; |   import { onMount } from 'svelte'; | ||||||
| @@ -64,20 +64,9 @@ | |||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const handleEdit = async (dto: UserAdminResponseDto) => { |   const handleEdit = async (dto: UserAdminResponseDto) => { | ||||||
|     const result = await modalManager.show(UserEditModal, { user: dto, canResetPassword: dto.id !== $user.id }); |     const result = await modalManager.show(UserEditModal, { user: dto }); | ||||||
|     switch (result?.action) { |     if (result) { | ||||||
|       case 'resetPassword': { |       await refresh(); | ||||||
|         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; |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
| @@ -123,7 +112,7 @@ | |||||||
|                     : 'bg-immich-bg dark:bg-immich-dark-gray/50'}" |                     : '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" |                 <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 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"> |                 <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