Our Personal Data Server from scratch!
tranquil.farm
pds
rust
database
fun
oauth
atproto
1<script lang="ts">
2 import { portal } from '../lib/portal'
3 import { getAuthState, getValidToken } from '../lib/auth.svelte'
4 import { api, ApiError } from '../lib/api'
5 import { _ } from '../lib/i18n'
6 import type { Session } from '../lib/types/api'
7 import {
8 prepareRequestOptions,
9 serializeAssertionResponse,
10 type WebAuthnRequestOptionsResponse,
11 } from '../lib/webauthn'
12
13 interface Props {
14 show: boolean
15 availableMethods?: string[]
16 onSuccess: () => void
17 onCancel: () => void
18 }
19
20 let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props()
21
22 const auth = $derived(getAuthState())
23
24 function getSession(): Session | null {
25 return auth.kind === 'authenticated' ? auth.session : null
26 }
27
28 const session = $derived(getSession())
29 let activeMethod = $state<'password' | 'totp' | 'passkey'>('password')
30 let password = $state('')
31 let totpCode = $state('')
32 let loading = $state(false)
33 let error = $state('')
34
35 $effect(() => {
36 if (show) {
37 password = ''
38 totpCode = ''
39 error = ''
40 if (availableMethods.includes('password')) {
41 activeMethod = 'password'
42 } else if (availableMethods.includes('totp')) {
43 activeMethod = 'totp'
44 } else if (availableMethods.includes('passkey')) {
45 activeMethod = 'passkey'
46 if (availableMethods.length === 1) {
47 handlePasskeyAuth()
48 }
49 }
50 }
51 })
52
53 async function handlePasswordSubmit(e: Event) {
54 e.preventDefault()
55 if (!session || !password) return
56 loading = true
57 error = ''
58 try {
59 const token = await getValidToken()
60 if (!token) {
61 error = 'Session expired. Please log in again.'
62 return
63 }
64 await api.reauthPassword(token, password)
65 show = false
66 onSuccess()
67 } catch (e) {
68 error = e instanceof ApiError ? e.message : 'Authentication failed'
69 } finally {
70 loading = false
71 }
72 }
73
74 async function handleTotpSubmit(e: Event) {
75 e.preventDefault()
76 if (!session || !totpCode) return
77 loading = true
78 error = ''
79 try {
80 const token = await getValidToken()
81 if (!token) {
82 error = 'Session expired. Please log in again.'
83 return
84 }
85 await api.reauthTotp(token, totpCode)
86 show = false
87 onSuccess()
88 } catch (e) {
89 error = e instanceof ApiError ? e.message : 'Invalid code'
90 } finally {
91 loading = false
92 }
93 }
94
95 async function handlePasskeyAuth() {
96 if (!session) return
97 if (!window.PublicKeyCredential) {
98 error = 'Passkeys are not supported in this browser'
99 return
100 }
101 loading = true
102 error = ''
103 try {
104 const token = await getValidToken()
105 if (!token) {
106 error = 'Session expired. Please log in again.'
107 return
108 }
109 const { options } = await api.reauthPasskeyStart(token)
110 const publicKeyOptions = prepareRequestOptions(options as unknown as WebAuthnRequestOptionsResponse)
111 const credential = await navigator.credentials.get({
112 publicKey: publicKeyOptions
113 })
114 if (!credential) {
115 error = 'Passkey authentication was cancelled'
116 return
117 }
118 const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential)
119 await api.reauthPasskeyFinish(token, credentialResponse)
120 show = false
121 onSuccess()
122 } catch (e) {
123 if (e instanceof DOMException && e.name === 'NotAllowedError') {
124 error = 'Passkey authentication was cancelled'
125 } else {
126 error = e instanceof ApiError ? e.message : 'Passkey authentication failed'
127 }
128 } finally {
129 loading = false
130 }
131 }
132
133 function handleClose() {
134 show = false
135 onCancel()
136 }
137</script>
138
139{#if show}
140 <div class="modal-backdrop" use:portal onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
141 <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
142 <div class="modal-header">
143 <h2>{$_('reauth.title')}</h2>
144 <button class="close-btn" onclick={handleClose} aria-label="Close">×</button>
145 </div>
146
147 {#if error}
148 <div class="error-message">{error}</div>
149 {/if}
150
151 {#if availableMethods.length > 1}
152 <div class="tabs">
153 {#if availableMethods.includes('password')}
154 <button
155 class="tab"
156 class:active={activeMethod === 'password'}
157 onclick={() => activeMethod = 'password'}
158 >
159 {$_('reauth.password')}
160 </button>
161 {/if}
162 {#if availableMethods.includes('totp')}
163 <button
164 class="tab"
165 class:active={activeMethod === 'totp'}
166 onclick={() => activeMethod = 'totp'}
167 >
168 {$_('reauth.totp')}
169 </button>
170 {/if}
171 {#if availableMethods.includes('passkey')}
172 <button
173 class="tab"
174 class:active={activeMethod === 'passkey'}
175 onclick={() => activeMethod = 'passkey'}
176 >
177 {$_('reauth.passkey')}
178 </button>
179 {/if}
180 </div>
181 {/if}
182
183 <div class="modal-content">
184 {#if activeMethod === 'password'}
185 <form id="reauth-form" onsubmit={handlePasswordSubmit}>
186 <div>
187 <label for="reauth-password">{$_('reauth.password')}</label>
188 <input
189 id="reauth-password"
190 type="password"
191 bind:value={password}
192 required
193 autocomplete="current-password"
194 />
195 </div>
196 </form>
197 {:else if activeMethod === 'totp'}
198 <form id="reauth-form" onsubmit={handleTotpSubmit}>
199 <div>
200 <label for="reauth-totp">{$_('reauth.authenticatorCode')}</label>
201 <input
202 id="reauth-totp"
203 type="text"
204 bind:value={totpCode}
205 required
206 autocomplete="one-time-code"
207 inputmode="numeric"
208 pattern="[0-9]*"
209 maxlength="6"
210 />
211 </div>
212 </form>
213 {:else if activeMethod === 'passkey'}
214 <div class="passkey-auth">
215 <p>{$_('reauth.usePasskey')}</p>
216 </div>
217 {/if}
218 </div>
219
220 <div class="modal-footer">
221 <button class="secondary" onclick={handleClose} disabled={loading}>
222 {$_('reauth.cancel')}
223 </button>
224 {#if activeMethod === 'passkey'}
225 <button onclick={handlePasskeyAuth} disabled={loading}>
226 {loading ? $_('reauth.authenticating') : $_('common.verify')}
227 </button>
228 {:else}
229 <button type="submit" form="reauth-form" disabled={loading || (activeMethod === 'password' ? !password : !totpCode)}>
230 {loading ? $_('common.verifying') : $_('common.verify')}
231 </button>
232 {/if}
233 </div>
234 </div>
235 </div>
236{/if}