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