refactor: apikeys, passkeys, and sessions (#32670)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jrasm91 <4334196+jrasm91@users.noreply.github.com>
This commit is contained in:
Jason Rasmussen
2026-10-24 11:51:43 +03:00
committed by Codex
co-authored by copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> jrasm91
parent e3bf54e2ba
commit 5d1167a36c
18 changed files with 274 additions and 311 deletions
@@ -1,4 +1,6 @@
import type {
AlbumResponseDto,
AlbumUserRole,
ApiKeyResponseDto,
AssetResponseDto,
IntegrityReport,
@@ -45,6 +47,12 @@ export type Events = {
AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }];
AlbumRemoveAssets: [{ assetIds: string[]; albumIds: string[] }];
AlbumCreate: [AlbumResponseDto];
AlbumUpdate: [AlbumResponseDto];
AlbumDelete: [AlbumResponseDto];
AlbumShare: [];
AlbumUserUpdate: [{ albumId: string; userId: string; role: AlbumUserRole }];
AlbumUserDelete: [{ albumId: string; userId: string }];
PersonUpdate: [PersonResponseDto];
PersonShare: [{ personId: string }];
+16 -7
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
import { handleCreateApiKey } from '$lib/services/api-key.service';
import { createApiKeyMutation } from '$lib/queries/api-key.query';
import { executeMutation } from '$lib/queries/client';
import { Permission } from '@immich/sdk';
import { Field, FormModal, Input, modalManager } from '@immich/ui';
import { Field, FormModal, Input, modalManager, toastManager } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -16,12 +17,20 @@
const isAllPermissions = $derived(selectedPermissions.length === Object.keys(Permission).length - 1);
const onSubmit = async () => {
const permissions = isAllPermissions ? [Permission.All] : selectedPermissions;
const response = await handleCreateApiKey({ name, permissions });
if (response) {
await modalManager.show(ApiKeySecretModal, { secret: response.secret });
onClose();
if (!name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (selectedPermissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
const permissions = isAllPermissions ? [Permission.All] : selectedPermissions;
await executeMutation(createApiKeyMutation, [{ apiKeyCreateDto: { name, permissions } }], {
onSuccess: ({ secret }) => void modalManager.show(ApiKeySecretModal, { secret }).then(onClose),
});
};
</script>
+16 -8
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
import { handleUpdateApiKey } from '$lib/services/api-key.service';
import { updateApiKeyMutation } from '$lib/queries/api-key.query';
import { executeMutation } from '$lib/queries/client';
import { Permission } from '@immich/sdk';
import { Field, FormModal, Input } from '@immich/ui';
import { Field, FormModal, Input, toastManager } from '@immich/ui';
import { mdiKeyVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -24,13 +25,20 @@
let selectedPermissions = $state<Permission[]>(mapPermissions(apiKey.permissions));
const onSubmit = async () => {
const success = await handleUpdateApiKey(apiKey, {
name,
permissions: isAllPermissions(selectedPermissions) ? [Permission.All] : selectedPermissions,
});
if (success) {
onClose();
if (!name) {
toastManager.warning($t('api_key_empty'));
return;
}
if (selectedPermissions.length === 0) {
toastManager.warning($t('permission_empty'));
return;
}
const permissions = isAllPermissions(selectedPermissions) ? [Permission.All] : selectedPermissions;
await executeMutation(updateApiKeyMutation, [{ id: apiKey.id, apiKeyUpdateDto: { name, permissions } }], {
onSuccess: onClose,
});
};
</script>
@@ -1,5 +1,6 @@
<script lang="ts">
import { handleCreateApiKey } from '$lib/services/api-key.service';
import { createApiKeyMutation } from '$lib/queries/api-key.query';
import { executeMutation } from '$lib/queries/client';
import { Permission } from '@immich/sdk';
import { Button, Field, Input, Modal, ModalBody, obtainiumBadge, Select, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
@@ -16,12 +17,12 @@
let { onClose }: Props = $props();
const handleCreate = async () => {
const response = await handleCreateApiKey({ name: 'Obtainium', permissions: [Permission.ServerApkLinks] });
if (response) {
inputApiKey = response.secret;
}
};
const handleCreate = () =>
executeMutation(
createApiKeyMutation,
[{ apiKeyCreateDto: { name: 'Obtainium', permissions: [Permission.ServerApkLinks] } }],
{ onSuccess: ({ secret }) => (inputApiKey = secret) },
);
</script>
<Modal title={$t('obtainium_configurator')} size="medium" {onClose}>
+5 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { handleUpdatePasskey } from '$lib/services/passkey.service';
import { executeMutation } from '$lib/queries/client';
import { updatePasskeyMutation } from '$lib/queries/passkey.query';
import { Field, FormModal, Input } from '@immich/ui';
import { mdiShieldKeyOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -14,10 +15,9 @@
let name = $state(passkey.name ?? '');
const onSubmit = async () => {
const success = await handleUpdatePasskey(passkey, { name });
if (success) {
onClose();
}
await executeMutation(updatePasskeyMutation, [{ id: passkey.id, passkeyUpdateDto: { name: name || null } }], {
onSuccess: onClose,
});
};
</script>
+39
View File
@@ -0,0 +1,39 @@
import { createApiKey, deleteApiKey, getApiKeys, rotateApiKey, updateApiKey } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
import { asMutation, queryClient } from '$lib/queries/client';
export const getApiKeysQuery = () =>
queryOptions({
queryKey: ['api-keys'],
queryFn: ({ signal }) => getApiKeys({ signal }),
});
export const createApiKeyMutation = () =>
asMutation(createApiKey, {
onSuccess: () => queryClient.invalidateQueries({ queryKey: getApiKeysQuery().queryKey }),
meta: { error: { toast: 'errors.unable_to_create_api_key' } },
});
export const updateApiKeyMutation = () =>
asMutation(updateApiKey, {
onSuccess: (apiKey) =>
queryClient.setQueryData(getApiKeysQuery().queryKey, (keys) =>
keys?.map((item) => (item.id === apiKey.id ? apiKey : item)),
),
meta: { success: { toast: 'saved_api_key' }, error: { toast: 'errors.unable_to_save_api_key' } },
});
export const rotateApiKeyMutation = () =>
asMutation(rotateApiKey, {
onSuccess: ({ apiKey }) =>
queryClient.setQueryData(getApiKeysQuery().queryKey, (keys) =>
keys?.map((item) => (item.id === apiKey.id ? apiKey : item)),
),
});
export const deleteApiKeyMutation = () =>
asMutation(deleteApiKey, {
onSuccess: (_, [{ id }]) =>
queryClient.setQueryData(getApiKeysQuery().queryKey, (keys) => keys?.filter((key) => key.id !== id)),
meta: { error: { toast: 'errors.unable_to_remove_api_key' } },
});
+14 -2
View File
@@ -29,11 +29,18 @@ declare module '@tanstack/svelte-query' {
interface Register {
mutationMeta: {
success?: { toast?: ToastMeta };
error?: { toast?: Translations };
error?: { toast?: Translations | false };
};
}
}
export class MutationCancelledError extends Error {
constructor() {
super('Mutation cancelled');
this.name = 'MutationCancelledError';
}
}
const createQueryClient = () => {
const client = new QueryClient({
defaultOptions: {
@@ -63,8 +70,13 @@ const createQueryClient = () => {
toastManager[toast.color]($t(toast.description));
},
onError: async (error, _variables, _onMutateResult, { meta }) => {
const toast = meta?.error?.toast;
if (toast === false || error instanceof MutationCancelledError) {
return;
}
const $t = await getFormatter();
handleError(error, $t(meta?.error?.toast ?? 'errors.something_went_wrong'));
handleError(error, $t(toast ?? 'errors.something_went_wrong'));
},
}),
});
+76
View File
@@ -0,0 +1,76 @@
import {
deletePasskey,
finishAuthentication,
finishRegistration,
searchPasskeys,
startAuthentication,
startRegistration,
updatePasskey,
} from '@immich/sdk';
import {
startAuthentication as startBrowserAuthentication,
startRegistration as startBrowserRegistration,
type PublicKeyCredentialCreationOptionsJSON,
type PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/browser';
import { mutationOptions, queryOptions } from '@tanstack/svelte-query';
import { asMutation, MutationCancelledError, queryClient } from '$lib/queries/client';
const isPasskeyCancelled = (error: unknown) => error instanceof Error && error.name === 'NotAllowedError';
const withPasskeyPrompt = async <T>(flow: () => Promise<T>) => {
try {
return await flow();
} catch (error) {
throw isPasskeyCancelled(error) ? new MutationCancelledError() : error;
}
};
export const searchPasskeysQuery = () =>
queryOptions({
queryKey: ['passkeys'],
queryFn: ({ signal }) => searchPasskeys({}, { signal }),
});
export const createPasskeyMutation = () =>
mutationOptions({
mutationFn: () =>
withPasskeyPrompt(async () => {
const options = await startRegistration();
const response = await startBrowserRegistration({
optionsJSON: options as PublicKeyCredentialCreationOptionsJSON,
});
return finishRegistration({ passkeyRegistrationFinishDto: { response } });
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: searchPasskeysQuery().queryKey, exact: true }),
});
export const loginWithPasskeyMutation = () =>
mutationOptions({
mutationFn: () =>
withPasskeyPrompt(async () => {
const options = await startAuthentication();
const response = await startBrowserAuthentication({
optionsJSON: options as PublicKeyCredentialRequestOptionsJSON,
});
return finishAuthentication({ passkeyAuthenticationFinishDto: { response } });
}),
meta: { error: { toast: false } },
});
export const updatePasskeyMutation = () =>
asMutation(updatePasskey, {
onSuccess: (passkey) =>
queryClient.setQueryData(searchPasskeysQuery().queryKey, (passkeys) =>
passkeys?.map((item) => (item.id === passkey.id ? passkey : item)),
),
meta: { success: { toast: true } },
});
export const deletePasskeyMutation = () =>
asMutation(deletePasskey, {
onSuccess: (_, [{ id }]) =>
queryClient.setQueryData(searchPasskeysQuery().queryKey, (passkeys) =>
passkeys?.filter((passkey) => passkey.id !== id),
),
});
+22
View File
@@ -0,0 +1,22 @@
import { deleteAllSessions, deleteSession, getSessions } from '@immich/sdk';
import { mutationOptions, queryOptions } from '@tanstack/svelte-query';
import { asMutation, queryClient } from '$lib/queries/client';
export const getSessionsQuery = () =>
queryOptions({
queryKey: ['sessions'],
queryFn: ({ signal }) => getSessions({ signal }),
});
export const deleteSessionMutation = () =>
asMutation(deleteSession, {
onSettled: () => queryClient.invalidateQueries({ queryKey: getSessionsQuery().queryKey }),
meta: { success: { toast: 'logged_out_device' }, error: { toast: 'errors.unable_to_log_out_device' } },
});
export const deleteAllSessionsMutation = () =>
mutationOptions({
mutationFn: () => deleteAllSessions(),
onSettled: () => queryClient.invalidateQueries({ queryKey: getSessionsQuery().queryKey }),
meta: { success: { toast: 'logged_out_all_devices' }, error: { toast: 'errors.unable_to_log_out_all_devices' } },
});
+11 -73
View File
@@ -1,20 +1,12 @@
import {
createApiKey,
deleteApiKey,
rotateApiKey,
updateApiKey,
type ApiKeyCreateDto,
type ApiKeyResponseDto,
type ApiKeyUpdateDto,
} from '@immich/sdk';
import type { ApiKeyResponseDto } from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiAutorenew, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
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 { deleteApiKeyMutation, rotateApiKeyMutation } from '$lib/queries/api-key.query';
import { executeMutation } from '$lib/queries/client';
import { getFormatter } from '$lib/utils/i18n';
export const getApiKeysActions = ($t: MessageFormatter) => {
@@ -49,53 +41,7 @@ export const getApiKeyActions = ($t: MessageFormatter, apiKey: ApiKeyResponseDto
return { Update, Rotate, 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 response = await createApiKey({ apiKeyCreateDto: dto });
eventManager.emit('ApiKeyCreate', response);
return response;
} 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.primary($t('saved_api_key'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_save_api_key'));
}
};
export const handleRotateApiKey = async (apiKey: ApiKeyResponseDto) => {
const handleRotateApiKey = async (apiKey: ApiKeyResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('rotate_api_key_prompt') });
@@ -103,16 +49,12 @@ export const handleRotateApiKey = async (apiKey: ApiKeyResponseDto) => {
return;
}
try {
const response = await rotateApiKey({ id: apiKey.id });
eventManager.emit('ApiKeyUpdate', response);
await modalManager.show(ApiKeySecretModal, { secret: response.secret });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
await executeMutation(rotateApiKeyMutation, [{ id: apiKey.id }], {
onSuccess: ({ secret }) => void modalManager.show(ApiKeySecretModal, { secret }),
});
};
export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') });
@@ -120,11 +62,7 @@ export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
return;
}
try {
await deleteApiKey({ id: apiKey.id });
eventManager.emit('ApiKeyDelete', apiKey);
toastManager.primary($t('removed_api_key', { values: { name: apiKey.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_api_key'));
}
await executeMutation(deleteApiKeyMutation, [{ id: apiKey.id }], {
onSuccess: () => toastManager.primary($t('removed_api_key', { values: { name: apiKey.name } })),
});
};
+15 -82
View File
@@ -1,47 +1,22 @@
import {
deletePasskey,
finishAuthentication,
finishRegistration,
startAuthentication,
startRegistration,
updatePasskey,
type PasskeyResponseDto,
type PasskeyUpdateDto,
} from '@immich/sdk';
import { type PasskeyResponseDto } from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import {
browserSupportsWebAuthn,
startAuthentication as startBrowserAuthentication,
startRegistration as startBrowserRegistration,
type PublicKeyCredentialCreationOptionsJSON,
type PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/browser';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import type { MessageFormatter } from 'svelte-i18n';
import { eventManager } from '$lib/managers/event-manager.svelte';
import PasskeyUpdateModal from '$lib/modals/PasskeyUpdateModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { executeMutation } from '$lib/queries/client';
import { createPasskeyMutation, deletePasskeyMutation } from '$lib/queries/passkey.query';
import { getFormatter } from '$lib/utils/i18n';
export const isPasskeySupported = () => browserSupportsWebAuthn();
export const isPasskeyCancelled = (error: unknown) => error instanceof Error && error.name === 'NotAllowedError';
export const getPasskeysActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('new_passkey'),
icon: mdiPlus,
onAction: async () => {
if (!isPasskeySupported()) {
toastManager.warning($t('passkeys_not_supported'));
return;
}
const passkey = await handleCreatePasskey();
if (passkey) {
await modalManager.show(PasskeyUpdateModal, { passkey });
}
},
$if: () => browserSupportsWebAuthn(),
onAction: () =>
executeMutation(createPasskeyMutation, undefined, {
onSuccess: (passkey) => void modalManager.show(PasskeyUpdateModal, { passkey }),
}),
};
return { Create };
@@ -63,45 +38,6 @@ export const getPasskeyActions = ($t: MessageFormatter, passkey: PasskeyResponse
return { Update, Delete };
};
const handleCreatePasskey = async () => {
const $t = await getFormatter();
try {
const options = await startRegistration();
const response = await startBrowserRegistration({
optionsJSON: options as PublicKeyCredentialCreationOptionsJSON,
});
const passkey = await finishRegistration({ passkeyRegistrationFinishDto: { response } });
eventManager.emit('PasskeyCreate', passkey);
return passkey;
} catch (error) {
if (isPasskeyCancelled(error)) {
return;
}
handleError(error, $t('errors.something_went_wrong'));
}
};
export const handleLoginWithPasskey = async () => {
const options = await startAuthentication();
const response = await startBrowserAuthentication({ optionsJSON: options as PublicKeyCredentialRequestOptionsJSON });
return finishAuthentication({ passkeyAuthenticationFinishDto: { response } });
};
export const handleUpdatePasskey = async (passkey: { id: string }, dto: PasskeyUpdateDto) => {
const $t = await getFormatter();
try {
const response = await updatePasskey({ id: passkey.id, passkeyUpdateDto: { name: dto.name || null } });
eventManager.emit('PasskeyUpdate', response);
toastManager.primary();
return true;
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
const handleDeletePasskey = async (passkey: PasskeyResponseDto) => {
const $t = await getFormatter();
@@ -110,13 +46,10 @@ const handleDeletePasskey = async (passkey: PasskeyResponseDto) => {
return;
}
try {
await deletePasskey({ id: passkey.id });
eventManager.emit('PasskeyDelete', passkey);
toastManager.primary(
$t('removed_passkey', { values: { name: passkey.name ?? $t('passkeys', { values: { count: 1 } }) } }),
);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
await executeMutation(deletePasskeyMutation, [{ id: passkey.id }], {
onSuccess: () =>
toastManager.primary(
$t('removed_passkey', { values: { name: passkey.name ?? $t('passkeys', { values: { count: 1 } }) } }),
),
});
};
@@ -17,11 +17,6 @@
<UserPageLayout title={data.meta.title} actions={[KeyboardShortcuts]}>
<Container size="medium" center>
<UserSettingsList
keys={data.keys}
sessions={data.sessions}
passkeys={data.passkeys}
passkeyEnabled={data.publicConfig.passkey.enabled}
/>
<UserSettingsList />
</Container>
</UserPageLayout>
+8 -10
View File
@@ -1,4 +1,7 @@
import { getApiKeys, getPublicConfig, getSessions, searchPasskeys } from '@immich/sdk';
import { getApiKeysQuery } from '$lib/queries/api-key.query';
import { queryClient } from '$lib/queries/client';
import { searchPasskeysQuery } from '$lib/queries/passkey.query';
import { getSessionsQuery } from '$lib/queries/session.query';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
@@ -6,19 +9,14 @@ import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const publicConfig = await getPublicConfig();
const [keys, sessions, passkeys, $t] = await Promise.all([
getApiKeys(),
getSessions(),
publicConfig.passkey.enabled ? searchPasskeys({}) : Promise.resolve([]),
const [$t] = await Promise.all([
getFormatter(),
queryClient.query(getApiKeysQuery()),
queryClient.query(getSessionsQuery()),
queryClient.query(searchPasskeysQuery()),
]);
return {
keys,
sessions,
passkeys,
publicConfig,
meta: {
title: $t('settings'),
},
@@ -1,18 +1,14 @@
<script lang="ts">
import { handleError } from '$lib/utils/handle-error';
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
import { Button, modalManager, Text, toastManager } from '@immich/ui';
import { createPreloadedQuery, executeMutation } from '$lib/queries/client';
import { deleteAllSessionsMutation, deleteSessionMutation, getSessionsQuery } from '$lib/queries/session.query';
import { type SessionResponseDto } from '@immich/sdk';
import { Button, modalManager, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import DeviceCard from '$lib/components/user-settings-page/DeviceCard.svelte';
interface Props {
devices: SessionResponseDto[];
}
let { devices = $bindable() }: Props = $props();
const refresh = () => getSessions().then((_devices) => (devices = _devices));
const query = createPreloadedQuery(getSessionsQuery);
const devices = $derived(query.data);
let currentSession = $derived(devices.find((device) => device.current));
let otherSessions = $derived(devices.filter((device) => !device.current));
@@ -23,14 +19,7 @@
return;
}
try {
await deleteSession({ id: device.id });
toastManager.primary($t('logged_out_device'));
} catch (error) {
handleError(error, $t('errors.unable_to_log_out_device'));
} finally {
await refresh();
}
await executeMutation(deleteSessionMutation, [{ id: device.id }]);
};
const handleDeleteAll = async () => {
@@ -39,14 +28,7 @@
return;
}
try {
await deleteAllSessions();
toastManager.primary($t('logged_out_all_devices'));
} catch (error) {
handleError(error, $t('errors.unable_to_log_out_all_devices'));
} finally {
await refresh();
}
await executeMutation(deleteAllSessionsMutation);
};
</script>
@@ -1,37 +1,20 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { dateFormats } from '$lib/constants';
import { getApiKeysQuery } from '$lib/queries/api-key.query';
import { createPreloadedQuery } from '$lib/queries/client';
import { getApiKeyActions, getApiKeysActions } from '$lib/services/api-key.service';
import { locale } from '$lib/stores/preferences.store';
import { getApiKeys, type ApiKeyResponseDto } from '@immich/sdk';
import { Button, Table, TableBody, TableCell, TableHeader, TableHeading, TableRow, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
type Props = {
keys: ApiKeyResponseDto[];
};
let { keys = $bindable() }: Props = $props();
const onApiKeyCreate = async () => {
keys = await getApiKeys();
};
const onApiKeyUpdate = (update: ApiKeyResponseDto) => {
keys = keys.map((key) => (key.id === update.id ? update : key));
};
const onApiKeyDelete = ({ id }: ApiKeyResponseDto) => {
keys = keys.filter((key) => key.id !== id);
};
const query = createPreloadedQuery(getApiKeysQuery);
const keys = $derived(query.data);
const { Create } = $derived(getApiKeysActions($t));
</script>
<OnEvents {onApiKeyCreate} {onApiKeyUpdate} {onApiKeyDelete} />
<section class="my-4">
<div class="flex flex-col gap-2 sm:ms-8" in:fade={{ duration: 500 }}>
<div class="mb-2 flex justify-end">
@@ -1,40 +1,20 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import TableButton from '$lib/components/TableButton.svelte';
import { dateFormats } from '$lib/constants';
import { createPreloadedQuery } from '$lib/queries/client';
import { searchPasskeysQuery } from '$lib/queries/passkey.query';
import { getPasskeyActions, getPasskeysActions } from '$lib/services/passkey.service';
import { locale } from '$lib/stores/preferences.store';
import { type PasskeyResponseDto } from '@immich/sdk';
import { Button, Table, TableBody, TableCell, TableHeader, TableHeading, TableRow } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
type Props = {
passkeys: PasskeyResponseDto[];
};
let { passkeys = $bindable() }: Props = $props();
const onPasskeyCreate = (passkey: PasskeyResponseDto) => {
passkeys = [passkey, ...passkeys];
};
const onPasskeyUpdate = (update: PasskeyResponseDto) => {
const index = passkeys.findIndex((passkey) => passkey.id === update.id);
if (index !== -1) {
passkeys[index] = update;
}
};
const onPasskeyDelete = ({ id }: PasskeyResponseDto) => {
passkeys = passkeys.filter((passkey) => passkey.id !== id);
};
const query = createPreloadedQuery(searchPasskeysQuery);
const passkeys = $derived(query.data);
const { Create } = $derived(getPasskeysActions($t));
</script>
<OnEvents {onPasskeyCreate} {onPasskeyUpdate} {onPasskeyDelete} />
<section class="my-4">
<div class="flex flex-col gap-2 sm:ms-8" in:fade={{ duration: 500 }}>
<div class="mb-2 flex justify-end">
@@ -8,7 +8,6 @@
import UserUsageStatistic from './UserUsageStatistic.svelte';
import { OpenQueryParam, QueryParameter } from '$lib/constants';
import { oauth } from '$lib/utils';
import { type ApiKeyResponseDto, type PasskeyResponseDto, type SessionResponseDto } from '@immich/sdk';
import {
mdiAccountGroupOutline,
mdiAccountOutline,
@@ -42,20 +41,6 @@
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
interface Props {
keys?: ApiKeyResponseDto[];
sessions?: SessionResponseDto[];
passkeys?: PasskeyResponseDto[];
passkeyEnabled?: boolean;
}
let {
keys = $bindable([]),
sessions = $bindable([]),
passkeys = $bindable([]),
passkeyEnabled = false,
}: Props = $props();
let oauthOpen =
oauth.isCallback(location) || $page.url.searchParams.get(QueryParameter.OPEN_SETTING) === OpenQueryParam.OAUTH;
</script>
@@ -92,7 +77,7 @@
</SettingAccordion>
<SettingAccordion icon={mdiApi} key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}>
<UserApiKeyList bind:keys />
<UserApiKeyList />
</SettingAccordion>
<SettingAccordion
@@ -101,7 +86,7 @@
title={$t('authorized_devices')}
subtitle={$t('manage_your_devices')}
>
<DeviceList bind:devices={sessions} />
<DeviceList />
</SettingAccordion>
<SettingAccordion
@@ -143,14 +128,14 @@
</SettingAccordion>
{/if}
{#if passkeyEnabled}
{#if config.passkey.enabled}
<SettingAccordion
icon={mdiShieldKeyOutline}
key="passkeys"
title={$t('passkeys', { values: { count: passkeys.length } })}
title={$t('passkeys', { values: { count: 5 } })}
subtitle={$t('manage_your_passkeys')}
>
<UserPasskeyList bind:passkeys />
<UserPasskeyList />
</SettingAccordion>
{/if}
+14 -20
View File
@@ -2,11 +2,13 @@
import { goto } from '$app/navigation';
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { createPreloadedQuery } from '$lib/queries/client';
import { createPreloadedQuery, MutationCancelledError } from '$lib/queries/client';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
import { loginWithPasskeyMutation } from '$lib/queries/passkey.query';
import { getServerConfigQuery } from '$lib/queries/server.query';
import { Route } from '$lib/route';
import { handleLoginWithPasskey, isPasskeyCancelled, isPasskeySupported } from '$lib/services/passkey.service';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import { createMutation } from '@tanstack/svelte-query';
import { oauth } from '$lib/utils';
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
import { login, type LoginResponseDto } from '@immich/sdk';
@@ -26,10 +28,8 @@
let email = $state('');
let password = $state('');
let oauthError = $state('');
let passkeyError = $state('');
let loading = $state(false);
let oauthLoading = $state(true);
let passkeyLoading = $state(false);
const queries = {
publicConfig: createPreloadedQuery(getPublicConfigQuery),
@@ -39,9 +39,17 @@
const publicConfig = $derived(queries.publicConfig.data);
const serverConfig = $derived(queries.serverConfig.data);
const passkeySupported = isPasskeySupported();
const passkeySupported = browserSupportsWebAuthn();
const passkeyEnabled = $derived(publicConfig.passkey.enabled && passkeySupported);
const passkeyLogin = createMutation(() => ({ ...loginWithPasskeyMutation(), onSuccess: (user) => onLogin(user) }));
const passkeyLoading = $derived(passkeyLogin.isPending || passkeyLogin.isSuccess);
const passkeyError = $derived(
passkeyLogin.error && !(passkeyLogin.error instanceof MutationCancelledError)
? getServerErrorMessage(passkeyLogin.error) || $t('errors.something_went_wrong')
: '',
);
const onSuccess = async (user: LoginResponseDto) => {
await goto(data.continueUrl, { invalidateAll: true });
eventManager.emit('AuthLogin', user);
@@ -125,20 +133,6 @@
}
};
const handlePasskeyLogin = async () => {
try {
passkeyError = '';
passkeyLoading = true;
const user = await handleLoginWithPasskey();
await onLogin(user);
} catch (error) {
if (!isPasskeyCancelled(error)) {
passkeyError = getServerErrorMessage(error) || $t('errors.something_went_wrong');
}
passkeyLoading = false;
}
};
const handleOAuthLogin = async () => {
oauthLoading = true;
oauthError = '';
@@ -208,7 +202,7 @@
size="large"
fullWidth
color={publicConfig.passwordLogin.enabled ? 'secondary' : 'primary'}
onclick={handlePasskeyLogin}
onclick={() => passkeyLogin.mutate()}
>
{$t('login_with_passkey')}
</Button>