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