Something went wrong. Try again.
Our Personal Data Server from scratch!
Something went wrong. Try again.
7.2 kB · 236 lines
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237<script lang="ts"> import { portal } from '../lib/portal' import { getAuthState, getValidToken } from '../lib/auth.svelte' import { api, ApiError } from '../lib/api' import { _ } from '../lib/i18n' import type { Session } from '../lib/types/api' import { prepareRequestOptions, serializeAssertionResponse, type WebAuthnRequestOptionsResponse, } from '../lib/webauthn'
interface Props { show: boolean availableMethods?: string[] onSuccess: () => void onCancel: () => void }
let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props()
const auth = $derived(getAuthState())
function getSession(): Session | null { return auth.kind === 'authenticated' ? auth.session : null }
const session = $derived(getSession()) let activeMethod = $state<'password' | 'totp' | 'passkey'>('password') let password = $state('') let totpCode = $state('') let loading = $state(false) let error = $state('')
$effect(() => { if (show) { password = '' totpCode = '' error = '' if (availableMethods.includes('password')) { activeMethod = 'password' } else if (availableMethods.includes('totp')) { activeMethod = 'totp' } else if (availableMethods.includes('passkey')) { activeMethod = 'passkey' if (availableMethods.length === 1) { handlePasskeyAuth() } } } })
async function handlePasswordSubmit(e: Event) { e.preventDefault() if (!session || !password) return loading = true error = '' try { const token = await getValidToken() if (!token) { error = 'Session expired. Please log in again.' return } await api.reauthPassword(token, password) show = false onSuccess() } catch (e) { error = e instanceof ApiError ? e.message : 'Authentication failed' } finally { loading = false } }
async function handleTotpSubmit(e: Event) { e.preventDefault() if (!session || !totpCode) return loading = true error = '' try { const token = await getValidToken() if (!token) { error = 'Session expired. Please log in again.' return } await api.reauthTotp(token, totpCode) show = false onSuccess() } catch (e) { error = e instanceof ApiError ? e.message : 'Invalid code' } finally { loading = false } }
async function handlePasskeyAuth() { if (!session) return if (!window.PublicKeyCredential) { error = 'Passkeys are not supported in this browser' return } loading = true error = '' try { const token = await getValidToken() if (!token) { error = 'Session expired. Please log in again.' return } const { options } = await api.reauthPasskeyStart(token) const publicKeyOptions = prepareRequestOptions(options as unknown as WebAuthnRequestOptionsResponse) const credential = await navigator.credentials.get({ publicKey: publicKeyOptions }) if (!credential) { error = 'Passkey authentication was cancelled' return } const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential) await api.reauthPasskeyFinish(token, credentialResponse) show = false onSuccess() } catch (e) { if (e instanceof DOMException && e.name === 'NotAllowedError') { error = 'Passkey authentication was cancelled' } else { error = e instanceof ApiError ? e.message : 'Passkey authentication failed' } } finally { loading = false } }
function handleClose() { show = false onCancel() }</script>
{#if show} <div class="modal-backdrop" use:portal onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation"> <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1"> <div class="modal-header"> <h2>{$_('reauth.title')}</h2> <button class="close-btn" onclick={handleClose} aria-label="Close">×</button> </div>
{#if error} <div class="error-message">{error}</div> {/if}
{#if availableMethods.length > 1} <div class="tabs"> {#if availableMethods.includes('password')} <button class="tab" class:active={activeMethod === 'password'} onclick={() => activeMethod = 'password'} > {$_('reauth.password')} </button> {/if} {#if availableMethods.includes('totp')} <button class="tab" class:active={activeMethod === 'totp'} onclick={() => activeMethod = 'totp'} > {$_('reauth.totp')} </button> {/if} {#if availableMethods.includes('passkey')} <button class="tab" class:active={activeMethod === 'passkey'} onclick={() => activeMethod = 'passkey'} > {$_('reauth.passkey')} </button> {/if} </div> {/if}
<div class="modal-content"> {#if activeMethod === 'password'} <form id="reauth-form" onsubmit={handlePasswordSubmit}> <div> <label for="reauth-password">{$_('reauth.password')}</label> <input id="reauth-password" type="password" bind:value={password} required autocomplete="current-password" /> </div> </form> {:else if activeMethod === 'totp'} <form id="reauth-form" onsubmit={handleTotpSubmit}> <div> <label for="reauth-totp">{$_('reauth.authenticatorCode')}</label> <input id="reauth-totp" type="text" bind:value={totpCode} required autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]*" maxlength="6" /> </div> </form> {:else if activeMethod === 'passkey'} <div class="passkey-auth"> <p>{$_('reauth.usePasskey')}</p> </div> {/if} </div>
<div class="modal-footer"> <button class="secondary" onclick={handleClose} disabled={loading}> {$_('reauth.cancel')} </button> {#if activeMethod === 'passkey'} <button onclick={handlePasskeyAuth} disabled={loading}> {loading ? $_('reauth.authenticating') : $_('common.verify')} </button> {:else} <button type="submit" form="reauth-form" disabled={loading || (activeMethod === 'password' ? !password : !totpCode)}> {loading ? $_('common.verifying') : $_('common.verify')} </button> {/if} </div> </div> </div>{/if}