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">&times;</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>