this repo has no description
1<script lang="ts"> 2 import { getAuthState, getValidToken } from '../lib/auth.svelte' 3 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 4 import { api, ApiError } from '../lib/api' 5 import ReauthModal from '../components/ReauthModal.svelte' 6 import { _ } from '../lib/i18n' 7 import { formatDate as formatDateUtil } from '../lib/date' 8 import type { Session } from '../lib/types/api' 9 import { 10 prepareCreationOptions, 11 serializeAttestationResponse, 12 type WebAuthnCreationOptionsResponse, 13 } from '../lib/webauthn' 14 import { toast } from '../lib/toast.svelte' 15 16 const auth = $derived(getAuthState()) 17 18 function getSession(): Session | null { 19 return auth.kind === 'authenticated' ? auth.session : null 20 } 21 22 function isLoading(): boolean { 23 return auth.kind === 'loading' 24 } 25 26 const session = $derived(getSession()) 27 const authLoading = $derived(isLoading()) 28 29 let loading = $state(true) 30 let totpEnabled = $state(false) 31 let hasBackupCodes = $state(false) 32 let setupStep = $state<'idle' | 'qr' | 'verify' | 'backup'>('idle') 33 let qrBase64 = $state('') 34 let totpUri = $state('') 35 let verifyCodeRaw = $state('') 36 let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, '')) 37 let verifyLoading = $state(false) 38 let backupCodes = $state<string[]>([]) 39 let disablePassword = $state('') 40 let disableCode = $state('') 41 let disableLoading = $state(false) 42 let showDisableForm = $state(false) 43 let regenPassword = $state('') 44 let regenCode = $state('') 45 let regenLoading = $state(false) 46 let showRegenForm = $state(false) 47 48 interface Passkey { 49 id: string 50 credentialId: string 51 friendlyName: string | null 52 createdAt: string 53 lastUsed: string | null 54 } 55 let passkeys = $state<Passkey[]>([]) 56 let passkeysLoading = $state(true) 57 let addingPasskey = $state(false) 58 let newPasskeyName = $state('') 59 let editingPasskeyId = $state<string | null>(null) 60 let editPasskeyName = $state('') 61 62 let hasPassword = $state(true) 63 let passwordLoading = $state(true) 64 let showRemovePasswordForm = $state(false) 65 let removePasswordLoading = $state(false) 66 67 let allowLegacyLogin = $state(true) 68 let hasMfa = $state(false) 69 let legacyLoginLoading = $state(true) 70 let legacyLoginUpdating = $state(false) 71 72 let showReauthModal = $state(false) 73 let reauthMethods = $state<string[]>(['password']) 74 let pendingAction = $state<(() => Promise<void>) | null>(null) 75 76 $effect(() => { 77 if (!authLoading && !session) { 78 navigate(routes.login) 79 } 80 }) 81 82 $effect(() => { 83 if (session) { 84 loadTotpStatus() 85 loadPasskeys() 86 loadPasswordStatus() 87 loadLegacyLoginPreference() 88 } 89 }) 90 91 async function loadPasswordStatus() { 92 if (!session) return 93 passwordLoading = true 94 try { 95 const status = await api.getPasswordStatus(session.accessJwt) 96 hasPassword = status.hasPassword 97 } catch { 98 hasPassword = true 99 } finally { 100 passwordLoading = false 101 } 102 } 103 104 async function loadLegacyLoginPreference() { 105 if (!session) return 106 legacyLoginLoading = true 107 try { 108 const pref = await api.getLegacyLoginPreference(session.accessJwt) 109 allowLegacyLogin = pref.allowLegacyLogin 110 hasMfa = pref.hasMfa 111 } catch { 112 allowLegacyLogin = true 113 hasMfa = false 114 } finally { 115 legacyLoginLoading = false 116 } 117 } 118 119 async function handleToggleLegacyLogin() { 120 if (!session) return 121 legacyLoginUpdating = true 122 try { 123 const result = await api.updateLegacyLoginPreference(session.accessJwt, !allowLegacyLogin) 124 allowLegacyLogin = result.allowLegacyLogin 125 toast.success(allowLegacyLogin 126 ? $_('security.legacyLoginEnabled') 127 : $_('security.legacyLoginDisabled')) 128 } catch (e) { 129 if (e instanceof ApiError) { 130 if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') { 131 reauthMethods = e.reauthMethods || ['password'] 132 pendingAction = handleToggleLegacyLogin 133 showReauthModal = true 134 } else { 135 toast.error(e.message) 136 } 137 } else { 138 toast.error($_('security.failedToUpdatePreference')) 139 } 140 } finally { 141 legacyLoginUpdating = false 142 } 143 } 144 145 async function handleRemovePassword() { 146 if (!session) return 147 removePasswordLoading = true 148 try { 149 const token = await getValidToken() 150 if (!token) { 151 toast.error($_('security.sessionExpired')) 152 return 153 } 154 await api.removePassword(token) 155 hasPassword = false 156 showRemovePasswordForm = false 157 toast.success($_('security.passwordRemoved')) 158 } catch (e) { 159 if (e instanceof ApiError) { 160 if (e.error === 'ReauthRequired') { 161 reauthMethods = e.reauthMethods || ['password'] 162 pendingAction = handleRemovePassword 163 showReauthModal = true 164 } else { 165 toast.error(e.message) 166 } 167 } else { 168 toast.error($_('security.failedToRemovePassword')) 169 } 170 } finally { 171 removePasswordLoading = false 172 } 173 } 174 175 function handleReauthSuccess() { 176 if (pendingAction) { 177 pendingAction() 178 pendingAction = null 179 } 180 } 181 182 function handleReauthCancel() { 183 pendingAction = null 184 } 185 186 async function loadTotpStatus() { 187 if (!session) return 188 loading = true 189 try { 190 const status = await api.getTotpStatus(session.accessJwt) 191 totpEnabled = status.enabled 192 hasBackupCodes = status.hasBackupCodes 193 } catch { 194 toast.error($_('security.failedToLoadTotpStatus')) 195 } finally { 196 loading = false 197 } 198 } 199 200 async function handleStartSetup() { 201 if (!session) return 202 verifyLoading = true 203 try { 204 const result = await api.createTotpSecret(session.accessJwt) 205 qrBase64 = result.qrBase64 206 totpUri = result.uri 207 setupStep = 'qr' 208 } catch (e) { 209 toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret') 210 } finally { 211 verifyLoading = false 212 } 213 } 214 215 async function handleVerifySetup(e: Event) { 216 e.preventDefault() 217 if (!session || !verifyCode) return 218 verifyLoading = true 219 try { 220 const result = await api.enableTotp(session.accessJwt, verifyCode) 221 backupCodes = result.backupCodes 222 setupStep = 'backup' 223 totpEnabled = true 224 hasBackupCodes = true 225 verifyCodeRaw = '' 226 } catch (e) { 227 toast.error(e instanceof ApiError ? e.message : 'Invalid code. Please try again.') 228 } finally { 229 verifyLoading = false 230 } 231 } 232 233 function handleFinishSetup() { 234 setupStep = 'idle' 235 backupCodes = [] 236 qrBase64 = '' 237 totpUri = '' 238 toast.success($_('security.totpEnabledSuccess')) 239 } 240 241 async function handleDisable(e: Event) { 242 e.preventDefault() 243 if (!session || !disablePassword || !disableCode) return 244 disableLoading = true 245 try { 246 await api.disableTotp(session.accessJwt, disablePassword, disableCode) 247 totpEnabled = false 248 hasBackupCodes = false 249 showDisableForm = false 250 disablePassword = '' 251 disableCode = '' 252 toast.success($_('security.totpDisabledSuccess')) 253 } catch (e) { 254 toast.error(e instanceof ApiError ? e.message : 'Failed to disable TOTP') 255 } finally { 256 disableLoading = false 257 } 258 } 259 260 async function handleRegenerate(e: Event) { 261 e.preventDefault() 262 if (!session || !regenPassword || !regenCode) return 263 regenLoading = true 264 try { 265 const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode) 266 backupCodes = result.backupCodes 267 setupStep = 'backup' 268 showRegenForm = false 269 regenPassword = '' 270 regenCode = '' 271 } catch (e) { 272 toast.error(e instanceof ApiError ? e.message : 'Failed to regenerate backup codes') 273 } finally { 274 regenLoading = false 275 } 276 } 277 278 function copyBackupCodes() { 279 const text = backupCodes.join('\n') 280 navigator.clipboard.writeText(text) 281 toast.success($_('security.backupCodesCopied')) 282 } 283 284 async function loadPasskeys() { 285 if (!session) return 286 passkeysLoading = true 287 try { 288 const result = await api.listPasskeys(session.accessJwt) 289 passkeys = result.passkeys 290 } catch { 291 toast.error($_('security.failedToLoadPasskeys')) 292 } finally { 293 passkeysLoading = false 294 } 295 } 296 297 async function handleAddPasskey() { 298 if (!session) return 299 if (!window.PublicKeyCredential) { 300 toast.error($_('security.passkeysNotSupported')) 301 return 302 } 303 addingPasskey = true 304 try { 305 const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined) 306 const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse) 307 const credential = await navigator.credentials.create({ 308 publicKey: publicKeyOptions 309 }) 310 if (!credential) { 311 toast.error($_('security.passkeyCreationCancelled')) 312 return 313 } 314 const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential) 315 await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined) 316 await loadPasskeys() 317 newPasskeyName = '' 318 toast.success($_('security.passkeyAddedSuccess')) 319 } catch (e) { 320 if (e instanceof DOMException && e.name === 'NotAllowedError') { 321 toast.error($_('security.passkeyCreationCancelled')) 322 } else { 323 toast.error(e instanceof ApiError ? e.message : 'Failed to add passkey') 324 } 325 } finally { 326 addingPasskey = false 327 } 328 } 329 330 async function handleDeletePasskey(id: string) { 331 if (!session) return 332 const passkey = passkeys.find(p => p.id === id) 333 const name = passkey?.friendlyName || 'this passkey' 334 if (!confirm($_('security.deletePasskeyConfirm', { values: { name } }))) return 335 try { 336 await api.deletePasskey(session.accessJwt, id) 337 await loadPasskeys() 338 toast.success($_('security.passkeyDeleted')) 339 } catch (e) { 340 toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey') 341 } 342 } 343 344 async function handleSavePasskeyName() { 345 if (!session || !editingPasskeyId || !editPasskeyName.trim()) return 346 try { 347 await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim()) 348 await loadPasskeys() 349 editingPasskeyId = null 350 editPasskeyName = '' 351 toast.success($_('security.passkeyRenamed')) 352 } catch (e) { 353 toast.error(e instanceof ApiError ? e.message : 'Failed to rename passkey') 354 } 355 } 356 357 function startEditPasskey(passkey: Passkey) { 358 editingPasskeyId = passkey.id 359 editPasskeyName = passkey.friendlyName || '' 360 } 361 362 function cancelEditPasskey() { 363 editingPasskeyId = null 364 editPasskeyName = '' 365 } 366 367 function formatDate(dateStr: string): string { 368 return formatDateUtil(dateStr) 369 } 370</script> 371 372<div class="page"> 373 <header> 374 <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a> 375 <h1>{$_('security.title')}</h1> 376 </header> 377 378 {#if loading} 379 <div class="skeleton-grid"> 380 {#each Array(4) as _} 381 <div class="skeleton-section"></div> 382 {/each} 383 </div> 384 {:else} 385 <div class="sections-grid"> 386 <section> 387 <h2>{$_('security.totp')}</h2> 388 <p class="description"> 389 {$_('security.totpDescription')} 390 </p> 391 392 {#if setupStep === 'idle'} 393 {#if totpEnabled} 394 <div class="status enabled"> 395 <span>{$_('security.totpEnabled')}</span> 396 </div> 397 398 {#if !showDisableForm && !showRegenForm} 399 <div class="totp-actions"> 400 <button type="button" class="secondary" onclick={() => showRegenForm = true}> 401 {$_('security.regenerateBackupCodes')} 402 </button> 403 <button type="button" class="danger-outline" onclick={() => showDisableForm = true}> 404 {$_('security.disableTotp')} 405 </button> 406 </div> 407 {/if} 408 409 {#if showRegenForm} 410 <form onsubmit={handleRegenerate} class="inline-form"> 411 <h3>{$_('security.regenerateBackupCodes')}</h3> 412 <p class="warning-text">{$_('security.regenerateConfirm')}</p> 413 <div class="field"> 414 <label for="regen-password">{$_('security.password')}</label> 415 <input 416 id="regen-password" 417 type="password" 418 bind:value={regenPassword} 419 placeholder={$_('security.enterPassword')} 420 disabled={regenLoading} 421 required 422 /> 423 </div> 424 <div class="field"> 425 <label for="regen-code">{$_('security.totpCode')}</label> 426 <input 427 id="regen-code" 428 type="text" 429 bind:value={regenCode} 430 placeholder="{$_('security.totpCodePlaceholder')}" 431 disabled={regenLoading} 432 required 433 maxlength="6" 434 pattern="[0-9]{6}" 435 inputmode="numeric" 436 /> 437 </div> 438 <div class="actions"> 439 <button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}> 440 {$_('common.cancel')} 441 </button> 442 <button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}> 443 {regenLoading ? $_('security.regenerating') : $_('security.regenerateBackupCodes')} 444 </button> 445 </div> 446 </form> 447 {/if} 448 449 {#if showDisableForm} 450 <form onsubmit={handleDisable} class="inline-form danger-form"> 451 <h3>{$_('security.disableTotp')}</h3> 452 <p class="warning-text">{$_('security.disableTotpWarning')}</p> 453 <div class="field"> 454 <label for="disable-password">{$_('security.password')}</label> 455 <input 456 id="disable-password" 457 type="password" 458 bind:value={disablePassword} 459 placeholder={$_('security.enterPassword')} 460 disabled={disableLoading} 461 required 462 /> 463 </div> 464 <div class="field"> 465 <label for="disable-code">{$_('security.totpCode')}</label> 466 <input 467 id="disable-code" 468 type="text" 469 bind:value={disableCode} 470 placeholder="{$_('security.totpCodePlaceholder')}" 471 disabled={disableLoading} 472 required 473 maxlength="6" 474 pattern="[0-9]{6}" 475 inputmode="numeric" 476 /> 477 </div> 478 <div class="actions"> 479 <button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}> 480 {$_('common.cancel')} 481 </button> 482 <button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}> 483 {disableLoading ? $_('security.disabling') : $_('security.disableTotp')} 484 </button> 485 </div> 486 </form> 487 {/if} 488 {:else} 489 <div class="status disabled"> 490 <span>{$_('security.totpDisabled')}</span> 491 </div> 492 <button onclick={handleStartSetup} disabled={verifyLoading}> 493 {$_('security.enableTotp')} 494 </button> 495 {/if} 496 {:else if setupStep === 'qr'} 497 <div class="setup-step"> 498 <h3>{$_('security.totpSetup')}</h3> 499 <p>{$_('security.totpSetupInstructions')}</p> 500 <div class="qr-container"> 501 <img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" /> 502 </div> 503 <details class="manual-entry"> 504 <summary>{$_('security.cantScan')}</summary> 505 <code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code> 506 </details> 507 <button onclick={() => setupStep = 'verify'}> 508 {$_('security.next')} 509 </button> 510 </div> 511 {:else if setupStep === 'verify'} 512 <div class="setup-step"> 513 <h3>{$_('security.totpSetup')}</h3> 514 <p>{$_('security.totpCodePlaceholder')}</p> 515 <form onsubmit={handleVerifySetup}> 516 <div class="field"> 517 <input 518 type="text" 519 bind:value={verifyCodeRaw} 520 placeholder="000000" 521 disabled={verifyLoading} 522 inputmode="numeric" 523 class="code-input" 524 /> 525 </div> 526 <div class="actions"> 527 <button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}> 528 {$_('common.back')} 529 </button> 530 <button type="submit" disabled={verifyLoading || verifyCode.length !== 6}> 531 {$_('security.verifyAndEnable')} 532 </button> 533 </div> 534 </form> 535 </div> 536 {:else if setupStep === 'backup'} 537 <div class="setup-step"> 538 <h3>{$_('security.backupCodes')}</h3> 539 <p class="warning-text"> 540 {$_('security.backupCodesDescription')} 541 </p> 542 <div class="backup-codes"> 543 {#each backupCodes as code} 544 <code class="backup-code">{code}</code> 545 {/each} 546 </div> 547 <div class="actions"> 548 <button type="button" class="secondary" onclick={copyBackupCodes}> 549 {$_('security.copyToClipboard')} 550 </button> 551 <button onclick={handleFinishSetup}> 552 {$_('security.savedMyCodes')} 553 </button> 554 </div> 555 </div> 556 {/if} 557 </section> 558 559 <section> 560 <h2>{$_('security.passkeys')}</h2> 561 <p class="description"> 562 {$_('security.passkeysDescription')} 563 </p> 564 565 {#if !passkeysLoading} 566 {#if passkeys.length > 0} 567 <div class="passkey-list"> 568 {#each passkeys as passkey} 569 <div class="passkey-item"> 570 {#if editingPasskeyId === passkey.id} 571 <div class="passkey-edit"> 572 <input 573 type="text" 574 bind:value={editPasskeyName} 575 placeholder="{$_('security.passkeyName')}" 576 class="passkey-name-input" 577 /> 578 <div class="passkey-edit-actions"> 579 <button type="button" class="small" onclick={handleSavePasskeyName}>{$_('common.save')}</button> 580 <button type="button" class="small secondary" onclick={cancelEditPasskey}>{$_('common.cancel')}</button> 581 </div> 582 </div> 583 {:else} 584 <div class="passkey-info"> 585 <span class="passkey-name">{passkey.friendlyName || $_('security.unnamedPasskey')}</span> 586 <span class="passkey-meta"> 587 {$_('security.added')} {formatDate(passkey.createdAt)} 588 {#if passkey.lastUsed} 589 &middot; {$_('security.lastUsed')} {formatDate(passkey.lastUsed)} 590 {/if} 591 </span> 592 </div> 593 <div class="passkey-actions"> 594 <button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}> 595 {$_('security.rename')} 596 </button> 597 {#if hasPassword || passkeys.length > 1} 598 <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}> 599 {$_('security.deletePasskey')} 600 </button> 601 {/if} 602 </div> 603 {/if} 604 </div> 605 {/each} 606 </div> 607 {:else} 608 <div class="status disabled"> 609 <span>{$_('security.noPasskeys')}</span> 610 </div> 611 {/if} 612 613 <div class="add-passkey"> 614 <div class="field"> 615 <label for="passkey-name">{$_('security.passkeyName')}</label> 616 <input 617 id="passkey-name" 618 type="text" 619 bind:value={newPasskeyName} 620 placeholder="{$_('security.passkeyNamePlaceholder')}" 621 disabled={addingPasskey} 622 /> 623 </div> 624 <button onclick={handleAddPasskey} disabled={addingPasskey}> 625 {addingPasskey ? $_('security.adding') : $_('security.addPasskey')} 626 </button> 627 </div> 628 {/if} 629 </section> 630 631 <section> 632 <h2>{$_('security.password')}</h2> 633 <p class="description"> 634 {$_('security.passwordDescription')} 635 </p> 636 637 {#if !passwordLoading && hasPassword} 638 <div class="status enabled"> 639 <span>{$_('security.passwordStatus')}</span> 640 </div> 641 642 {#if passkeys.length > 0} 643 {#if !showRemovePasswordForm} 644 <button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}> 645 {$_('security.removePassword')} 646 </button> 647 {:else} 648 <div class="inline-form danger-form"> 649 <h3>{$_('security.removePassword')}</h3> 650 <p class="warning-text"> 651 {$_('security.removePasswordWarning')} 652 </p> 653 <div class="info-box-inline"> 654 <strong>{$_('security.beforeProceeding')}</strong> 655 <ul> 656 <li>{$_('security.beforeProceedingItem1')}</li> 657 <li>{$_('security.beforeProceedingItem2')}</li> 658 <li>{$_('security.beforeProceedingItem3')}</li> 659 </ul> 660 </div> 661 <div class="actions"> 662 <button type="button" class="secondary" onclick={() => showRemovePasswordForm = false}> 663 {$_('common.cancel')} 664 </button> 665 <button type="button" class="danger" onclick={handleRemovePassword} disabled={removePasswordLoading}> 666 {removePasswordLoading ? $_('security.removing') : $_('security.removePassword')} 667 </button> 668 </div> 669 </div> 670 {/if} 671 {:else} 672 <p class="hint">{$_('security.addPasskeyFirst')}</p> 673 {/if} 674 {:else} 675 <div class="status passkey-only"> 676 <span>{$_('security.noPassword')}</span> 677 </div> 678 <p class="hint"> 679 {$_('security.passkeyOnlyHint')} 680 </p> 681 {/if} 682 </section> 683 684 <section> 685 <h2>{$_('security.trustedDevices')}</h2> 686 <p class="description"> 687 {$_('security.trustedDevicesDescription')} 688 </p> 689 <a href={getFullUrl(routes.trustedDevices)} class="section-link"> 690 {$_('security.manageTrustedDevices')} &rarr; 691 </a> 692 </section> 693 </div> 694 695 {#if hasMfa} 696 <section> 697 <h2>{$_('security.appCompatibility')}</h2> 698 <p class="description"> 699 {$_('security.legacyLoginDescription')} 700 </p> 701 702 {#if !legacyLoginLoading} 703 <div class="toggle-row"> 704 <div class="toggle-info"> 705 <span class="toggle-label">{$_('security.legacyLogin')}</span> 706 <span class="toggle-description"> 707 {#if allowLegacyLogin} 708 {$_('security.legacyLoginOn')} 709 {:else} 710 {$_('security.legacyLoginOff')} 711 {/if} 712 </span> 713 </div> 714 <button 715 type="button" 716 class="toggle-button {allowLegacyLogin ? 'on' : 'off'}" 717 onclick={handleToggleLegacyLogin} 718 disabled={legacyLoginUpdating} 719 aria-label={allowLegacyLogin ? $_('security.disableLegacyLogin') : $_('security.enableLegacyLogin')} 720 > 721 <span class="toggle-slider"></span> 722 </button> 723 </div> 724 725 {#if totpEnabled} 726 <div class="warning-box"> 727 <strong>{$_('security.legacyLoginWarning')}</strong> 728 <p>{$_('security.totpPasswordWarning')}</p> 729 <ol> 730 <li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li> 731 <li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li> 732 </ol> 733 </div> 734 {/if} 735 736 <div class="info-box-inline"> 737 <strong>{$_('security.legacyAppsTitle')}</strong> 738 <p>{$_('security.legacyAppsDescription')}</p> 739 </div> 740 {/if} 741 </section> 742 {/if} 743 {/if} 744</div> 745 746<ReauthModal 747 bind:show={showReauthModal} 748 availableMethods={reauthMethods} 749 onSuccess={handleReauthSuccess} 750 onCancel={handleReauthCancel} 751/> 752 753<style> 754 .page { 755 max-width: var(--width-lg); 756 margin: 0 auto; 757 padding: var(--space-7); 758 } 759 760 header { 761 margin-bottom: var(--space-7); 762 } 763 764 .sections-grid { 765 display: flex; 766 flex-direction: column; 767 gap: var(--space-6); 768 margin-bottom: var(--space-6); 769 } 770 771 @media (min-width: 800px) { 772 .sections-grid { 773 columns: 2; 774 column-gap: var(--space-6); 775 display: block; 776 } 777 778 .sections-grid section { 779 break-inside: avoid; 780 margin-bottom: var(--space-6); 781 } 782 } 783 784 .back { 785 color: var(--text-secondary); 786 text-decoration: none; 787 font-size: var(--text-sm); 788 } 789 790 .back:hover { 791 color: var(--accent); 792 } 793 794 h1 { 795 margin: var(--space-2) 0 0 0; 796 } 797 798 section { 799 padding: var(--space-6); 800 background: var(--bg-secondary); 801 border-radius: var(--radius-xl); 802 margin-bottom: var(--space-6); 803 height: fit-content; 804 } 805 806 section h2 { 807 margin: 0 0 var(--space-2) 0; 808 font-size: var(--text-lg); 809 } 810 811 .description { 812 color: var(--text-secondary); 813 font-size: var(--text-sm); 814 margin-bottom: var(--space-6); 815 } 816 817 .status { 818 display: flex; 819 align-items: center; 820 gap: var(--space-2); 821 padding: var(--space-3); 822 border-radius: var(--radius-md); 823 margin-bottom: var(--space-4); 824 } 825 826 .status.enabled { 827 background: var(--success-bg); 828 border: 1px solid var(--success-border); 829 color: var(--success-text); 830 } 831 832 .status.disabled { 833 background: var(--warning-bg); 834 border: 1px solid var(--border-color); 835 color: var(--warning-text); 836 } 837 838 .status.passkey-only { 839 background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15)); 840 border: 1px solid var(--accent); 841 color: var(--accent); 842 } 843 844 .totp-actions { 845 display: flex; 846 gap: var(--space-2); 847 flex-wrap: wrap; 848 } 849 850 .code-input { 851 font-size: var(--text-2xl); 852 letter-spacing: 0.5em; 853 text-align: center; 854 max-width: 200px; 855 margin: 0 auto; 856 display: block; 857 } 858 859 .actions { 860 display: flex; 861 gap: var(--space-2); 862 margin-top: var(--space-4); 863 } 864 865 .inline-form { 866 margin-top: var(--space-4); 867 padding: var(--space-4); 868 background: var(--bg-card); 869 border: 1px solid var(--border-color); 870 border-radius: var(--radius-lg); 871 } 872 873 .inline-form h3 { 874 margin: 0 0 var(--space-2) 0; 875 font-size: var(--text-base); 876 } 877 878 .danger-form { 879 border-color: var(--error-border); 880 background: var(--error-bg); 881 } 882 883 .warning-text { 884 color: var(--error-text); 885 font-size: var(--text-sm); 886 margin-bottom: var(--space-4); 887 } 888 889 .setup-step { 890 padding: var(--space-4); 891 background: var(--bg-card); 892 border: 1px solid var(--border-color); 893 border-radius: var(--radius-lg); 894 } 895 896 .setup-step h3 { 897 margin: 0 0 var(--space-2) 0; 898 } 899 900 .setup-step p { 901 color: var(--text-secondary); 902 font-size: var(--text-sm); 903 margin-bottom: var(--space-4); 904 } 905 906 .qr-container { 907 display: flex; 908 justify-content: center; 909 margin: var(--space-6) 0; 910 } 911 912 .qr-code { 913 width: 200px; 914 height: 200px; 915 image-rendering: pixelated; 916 } 917 918 .manual-entry { 919 margin-bottom: var(--space-4); 920 font-size: var(--text-sm); 921 } 922 923 .manual-entry summary { 924 cursor: pointer; 925 color: var(--accent); 926 } 927 928 .secret-code { 929 display: block; 930 margin-top: var(--space-2); 931 padding: var(--space-2); 932 background: var(--bg-input); 933 border-radius: var(--radius-md); 934 word-break: break-all; 935 font-size: var(--text-xs); 936 } 937 938 .backup-codes { 939 display: grid; 940 grid-template-columns: repeat(2, 1fr); 941 gap: var(--space-2); 942 margin: var(--space-4) 0; 943 } 944 945 .backup-code { 946 padding: var(--space-2); 947 background: var(--bg-input); 948 border-radius: var(--radius-md); 949 text-align: center; 950 font-size: var(--text-sm); 951 font-family: ui-monospace, monospace; 952 } 953 954 .passkey-list { 955 display: flex; 956 flex-direction: column; 957 gap: var(--space-2); 958 margin-bottom: var(--space-4); 959 } 960 961 .passkey-item { 962 display: flex; 963 justify-content: space-between; 964 align-items: center; 965 padding: var(--space-3); 966 background: var(--bg-card); 967 border: 1px solid var(--border-color); 968 border-radius: var(--radius-lg); 969 gap: var(--space-4); 970 } 971 972 .passkey-info { 973 display: flex; 974 flex-direction: column; 975 gap: var(--space-1); 976 flex: 1; 977 min-width: 0; 978 } 979 980 .passkey-name { 981 font-weight: var(--font-medium); 982 overflow: hidden; 983 text-overflow: ellipsis; 984 white-space: nowrap; 985 } 986 987 .passkey-meta { 988 font-size: var(--text-xs); 989 color: var(--text-secondary); 990 } 991 992 .passkey-actions { 993 display: flex; 994 gap: var(--space-2); 995 flex-shrink: 0; 996 } 997 998 .passkey-edit { 999 display: flex; 1000 flex: 1; 1001 gap: var(--space-2); 1002 align-items: center; 1003 } 1004 1005 .passkey-name-input { 1006 flex: 1; 1007 padding: var(--space-2); 1008 font-size: var(--text-sm); 1009 } 1010 1011 .passkey-edit-actions { 1012 display: flex; 1013 gap: var(--space-1); 1014 } 1015 1016 button.small { 1017 padding: var(--space-2) var(--space-3); 1018 font-size: var(--text-xs); 1019 } 1020 1021 .add-passkey { 1022 margin-top: var(--space-4); 1023 padding-top: var(--space-4); 1024 border-top: 1px solid var(--border-color); 1025 } 1026 1027 .add-passkey .field { 1028 margin-bottom: var(--space-3); 1029 } 1030 1031 .section-link { 1032 display: inline-block; 1033 color: var(--accent); 1034 text-decoration: none; 1035 font-weight: var(--font-medium); 1036 } 1037 1038 .section-link:hover { 1039 text-decoration: underline; 1040 } 1041 1042 .hint { 1043 font-size: var(--text-sm); 1044 color: var(--text-secondary); 1045 margin: 0; 1046 } 1047 1048 .info-box-inline { 1049 background: var(--bg-card); 1050 border: 1px solid var(--border-color); 1051 border-radius: var(--radius-lg); 1052 padding: var(--space-4); 1053 margin-bottom: var(--space-4); 1054 font-size: var(--text-sm); 1055 } 1056 1057 .info-box-inline strong { 1058 display: block; 1059 margin-bottom: var(--space-2); 1060 } 1061 1062 .info-box-inline ul { 1063 margin: 0; 1064 padding-left: var(--space-5); 1065 color: var(--text-secondary); 1066 } 1067 1068 .info-box-inline li { 1069 margin-bottom: var(--space-1); 1070 } 1071 1072 .info-box-inline p { 1073 margin: 0; 1074 color: var(--text-secondary); 1075 } 1076 1077 .toggle-row { 1078 display: flex; 1079 justify-content: space-between; 1080 align-items: flex-start; 1081 gap: var(--space-4); 1082 padding: var(--space-4); 1083 background: var(--bg-card); 1084 border: 1px solid var(--border-color); 1085 border-radius: var(--radius-lg); 1086 margin-bottom: var(--space-4); 1087 } 1088 1089 .toggle-info { 1090 display: flex; 1091 flex-direction: column; 1092 gap: var(--space-1); 1093 } 1094 1095 .toggle-label { 1096 font-weight: var(--font-medium); 1097 } 1098 1099 .toggle-description { 1100 font-size: var(--text-sm); 1101 color: var(--text-secondary); 1102 } 1103 1104 .toggle-button { 1105 position: relative; 1106 width: 50px; 1107 height: 26px; 1108 padding: 0; 1109 border: none; 1110 border-radius: 13px; 1111 cursor: pointer; 1112 transition: background var(--transition-fast); 1113 flex-shrink: 0; 1114 } 1115 1116 .toggle-button.on { 1117 background: var(--success-text); 1118 } 1119 1120 .toggle-button.off { 1121 background: var(--text-secondary); 1122 } 1123 1124 .toggle-button:disabled { 1125 opacity: 0.6; 1126 cursor: not-allowed; 1127 } 1128 1129 .toggle-slider { 1130 position: absolute; 1131 top: 3px; 1132 width: 20px; 1133 height: 20px; 1134 background: white; 1135 border-radius: 50%; 1136 transition: left var(--transition-fast); 1137 } 1138 1139 .toggle-button.on .toggle-slider { 1140 left: 27px; 1141 } 1142 1143 .toggle-button.off .toggle-slider { 1144 left: 3px; 1145 } 1146 1147 .warning-box { 1148 background: var(--warning-bg); 1149 border: 1px solid var(--warning-border); 1150 border-left: 4px solid var(--warning-text); 1151 border-radius: var(--radius-lg); 1152 padding: var(--space-4); 1153 margin-bottom: var(--space-4); 1154 } 1155 1156 .warning-box strong { 1157 display: block; 1158 margin-bottom: var(--space-2); 1159 color: var(--warning-text); 1160 } 1161 1162 .warning-box p { 1163 margin: 0 0 var(--space-3) 0; 1164 font-size: var(--text-sm); 1165 color: var(--text-primary); 1166 } 1167 1168 .warning-box ol { 1169 margin: 0; 1170 padding-left: var(--space-5); 1171 font-size: var(--text-sm); 1172 } 1173 1174 .warning-box li { 1175 margin-bottom: var(--space-2); 1176 } 1177 1178 .warning-box a { 1179 color: var(--accent); 1180 } 1181 1182 .skeleton-grid { 1183 display: grid; 1184 grid-template-columns: repeat(2, 1fr); 1185 gap: var(--space-6); 1186 } 1187 1188 .skeleton-section { 1189 height: 200px; 1190 background: var(--bg-secondary); 1191 border-radius: var(--radius-xl); 1192 animation: skeleton-pulse 1.5s ease-in-out infinite; 1193 } 1194 1195 @keyframes skeleton-pulse { 1196 0%, 100% { opacity: 1; } 1197 50% { opacity: 0.5; } 1198 } 1199 1200 @media (max-width: 900px) { 1201 .skeleton-grid { 1202 grid-template-columns: 1fr; 1203 } 1204 } 1205</style>