diff --git a/i18n/en.json b/i18n/en.json index 2db9976fa6..fde78a34a3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -362,6 +362,7 @@ "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.", "user_delete_immediately": "{user}'s account and assets will be queued for permanent deletion immediately.", "user_delete_immediately_checkbox": "Queue user and assets for immediate deletion", + "user_details": "User Details", "user_management": "User Management", "user_password_has_been_reset": "The user's password has been reset:", "user_password_reset_description": "Please provide the temporary password to the user and inform them they will need to change the password at their next login.", @@ -1290,6 +1291,7 @@ "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", "notification_toggle_setting_description": "Enable email notifications", + "email_notifications": "Email notifications", "notifications": "Notifications", "notifications_setting_description": "Manage notifications", "oauth": "OAuth", @@ -1394,6 +1396,7 @@ "previous_or_next_photo": "Previous or next photo", "primary": "Primary", "privacy": "Privacy", + "profile": "Profile", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", @@ -1753,6 +1756,7 @@ "storage": "Storage space", "storage_label": "Storage label", "storage_usage": "{used} of {available} used", + "storage_quota": "Storage Quota", "submit": "Submit", "suggestions": "Suggestions", "sunrise_on_the_beach": "Sunrise on the beach", @@ -1857,6 +1861,7 @@ "upload_success": "Upload success, refresh the page to see new upload assets.", "upload_to_immich": "Upload to Immich ({count})", "uploading": "Uploading", + "id": "ID", "url": "URL", "usage": "Usage", "use_current_connection": "use current connection", @@ -1864,6 +1869,8 @@ "user": "User", "user_id": "User ID", "user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", + "created_at": "Created", + "updated_at": "Updated", "user_purchase_settings": "Purchase", "user_purchase_settings_description": "Manage your purchase", "user_role_set": "Set {user} as {role}", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index a141d465d1..9a3055911d 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -253,6 +253,7 @@ Class | Method | HTTP request | Description *UsersAdminApi* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} | *UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /admin/users/{id} | *UsersAdminApi* | [**getUserPreferencesAdmin**](doc//UsersAdminApi.md#getuserpreferencesadmin) | **GET** /admin/users/{id}/preferences | +*UsersAdminApi* | [**getUserStatisticsAdmin**](doc//UsersAdminApi.md#getuserstatisticsadmin) | **GET** /admin/users/{id}/statistics | *UsersAdminApi* | [**restoreUserAdmin**](doc//UsersAdminApi.md#restoreuseradmin) | **POST** /admin/users/{id}/restore | *UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users | *UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} | diff --git a/mobile/openapi/lib/api/users_admin_api.dart b/mobile/openapi/lib/api/users_admin_api.dart index b4508d7dcd..58263504ce 100644 --- a/mobile/openapi/lib/api/users_admin_api.dart +++ b/mobile/openapi/lib/api/users_admin_api.dart @@ -211,6 +211,76 @@ class UsersAdminApi { return null; } + /// Performs an HTTP 'GET /admin/users/{id}/statistics' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [bool] isFavorite: + /// + /// * [bool] isTrashed: + /// + /// * [AssetVisibility] visibility: + Future getUserStatisticsAdminWithHttpInfo(String id, { bool? isFavorite, bool? isTrashed, AssetVisibility? visibility, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/users/{id}/statistics' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (isFavorite != null) { + queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); + } + if (isTrashed != null) { + queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); + } + if (visibility != null) { + queryParams.addAll(_queryParams('', 'visibility', visibility)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [bool] isFavorite: + /// + /// * [bool] isTrashed: + /// + /// * [AssetVisibility] visibility: + Future getUserStatisticsAdmin(String id, { bool? isFavorite, bool? isTrashed, AssetVisibility? visibility, }) async { + final response = await getUserStatisticsAdminWithHttpInfo(id, isFavorite: isFavorite, isTrashed: isTrashed, visibility: visibility, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetStatsResponseDto',) as AssetStatsResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /admin/users/{id}/restore' operation and returns the [Response]. /// Parameters: /// @@ -262,8 +332,10 @@ class UsersAdminApi { /// Performs an HTTP 'GET /admin/users' operation and returns the [Response]. /// Parameters: /// + /// * [String] id: + /// /// * [bool] withDeleted: - Future searchUsersAdminWithHttpInfo({ bool? withDeleted, }) async { + Future searchUsersAdminWithHttpInfo({ String? id, bool? withDeleted, }) async { // ignore: prefer_const_declarations final apiPath = r'/admin/users'; @@ -274,6 +346,9 @@ class UsersAdminApi { final headerParams = {}; final formParams = {}; + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } if (withDeleted != null) { queryParams.addAll(_queryParams('', 'withDeleted', withDeleted)); } @@ -294,9 +369,11 @@ class UsersAdminApi { /// Parameters: /// + /// * [String] id: + /// /// * [bool] withDeleted: - Future?> searchUsersAdmin({ bool? withDeleted, }) async { - final response = await searchUsersAdminWithHttpInfo( withDeleted: withDeleted, ); + Future?> searchUsersAdmin({ String? id, bool? withDeleted, }) async { + final response = await searchUsersAdminWithHttpInfo( id: id, withDeleted: withDeleted, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index a98750edaa..3c0dc09953 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -345,6 +345,15 @@ "get": { "operationId": "searchUsersAdmin", "parameters": [ + { + "name": "id", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "withDeleted", "required": false, @@ -701,6 +710,72 @@ ] } }, + "/admin/users/{id}/statistics": { + "get": { + "operationId": "getUserStatisticsAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "visibility", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetVisibility" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetStatsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Users (admin)" + ] + } + }, "/albums": { "get": { "operationId": "getAllAlbums", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 41898e12da..144e7f8ac1 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -224,6 +224,11 @@ export type UserPreferencesUpdateDto = { sharedLinks?: SharedLinksUpdate; tags?: TagsUpdate; }; +export type AssetStatsResponseDto = { + images: number; + total: number; + videos: number; +}; export type AlbumUserResponseDto = { role: AlbumUserRole; user: UserResponseDto; @@ -462,11 +467,6 @@ export type AssetJobsDto = { assetIds: string[]; name: AssetJobName; }; -export type AssetStatsResponseDto = { - images: number; - total: number; - videos: number; -}; export type UpdateAssetDto = { dateTimeOriginal?: string; description?: string; @@ -1502,13 +1502,15 @@ export function sendTestEmailAdmin({ systemConfigSmtpDto }: { body: systemConfigSmtpDto }))); } -export function searchUsersAdmin({ withDeleted }: { +export function searchUsersAdmin({ id, withDeleted }: { + id?: string; withDeleted?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: UserAdminResponseDto[]; }>(`/admin/users${QS.query(QS.explode({ + id, withDeleted }))}`, { ...opts @@ -1596,6 +1598,23 @@ export function restoreUserAdmin({ id }: { method: "POST" })); } +export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }: { + id: string; + isFavorite?: boolean; + isTrashed?: boolean; + visibility?: AssetVisibility; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetStatsResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}/statistics${QS.query(QS.explode({ + isFavorite, + isTrashed, + visibility + }))}`, { + ...opts + })); +} export function getAllAlbums({ assetId, shared }: { assetId?: string; shared?: boolean; @@ -3552,6 +3571,11 @@ export enum UserStatus { Removing = "removing", Deleted = "deleted" } +export enum AssetVisibility { + Archive = "archive", + Timeline = "timeline", + Hidden = "hidden" +} export enum AlbumUserRole { Editor = "editor", Viewer = "viewer" @@ -3661,11 +3685,6 @@ export enum Permission { AdminUserUpdate = "admin.user.update", AdminUserDelete = "admin.user.delete" } -export enum AssetVisibility { - Archive = "archive", - Timeline = "timeline", - Hidden = "hidden" -} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index 4dfeae949a..83d7caef08 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { @@ -57,6 +58,16 @@ export class UserAdminController { return this.service.delete(auth, id, dto); } + @Get(':id/statistics') + @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) + getUserStatisticsAdmin( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Query() dto: AssetStatsDto, + ): Promise { + return this.service.getStatistics(auth, id, dto); + } + @Get(':id/preferences') @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 9efb531bc7..9d43e53f89 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -4,7 +4,7 @@ import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from import { User, UserAdmin } from 'src/database'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserMetadataItem } from 'src/types'; -import { Optional, PinCode, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; +import { Optional, PinCode, ValidateBoolean, ValidateUUID, toEmail, toSanitized } from 'src/validation'; export class UserUpdateMeDto { @Optional() @@ -67,6 +67,9 @@ export const mapUser = (entity: User | UserAdmin): UserResponseDto => { export class UserAdminSearchDto { @ValidateBoolean({ optional: true }) withDeleted?: boolean; + + @ValidateUUID({ optional: true }) + id?: string; } export class UserAdminCreateDto { diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index f8710746aa..6972479df6 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -14,6 +14,7 @@ import { asUuid } from 'src/utils/database'; type Upsert = Insertable; export interface UserListFilter { + id?: string; withDeleted?: boolean; } @@ -141,12 +142,13 @@ export class UserRepository { { name: 'with deleted', params: [{ withDeleted: true }] }, { name: 'without deleted', params: [{ withDeleted: false }] }, ) - getList({ withDeleted }: UserListFilter = {}) { + getList({ id, withDeleted }: UserListFilter = {}) { return this.db .selectFrom('users') .select(columns.userAdmin) .select(withMetadata) .$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) + .$if(!!id, (eb) => eb.where('users.id', '=', id!)) .orderBy('createdAt', 'desc') .execute(); } diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 38c0106f4b..d1fe5ce67e 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { SALT_ROUNDS } from 'src/constants'; +import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { @@ -18,7 +19,10 @@ import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/uti @Injectable() export class UserAdminService extends BaseService { async search(auth: AuthDto, dto: UserAdminSearchDto): Promise { - const users = await this.userRepository.getList({ withDeleted: dto.withDeleted }); + const users = await this.userRepository.getList({ + id: dto.id, + withDeleted: dto.withDeleted, + }); return users.map((user) => mapUserAdmin(user)); } @@ -109,6 +113,11 @@ export class UserAdminService extends BaseService { return mapUserAdmin(user); } + async getStatistics(auth: AuthDto, id: string, dto: AssetStatsDto): Promise { + const stats = await this.assetRepository.getStatistics(auth.user.id, dto); + return mapStats(stats); + } + async getPreferences(auth: AuthDto, id: string): Promise { await this.findOrFail(id, { withDeleted: true }); const metadata = await this.userRepository.getMetadata(id); diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 2c59f59416..73dfb30908 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -3,6 +3,7 @@ notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; import { featureFlags } from '$lib/stores/server-config.store'; import { getJobName } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; @@ -20,10 +21,9 @@ mdiVideo, } from '@mdi/js'; import type { Component } from 'svelte'; + import { t } from 'svelte-i18n'; import JobTile from './job-tile.svelte'; import StorageMigrationDescription from './storage-migration-description.svelte'; - import { dialogController } from '$lib/components/shared-components/dialog/dialog'; - import { t } from 'svelte-i18n'; interface Props { jobs: AllJobStatusResponseDto; @@ -45,7 +45,7 @@ const handleConfirmCommand = async (jobId: JobName, dto: JobCommandDto) => { if (dto.force) { - const isConfirmed = await dialogController.show({ + const isConfirmed = await modalManager.showDialog({ prompt: $t('admin.confirm_reprocess_all_faces'), }); diff --git a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte index 3dcd3b4594..2f8d391954 100644 --- a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte +++ b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte @@ -103,9 +103,7 @@ class="block max-h-[320px] w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg" > {#each stats.usageByUser as user (user.userId)} - + {user.userName} {user.photos.toLocaleString($locale)} ({getByteUnitString(user.usagePhotos, $locale, 0)}) import FormatMessage from '$lib/components/i18n/format-message.svelte'; - import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { SettingInputFieldType } from '$lib/constants'; + import ConfirmModal from '$lib/modals/ConfirmModal.svelte'; import { OAuthTokenEndpointAuthMethod, type SystemConfigDto } from '@immich/sdk'; import { isEqual } from 'lodash-es'; import { t } from 'svelte-i18n'; @@ -47,7 +47,7 @@ {#if isConfirmOpen} - (confirmed ? handleSave(true) : (isConfirmOpen = false))} > @@ -70,7 +70,7 @@

{/snippet} -
+ {/if}
diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte index 4a8c018fbd..d63de9bdee 100644 --- a/web/src/lib/components/album-page/album-options.svelte +++ b/web/src/lib/components/album-page/album-options.svelte @@ -2,10 +2,10 @@ import Icon from '$lib/components/elements/icon.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; - import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; + import ConfirmModal from '$lib/modals/ConfirmModal.svelte'; import { handleError } from '$lib/utils/handle-error'; import { AlbumUserRole, @@ -191,7 +191,7 @@ {/if} {#if selectedRemoveUser} - - import { updateAlbumInfo } from '@immich/sdk'; - import { handleError } from '$lib/utils/handle-error'; import { shortcut } from '$lib/actions/shortcut'; + import { handleError } from '$lib/utils/handle-error'; + import { updateAlbumInfo } from '@immich/sdk'; import { t } from 'svelte-i18n'; interface Props { @@ -40,7 +40,7 @@ onblur={handleUpdateName} class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned ? 'hover:border-gray-400' - : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" + : 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" type="text" bind:value={newAlbumName} disabled={!isOwned} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 1f15e22d9e..f3688e780c 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -1,11 +1,18 @@ goto(`${AppRoute.ALBUMS}/${album.id}`)} {oncontextmenu} > diff --git a/web/src/lib/components/album-page/share-info-modal.svelte b/web/src/lib/components/album-page/share-info-modal.svelte index 97bbb81ea5..f6c3d4dae5 100644 --- a/web/src/lib/components/album-page/share-info-modal.svelte +++ b/web/src/lib/components/album-page/share-info-modal.svelte @@ -1,6 +1,13 @@
-
+
@@ -33,7 +33,7 @@
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index d15b371049..747dda91a6 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -51,7 +51,7 @@
@@ -83,7 +61,7 @@ {#if $user.isAdmin}
- -{#if isShowSelectAvatar} - (isShowSelectAvatar = false)} onChoose={handleSaveProfile} /> -{/if} diff --git a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte deleted file mode 100644 index ce441d553b..0000000000 --- a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - -
-
- {#each colors as color (color)} - - {/each} -
-
-
diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index bdd3d14d98..b5dfdb7fa2 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -55,7 +55,7 @@ >
@@ -506,7 +506,7 @@ {#if assetStore.scrolling && scrollHoverLabel && !isHover}

{scrollHoverLabel}

diff --git a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte index b8f02202a7..3607c06f2e 100644 --- a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte @@ -8,7 +8,7 @@ - + diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte index 8043023ef0..8f1a03ba67 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte @@ -35,7 +35,7 @@ id="sidebar" aria-label={ariaLabel} tabindex="-1" - class="immich-scrollbar relative z-auto w-0 sidebar:w-[16rem] overflow-y-auto overflow-x-hidden bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg" + class="immich-scrollbar relative z-auto w-0 sidebar:w-[16rem] overflow-y-auto overflow-x-hidden pt-8 transition-all duration-200" class:shadow-2xl={isExpanded} class:dark:border-e-immich-dark-gray={isExpanded} class:border-r={isExpanded} diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte index bd3b7856d1..451874ca8b 100644 --- a/web/src/lib/components/shared-components/upload-asset-preview.svelte +++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte @@ -40,11 +40,7 @@ }; -
+
{#if uploadAsset.state === UploadState.PENDING} diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte index 96870046df..a56f4cc316 100644 --- a/web/src/lib/components/user-settings-page/device-list.svelte +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -1,11 +1,11 @@ + + + +
+ {#each colors as color (color)} + + {/each} +
+
+
diff --git a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte b/web/src/lib/modals/ConfirmModal.svelte similarity index 74% rename from web/src/lib/components/shared-components/dialog/confirm-dialog.svelte rename to web/src/lib/modals/ConfirmModal.svelte index 3d6582d65f..5717fb770c 100644 --- a/web/src/lib/components/shared-components/dialog/confirm-dialog.svelte +++ b/web/src/lib/modals/ConfirmModal.svelte @@ -8,9 +8,6 @@ prompt?: string; confirmText?: string; confirmColor?: Color; - cancelText?: string; - cancelColor?: Color; - hideCancelButton?: boolean; disabled?: boolean; size?: 'small' | 'medium'; onClose: (confirmed: boolean) => void; @@ -22,9 +19,6 @@ prompt = $t('are_you_sure_to_do_this'), confirmText = $t('confirm'), confirmColor = 'danger', - cancelText = $t('cancel'), - cancelColor = 'secondary', - hideCancelButton = false, disabled = false, size = 'small', onClose, @@ -44,12 +38,10 @@ -
- {#if !hideCancelButton} - - {/if} +
+ diff --git a/web/src/lib/modals/JobCreateModal.svelte b/web/src/lib/modals/JobCreateModal.svelte index 6c173f918c..dbb97fdcf7 100644 --- a/web/src/lib/modals/JobCreateModal.svelte +++ b/web/src/lib/modals/JobCreateModal.svelte @@ -1,10 +1,10 @@ - {/snippet} - + diff --git a/web/src/lib/forms/password-reset-success.svelte b/web/src/lib/modals/PasswordResetSuccessModal.svelte similarity index 64% rename from web/src/lib/forms/password-reset-success.svelte rename to web/src/lib/modals/PasswordResetSuccessModal.svelte index 7091047eb8..74e035b93b 100644 --- a/web/src/lib/forms/password-reset-success.svelte +++ b/web/src/lib/modals/PasswordResetSuccessModal.svelte @@ -1,8 +1,7 @@ - onClose()} + size="small" + class="bg-light text-dark" > - {#snippet promptSnippet()} +
{$t('admin.user_password_has_been_reset')} @@ -39,5 +38,13 @@ {$t('admin.user_password_reset_description')}
- {/snippet} -
+ + + +
+ +
+
+ diff --git a/web/src/lib/modals/UserDeleteConfirmModal.svelte b/web/src/lib/modals/UserDeleteConfirmModal.svelte index 8bd7d35cd8..9439c23568 100644 --- a/web/src/lib/modals/UserDeleteConfirmModal.svelte +++ b/web/src/lib/modals/UserDeleteConfirmModal.svelte @@ -1,7 +1,7 @@ - (confirmed ? handleDeleteUser() : onClose())} @@ -98,4 +98,4 @@ {/if}
{/snippet} - + diff --git a/web/src/lib/modals/UserEditModal.svelte b/web/src/lib/modals/UserEditModal.svelte index 5ad0a055c4..a54dd90590 100644 --- a/web/src/lib/modals/UserEditModal.svelte +++ b/web/src/lib/modals/UserEditModal.svelte @@ -1,40 +1,32 @@ @@ -172,34 +103,11 @@ -
-
- {#if canResetPassword} - - {/if} - - -
- -
- -
+
+ +
diff --git a/web/src/lib/modals/UserRestoreConfirmModal.svelte b/web/src/lib/modals/UserRestoreConfirmModal.svelte index 8f8b372c83..130164c80e 100644 --- a/web/src/lib/modals/UserRestoreConfirmModal.svelte +++ b/web/src/lib/modals/UserRestoreConfirmModal.svelte @@ -1,6 +1,6 @@ -

{/snippet} -
+ diff --git a/web/src/lib/utils/album-utils.ts b/web/src/lib/utils/album-utils.ts index a5b830774c..1a4097b78d 100644 --- a/web/src/lib/utils/album-utils.ts +++ b/web/src/lib/utils/album-utils.ts @@ -1,6 +1,6 @@ import { goto } from '$app/navigation'; -import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { AppRoute } from '$lib/constants'; +import { modalManager } from '$lib/managers/modal-manager.svelte'; import { AlbumFilter, AlbumGroupBy, @@ -213,7 +213,7 @@ export const confirmAlbumDelete = async (album: AlbumResponseDto) => { const description = $t('album_delete_confirmation_description'); const prompt = `${confirmation} ${description}`; - return dialogController.show({ prompt }); + return modalManager.showDialog({ prompt }); }; interface AlbumSortOption { diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 88840b382d..d84e3bda63 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -588,9 +588,7 @@ {/if} {/if} -
+
(viewMode = AlbumPageViewMode.SELECT_ASSETS)} - class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary" + class="mt-5 bg-subtle flex w-full place-items-center gap-6 rounded-md border px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:text-immich-dark-fg dark:hover:text-immich-dark-primary" > @@ -709,7 +707,7 @@
- +
diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2f916d732c..c921d6a7e9 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -42,7 +42,7 @@ }; -
+
{#if assetInteraction.selectionActive}
{ diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index dc03a2ae70..447712177e 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -291,10 +291,7 @@ {:else}
goto(previousRoute)} backIcon={mdiArrowLeft}> -
+
diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 7e6057696a..33e8f43cf5 100644 --- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -4,17 +4,17 @@ import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte'; - import ThemeButton from '$lib/components/shared-components/theme-button.svelte'; import PasswordField from '$lib/components/shared-components/password-field.svelte'; - import { user } from '$lib/stores/user.store'; - import { handleError } from '$lib/utils/handle-error'; - import { getMySharedLink, SharedLinkType } from '@immich/sdk'; - import type { PageData } from './$types'; - import { setSharedLink } from '$lib/utils'; - import { t } from 'svelte-i18n'; - import { navigate } from '$lib/utils/navigation'; + import ThemeButton from '$lib/components/shared-components/theme-button.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { user } from '$lib/stores/user.store'; + import { setSharedLink } from '$lib/utils'; + import { handleError } from '$lib/utils/handle-error'; + import { navigate } from '$lib/utils/navigation'; + import { getMySharedLink, SharedLinkType } from '@immich/sdk'; import { tick } from 'svelte'; + import { t } from 'svelte-i18n'; + import type { PageData } from './$types'; interface Props { data: PageData; @@ -70,7 +70,7 @@
{$t('password_required')}
diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte index 1c1e1cfbd4..7062966b71 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.svelte @@ -3,13 +3,13 @@ import { page } from '$app/state'; import GroupTab from '$lib/components/elements/group-tab.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; - import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; import SharedLinkCard from '$lib/components/sharedlinks-page/shared-link-card.svelte'; import { AppRoute } from '$lib/constants'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import { handleError } from '$lib/utils/handle-error'; import { getAllSharedLinks, removeSharedLink, SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk'; @@ -35,7 +35,7 @@ }); const handleDeleteLink = async (id: string) => { - const isConfirmed = await dialogController.show({ + const isConfirmed = await modalManager.showDialog({ title: $t('delete_shared_link'), prompt: $t('confirm_delete_shared_link'), confirmText: $t('delete'), diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index 8d33a2eb6e..52667abc94 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -4,7 +4,6 @@ import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; - import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import { notificationController, @@ -16,15 +15,16 @@ import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetStore } from '$lib/stores/assets-store.svelte'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk'; import { Button, HStack, Text } from '@immich/ui'; import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js'; + import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; - import { onDestroy } from 'svelte'; interface Props { data: PageData; @@ -116,11 +116,10 @@ return; } - const isConfirm = await dialogController.show({ + const isConfirm = await modalManager.showDialog({ title: $t('delete_tag'), prompt: $t('delete_tag_confirmation_prompt', { values: { tagName: tag.value } }), confirmText: $t('delete'), - cancelText: $t('cancel'), }); if (!isConfirm) { diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 209f75a302..3750b239d5 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -7,13 +7,13 @@ import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; - import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import { NotificationType, notificationController, } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetStore } from '$lib/stores/assets-store.svelte'; import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; @@ -43,10 +43,7 @@ const assetInteraction = new AssetInteraction(); const handleEmptyTrash = async () => { - const isConfirmed = await dialogController.show({ - prompt: $t('empty_trash_confirmation'), - }); - + const isConfirmed = await modalManager.showDialog({ prompt: $t('empty_trash_confirmation') }); if (!isConfirmed) { return; } @@ -64,10 +61,7 @@ }; const handleRestoreTrash = async () => { - const isConfirmed = await dialogController.show({ - prompt: $t('assets_restore_confirmation'), - }); - + const isConfirmed = await modalManager.showDialog({ prompt: $t('assets_restore_confirmation') }); if (!isConfirmed) { return; } diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 14b1420110..0bb581b885 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,7 +1,6 @@ + + + {#snippet buttons()} + + {#if canResetPassword} + + {/if} + + + + + + {/snippet} +
+ +
+
+ + {user.name} +
+
+
+ + + +
+
+
+ + +
+ + {$t('profile')} +
+
+ + +
+ {$t('name')} + {user.name} +
+
+ {$t('email')} + {user.email} +
+
+ {$t('created_at')} + {user.createdAt} +
+
+ {$t('updated_at')} + {user.updatedAt} +
+
+ {$t('id')} + {user.id} +
+
+
+
+
+ + +
+ + {$t('features')} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ + {$t('storage_quota')} +
+
+ +
+ {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} + + {$t('storage_usage', { + values: { + used: getByteUnitString(usedBytes, $locale, 3), + available: getByteUnitString(availableBytes, $locale, 3), + }, + })} + + {:else} + + + {$t('unlimited')} + + {/if} +
+ + {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} +
+

{$t('storage')}

+
+
+
+
+ {/if} +
+
+
+
+
+
diff --git a/web/src/routes/admin/users/[id]/+page.ts b/web/src/routes/admin/users/[id]/+page.ts new file mode 100644 index 0000000000..ddf3ddbef7 --- /dev/null +++ b/web/src/routes/admin/users/[id]/+page.ts @@ -0,0 +1,31 @@ +import { AppRoute } from '$lib/constants'; +import { authenticate, requestServerInfo } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getUserPreferencesAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params }) => { + await authenticate({ admin: true }); + await requestServerInfo(); + const [user] = await searchUsersAdmin({ id: params.id }).catch(() => []); + if (!user) { + redirect(302, AppRoute.ADMIN_USERS); + } + + const [userPreferences, userStatistics] = await Promise.all([ + getUserPreferencesAdmin({ id: user.id }), + getUserStatisticsAdmin({ id: user.id }), + ]); + + const $t = await getFormatter(); + + return { + user, + userPreferences, + userStatistics, + meta: { + title: $t('admin.user_details'), + }, + }; +}) satisfies PageLoad;