this repo has no description
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="form-group">
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" class="btn-primary" 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="form-group">
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" class="btn-primary" 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
226 class="btn-primary"
227 onclick={handlePasskeyAuth}
228 disabled={loading}
229 >
230 {loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')}
231 </button>
232 </div>
233 {/if}
234 </div>
235
236 <div class="modal-footer">
237 <button class="btn-secondary" onclick={handleClose} disabled={loading}>
238 {$_('reauth.cancel')}
239 </button>
240 </div>
241 </div>
242 </div>
243{/if}
244
245<style>
246 .modal-backdrop {
247 position: fixed;
248 inset: 0;
249 background: rgba(0, 0, 0, 0.5);
250 display: flex;
251 align-items: center;
252 justify-content: center;
253 z-index: 1000;
254 }
255
256 .modal {
257 background: var(--bg-card);
258 border-radius: 8px;
259 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
260 max-width: 400px;
261 width: 90%;
262 max-height: 90vh;
263 overflow-y: auto;
264 }
265
266 .modal-header {
267 display: flex;
268 justify-content: space-between;
269 align-items: center;
270 padding: 1rem 1.5rem;
271 border-bottom: 1px solid var(--border-color);
272 }
273
274 .modal-header h2 {
275 margin: 0;
276 font-size: 1.25rem;
277 }
278
279 .close-btn {
280 background: none;
281 border: none;
282 font-size: 1.5rem;
283 cursor: pointer;
284 color: var(--text-secondary);
285 padding: 0;
286 line-height: 1;
287 }
288
289 .close-btn:hover {
290 color: var(--text-primary);
291 }
292
293 .modal-description {
294 padding: 1rem 1.5rem 0;
295 margin: 0;
296 color: var(--text-secondary);
297 }
298
299 .error-message {
300 margin: 1rem 1.5rem 0;
301 padding: 0.75rem;
302 background: var(--error-bg);
303 border: 1px solid var(--error-border);
304 border-radius: 4px;
305 color: var(--error-text);
306 font-size: 0.875rem;
307 }
308
309 .method-tabs {
310 display: flex;
311 gap: 0.5rem;
312 padding: 1rem 1.5rem 0;
313 }
314
315 .tab {
316 flex: 1;
317 padding: 0.5rem 1rem;
318 background: var(--bg-input);
319 border: 1px solid var(--border-color);
320 border-radius: 4px;
321 cursor: pointer;
322 color: var(--text-secondary);
323 font-size: 0.875rem;
324 }
325
326 .tab:hover {
327 background: var(--bg-secondary);
328 }
329
330 .tab.active {
331 background: var(--accent);
332 border-color: var(--accent);
333 color: var(--text-inverse);
334 }
335
336 .modal-content {
337 padding: 1.5rem;
338 }
339
340 .form-group {
341 margin-bottom: 1rem;
342 }
343
344 .form-group label {
345 display: block;
346 margin-bottom: 0.5rem;
347 font-weight: 500;
348 }
349
350 .form-group input {
351 width: 100%;
352 padding: 0.75rem;
353 border: 1px solid var(--border-color);
354 border-radius: 4px;
355 background: var(--bg-input);
356 color: var(--text-primary);
357 font-size: 1rem;
358 }
359
360 .form-group input:focus {
361 outline: none;
362 border-color: var(--accent);
363 }
364
365 .passkey-auth {
366 text-align: center;
367 }
368
369 .passkey-auth p {
370 margin-bottom: 1rem;
371 color: var(--text-secondary);
372 }
373
374 .btn-primary {
375 width: 100%;
376 padding: 0.75rem 1.5rem;
377 background: var(--accent);
378 color: var(--text-inverse);
379 border: none;
380 border-radius: 4px;
381 font-size: 1rem;
382 cursor: pointer;
383 }
384
385 .btn-primary:hover:not(:disabled) {
386 background: var(--accent-hover);
387 }
388
389 .btn-primary:disabled {
390 opacity: 0.6;
391 cursor: not-allowed;
392 }
393
394 .modal-footer {
395 padding: 0 1.5rem 1.5rem;
396 display: flex;
397 justify-content: flex-end;
398 }
399
400 .btn-secondary {
401 padding: 0.5rem 1rem;
402 background: var(--bg-input);
403 border: 1px solid var(--border-color);
404 border-radius: 4px;
405 color: var(--text-secondary);
406 cursor: pointer;
407 font-size: 0.875rem;
408 }
409
410 .btn-secondary:hover:not(:disabled) {
411 background: var(--bg-secondary);
412 }
413
414 .btn-secondary:disabled {
415 opacity: 0.6;
416 cursor: not-allowed;
417 }
418</style>