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:
committed by
Codex
co-authored by
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
jrasm91
parent
e3bf54e2ba
commit
5d1167a36c
@@ -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 }];
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' } },
|
||||
});
|
||||
@@ -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'));
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
});
|
||||
@@ -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' } },
|
||||
});
|
||||
@@ -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 } })),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
await executeMutation(deletePasskeyMutation, [{ id: passkey.id }], {
|
||||
onSuccess: () =>
|
||||
toastManager.primary(
|
||||
$t('removed_passkey', { values: { name: passkey.name ?? $t('passkeys', { values: { count: 1 } }) } }),
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user