Our Personal Data Server from scratch!
tranquil.farm
oauth
atproto
pds
rust
postgresql
objectstorage
fun
1<script lang="ts">
2 import { getAuthState, getValidToken } from '../lib/auth.svelte'
3 import { api, ApiError } from '../lib/api'
4 import { _ } from '../lib/i18n'
5 import type { Session } from '../lib/types/api'
6 import {
7 prepareRequestOptions,
8 serializeAssertionResponse,
9 type WebAuthnRequestOptionsResponse,
10 } from '../lib/webauthn'
11
12 interface Props {
13 show: boolean
14 availableMethods?: string[]
15 onSuccess: () => void
16 onCancel: () => void
17 }
18
19 let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props()
20
21 const auth = $derived(getAuthState())
22
23 function getSession(): Session | null {
24 return auth.kind === 'authenticated' ? auth.session : null
25 }
26
27 const session = $derived(getSession())
28 let activeMethod = $state<'password' | 'totp' | 'passkey'>('password')
29 let password = $state('')
30 let totpCode = $state('')
31 let loading = $state(false)
32 let error = $state('')
33
34 $effect(() => {
35 if (show) {
36 password = ''
37 totpCode = ''
38 error = ''
39 if (availableMethods.includes('password')) {
40 activeMethod = 'password'
41 } else if (availableMethods.includes('totp')) {
42 activeMethod = 'totp'
43 } else if (availableMethods.includes('passkey')) {
44 activeMethod = 'passkey'
45 if (availableMethods.length === 1) {
46 handlePasskeyAuth()
47 }
48 }
49 }
50 })
51
52 async function handlePasswordSubmit(e: Event) {
53 e.preventDefault()
54 if (!session || !password) return
55 loading = true
56 error = ''
57 try {
58 const token = await getValidToken()
59 if (!token) {
60 error = 'Session expired. Please log in again.'
61 return
62 }
63 await api.reauthPassword(token, password)
64 show = false
65 onSuccess()
66 } catch (e) {
67 error = e instanceof ApiError ? e.message : 'Authentication failed'
68 } finally {
69 loading = false
70 }
71 }
72
73 async function handleTotpSubmit(e: Event) {
74 e.preventDefault()
75 if (!session || !totpCode) return
76 loading = true
77 error = ''
78 try {
79 const token = await getValidToken()
80 if (!token) {
81 error = 'Session expired. Please log in again.'
82 return
83 }
84 await api.reauthTotp(token, totpCode)
85 show = false
86 onSuccess()
87 } catch (e) {
88 error = e instanceof ApiError ? e.message : 'Invalid code'
89 } finally {
90 loading = false
91 }
92 }
93
94 async function handlePasskeyAuth() {
95 if (!session) return
96 if (!window.PublicKeyCredential) {
97 error = 'Passkeys are not supported in this browser'
98 return
99 }
100 loading = true
101 error = ''
102 try {
103 const token = await getValidToken()
104 if (!token) {
105 error = 'Session expired. Please log in again.'
106 return
107 }
108 const { options } = await api.reauthPasskeyStart(token)
109 const publicKeyOptions = prepareRequestOptions(options as unknown as WebAuthnRequestOptionsResponse)
110 const credential = await navigator.credentials.get({
111 publicKey: publicKeyOptions
112 })
113 if (!credential) {
114 error = 'Passkey authentication was cancelled'
115 return
116 }
117 const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential)
118 await api.reauthPasskeyFinish(token, credentialResponse)
119 show = false
120 onSuccess()
121 } catch (e) {
122 if (e instanceof DOMException && e.name === 'NotAllowedError') {
123 error = 'Passkey authentication was cancelled'
124 } else {
125 error = e instanceof ApiError ? e.message : 'Passkey authentication failed'
126 }
127 } finally {
128 loading = false
129 }
130 }
131
132 function handleClose() {
133 show = false
134 onCancel()
135 }
136</script>
137
138{#if show}
139 <div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
140 <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
141 <div class="modal-header">
142 <h2>{$_('reauth.title')}</h2>
143 <button class="close-btn" onclick={handleClose} aria-label="Close">×</button>
144 </div>
145
146 {#if error}
147 <div class="error-message">{error}</div>
148 {/if}
149
150 {#if availableMethods.length > 1}
151 <div class="method-tabs">
152 {#if availableMethods.includes('password')}
153 <button
154 class="tab"
155 class:active={activeMethod === 'password'}
156 onclick={() => activeMethod = 'password'}
157 >
158 {$_('reauth.password')}
159 </button>
160 {/if}
161 {#if availableMethods.includes('totp')}
162 <button
163 class="tab"
164 class:active={activeMethod === 'totp'}
165 onclick={() => activeMethod = 'totp'}
166 >
167 {$_('reauth.totp')}
168 </button>
169 {/if}
170 {#if availableMethods.includes('passkey')}
171 <button
172 class="tab"
173 class:active={activeMethod === 'passkey'}
174 onclick={() => activeMethod = 'passkey'}
175 >
176 {$_('reauth.passkey')}
177 </button>
178 {/if}
179 </div>
180 {/if}
181
182 <div class="modal-content">
183 {#if activeMethod === 'password'}
184 <form onsubmit={handlePasswordSubmit}>
185 <div class="field">
186 <label for="reauth-password">{$_('reauth.password')}</label>
187 <input
188 id="reauth-password"
189 type="password"
190 bind:value={password}
191 required
192 autocomplete="current-password"
193 />
194 </div>
195 <button type="submit" disabled={loading || !password}>
196 {loading ? $_('common.verifying') : $_('common.verify')}
197 </button>
198 </form>
199 {:else if activeMethod === 'totp'}
200 <form onsubmit={handleTotpSubmit}>
201 <div class="field">
202 <label for="reauth-totp">{$_('reauth.authenticatorCode')}</label>
203 <input
204 id="reauth-totp"
205 type="text"
206 bind:value={totpCode}
207 required
208 autocomplete="one-time-code"
209 inputmode="numeric"
210 pattern="[0-9]*"
211 maxlength="6"
212 />
213 </div>
214 <button type="submit" disabled={loading || !totpCode}>
215 {loading ? $_('common.verifying') : $_('common.verify')}
216 </button>
217 </form>
218 {:else if activeMethod === 'passkey'}
219 <div class="passkey-auth">
220 <button onclick={handlePasskeyAuth} disabled={loading}>
221 {loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')}
222 </button>
223 </div>
224 {/if}
225 </div>
226
227 <div class="modal-footer">
228 <button class="secondary" onclick={handleClose} disabled={loading}>
229 {$_('reauth.cancel')}
230 </button>
231 </div>
232 </div>
233 </div>
234{/if}
235
236<style>
237 .modal-backdrop {
238 position: fixed;
239 inset: 0;
240 background: var(--overlay-bg);
241 display: flex;
242 align-items: center;
243 justify-content: center;
244 z-index: var(--z-modal);
245 }
246
247 .modal {
248 background: var(--bg-card);
249 border-radius: var(--radius-xl);
250 box-shadow: var(--shadow-lg);
251 max-width: var(--width-sm);
252 width: 90%;
253 max-height: 90vh;
254 overflow-y: auto;
255 }
256
257 .modal-header {
258 display: flex;
259 justify-content: space-between;
260 align-items: center;
261 padding: var(--space-4) var(--space-6);
262 border-bottom: 1px solid var(--border-color);
263 }
264
265 .modal-header h2 {
266 margin: 0;
267 font-size: var(--text-lg);
268 }
269
270 .close-btn {
271 background: none;
272 border: none;
273 font-size: var(--text-xl);
274 cursor: pointer;
275 color: var(--text-secondary);
276 padding: 0;
277 line-height: 1;
278 }
279
280 .close-btn:hover {
281 color: var(--text-primary);
282 }
283
284 .error-message {
285 margin: var(--space-4) var(--space-6) 0;
286 padding: var(--space-3);
287 background: var(--error-bg);
288 border: 1px solid var(--error-border);
289 border-radius: var(--radius-md);
290 color: var(--error-text);
291 font-size: var(--text-sm);
292 }
293
294 .method-tabs {
295 display: flex;
296 gap: var(--space-2);
297 padding: var(--space-4) var(--space-6) 0;
298 }
299
300 .tab {
301 flex: 1;
302 padding: var(--space-2) var(--space-4);
303 background: var(--bg-input);
304 border: 1px solid var(--border-color);
305 border-radius: var(--radius-md);
306 cursor: pointer;
307 color: var(--text-secondary);
308 font-size: var(--text-sm);
309 }
310
311 .tab:hover {
312 background: var(--bg-secondary);
313 }
314
315 .tab.active {
316 background: var(--accent);
317 border-color: var(--accent);
318 color: var(--text-inverse);
319 }
320
321 .modal-content {
322 padding: var(--space-6);
323 }
324
325 .modal-content .field {
326 margin-bottom: var(--space-4);
327 }
328
329 .passkey-auth {
330 text-align: center;
331 }
332
333 .modal-content button:not(.tab) {
334 width: 100%;
335 }
336
337 .modal-footer {
338 padding: 0 var(--space-6) var(--space-6);
339 display: flex;
340 justify-content: flex-end;
341 }
342</style>