this repo has no description
1<script lang="ts">
2 import { getAuthState } 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 await api.reauthPassword(auth.session.accessJwt, password)
78 show = false
79 onSuccess()
80 } catch (e) {
81 error = e instanceof ApiError ? e.message : 'Authentication failed'
82 } finally {
83 loading = false
84 }
85 }
86
87 async function handleTotpSubmit(e: Event) {
88 e.preventDefault()
89 if (!auth.session || !totpCode) return
90 loading = true
91 error = ''
92 try {
93 await api.reauthTotp(auth.session.accessJwt, totpCode)
94 show = false
95 onSuccess()
96 } catch (e) {
97 error = e instanceof ApiError ? e.message : 'Invalid code'
98 } finally {
99 loading = false
100 }
101 }
102
103 async function handlePasskeyAuth() {
104 if (!auth.session) return
105 if (!window.PublicKeyCredential) {
106 error = 'Passkeys are not supported in this browser'
107 return
108 }
109 loading = true
110 error = ''
111 try {
112 const { options } = await api.reauthPasskeyStart(auth.session.accessJwt)
113 const publicKeyOptions = prepareAuthOptions(options)
114 const credential = await navigator.credentials.get({
115 publicKey: publicKeyOptions
116 })
117 if (!credential) {
118 error = 'Passkey authentication was cancelled'
119 return
120 }
121 const pkCredential = credential as PublicKeyCredential
122 const response = pkCredential.response as AuthenticatorAssertionResponse
123 const credentialResponse = {
124 id: pkCredential.id,
125 type: pkCredential.type,
126 rawId: arrayBufferToBase64Url(pkCredential.rawId),
127 response: {
128 clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
129 authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
130 signature: arrayBufferToBase64Url(response.signature),
131 userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
132 },
133 }
134 await api.reauthPasskeyFinish(auth.session.accessJwt, credentialResponse)
135 show = false
136 onSuccess()
137 } catch (e) {
138 if (e instanceof DOMException && e.name === 'NotAllowedError') {
139 error = 'Passkey authentication was cancelled'
140 } else {
141 error = e instanceof ApiError ? e.message : 'Passkey authentication failed'
142 }
143 } finally {
144 loading = false
145 }
146 }
147
148 function handleClose() {
149 show = false
150 onCancel()
151 }
152</script>
153
154{#if show}
155 <div class="modal-backdrop" onclick={handleClose} role="presentation">
156 <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
157 <div class="modal-header">
158 <h2>Re-authentication Required</h2>
159 <button class="close-btn" onclick={handleClose} aria-label="Close">×</button>
160 </div>
161
162 <p class="modal-description">
163 This action requires you to verify your identity.
164 </p>
165
166 {#if error}
167 <div class="error-message">{error}</div>
168 {/if}
169
170 {#if availableMethods.length > 1}
171 <div class="method-tabs">
172 {#if availableMethods.includes('password')}
173 <button
174 class="tab"
175 class:active={activeMethod === 'password'}
176 onclick={() => activeMethod = 'password'}
177 >
178 Password
179 </button>
180 {/if}
181 {#if availableMethods.includes('totp')}
182 <button
183 class="tab"
184 class:active={activeMethod === 'totp'}
185 onclick={() => activeMethod = 'totp'}
186 >
187 TOTP
188 </button>
189 {/if}
190 {#if availableMethods.includes('passkey')}
191 <button
192 class="tab"
193 class:active={activeMethod === 'passkey'}
194 onclick={() => activeMethod = 'passkey'}
195 >
196 Passkey
197 </button>
198 {/if}
199 </div>
200 {/if}
201
202 <div class="modal-content">
203 {#if activeMethod === 'password'}
204 <form onsubmit={handlePasswordSubmit}>
205 <div class="form-group">
206 <label for="reauth-password">Password</label>
207 <input
208 id="reauth-password"
209 type="password"
210 bind:value={password}
211 required
212 autocomplete="current-password"
213 />
214 </div>
215 <button type="submit" class="btn-primary" disabled={loading || !password}>
216 {loading ? 'Verifying...' : 'Verify'}
217 </button>
218 </form>
219 {:else if activeMethod === 'totp'}
220 <form onsubmit={handleTotpSubmit}>
221 <div class="form-group">
222 <label for="reauth-totp">Authenticator Code</label>
223 <input
224 id="reauth-totp"
225 type="text"
226 bind:value={totpCode}
227 required
228 autocomplete="one-time-code"
229 inputmode="numeric"
230 pattern="[0-9]*"
231 maxlength="6"
232 />
233 </div>
234 <button type="submit" class="btn-primary" disabled={loading || !totpCode}>
235 {loading ? 'Verifying...' : 'Verify'}
236 </button>
237 </form>
238 {:else if activeMethod === 'passkey'}
239 <div class="passkey-auth">
240 <p>Click the button below to authenticate with your passkey.</p>
241 <button
242 class="btn-primary"
243 onclick={handlePasskeyAuth}
244 disabled={loading}
245 >
246 {loading ? 'Authenticating...' : 'Use Passkey'}
247 </button>
248 </div>
249 {/if}
250 </div>
251
252 <div class="modal-footer">
253 <button class="btn-secondary" onclick={handleClose} disabled={loading}>
254 Cancel
255 </button>
256 </div>
257 </div>
258 </div>
259{/if}
260
261<style>
262 .modal-backdrop {
263 position: fixed;
264 inset: 0;
265 background: rgba(0, 0, 0, 0.5);
266 display: flex;
267 align-items: center;
268 justify-content: center;
269 z-index: 1000;
270 }
271
272 .modal {
273 background: var(--bg-card);
274 border-radius: 8px;
275 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
276 max-width: 400px;
277 width: 90%;
278 max-height: 90vh;
279 overflow-y: auto;
280 }
281
282 .modal-header {
283 display: flex;
284 justify-content: space-between;
285 align-items: center;
286 padding: 1rem 1.5rem;
287 border-bottom: 1px solid var(--border-color);
288 }
289
290 .modal-header h2 {
291 margin: 0;
292 font-size: 1.25rem;
293 }
294
295 .close-btn {
296 background: none;
297 border: none;
298 font-size: 1.5rem;
299 cursor: pointer;
300 color: var(--text-secondary);
301 padding: 0;
302 line-height: 1;
303 }
304
305 .close-btn:hover {
306 color: var(--text-primary);
307 }
308
309 .modal-description {
310 padding: 1rem 1.5rem 0;
311 margin: 0;
312 color: var(--text-secondary);
313 }
314
315 .error-message {
316 margin: 1rem 1.5rem 0;
317 padding: 0.75rem;
318 background: var(--error-bg);
319 border: 1px solid var(--error-border);
320 border-radius: 4px;
321 color: var(--error-text);
322 font-size: 0.875rem;
323 }
324
325 .method-tabs {
326 display: flex;
327 gap: 0.5rem;
328 padding: 1rem 1.5rem 0;
329 }
330
331 .tab {
332 flex: 1;
333 padding: 0.5rem 1rem;
334 background: var(--bg-input);
335 border: 1px solid var(--border-color);
336 border-radius: 4px;
337 cursor: pointer;
338 color: var(--text-secondary);
339 font-size: 0.875rem;
340 }
341
342 .tab:hover {
343 background: var(--bg-secondary);
344 }
345
346 .tab.active {
347 background: var(--accent);
348 border-color: var(--accent);
349 color: white;
350 }
351
352 .modal-content {
353 padding: 1.5rem;
354 }
355
356 .form-group {
357 margin-bottom: 1rem;
358 }
359
360 .form-group label {
361 display: block;
362 margin-bottom: 0.5rem;
363 font-weight: 500;
364 }
365
366 .form-group input {
367 width: 100%;
368 padding: 0.75rem;
369 border: 1px solid var(--border-color);
370 border-radius: 4px;
371 background: var(--bg-input);
372 color: var(--text-primary);
373 font-size: 1rem;
374 }
375
376 .form-group input:focus {
377 outline: none;
378 border-color: var(--accent);
379 }
380
381 .passkey-auth {
382 text-align: center;
383 }
384
385 .passkey-auth p {
386 margin-bottom: 1rem;
387 color: var(--text-secondary);
388 }
389
390 .btn-primary {
391 width: 100%;
392 padding: 0.75rem 1.5rem;
393 background: var(--accent);
394 color: white;
395 border: none;
396 border-radius: 4px;
397 font-size: 1rem;
398 cursor: pointer;
399 }
400
401 .btn-primary:hover:not(:disabled) {
402 background: var(--accent-hover);
403 }
404
405 .btn-primary:disabled {
406 opacity: 0.6;
407 cursor: not-allowed;
408 }
409
410 .modal-footer {
411 padding: 0 1.5rem 1.5rem;
412 display: flex;
413 justify-content: flex-end;
414 }
415
416 .btn-secondary {
417 padding: 0.5rem 1rem;
418 background: var(--bg-input);
419 border: 1px solid var(--border-color);
420 border-radius: 4px;
421 color: var(--text-secondary);
422 cursor: pointer;
423 font-size: 0.875rem;
424 }
425
426 .btn-secondary:hover:not(:disabled) {
427 background: var(--bg-secondary);
428 }
429
430 .btn-secondary:disabled {
431 opacity: 0.6;
432 cursor: not-allowed;
433 }
434</style>