Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Rasmussen
7b37c64171 feat: new user route 2025-12-19 17:02:55 -05:00
36 changed files with 725 additions and 611 deletions

View File

@@ -37,13 +37,7 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
afterInit(websocketServer: Server) { afterInit(websocketServer: Server) {
this.logger.log('Initialized websocket server'); this.logger.log('Initialized websocket server');
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
websocketServer.on('AppRestart', (event: ArgsOf<'AppRestart'>, ack?: (ok: 'ok') => void) => {
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
ack?.('ok');
this.appRepository.exitApp();
});
} }
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) { clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {

View File

@@ -1,10 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { Server as SocketIO } from 'socket.io';
import { ExitCode } from 'src/enum'; import { ExitCode } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
@Injectable() @Injectable()
export class AppRepository { export class AppRepository {
@@ -22,26 +17,4 @@ export class AppRepository {
setCloseFn(fn: () => Promise<void>) { setCloseFn(fn: () => Promise<void>) {
this.closeFn = fn; this.closeFn = fn;
} }
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis({ ...redis, lazyConnect: true });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
server.adapter(createAdapter(pubClient, subClient));
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, async () => {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.some((response) => response !== 'ok')) {
throw new Error("One or more node(s) returned a non-'ok' response to our restart request!");
}
pubClient.disconnect();
subClient.disconnect();
});
}
} }

View File

@@ -89,7 +89,6 @@ describe(CliService.name, () => {
alreadyDisabled: true, alreadyDisabled: true,
}); });
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0); expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0); expect(mocks.event.emit).toHaveBeenCalledTimes(0);
}); });
@@ -100,7 +99,6 @@ describe(CliService.name, () => {
alreadyDisabled: false, alreadyDisabled: false,
}); });
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false, isMaintenanceMode: false,
}); });
@@ -116,7 +114,6 @@ describe(CliService.name, () => {
}), }),
); );
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0); expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0); expect(mocks.event.emit).toHaveBeenCalledTimes(0);
}); });
@@ -129,7 +126,6 @@ describe(CliService.name, () => {
}), }),
); );
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true, isMaintenanceMode: true,
secret: expect.stringMatching(/^\w{128}$/), secret: expect.stringMatching(/^\w{128}$/),

View File

@@ -5,7 +5,7 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance'; import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc'; import { getExternalDomain } from 'src/utils/misc';
@Injectable() @Injectable()
@@ -55,7 +55,8 @@ export class CliService extends BaseService {
const state = { isMaintenanceMode: false as const }; const state = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state); await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
await this.appRepository.sendOneShotAppRestart(state);
sendOneShotAppRestart(state);
return { return {
alreadyDisabled: false, alreadyDisabled: false,
@@ -88,7 +89,7 @@ export class CliService extends BaseService {
secret, secret,
}); });
await this.appRepository.sendOneShotAppRestart({ sendOneShotAppRestart({
isMaintenanceMode: true, isMaintenanceMode: true,
}); });

View File

@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types'; import { MaintenanceModeState } from 'src/types';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance'; import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
@@ -32,10 +31,7 @@ export class MaintenanceService extends BaseService {
} }
@OnEvent({ name: 'AppRestart', server: true }) @OnEvent({ name: 'AppRestart', server: true })
onRestart(event: ArgOf<'AppRestart'>, ack?: (ok: 'ok') => void): void { onRestart(): void {
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
ack?.('ok');
this.appRepository.exitApp(); this.appRepository.exitApp();
} }

View File

@@ -1,6 +1,55 @@
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose'; import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { Server as SocketIO } from 'socket.io';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
export function sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
/**
* Keep trying until we manage to stop Immich
*
* Sometimes there appear to be communication
* issues between to the other servers.
*
* This issue only occurs with this method.
*/
async function tryTerminate() {
while (true) {
try {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.length > 0) {
return;
}
} catch (error) {
console.error(error);
console.error('Encountered an error while telling Immich to stop.');
}
console.info(
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
);
await new Promise((r) => setTimeout(r, 1e3));
}
}
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, () => {
void tryTerminate().finally(() => {
pubClient.disconnect();
subClient.disconnect();
});
});
}
export async function createMaintenanceLoginUrl( export async function createMaintenanceLoginUrl(
baseUrl: string, baseUrl: string,

View File

@@ -1,3 +1,5 @@
export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/;
export enum AssetAction { export enum AssetAction {
ARCHIVE = 'archive', ARCHIVE = 'archive',
UNARCHIVE = 'unarchive', UNARCHIVE = 'unarchive',
@@ -20,7 +22,9 @@ export enum AssetAction {
export enum AppRoute { export enum AppRoute {
ADMIN_USERS = '/admin/users', ADMIN_USERS = '/admin/users',
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management', ADMIN_USERS_NEW = '/admin/users/new',
ADMIN_LIBRARIES = '/admin/library-management',
ADMIN_LIBRARIES_NEW = '/admin/library-management/new',
ADMIN_SETTINGS = '/admin/system-settings', ADMIN_SETTINGS = '/admin/system-settings',
ADMIN_STATS = '/admin/server-status', ADMIN_STATS = '/admin/server-status',
ADMIN_QUEUES = '/admin/queues', ADMIN_QUEUES = '/admin/queues',

View File

@@ -1,41 +0,0 @@
<script lang="ts">
import { handleRenameLibrary } from '$lib/services/library.service';
import type { LibraryResponseDto } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiRenameOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
library: LibraryResponseDto;
onClose: () => void;
};
let { library, onClose }: Props = $props();
let newName = $state(library.name);
const onsubmit = async () => {
const success = await handleRenameLibrary(library, newName);
if (success) {
onClose();
}
};
</script>
<Modal icon={mdiRenameOutline} title={$t('rename')} {onClose} size="small">
<ModalBody>
<form {onsubmit} autocomplete="off" id="rename-library-form">
<Field label={$t('name')}>
<Input bind:value={newName} />
</Field>
</form>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" fullWidth color="secondary" onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" fullWidth type="submit" form="rename-library-form">{$t('save')}</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -1,12 +1,10 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import LibraryCreateModal from '$lib/modals/LibraryCreateModal.svelte';
import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte'; import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte';
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte'; import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte'; import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte'; import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { import {
@@ -19,6 +17,7 @@ import {
updateLibrary, updateLibrary,
type CreateLibraryDto, type CreateLibraryDto,
type LibraryResponseDto, type LibraryResponseDto,
type UpdateLibraryDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js'; import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
@@ -38,7 +37,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
title: $t('create_library'), title: $t('create_library'),
type: $t('command'), type: $t('command'),
icon: mdiPlusBoxOutline, icon: mdiPlusBoxOutline,
onAction: () => handleShowLibraryCreateModal(), onAction: () => goto(AppRoute.ADMIN_LIBRARIES_NEW),
shortcuts: { shift: true, key: 'n' }, shortcuts: { shift: true, key: 'n' },
}; };
@@ -46,11 +45,11 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
}; };
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => { export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
const Rename: ActionItem = { const Edit: ActionItem = {
icon: mdiPencilOutline, icon: mdiPencilOutline,
type: $t('command'), type: $t('command'),
title: $t('rename'), title: $t('edit'),
onAction: () => modalManager.show(LibraryRenameModal, { library }), onAction: () => goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}/edit`),
shortcuts: { key: 'r' }, shortcuts: { key: 'r' },
}; };
@@ -85,7 +84,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
shortcuts: { shift: true, key: 'r' }, shortcuts: { shift: true, key: 'r' },
}; };
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan }; return { Edit, Delete, AddFolder, AddExclusionPattern, Scan };
}; };
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => { export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
@@ -150,7 +149,7 @@ const handleScanLibrary = async (library: LibraryResponseDto) => {
}; };
export const handleViewLibrary = async (library: LibraryResponseDto) => { export const handleViewLibrary = async (library: LibraryResponseDto) => {
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`); await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
}; };
export const handleCreateLibrary = async (dto: CreateLibraryDto) => { export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
@@ -160,33 +159,24 @@ export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
const library = await createLibrary({ createLibraryDto: dto }); const library = await createLibrary({ createLibraryDto: dto });
eventManager.emit('LibraryCreate', library); eventManager.emit('LibraryCreate', library);
toastManager.success($t('admin.library_created', { values: { library: library.name } })); toastManager.success($t('admin.library_created', { values: { library: library.name } }));
return true; return library;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_create_library')); handleError(error, $t('errors.unable_to_create_library'));
return false;
} }
}; };
export const handleRenameLibrary = async (library: { id: string }, name?: string) => { export const handleUpdateLibrary = async (library: LibraryResponseDto, dto: UpdateLibraryDto) => {
const $t = await getFormatter(); const $t = await getFormatter();
if (!name) {
return false;
}
try { try {
const updatedLibrary = await updateLibrary({ const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto });
id: library.id,
updateLibraryDto: { name },
});
eventManager.emit('LibraryUpdate', updatedLibrary); eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated')); toastManager.success($t('admin.library_updated'));
return true;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_update_library')); handleError(error, $t('errors.unable_to_update_library'));
return false; return false;
} }
return true;
}; };
const handleDeleteLibrary = async (library: LibraryResponseDto) => { const handleDeleteLibrary = async (library: LibraryResponseDto) => {
@@ -357,7 +347,3 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
handleError(error, $t('errors.unable_to_update_library')); handleError(error, $t('errors.unable_to_update_library'));
} }
}; };
export const handleShowLibraryCreateModal = async () => {
await modalManager.show(LibraryCreateModal, {});
};

View File

@@ -1,10 +1,9 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte'; import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte'; import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
import UserEditModal from '$lib/modals/UserEditModal.svelte';
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte'; import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
import { user as authUser } from '$lib/stores/user.store'; import { user as authUser } from '$lib/stores/user.store';
import type { HeaderButtonActionItem } from '$lib/types'; import type { HeaderButtonActionItem } from '$lib/types';
@@ -39,7 +38,7 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
title: $t('create_user'), title: $t('create_user'),
type: $t('command'), type: $t('command'),
icon: mdiPlusBoxOutline, icon: mdiPlusBoxOutline,
onAction: () => modalManager.show(UserCreateModal, {}), onAction: () => goto(AppRoute.ADMIN_USERS_NEW),
shortcuts: { shift: true, key: 'n' }, shortcuts: { shift: true, key: 'n' },
}; };
@@ -50,7 +49,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
const Update: ActionItem = { const Update: ActionItem = {
icon: mdiPencilOutline, icon: mdiPencilOutline,
title: $t('edit'), title: $t('edit'),
onAction: () => modalManager.show(UserEditModal, { user }), onAction: () => goto(`${AppRoute.ADMIN_USERS}/${user.id}/edit`),
}; };
const Delete: ActionItem = { const Delete: ActionItem = {
@@ -103,7 +102,7 @@ export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
const response = await createUserAdmin({ userAdminCreateDto: dto }); const response = await createUserAdmin({ userAdminCreateDto: dto });
eventManager.emit('UserAdminCreate', response); eventManager.emit('UserAdminCreate', response);
toastManager.success(); toastManager.success();
return true; return response;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_create_user')); handleError(error, $t('errors.unable_to_create_user'));
} }

View File

@@ -9,7 +9,7 @@
<div class="h-full flex flex-col justify-between gap-2"> <div class="h-full flex flex-col justify-between gap-2">
<div class="flex flex-col pt-8 pe-4 gap-1"> <div class="flex flex-col pt-8 pe-4 gap-1">
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} /> <NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} /> <NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARIES} icon={mdiBookshelf} />
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} /> <NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} /> <NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} /> <NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />

View File

@@ -1,6 +1,7 @@
import { UUID_REGEX } from '$lib/constants';
import type { ParamMatcher } from '@sveltejs/kit'; import type { ParamMatcher } from '@sveltejs/kit';
/* Returns true if the given param matches UUID format */ /* Returns true if the given param matches UUID format */
export const match: ParamMatcher = (param: string) => { export const match: ParamMatcher = (param: string) => {
return /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/.test(param); return UUID_REGEX.test(param);
}; };

View File

@@ -166,7 +166,7 @@
title: $t('external_libraries'), title: $t('external_libraries'),
description: $t('admin.external_libraries_page_description'), description: $t('admin.external_libraries_page_description'),
icon: mdiBookshelf, icon: mdiBookshelf,
onAction: () => goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT), onAction: () => goto(AppRoute.ADMIN_LIBRARIES),
}, },
{ {
title: $t('server_stats'), title: $t('server_stats'),

View File

@@ -4,27 +4,29 @@
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { getLibrariesActions, handleShowLibraryCreateModal, handleViewLibrary } from '$lib/services/library.service'; import { getLibrariesActions, handleViewLibrary } from '$lib/services/library.service';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units'; import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk'; import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { Button, CommandPaletteContext } from '@immich/ui'; import { Button, CommandPaletteContext } from '@immich/ui';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { PageData } from './$types'; import type { LayoutData } from './$types';
type Props = { type Props = {
data: PageData; children?: Snippet;
data: LayoutData;
}; };
let { data }: Props = $props(); let { children, data }: Props = $props();
let libraries = $state(data.libraries); let libraries = $state(data.libraries);
let statistics = $state(data.statistics); let statistics = $state(data.statistics);
let owners = $state(data.owners); let owners = $state(data.owners);
const onLibraryCreate = async (library: LibraryResponseDto) => { const onLibraryCreate = async (library: LibraryResponseDto) => {
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`); await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
}; };
const onLibraryUpdate = async (library: LibraryResponseDto) => { const onLibraryUpdate = async (library: LibraryResponseDto) => {
@@ -100,10 +102,12 @@
{:else} {:else}
<EmptyPlaceholder <EmptyPlaceholder
text={$t('no_libraries_message')} text={$t('no_libraries_message')}
onClick={handleShowLibraryCreateModal} onClick={() => goto(AppRoute.ADMIN_LIBRARIES_NEW)}
class="mt-10 mx-auto" class="mt-10 mx-auto"
/> />
{/if} {/if}
{@render children?.()}
</div> </div>
</section> </section>
</AdminPageLayout> </AdminPageLayout>

View File

@@ -1,7 +1,7 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth'; import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk'; import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types'; import type { LayoutLoad } from './$types';
export const load = (async ({ url }) => { export const load = (async ({ url }) => {
await authenticate(url, { admin: true }); await authenticate(url, { admin: true });
@@ -26,4 +26,4 @@ export const load = (async ({ url }) => {
title: $t('external_libraries'), title: $t('external_libraries'),
}, },
}; };
}) satisfies PageLoad; }) satisfies LayoutLoad;

View File

@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import { AppRoute } from '$lib/constants';
import { handleCreateLibrary } from '$lib/services/library.service'; import { handleCreateLibrary } from '$lib/services/library.service';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { searchUsersAdmin } from '@immich/sdk'; import { searchUsersAdmin } from '@immich/sdk';
@@ -8,12 +10,6 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = {
onClose: () => void;
};
let { onClose }: Props = $props();
let ownerId: string = $state($user.id); let ownerId: string = $state($user.id);
let userOptions: { value: string; text: string }[] = $state([]); let userOptions: { value: string; text: string }[] = $state([]);
@@ -23,10 +19,14 @@
userOptions = users.map((user) => ({ value: user.id, text: user.name })); userOptions = users.map((user) => ({ value: user.id, text: user.name }));
}); });
const onClose = async () => {
await goto(AppRoute.ADMIN_LIBRARIES);
};
const onSubmit = async () => { const onSubmit = async () => {
const success = await handleCreateLibrary({ ownerId }); const library = await handleCreateLibrary({ ownerId });
if (success) { if (library) {
onClose(); await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`, { replaceState: true });
} }
}; };
</script> </script>

View File

@@ -0,0 +1,14 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
return {
meta: {
title: $t('external_libraries'),
},
};
}) satisfies PageLoad;

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import { goto } from '$app/navigation';
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { AppRoute } from '$lib/constants';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import {
getLibraryActions,
getLibraryExclusionPatternActions,
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { LibraryResponseDto } from '@immich/sdk';
import { Code, CommandPaletteContext, Container, Heading, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { LayoutData } from './$types';
type Props = {
children?: Snippet;
data: LayoutData;
};
const { children, data }: Props = $props();
const statistics = data.statistics;
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
let library = $state(data.library);
const onLibraryUpdate = (newLibrary: LibraryResponseDto) => {
if (newLibrary.id === library.id) {
library = newLibrary;
}
};
const onLibraryDelete = async ({ id }: { id: string }) => {
if (id === library.id) {
await goto(AppRoute.ADMIN_LIBRARIES);
}
};
const { Edit, Delete, AddFolder, AddExclusionPattern, Scan } = $derived(getLibraryActions($t, library));
</script>
<OnEvents {onLibraryUpdate} {onLibraryDelete} />
<CommandPaletteContext commands={[Edit, Delete, AddFolder, AddExclusionPattern, Scan]} />
<AdminPageLayout
breadcrumbs={[{ title: $t('external_libraries'), href: AppRoute.ADMIN_LIBRARIES }, { title: library.name }]}
actions={[Scan, Edit, Delete]}
>
<Container size="large" center>
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics.photos} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
</div>
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
{#if library.importPaths.length === 0}
<EmptyPlaceholder
src={emptyFoldersUrl}
text={$t('admin.library_folder_description')}
fullWidth
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
/>
{:else}
<table class="w-full">
<tbody>
{#each library.importPaths as folder (folder)}
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
<tr class="h-12">
<td>
<Code>{folder}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</AdminCard>
<AdminCard icon={mdiFilterMinusOutline} title={$t('exclusion_pattern')} headerAction={AddExclusionPattern}>
<table class="w-full">
<tbody>
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
<tr class="h-12">
<td>
<Code>{exclusionPattern}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
</AdminCard>
</div>
{@render children?.()}
</Container>
</AdminPageLayout>

View File

@@ -0,0 +1,30 @@
import { AppRoute } from '$lib/constants';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { LayoutLoad } from './$types';
export const load = (async ({ params: { id }, url }) => {
await authenticate(url, { admin: true });
let library: LibraryResponseDto;
try {
library = await getLibrary({ id });
console.log(`Fetched latest library: ${library.name}`);
} catch {
redirect(302, AppRoute.ADMIN_LIBRARIES);
}
const statistics = await getLibraryStatistics({ id });
const $t = await getFormatter();
return {
library,
statistics,
meta: {
title: $t('admin.library_details'),
},
};
}) satisfies LayoutLoad;

View File

@@ -1,105 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { AppRoute } from '$lib/constants';
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
import {
getLibraryActions,
getLibraryExclusionPatternActions,
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { Code, CommandPaletteContext, Container, Heading, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
const statistics = data.statistics;
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
let library = $derived(data.library);
const { Rename, Delete, AddFolder, AddExclusionPattern, Scan } = $derived(getLibraryActions($t, library));
</script>
<OnEvents
onLibraryUpdate={(newLibrary) => (library = newLibrary)}
onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)}
/>
<CommandPaletteContext commands={[Rename, Delete, AddFolder, AddExclusionPattern, Scan]} />
<AdminPageLayout
breadcrumbs={[{ title: $t('external_libraries'), href: AppRoute.ADMIN_LIBRARY_MANAGEMENT }, { title: library.name }]}
actions={[Scan, Rename, Delete]}
>
<Container size="large" center>
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics.photos} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
</div>
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
{#if library.importPaths.length === 0}
<EmptyPlaceholder
src={emptyFoldersUrl}
text={$t('admin.library_folder_description')}
fullWidth
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
/>
{:else}
<table class="w-full">
<tbody>
{#each library.importPaths as folder (folder)}
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
<tr class="h-12">
<td>
<Code>{folder}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</AdminCard>
<AdminCard icon={mdiFilterMinusOutline} title={$t('exclusion_pattern')} headerAction={AddExclusionPattern}>
<table class="w-full">
<tbody>
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
<tr class="h-12">
<td>
<Code>{exclusionPattern}</Code>
</td>
<td class="flex gap-2 justify-end">
<TableButton action={Edit} />
<TableButton action={Delete} />
</td>
</tr>
{/each}
</tbody>
</table>
</AdminCard>
</div>
</Container>
</AdminPageLayout>

View File

@@ -1,26 +1,13 @@
import { AppRoute } from '$lib/constants';
import { authenticate } from '$lib/utils/auth'; import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async ({ params: { id }, url }) => { export const load = (async ({ url }) => {
await authenticate(url, { admin: true }); await authenticate(url, { admin: true });
let library: LibraryResponseDto;
try {
library = await getLibrary({ id });
} catch {
redirect(302, AppRoute.ADMIN_LIBRARY_MANAGEMENT);
}
const statistics = await getLibraryStatistics({ id });
const $t = await getFormatter(); const $t = await getFormatter();
return { return {
library,
statistics,
meta: { meta: {
title: $t('admin.library_details'), title: $t('admin.library_details'),
}, },

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { handleUpdateLibrary } from '$lib/services/library.service';
import { Field, FormModal, Input } from '@immich/ui';
import { mdiRenameOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from '../$types';
type Props = {
data: PageData;
};
let { data }: Props = $props();
const library = $derived(data.library);
let name = $state(library.name);
const onClose = async () => {
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
};
const onSubmit = async () => {
const success = await handleUpdateLibrary(library, { name });
if (success) {
await onClose();
}
};
</script>
<FormModal icon={mdiRenameOutline} title={$t('edit')} {onSubmit} {onClose} size="small">
<Field label={$t('name')}>
<Input bind:value={name} />
</Field>
</FormModal>

View File

@@ -0,0 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
return {
meta: {
title: $t('admin.library_details'),
},
};
}) satisfies PageLoad;

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, CommandPaletteContext, Container, Icon } from '@immich/ui';
import { mdiInfinity } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { LayoutData } from './$types';
type Props = {
children?: Snippet;
data: LayoutData;
};
let { children, data }: Props = $props();
let users: UserAdminResponseDto[] = $state(data.users);
const onUpdate = async (user: UserAdminResponseDto) => {
const index = users.findIndex(({ id }) => id === user.id);
if (index === -1) {
users = await searchUsersAdmin({ withDeleted: true });
} else {
users[index] = user;
}
};
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
users = users.filter(({ id }) => id !== userId);
};
const { Create } = $derived(getUserAdminsActions($t));
</script>
<OnEvents
onUserAdminCreate={onUpdate}
onUserAdminUpdate={onUpdate}
onUserAdminDelete={onUpdate}
onUserAdminRestore={onUpdate}
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[Create]} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
<Container center size="large">
<table class="my-5 w-full text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium">{$t('email')}</th>
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each users as user (user.id)}
<tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
? 'bg-red-300 dark:bg-red-900'
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
>
<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">
{user.email}
</td>
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
<div class="container mx-auto flex flex-wrap justify-center">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
{getByteUnitString(user.quotaSizeInBytes, $locale)}
{:else}
<Icon icon={mdiInfinity} size="16" />
{/if}
</div>
</td>
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
</td>
</tr>
{/each}
</tbody>
</table>
{@render children?.()}
</Container>
</AdminPageLayout>

View File

@@ -1,18 +1,18 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth'; import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { searchUsersAdmin } from '@immich/sdk'; import { searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types'; import type { LayoutLoad } from './$types';
export const load = (async ({ url }) => { export const load = (async ({ url }) => {
await authenticate(url, { admin: true }); await authenticate(url, { admin: true });
await requestServerInfo(); await requestServerInfo();
const allUsers = await searchUsersAdmin({ withDeleted: true }); const users = await searchUsersAdmin({ withDeleted: true });
const $t = await getFormatter(); const $t = await getFormatter();
return { return {
allUsers, users,
meta: { meta: {
title: $t('admin.user_management'), title: $t('admin.user_management'),
}, },
}; };
}) satisfies PageLoad; }) satisfies LayoutLoad;

View File

@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleCreateUserAdmin } from '$lib/services/user-admin.service'; import { handleCreateUserAdmin } from '$lib/services/user-admin.service';
import { userInteraction } from '$lib/stores/user.svelte'; import { userInteraction } from '$lib/stores/user.svelte';
@@ -18,12 +20,6 @@
} from '@immich/ui'; } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = {
onClose: () => void;
};
let { onClose }: Props = $props();
let success = $state(false); let success = $state(false);
let email = $state(''); let email = $state('');
@@ -46,6 +42,10 @@
const passwordMismatchMessage = $derived(passwordMismatch ? $t('password_does_not_match') : ''); const passwordMismatchMessage = $derived(passwordMismatch ? $t('password_does_not_match') : '');
const valid = $derived(!passwordMismatch && !isCreatingUser); const valid = $derived(!passwordMismatch && !isCreatingUser);
const onClose = async () => {
await goto(AppRoute.ADMIN_USERS);
};
const onSubmit = async (event: Event) => { const onSubmit = async (event: Event) => {
event.preventDefault(); event.preventDefault();
@@ -55,7 +55,7 @@
isCreatingUser = true; isCreatingUser = true;
const success = await handleCreateUserAdmin({ const user = await handleCreateUserAdmin({
email, email,
password, password,
shouldChangePassword, shouldChangePassword,
@@ -65,8 +65,8 @@
isAdmin, isAdmin,
}); });
if (success) { if (user) {
onClose(); await goto(`${AppRoute.ADMIN_USERS}/${user.id}`, { replaceState: true });
} }
isCreatingUser = false; isCreatingUser = false;

View File

@@ -0,0 +1,14 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
return {
meta: {
title: $t('admin.user_management'),
},
};
}) satisfies PageLoad;

View File

@@ -1,93 +0,0 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, CommandPaletteContext, Icon } from '@immich/ui';
import { mdiInfinity } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
let { data }: Props = $props();
let allUsers: UserAdminResponseDto[] = $state(data.allUsers);
const onUpdate = async (user: UserAdminResponseDto) => {
const index = allUsers.findIndex(({ id }) => id === user.id);
if (index === -1) {
allUsers = await searchUsersAdmin({ withDeleted: true });
} else {
allUsers[index] = user;
}
};
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
allUsers = allUsers.filter(({ id }) => id !== userId);
};
const { Create } = $derived(getUserAdminsActions($t));
</script>
<OnEvents
onUserAdminCreate={onUpdate}
onUserAdminUpdate={onUpdate}
onUserAdminDelete={onUpdate}
onUserAdminRestore={onUpdate}
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[Create]} />
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 lg:w-212.5">
<table class="my-5 w-full text-start">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
>
<tr class="flex w-full place-items-center">
<th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium"
>{$t('email')}</th
>
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each allUsers as user (user.id)}
<tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
? 'bg-red-300 dark:bg-red-900'
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
>
<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">
{user.email}
</td>
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
<div class="container mx-auto flex flex-wrap justify-center">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
{getByteUnitString(user.quotaSizeInBytes, $locale)}
{:else}
<Icon icon={mdiInfinity} size="16" />
{/if}
</div>
</td>
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</section>
</section>
</AdminPageLayout>

View File

@@ -0,0 +1,222 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
import { AppRoute } from '$lib/constants';
import { getUserAdminActions } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { createDateFormatter, findLocale } from '$lib/utils';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { type UserAdminResponseDto } from '@immich/sdk';
import {
Alert,
Badge,
Code,
CommandPaletteContext,
Container,
getByteUnitString,
Heading,
Icon,
MenuItemType,
Stack,
Text,
} from '@immich/ui';
import {
mdiAccountOutline,
mdiCameraIris,
mdiChartPie,
mdiChartPieOutline,
mdiCheckCircle,
mdiDevices,
mdiFeatureSearchOutline,
mdiPlayCircle,
mdiTrashCanOutline,
} from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { LayoutData } from './$types';
type Props = {
children?: Snippet;
data: LayoutData;
};
const { children, data }: Props = $props();
let user = $state(data.user);
const userPreferences = $state(data.userPreferences);
const userStatistics = $state(data.userStatistics);
const userSessions = $state(data.userSessions);
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 editedLocale = $derived(findLocale($locale).code);
let createAtDate: Date = $derived(new Date(user.createdAt));
let updatedAtDate: Date = $derived(new Date(user.updatedAt));
let userCreatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(createAtDate));
let userUpdatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(updatedAtDate));
const getUsageClass = () => {
if (usedPercentage >= 95) {
return 'bg-red-500';
}
if (usedPercentage > 80) {
return 'bg-yellow-500';
}
return 'bg-primary';
};
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
const onUpdate = (update: UserAdminResponseDto) => {
if (update.id === user.id) {
user = update;
}
};
const onUserAdminDeleted = async ({ id }: { id: string }) => {
if (id === user.id) {
await goto(AppRoute.ADMIN_USERS);
}
};
</script>
<OnEvents
onUserAdminUpdate={onUpdate}
onUserAdminDelete={onUpdate}
onUserAdminRestore={onUpdate}
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
<AdminPageLayout
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
actions={[ResetPassword, ResetPinCode, Update, Restore, MenuItemType.Divider, Delete]}
>
<div>
<Container size="large" center>
{#if user.deletedAt}
<Alert color="danger" class="my-4" title={$t('user_has_been_deleted')} icon={mdiTrashCanOutline} />
{/if}
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<div class="col-span-full flex flex-col gap-4 my-4">
<div class="flex items-center gap-4">
<UserAvatar {user} size="md" />
<Heading tag="h1" size="large">{user.name}</Heading>
</div>
{#if user.isAdmin}
<div>
<Badge color="primary" size="small">{$t('admin.admin_user')}</Badge>
</div>
{/if}
</div>
<div class="col-span-full">
<div class="flex flex-col lg:flex-row gap-4 w-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={userStatistics.images} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={userStatistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
</div>
</div>
<AdminCard icon={mdiAccountOutline} title={$t('profile')}>
<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>{userCreatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
<Text>{userUpdatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
<Code>{user.id}</Code>
</div>
</Stack>
</AdminCard>
<AdminCard icon={mdiFeatureSearchOutline} title={$t('features')}>
<Stack gap={3}>
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
</Stack>
</AdminCard>
<AdminCard icon={mdiChartPieOutline} title={$t('storage_quota')}>
{#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}
{#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}
</AdminCard>
<AdminCard icon={mdiDevices} title={$t('authorized_devices')}>
<Stack gap={3}>
{#each userSessions as session (session.id)}
<DeviceCard {session} />
{:else}
<span class="text-dark">{$t('no_devices')}</span>
{/each}
</Stack>
</AdminCard>
</div>
{@render children?.()}
</Container>
</div>
</AdminPageLayout>

View File

@@ -0,0 +1,38 @@
import { AppRoute, UUID_REGEX } from '$lib/constants';
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { LayoutLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url, { admin: true });
await requestServerInfo();
if (!UUID_REGEX.test(params.id)) {
redirect(302, AppRoute.ADMIN_USERS);
}
const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []);
if (!user) {
redirect(302, AppRoute.ADMIN_USERS);
}
const [userPreferences, userStatistics, userSessions] = await Promise.all([
getUserPreferencesAdmin({ id: user.id }),
getUserStatisticsAdmin({ id: user.id }),
getUserSessionsAdmin({ id: user.id }),
]);
const $t = await getFormatter();
return {
user,
userPreferences,
userStatistics,
userSessions,
meta: {
title: $t('admin.user_details'),
},
};
}) satisfies LayoutLoad;

View File

@@ -1,218 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
import { AppRoute } from '$lib/constants';
import { getUserAdminActions } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { createDateFormatter, findLocale } from '$lib/utils';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { type UserAdminResponseDto } from '@immich/sdk';
import {
Alert,
Badge,
Code,
CommandPaletteContext,
Container,
getByteUnitString,
Heading,
Icon,
MenuItemType,
Stack,
Text,
} from '@immich/ui';
import {
mdiAccountOutline,
mdiCameraIris,
mdiChartPie,
mdiChartPieOutline,
mdiCheckCircle,
mdiDevices,
mdiFeatureSearchOutline,
mdiPlayCircle,
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
const { data }: Props = $props();
let user = $derived(data.user);
const userPreferences = $derived(data.userPreferences);
const userStatistics = $derived(data.userStatistics);
const userSessions = $derived(data.userSessions);
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 editedLocale = $derived(findLocale($locale).code);
let createAtDate: Date = $derived(new Date(user.createdAt));
let updatedAtDate: Date = $derived(new Date(user.updatedAt));
let userCreatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(createAtDate));
let userUpdatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(updatedAtDate));
const getUsageClass = () => {
if (usedPercentage >= 95) {
return 'bg-red-500';
}
if (usedPercentage > 80) {
return 'bg-yellow-500';
}
return 'bg-primary';
};
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
const onUpdate = (update: UserAdminResponseDto) => {
if (update.id === user.id) {
user = update;
}
};
const onUserAdminDeleted = async ({ id }: { id: string }) => {
if (id === user.id) {
await goto(AppRoute.ADMIN_USERS);
}
};
</script>
<OnEvents
onUserAdminUpdate={onUpdate}
onUserAdminDelete={onUpdate}
onUserAdminRestore={onUpdate}
{onUserAdminDeleted}
/>
<CommandPaletteContext commands={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
<AdminPageLayout
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
actions={[ResetPassword, ResetPinCode, Update, Restore, MenuItemType.Divider, Delete]}
>
<div>
<Container size="large" center>
{#if user.deletedAt}
<Alert color="danger" class="my-4" title={$t('user_has_been_deleted')} icon={mdiTrashCanOutline} />
{/if}
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<div class="col-span-full flex flex-col gap-4 my-4">
<div class="flex items-center gap-4">
<UserAvatar {user} size="md" />
<Heading tag="h1" size="large">{user.name}</Heading>
</div>
{#if user.isAdmin}
<div>
<Badge color="primary" size="small">{$t('admin.admin_user')}</Badge>
</div>
{/if}
</div>
<div class="col-span-full">
<div class="flex flex-col lg:flex-row gap-4 w-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={userStatistics.images} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={userStatistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
</div>
</div>
<AdminCard icon={mdiAccountOutline} title={$t('profile')}>
<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>{userCreatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
<Text>{userUpdatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
<Code>{user.id}</Code>
</div>
</Stack>
</AdminCard>
<AdminCard icon={mdiFeatureSearchOutline} title={$t('features')}>
<Stack gap={3}>
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
</Stack>
</AdminCard>
<AdminCard icon={mdiChartPieOutline} title={$t('storage_quota')}>
{#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}
{#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}
</AdminCard>
<AdminCard icon={mdiDevices} title={$t('authorized_devices')}>
<Stack gap={3}>
{#each userSessions as session (session.id)}
<DeviceCard {session} />
{:else}
<span class="text-dark">{$t('no_devices')}</span>
{/each}
</Stack>
</AdminCard>
</div>
</Container>
</div>
</AdminPageLayout>

View File

@@ -1,31 +1,12 @@
import { AppRoute } from '$lib/constants'; import { authenticate } from '$lib/utils/auth';
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n'; import { getFormatter } from '$lib/utils/i18n';
import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => { export const load = (async ({ url }) => {
await authenticate(url, { admin: true }); await authenticate(url, { admin: true });
await requestServerInfo();
const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []);
if (!user) {
redirect(302, AppRoute.ADMIN_USERS);
}
const [userPreferences, userStatistics, userSessions] = await Promise.all([
getUserPreferencesAdmin({ id: user.id }),
getUserStatisticsAdmin({ id: user.id }),
getUserSessionsAdmin({ id: user.id }),
]);
const $t = await getFormatter(); const $t = await getFormatter();
return { return {
user,
userPreferences,
userStatistics,
userSessions,
meta: { meta: {
title: $t('admin.user_details'), title: $t('admin.user_details'),
}, },

View File

@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { handleUpdateUserAdmin } from '$lib/services/user-admin.service'; import { handleUpdateUserAdmin } from '$lib/services/user-admin.service';
import { user as authUser } from '$lib/stores/user.store'; import { user as authUser } from '$lib/stores/user.store';
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 { type UserAdminResponseDto } from '@immich/sdk'; import type { UserAdminResponseDto } from '@immich/sdk';
import { import {
Button, Button,
Field, Field,
@@ -20,22 +21,23 @@
} from '@immich/ui'; } from '@immich/ui';
import { mdiAccountEditOutline } from '@mdi/js'; import { mdiAccountEditOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props { type Props = {
user: UserAdminResponseDto; data: PageData;
onClose: () => void; };
}
let { user, onClose }: Props = $props(); let { data }: Props = $props();
const user = $derived(data.user as UserAdminResponseDto);
let isAdmin = $derived(user.isAdmin); let isAdmin = $derived(user.isAdmin);
let name = $derived(user.name); let name = $derived(user.name);
let email = $derived(user.email); let email = $derived(user.email);
let storageLabel = $derived(user.storageLabel || ''); let storageLabel = $derived(user.storageLabel || '');
const previousQuota = user.quotaSizeInBytes; const previousQuota = $derived(user.quotaSizeInBytes);
let quotaSize = $state( let quotaSize = $derived(
typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined, typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined,
); );
@@ -48,6 +50,10 @@
quotaSizeBytes > userInteraction.serverInfo.diskSizeRaw, quotaSizeBytes > userInteraction.serverInfo.diskSizeRaw,
); );
const onClose = async () => {
await goto(`${AppRoute.ADMIN_USERS}/${user.id}`);
};
const onSubmit = async (event: Event) => { const onSubmit = async (event: Event) => {
event.preventDefault(); event.preventDefault();
@@ -60,7 +66,7 @@
}); });
if (success) { if (success) {
onClose(); await onClose();
} }
}; };
</script> </script>

View File

@@ -0,0 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const $t = await getFormatter();
return {
meta: {
title: $t('admin.user_details'),
},
};
}) satisfies PageLoad;