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
6 interface Props {
7 show: boolean
8 availableMethods?: string[]
9 onSuccess: () => void
10 onCancel: () => void
11 }
12
13 let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props()
14
15 const auth = getAuthState()
16 let activeMethod = $state<'password' | 'totp' | 'passkey'>('password')
17 let password = $state('')
18 let totpCode = $state('')
19 let loading = $state(false)
20 let error = $state('')
21
22 $effect(() => {
23 if (show) {
24 password = ''
25 totpCode = ''
26 error = ''
27 if (availableMethods.includes('password')) {
28 activeMethod = 'password'
29 } else if (availableMethods.includes('totp')) {
30 activeMethod = 'totp'
31 } else if (availableMethods.includes('passkey')) {
32 activeMethod = 'passkey'
33 if (availableMethods.length === 1) {
34 handlePasskeyAuth()
35 }
36 }
37 }
38 })
39
40 function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
41 const bytes = new Uint8Array(buffer)
42 let binary = ''
43 for (let i = 0; i < bytes.byteLength; i++) {
44 binary += String.fromCharCode(bytes[i])
45 }
46 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
47 }
48
49 function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
50 const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
51 const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
52 const binary = atob(padded)
53 const bytes = new Uint8Array(binary.length)
54 for (let i = 0; i < binary.length; i++) {
55 bytes[i] = binary.charCodeAt(i)
56 }
57 return bytes.buffer
58 }
59
60 function prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions {
61 return {
62 ...options.publicKey,
63 challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
64 allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
65 ...cred,
66 id: base64UrlToArrayBuffer(cred.id)
67 })) || []
68 }
69 }
70
71 async function handlePasswordSubmit(e: Event) {
72 e.preventDefault()
73 if (!auth.session || !password) return
74 loading = true
75 error = ''
76 try {
77 const token = await getValidToken()
78 if (!token) {
79 error = 'Session expired. Please log in again.'
80 return
81 }
82 await api.reauthPassword(token, password)
83 show = false
84 onSuccess()
85 } catch (e) {
86 error = e instanceof ApiError ? e.message : 'Authentication failed'
87 } finally {
88 loading = false
89 }
90 }
91
92 async function handleTotpSubmit(e: Event) {
93 e.preventDefault()
94 if (!auth.session || !totpCode) return
95 loading = true
96 error = ''
97 try {
98 const token = await getValidToken()
99 if (!token) {
100 error = 'Session expired. Please log in again.'
101 return
102 }
103 await api.reauthTotp(token, totpCode)
104 show = false
105 onSuccess()
106 } catch (e) {
107 error = e instanceof ApiError ? e.message : 'Invalid code'
108 } finally {
109 loading = false
110 }
111 }
112
113 async function handlePasskeyAuth() {
114 if (!auth.session) return
115 if (!window.PublicKeyCredential) {
116 error = 'Passkeys are not supported in this browser'
117 return
118 }
119 loading = true
120 error = ''
121 try {
122 const token = await getValidToken()
123 if (!token) {
124 error = 'Session expired. Please log in again.'
125 return
126 }
127 const { options } = await api.reauthPasskeyStart(token)
128 const publicKeyOptions = prepareAuthOptions(options)
129 const credential = await navigator.credentials.get({
130 publicKey: publicKeyOptions
131 })
132 if (!credential) {
133 error = 'Passkey authentication was cancelled'
134 return
135 }
136 const pkCredential = credential as PublicKeyCredential
137 const response = pkCredential.response as AuthenticatorAssertionResponse
138 const credentialResponse = {
139 id: pkCredential.id,
140 type: pkCredential.type,
141 rawId: arrayBufferToBase64Url(pkCredential.rawId),
142 response: {
143 clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
144 authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
145 signature: arrayBufferToBase64Url(response.signature),
146 userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
147 },
148 }
149 await api.reauthPasskeyFinish(token, credentialResponse)
150 show = false
151 onSuccess()
152 } catch (e) {
153 if (e instanceof DOMException && e.name === 'NotAllowedError') {
154 error = 'Passkey authentication was cancelled'
155 } else {
156 error = e instanceof ApiError ? e.message : 'Passkey authentication failed'
157 }
158 } finally {
159 loading = false
160 }
161 }
162
163 function handleClose() {
164 show = false
165 onCancel()
166 }
167</script>
168
169{#if show}
170 <div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
171 <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
172 <div class="modal-header">
173 <h2>{$_('reauth.title')}</h2>
174 <button class="close-btn" onclick={handleClose} aria-label="Close">×</button>
175 </div>
176
177 <p class="modal-description">
178 {$_('reauth.subtitle')}
179 </p>
180
181 {#if error}
182 <div class="error-message">{error}</div>
183 {/if}
184
185 {#if availableMethods.length > 1}
186 <div class="method-tabs">
187 {#if availableMethods.includes('password')}
188 <button
189 class="tab"
190 class:active={activeMethod === 'password'}
191 onclick={() => activeMethod = 'password'}
192 >
193 {$_('reauth.password')}
194 </button>
195 {/if}
196 {#if availableMethods.includes('totp')}
197 <button
198 class="tab"
199 class:active={activeMethod === 'totp'}
200 onclick={() => activeMethod = 'totp'}
201 >
202 {$_('reauth.totp')}
203 </button>
204 {/if}
205 {#if availableMethods.includes('passkey')}
206 <button
207 class="tab"
208 class:active={activeMethod === 'passkey'}
209 onclick={() => activeMethod = 'passkey'}
210 >
211 {$_('reauth.passkey')}
212 </button>
213 {/if}
214 </div>
215 {/if}
216
217 <div class="modal-content">
218 {#if activeMethod === 'password'}
219 <form onsubmit={handlePasswordSubmit}>
220 <div class="form-group">
221 <label for="reauth-password">{$_('reauth.password')}</label>
222 <input
223 id="reauth-password"
224 type="password"
225 bind:value={password}
226 required
227 autocomplete="current-password"
228 />
229 </div>
230 <button type="submit" class="btn-primary" disabled={loading || !password}>
231 {loading ? $_('common.verifying') : $_('common.verify')}
232 </button>
233 </form>
234 {:else if activeMethod === 'totp'}
235 <form onsubmit={handleTotpSubmit}>
236 <div class="form-group">
237 <label for="reauth-totp">{$_('reauth.authenticatorCode')}</label>
238 <input
239 id="reauth-totp"
240 type="text"
241 bind:value={totpCode}
242 required
243 autocomplete="one-time-code"
244 inputmode="numeric"
245 pattern="[0-9]*"
246 maxlength="6"
247 />
248 </div>
249 <button type="submit" class="btn-primary" disabled={loading || !totpCode}>
250 {loading ? $_('common.verifying') : $_('common.verify')}
251 </button>
252 </form>
253 {:else if activeMethod === 'passkey'}
254 <div class="passkey-auth">
255 <p>{$_('reauth.passkeyPrompt')}</p>
256 <button
257 class="btn-primary"
258 onclick={handlePasskeyAuth}
259 disabled={loading}
260 >
261 {loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')}
262 </button>
263 </div>
264 {/if}
265 </div>
266
267 <div class="modal-footer">
268 <button class="btn-secondary" onclick={handleClose} disabled={loading}>
269 {$_('reauth.cancel')}
270 </button>
271 </div>
272 </div>
273 </div>
274{/if}
275
276<style>
277 .modal-backdrop {
278 position: fixed;
279 inset: 0;
280 background: rgba(0, 0, 0, 0.5);
281 display: flex;
282 align-items: center;
283 justify-content: center;
284 z-index: 1000;
285 }
286
287 .modal {
288 background: var(--bg-card);
289 border-radius: 8px;
290 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
291 max-width: 400px;
292 width: 90%;
293 max-height: 90vh;
294 overflow-y: auto;
295 }
296
297 .modal-header {
298 display: flex;
299 justify-content: space-between;
300 align-items: center;
301 padding: 1rem 1.5rem;
302 border-bottom: 1px solid var(--border-color);
303 }
304
305 .modal-header h2 {
306 margin: 0;
307 font-size: 1.25rem;
308 }
309
310 .close-btn {
311 background: none;
312 border: none;
313 font-size: 1.5rem;
314 cursor: pointer;
315 color: var(--text-secondary);
316 padding: 0;
317 line-height: 1;
318 }
319
320 .close-btn:hover {
321 color: var(--text-primary);
322 }
323
324 .modal-description {
325 padding: 1rem 1.5rem 0;
326 margin: 0;
327 color: var(--text-secondary);
328 }
329
330 .error-message {
331 margin: 1rem 1.5rem 0;
332 padding: 0.75rem;
333 background: var(--error-bg);
334 border: 1px solid var(--error-border);
335 border-radius: 4px;
336 color: var(--error-text);
337 font-size: 0.875rem;
338 }
339
340 .method-tabs {
341 display: flex;
342 gap: 0.5rem;
343 padding: 1rem 1.5rem 0;
344 }
345
346 .tab {
347 flex: 1;
348 padding: 0.5rem 1rem;
349 background: var(--bg-input);
350 border: 1px solid var(--border-color);
351 border-radius: 4px;
352 cursor: pointer;
353 color: var(--text-secondary);
354 font-size: 0.875rem;
355 }
356
357 .tab:hover {
358 background: var(--bg-secondary);
359 }
360
361 .tab.active {
362 background: var(--accent);
363 border-color: var(--accent);
364 color: white;
365 }
366
367 .modal-content {
368 padding: 1.5rem;
369 }
370
371 .form-group {
372 margin-bottom: 1rem;
373 }
374
375 .form-group label {
376 display: block;
377 margin-bottom: 0.5rem;
378 font-weight: 500;
379 }
380
381 .form-group input {
382 width: 100%;
383 padding: 0.75rem;
384 border: 1px solid var(--border-color);
385 border-radius: 4px;
386 background: var(--bg-input);
387 color: var(--text-primary);
388 font-size: 1rem;
389 }
390
391 .form-group input:focus {
392 outline: none;
393 border-color: var(--accent);
394 }
395
396 .passkey-auth {
397 text-align: center;
398 }
399
400 .passkey-auth p {
401 margin-bottom: 1rem;
402 color: var(--text-secondary);
403 }
404
405 .btn-primary {
406 width: 100%;
407 padding: 0.75rem 1.5rem;
408 background: var(--accent);
409 color: white;
410 border: none;
411 border-radius: 4px;
412 font-size: 1rem;
413 cursor: pointer;
414 }
415
416 .btn-primary:hover:not(:disabled) {
417 background: var(--accent-hover);
418 }
419
420 .btn-primary:disabled {
421 opacity: 0.6;
422 cursor: not-allowed;
423 }
424
425 .modal-footer {
426 padding: 0 1.5rem 1.5rem;
427 display: flex;
428 justify-content: flex-end;
429 }
430
431 .btn-secondary {
432 padding: 0.5rem 1rem;
433 background: var(--bg-input);
434 border: 1px solid var(--border-color);
435 border-radius: 4px;
436 color: var(--text-secondary);
437 cursor: pointer;
438 font-size: 0.875rem;
439 }
440
441 .btn-secondary:hover:not(:disabled) {
442 background: var(--bg-secondary);
443 }
444
445 .btn-secondary:disabled {
446 opacity: 0.6;
447 cursor: not-allowed;
448 }
449</style>