refactor: api key service (#24779)

This commit is contained in:
Jason Rasmussen
2025-12-22 11:09:11 -05:00
committed by GitHub
parent c7510d572a
commit 40e750e8be
6 changed files with 186 additions and 154 deletions

View File

@@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import { IconButton, type ActionItem } from '@immich/ui'; import { IconButton, type ActionItem, type Size } from '@immich/ui';
type Props = { type Props = {
action: ActionItem; action: ActionItem;
size?: Size;
}; };
const { action }: Props = $props(); const { action, size }: Props = $props();
const { title, icon, onAction } = $derived(action); const { title, icon, onAction } = $derived(action);
</script> </script>
{#if action.$if?.() ?? true} {#if action.$if?.() ?? true}
<IconButton shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} /> <IconButton {size} shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
{/if} {/if}

View File

@@ -1,68 +1,47 @@
<script lang="ts"> <script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { dateFormats } from '$lib/constants'; import { dateFormats } from '$lib/constants';
import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte'; import { getApiKeyActions, getApiKeysActions } from '$lib/services/api-key.service';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { handleError } from '$lib/utils/handle-error'; import { getApiKeys, type ApiKeyResponseDto } from '@immich/sdk';
import { deleteApiKey, getApiKeys, type ApiKeyResponseDto } from '@immich/sdk'; import { Button } from '@immich/ui';
import { Button, IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
interface Props { type Props = {
keys: ApiKeyResponseDto[]; keys: ApiKeyResponseDto[];
} };
let { keys = $bindable() }: Props = $props(); let { keys = $bindable() }: Props = $props();
async function refreshKeys() { const onApiKeyCreate = async () => {
keys = await getApiKeys(); keys = await getApiKeys();
}
const handleCreate = async () => {
const secret = await modalManager.show(ApiKeyCreateModal);
if (!secret) {
return;
}
await modalManager.show(ApiKeySecretModal, { secret });
await refreshKeys();
}; };
const handleUpdate = async (key: ApiKeyResponseDto) => { const onApiKeyUpdate = (update: ApiKeyResponseDto) => {
const success = await modalManager.show(ApiKeyUpdateModal, { for (const key of keys) {
apiKey: key, if (key.id === update.id) {
}); Object.assign(key, update);
}
if (success) {
await refreshKeys();
} }
}; };
const handleDelete = async (key: ApiKeyResponseDto) => { const onApiKeyDelete = ({ id }: ApiKeyResponseDto) => {
const isConfirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') }); keys = keys.filter((apiKey) => apiKey.id !== id);
if (!isConfirmed) {
return;
}
try {
await deleteApiKey({ id: key.id });
toastManager.success($t('removed_api_key', { values: { name: key.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_api_key'));
} finally {
await refreshKeys();
}
}; };
const { Create } = $derived(getApiKeysActions($t));
</script> </script>
<OnEvents {onApiKeyCreate} {onApiKeyUpdate} {onApiKeyDelete} />
<section class="my-4"> <section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}> <div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="mb-2 flex justify-end"> <div class="mb-2 flex justify-end">
<Button shape="round" size="small" onclick={() => handleCreate()}>{$t('new_api_key')}</Button> <Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}
>{Create.title}</Button
>
</div> </div>
{#if keys.length > 0} {#if keys.length > 0}
@@ -79,6 +58,7 @@
</thead> </thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray"> <tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each keys as key (key.id)} {#each keys as key (key.id)}
{@const { Update, Delete } = getApiKeyActions($t, key)}
<tr <tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80" class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
> >
@@ -91,22 +71,8 @@
>{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)} >{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)}
</td> </td>
<td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/4"> <td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/4">
<IconButton <TableButton action={Update} size="small" />
shape="round" <TableButton action={Delete} size="small" />
color="primary"
icon={mdiPencilOutline}
aria-label={$t('edit_key')}
size="small"
onclick={() => handleUpdate(key)}
/>
<IconButton
shape="round"
color="primary"
icon={mdiTrashCanOutline}
aria-label={$t('delete_key')}
size="small"
onclick={() => handleDelete(key)}
/>
</td> </td>
</tr> </tr>
{/each} {/each}

View File

@@ -2,6 +2,7 @@ import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
import type { ReleaseEvent } from '$lib/types'; import type { ReleaseEvent } from '$lib/types';
import type { import type {
AlbumResponseDto, AlbumResponseDto,
ApiKeyResponseDto,
LibraryResponseDto, LibraryResponseDto,
LoginResponseDto, LoginResponseDto,
QueueResponseDto, QueueResponseDto,
@@ -19,6 +20,10 @@ export type Events = {
LanguageChange: [{ name: string; code: string; rtl?: boolean }]; LanguageChange: [{ name: string; code: string; rtl?: boolean }];
ThemeChange: [ThemeSetting]; ThemeChange: [ThemeSetting];
ApiKeyCreate: [ApiKeyResponseDto];
ApiKeyUpdate: [ApiKeyResponseDto];
ApiKeyDelete: [ApiKeyResponseDto];
AssetReplace: [{ oldAssetId: string; newAssetId: string }]; AssetReplace: [{ oldAssetId: string; newAssetId: string }];
AlbumDelete: [AlbumResponseDto]; AlbumDelete: [AlbumResponseDto];

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte'; import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleCreateApiKey } from '$lib/services/api-key.service';
import { createApiKey, Permission } from '@immich/sdk'; import { Permission } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui'; import { Field, FormModal, Input } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js'; import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { onClose: (secret?: string) => void }; type Props = { onClose: () => void };
const { onClose }: Props = $props(); const { onClose }: Props = $props();
@@ -14,47 +14,22 @@
let selectedPermissions = $state<Permission[]>([]); let selectedPermissions = $state<Permission[]>([]);
const isAllPermissions = $derived(selectedPermissions.length === Object.keys(Permission).length - 1); const isAllPermissions = $derived(selectedPermissions.length === Object.keys(Permission).length - 1);
const onsubmit = async () => { const onSubmit = async () => {
if (!name) { const success = await handleCreateApiKey({
toastManager.warning($t('api_key_empty'));
return;
}
if (selectedPermissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
try {
const { secret } = await createApiKey({
apiKeyCreateDto: {
name, name,
permissions: isAllPermissions ? [Permission.All] : selectedPermissions, permissions: isAllPermissions ? [Permission.All] : selectedPermissions,
},
}); });
onClose(secret); if (success) {
} catch (error) { onClose();
handleError(error, $t('errors.unable_to_create_api_key'));
} }
}; };
</script> </script>
<Modal title={$t('new_api_key')} icon={mdiKeyVariant} {onClose} size="giant"> <FormModal title={$t('new_api_key')} icon={mdiKeyVariant} {onClose} {onSubmit} submitText={$t('create')} size="giant">
<ModalBody>
<form {onsubmit} autocomplete="off" id="api-key-form">
<div class="mb-4 flex flex-col gap-2"> <div class="mb-4 flex flex-col gap-2">
<Field label={$t('name')}> <Field label={$t('name')}>
<Input bind:value={name} /> <Input bind:value={name} />
</Field> </Field>
</div> </div>
<ApiKeyPermissionsPicker bind:selectedPermissions /> <ApiKeyPermissionsPicker bind:selectedPermissions />
</form> </FormModal>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="api-key-form">{$t('create')}</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -1,69 +1,44 @@
<script lang="ts"> <script lang="ts">
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte'; import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleUpdateApiKey } from '$lib/services/api-key.service';
import { Permission, updateApiKey } from '@immich/sdk'; import { Permission } from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui'; import { Field, FormModal, Input } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js'; import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { type Props = {
apiKey: { id: string; name: string; permissions: Permission[] }; apiKey: { id: string; name: string; permissions: Permission[] };
onClose: (success?: true) => void; onClose: () => void;
} };
let { apiKey, onClose }: Props = $props(); let { apiKey, onClose }: Props = $props();
const isAllPermissions = (permissions: Permission[]) => permissions.length === Object.keys(Permission).length - 1;
const mapPermissions = (permissions: Permission[]) => const mapPermissions = (permissions: Permission[]) =>
permissions.includes(Permission.All) permissions.includes(Permission.All)
? Object.values(Permission).filter((permission) => permission !== Permission.All) ? Object.values(Permission).filter((permission) => permission !== Permission.All)
: permissions; : permissions;
const isAllPermissions = (permissions: Permission[]) => permissions.length === Object.keys(Permission).length - 1;
let name = $state(apiKey.name); let name = $state(apiKey.name);
let selectedPermissions = $state<Permission[]>(mapPermissions(apiKey.permissions)); let selectedPermissions = $state<Permission[]>(mapPermissions(apiKey.permissions));
const onsubmit = async () => { const onSubmit = async () => {
if (!name) { const success = await handleUpdateApiKey(apiKey, {
toastManager.warning($t('api_key_empty'));
return;
}
if (selectedPermissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
try {
await updateApiKey({
id: apiKey.id,
apiKeyUpdateDto: {
name, name,
permissions: isAllPermissions(selectedPermissions) ? [Permission.All] : selectedPermissions, permissions: isAllPermissions(selectedPermissions) ? [Permission.All] : selectedPermissions,
},
}); });
toastManager.success($t('saved_api_key')); if (success) {
onClose(true); onClose();
} catch (error) {
handleError(error, $t('errors.unable_to_save_api_key'));
} }
}; };
</script> </script>
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="giant"> <FormModal title={$t('api_key')} icon={mdiKeyVariant} {onClose} {onSubmit} size="giant" submitText={$t('save')}>
<ModalBody>
<form {onsubmit} autocomplete="off" id="api-key-form">
<div class="mb-4 flex flex-col gap-2"> <div class="mb-4 flex flex-col gap-2">
<Field label={$t('name')}> <Field label={$t('name')}>
<Input bind:value={name} /> <Input bind:value={name} />
</Field> </Field>
</div> </div>
<ApiKeyPermissionsPicker bind:selectedPermissions /> <ApiKeyPermissionsPicker bind:selectedPermissions />
</form> </FormModal>
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" type="submit" fullWidth form="api-key-form">{$t('save')}</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,110 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
createApiKey,
deleteApiKey,
updateApiKey,
type ApiKeyCreateDto,
type ApiKeyResponseDto,
type ApiKeyUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getApiKeysActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('new_api_key'),
icon: mdiPlus,
onAction: () => modalManager.show(ApiKeyCreateModal, {}),
};
return { Create };
};
export const getApiKeyActions = ($t: MessageFormatter, apiKey: ApiKeyResponseDto) => {
const Update: ActionItem = {
title: $t('edit_key'),
icon: mdiPencilOutline,
onAction: () => modalManager.show(ApiKeyUpdateModal, { apiKey }),
};
const Delete: ActionItem = {
title: $t('delete_key'),
icon: mdiTrashCanOutline,
onAction: () => handleDeleteApiKey(apiKey),
};
return { Update, Delete };
};
export const handleCreateApiKey = async (dto: ApiKeyCreateDto) => {
const $t = await getFormatter();
try {
if (!dto.name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (dto.permissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
const { apiKey, secret } = await createApiKey({ apiKeyCreateDto: dto });
eventManager.emit('ApiKeyCreate', apiKey);
// no nested modal
void modalManager.show(ApiKeySecretModal, { secret });
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_create_api_key'));
}
};
export const handleUpdateApiKey = async (apiKey: { id: string }, dto: ApiKeyUpdateDto) => {
const $t = await getFormatter();
if (!dto.name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (dto.permissions && dto.permissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
try {
const response = await updateApiKey({ id: apiKey.id, apiKeyUpdateDto: dto });
eventManager.emit('ApiKeyUpdate', response);
toastManager.success($t('saved_api_key'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_save_api_key'));
}
};
export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') });
if (!confirmed) {
return;
}
try {
await deleteApiKey({ id: apiKey.id });
eventManager.emit('ApiKeyDelete', apiKey);
toastManager.success($t('removed_api_key', { values: { name: apiKey.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_api_key'));
}
};