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