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