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