diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 28f88af73..b2e1c9aa2 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -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 }]; diff --git a/web/src/lib/modals/ApiKeyCreateModal.svelte b/web/src/lib/modals/ApiKeyCreateModal.svelte index 56078efc5..6b5d568cf 100644 --- a/web/src/lib/modals/ApiKeyCreateModal.svelte +++ b/web/src/lib/modals/ApiKeyCreateModal.svelte @@ -1,9 +1,10 @@ diff --git a/web/src/lib/modals/ApiKeyUpdateModal.svelte b/web/src/lib/modals/ApiKeyUpdateModal.svelte index 7de8032d8..0aafb9de1 100644 --- a/web/src/lib/modals/ApiKeyUpdateModal.svelte +++ b/web/src/lib/modals/ApiKeyUpdateModal.svelte @@ -1,8 +1,9 @@ diff --git a/web/src/lib/modals/ObtainiumConfigModal.svelte b/web/src/lib/modals/ObtainiumConfigModal.svelte index d283d5489..cf9cbb070 100644 --- a/web/src/lib/modals/ObtainiumConfigModal.svelte +++ b/web/src/lib/modals/ObtainiumConfigModal.svelte @@ -1,5 +1,6 @@ diff --git a/web/src/lib/modals/PasskeyUpdateModal.svelte b/web/src/lib/modals/PasskeyUpdateModal.svelte index 05c531c7d..0e4576b2a 100644 --- a/web/src/lib/modals/PasskeyUpdateModal.svelte +++ b/web/src/lib/modals/PasskeyUpdateModal.svelte @@ -1,5 +1,6 @@ diff --git a/web/src/lib/queries/api-key.query.ts b/web/src/lib/queries/api-key.query.ts new file mode 100644 index 000000000..68f3784bd --- /dev/null +++ b/web/src/lib/queries/api-key.query.ts @@ -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' } }, + }); diff --git a/web/src/lib/queries/client.ts b/web/src/lib/queries/client.ts index 9b8be2dfc..8d4651593 100644 --- a/web/src/lib/queries/client.ts +++ b/web/src/lib/queries/client.ts @@ -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')); }, }), }); diff --git a/web/src/lib/queries/passkey.query.ts b/web/src/lib/queries/passkey.query.ts new file mode 100644 index 000000000..ac5b20599 --- /dev/null +++ b/web/src/lib/queries/passkey.query.ts @@ -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 (flow: () => Promise) => { + 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), + ), + }); diff --git a/web/src/lib/queries/session.query.ts b/web/src/lib/queries/session.query.ts new file mode 100644 index 000000000..8be327a59 --- /dev/null +++ b/web/src/lib/queries/session.query.ts @@ -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' } }, + }); diff --git a/web/src/lib/services/api-key.service.ts b/web/src/lib/services/api-key.service.ts index 5dc990af9..12ec783e7 100644 --- a/web/src/lib/services/api-key.service.ts +++ b/web/src/lib/services/api-key.service.ts @@ -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 } })), + }); }; diff --git a/web/src/lib/services/passkey.service.ts b/web/src/lib/services/passkey.service.ts index 69885d86b..17631d061 100644 --- a/web/src/lib/services/passkey.service.ts +++ b/web/src/lib/services/passkey.service.ts @@ -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 } }) } }), + ), + }); }; diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte index b6248434a..38cbc1351 100644 --- a/web/src/routes/(user)/user-settings/+page.svelte +++ b/web/src/routes/(user)/user-settings/+page.svelte @@ -17,11 +17,6 @@ - + diff --git a/web/src/routes/(user)/user-settings/+page.ts b/web/src/routes/(user)/user-settings/+page.ts index 54dee8608..bff4a0d63 100644 --- a/web/src/routes/(user)/user-settings/+page.ts +++ b/web/src/routes/(user)/user-settings/+page.ts @@ -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'), }, diff --git a/web/src/routes/(user)/user-settings/DeviceList.svelte b/web/src/routes/(user)/user-settings/DeviceList.svelte index 576949ac3..87e354a20 100644 --- a/web/src/routes/(user)/user-settings/DeviceList.svelte +++ b/web/src/routes/(user)/user-settings/DeviceList.svelte @@ -1,18 +1,14 @@ diff --git a/web/src/routes/(user)/user-settings/UserApiKeyList.svelte b/web/src/routes/(user)/user-settings/UserApiKeyList.svelte index de4dda9da..879d618a5 100644 --- a/web/src/routes/(user)/user-settings/UserApiKeyList.svelte +++ b/web/src/routes/(user)/user-settings/UserApiKeyList.svelte @@ -1,37 +1,20 @@ - -
diff --git a/web/src/routes/(user)/user-settings/UserPasskeyList.svelte b/web/src/routes/(user)/user-settings/UserPasskeyList.svelte index 22021c433..4d66926d9 100644 --- a/web/src/routes/(user)/user-settings/UserPasskeyList.svelte +++ b/web/src/routes/(user)/user-settings/UserPasskeyList.svelte @@ -1,40 +1,20 @@ - -
diff --git a/web/src/routes/(user)/user-settings/UserSettingsList.svelte b/web/src/routes/(user)/user-settings/UserSettingsList.svelte index 06637b7e4..5e5868b93 100644 --- a/web/src/routes/(user)/user-settings/UserSettingsList.svelte +++ b/web/src/routes/(user)/user-settings/UserSettingsList.svelte @@ -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; @@ -92,7 +77,7 @@ - + - + {/if} -{#if passkeyEnabled} +{#if config.passkey.enabled} - + {/if} diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index 9adfd1f27..30a696949 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -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')}