forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
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 <p class="modal-description">
147 {$_('reauth.subtitle')}
148 </p>
149
150 {#if error}
151 <div class="error-message">{error}</div>
152 {/if}
153
154 {#if availableMethods.length > 1}
155 <div class="method-tabs">
156 {#if availableMethods.includes('password')}
157 <button
158 class="tab"
159 class:active={activeMethod === 'password'}
160 onclick={() => activeMethod = 'password'}
161 >
162 {$_('reauth.password')}
163 </button>
164 {/if}
165 {#if availableMethods.includes('totp')}
166 <button
167 class="tab"
168 class:active={activeMethod === 'totp'}
169 onclick={() => activeMethod = 'totp'}
170 >
171 {$_('reauth.totp')}
172 </button>
173 {/if}
174 {#if availableMethods.includes('passkey')}
175 <button
176 class="tab"
177 class:active={activeMethod === 'passkey'}
178 onclick={() => activeMethod = 'passkey'}
179 >
180 {$_('reauth.passkey')}
181 </button>
182 {/if}
183 </div>
184 {/if}
185
186 <div class="modal-content">
187 {#if activeMethod === 'password'}
188 <form onsubmit={handlePasswordSubmit}>
189 <div class="field">
190 <label for="reauth-password">{$_('reauth.password')}</label>
191 <input
192 id="reauth-password"
193 type="password"
194 bind:value={password}
195 required
196 autocomplete="current-password"
197 />
198 </div>
199 <button type="submit" disabled={loading || !password}>
200 {loading ? $_('common.verifying') : $_('common.verify')}
201 </button>
202 </form>
203 {:else if activeMethod === 'totp'}
204 <form onsubmit={handleTotpSubmit}>
205 <div class="field">
206 <label for="reauth-totp">{$_('reauth.authenticatorCode')}</label>
207 <input
208 id="reauth-totp"
209 type="text"
210 bind:value={totpCode}
211 required
212 autocomplete="one-time-code"
213 inputmode="numeric"
214 pattern="[0-9]*"
215 maxlength="6"
216 />
217 </div>
218 <button type="submit" disabled={loading || !totpCode}>
219 {loading ? $_('common.verifying') : $_('common.verify')}
220 </button>
221 </form>
222 {:else if activeMethod === 'passkey'}
223 <div class="passkey-auth">
224 <p>{$_('reauth.passkeyPrompt')}</p>
225 <button onclick={handlePasskeyAuth} disabled={loading}>
226 {loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')}
227 </button>
228 </div>
229 {/if}
230 </div>
231
232 <div class="modal-footer">
233 <button class="secondary" onclick={handleClose} disabled={loading}>
234 {$_('reauth.cancel')}
235 </button>
236 </div>
237 </div>
238 </div>
239{/if}
240
241<style>
242 .modal-backdrop {
243 position: fixed;
244 inset: 0;
245 background: var(--overlay-bg);
246 display: flex;
247 align-items: center;
248 justify-content: center;
249 z-index: var(--z-modal);
250 }
251
252 .modal {
253 background: var(--bg-card);
254 border-radius: var(--radius-xl);
255 box-shadow: var(--shadow-lg);
256 max-width: var(--width-sm);
257 width: 90%;
258 max-height: 90vh;
259 overflow-y: auto;
260 }
261
262 .modal-header {
263 display: flex;
264 justify-content: space-between;
265 align-items: center;
266 padding: var(--space-4) var(--space-6);
267 border-bottom: 1px solid var(--border-color);
268 }
269
270 .modal-header h2 {
271 margin: 0;
272 font-size: var(--text-lg);
273 }
274
275 .close-btn {
276 background: none;
277 border: none;
278 font-size: var(--text-xl);
279 cursor: pointer;
280 color: var(--text-secondary);
281 padding: 0;
282 line-height: 1;
283 }
284
285 .close-btn:hover {
286 color: var(--text-primary);
287 }
288
289 .modal-description {
290 padding: var(--space-4) var(--space-6) 0;
291 margin: 0;
292 color: var(--text-secondary);
293 }
294
295 .error-message {
296 margin: var(--space-4) var(--space-6) 0;
297 padding: var(--space-3);
298 background: var(--error-bg);
299 border: 1px solid var(--error-border);
300 border-radius: var(--radius-md);
301 color: var(--error-text);
302 font-size: var(--text-sm);
303 }
304
305 .method-tabs {
306 display: flex;
307 gap: var(--space-2);
308 padding: var(--space-4) var(--space-6) 0;
309 }
310
311 .tab {
312 flex: 1;
313 padding: var(--space-2) var(--space-4);
314 background: var(--bg-input);
315 border: 1px solid var(--border-color);
316 border-radius: var(--radius-md);
317 cursor: pointer;
318 color: var(--text-secondary);
319 font-size: var(--text-sm);
320 }
321
322 .tab:hover {
323 background: var(--bg-secondary);
324 }
325
326 .tab.active {
327 background: var(--accent);
328 border-color: var(--accent);
329 color: var(--text-inverse);
330 }
331
332 .modal-content {
333 padding: var(--space-6);
334 }
335
336 .modal-content .field {
337 margin-bottom: var(--space-4);
338 }
339
340 .passkey-auth {
341 text-align: center;
342 }
343
344 .passkey-auth p {
345 margin-bottom: var(--space-4);
346 color: var(--text-secondary);
347 }
348
349 .modal-content button:not(.tab) {
350 width: 100%;
351 }
352
353 .modal-footer {
354 padding: 0 var(--space-6) var(--space-6);
355 display: flex;
356 justify-content: flex-end;
357 }
358</style>