this repo has no description
at main 34 kB view raw
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 <p class="hint"> 682 {$_('security.addPasswordHint')} 683 </p> 684 <a href={getFullUrl(routes.settings)} class="section-link"> 685 {$_('security.goToSettings')} 686 </a> 687 {/if} 688 </section> 689 690 <section> 691 <h2>{$_('security.trustedDevices')}</h2> 692 <p class="description"> 693 {$_('security.trustedDevicesDescription')} 694 </p> 695 <a href={getFullUrl(routes.trustedDevices)} class="section-link"> 696 {$_('security.manageTrustedDevices')} &rarr; 697 </a> 698 </section> 699 </div> 700 701 {#if hasMfa} 702 <section> 703 <h2>{$_('security.appCompatibility')}</h2> 704 <p class="description"> 705 {$_('security.legacyLoginDescription')} 706 </p> 707 708 {#if !legacyLoginLoading} 709 <div class="toggle-row"> 710 <div class="toggle-info"> 711 <span class="toggle-label">{$_('security.legacyLogin')}</span> 712 <span class="toggle-description"> 713 {#if allowLegacyLogin} 714 {$_('security.legacyLoginOn')} 715 {:else} 716 {$_('security.legacyLoginOff')} 717 {/if} 718 </span> 719 </div> 720 <button 721 type="button" 722 class="toggle-button {allowLegacyLogin ? 'on' : 'off'}" 723 onclick={handleToggleLegacyLogin} 724 disabled={legacyLoginUpdating} 725 aria-label={allowLegacyLogin ? $_('security.disableLegacyLogin') : $_('security.enableLegacyLogin')} 726 > 727 <span class="toggle-slider"></span> 728 </button> 729 </div> 730 731 {#if totpEnabled} 732 <div class="warning-box"> 733 <strong>{$_('security.legacyLoginWarning')}</strong> 734 <p>{$_('security.totpPasswordWarning')}</p> 735 <ol> 736 <li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li> 737 <li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li> 738 </ol> 739 </div> 740 {/if} 741 742 <div class="info-box-inline"> 743 <strong>{$_('security.legacyAppsTitle')}</strong> 744 <p>{$_('security.legacyAppsDescription')}</p> 745 </div> 746 {/if} 747 </section> 748 {/if} 749 {/if} 750</div> 751 752<ReauthModal 753 bind:show={showReauthModal} 754 availableMethods={reauthMethods} 755 onSuccess={handleReauthSuccess} 756 onCancel={handleReauthCancel} 757/> 758 759<style> 760 .page { 761 max-width: var(--width-lg); 762 margin: 0 auto; 763 padding: var(--space-7); 764 } 765 766 header { 767 margin-bottom: var(--space-7); 768 } 769 770 .sections-grid { 771 display: flex; 772 flex-direction: column; 773 gap: var(--space-6); 774 margin-bottom: var(--space-6); 775 } 776 777 @media (min-width: 800px) { 778 .sections-grid { 779 columns: 2; 780 column-gap: var(--space-6); 781 display: block; 782 } 783 784 .sections-grid section { 785 break-inside: avoid; 786 margin-bottom: var(--space-6); 787 } 788 } 789 790 .back { 791 color: var(--text-secondary); 792 text-decoration: none; 793 font-size: var(--text-sm); 794 } 795 796 .back:hover { 797 color: var(--accent); 798 } 799 800 h1 { 801 margin: var(--space-2) 0 0 0; 802 } 803 804 section { 805 padding: var(--space-6); 806 background: var(--bg-secondary); 807 border-radius: var(--radius-xl); 808 margin-bottom: var(--space-6); 809 height: fit-content; 810 } 811 812 section h2 { 813 margin: 0 0 var(--space-2) 0; 814 font-size: var(--text-lg); 815 } 816 817 .description { 818 color: var(--text-secondary); 819 font-size: var(--text-sm); 820 margin-bottom: var(--space-6); 821 } 822 823 .status { 824 display: flex; 825 align-items: center; 826 gap: var(--space-2); 827 padding: var(--space-3); 828 border-radius: var(--radius-md); 829 margin-bottom: var(--space-4); 830 } 831 832 .status.enabled { 833 background: var(--success-bg); 834 border: 1px solid var(--success-border); 835 color: var(--success-text); 836 } 837 838 .status.disabled { 839 background: var(--warning-bg); 840 border: 1px solid var(--border-color); 841 color: var(--warning-text); 842 } 843 844 .status.passkey-only { 845 background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15)); 846 border: 1px solid var(--accent); 847 color: var(--accent); 848 } 849 850 .totp-actions { 851 display: flex; 852 gap: var(--space-2); 853 flex-wrap: wrap; 854 } 855 856 .code-input { 857 font-size: var(--text-2xl); 858 letter-spacing: 0.5em; 859 text-align: center; 860 max-width: 200px; 861 margin: 0 auto; 862 display: block; 863 } 864 865 .actions { 866 display: flex; 867 gap: var(--space-2); 868 margin-top: var(--space-4); 869 } 870 871 .inline-form { 872 margin-top: var(--space-4); 873 padding: var(--space-4); 874 background: var(--bg-card); 875 border: 1px solid var(--border-color); 876 border-radius: var(--radius-lg); 877 } 878 879 .inline-form h3 { 880 margin: 0 0 var(--space-2) 0; 881 font-size: var(--text-base); 882 } 883 884 .danger-form { 885 border-color: var(--error-border); 886 background: var(--error-bg); 887 } 888 889 .warning-text { 890 color: var(--error-text); 891 font-size: var(--text-sm); 892 margin-bottom: var(--space-4); 893 } 894 895 .setup-step { 896 padding: var(--space-4); 897 background: var(--bg-card); 898 border: 1px solid var(--border-color); 899 border-radius: var(--radius-lg); 900 } 901 902 .setup-step h3 { 903 margin: 0 0 var(--space-2) 0; 904 } 905 906 .setup-step p { 907 color: var(--text-secondary); 908 font-size: var(--text-sm); 909 margin-bottom: var(--space-4); 910 } 911 912 .qr-container { 913 display: flex; 914 justify-content: center; 915 margin: var(--space-6) 0; 916 } 917 918 .qr-code { 919 width: 200px; 920 height: 200px; 921 image-rendering: pixelated; 922 } 923 924 .manual-entry { 925 margin-bottom: var(--space-4); 926 font-size: var(--text-sm); 927 } 928 929 .manual-entry summary { 930 cursor: pointer; 931 color: var(--accent); 932 } 933 934 .secret-code { 935 display: block; 936 margin-top: var(--space-2); 937 padding: var(--space-2); 938 background: var(--bg-input); 939 border-radius: var(--radius-md); 940 word-break: break-all; 941 font-size: var(--text-xs); 942 } 943 944 .backup-codes { 945 display: grid; 946 grid-template-columns: repeat(2, 1fr); 947 gap: var(--space-2); 948 margin: var(--space-4) 0; 949 } 950 951 .backup-code { 952 padding: var(--space-2); 953 background: var(--bg-input); 954 border-radius: var(--radius-md); 955 text-align: center; 956 font-size: var(--text-sm); 957 font-family: ui-monospace, monospace; 958 } 959 960 .passkey-list { 961 display: flex; 962 flex-direction: column; 963 gap: var(--space-2); 964 margin-bottom: var(--space-4); 965 } 966 967 .passkey-item { 968 display: flex; 969 justify-content: space-between; 970 align-items: center; 971 padding: var(--space-3); 972 background: var(--bg-card); 973 border: 1px solid var(--border-color); 974 border-radius: var(--radius-lg); 975 gap: var(--space-4); 976 } 977 978 .passkey-info { 979 display: flex; 980 flex-direction: column; 981 gap: var(--space-1); 982 flex: 1; 983 min-width: 0; 984 } 985 986 .passkey-name { 987 font-weight: var(--font-medium); 988 overflow: hidden; 989 text-overflow: ellipsis; 990 white-space: nowrap; 991 } 992 993 .passkey-meta { 994 font-size: var(--text-xs); 995 color: var(--text-secondary); 996 } 997 998 .passkey-actions { 999 display: flex; 1000 gap: var(--space-2); 1001 flex-shrink: 0; 1002 } 1003 1004 .passkey-edit { 1005 display: flex; 1006 flex: 1; 1007 gap: var(--space-2); 1008 align-items: center; 1009 } 1010 1011 .passkey-name-input { 1012 flex: 1; 1013 padding: var(--space-2); 1014 font-size: var(--text-sm); 1015 } 1016 1017 .passkey-edit-actions { 1018 display: flex; 1019 gap: var(--space-1); 1020 } 1021 1022 button.small { 1023 padding: var(--space-2) var(--space-3); 1024 font-size: var(--text-xs); 1025 } 1026 1027 .add-passkey { 1028 margin-top: var(--space-4); 1029 padding-top: var(--space-4); 1030 border-top: 1px solid var(--border-color); 1031 } 1032 1033 .add-passkey .field { 1034 margin-bottom: var(--space-3); 1035 } 1036 1037 .section-link { 1038 display: inline-block; 1039 color: var(--accent); 1040 text-decoration: none; 1041 font-weight: var(--font-medium); 1042 } 1043 1044 .section-link:hover { 1045 text-decoration: underline; 1046 } 1047 1048 .hint { 1049 font-size: var(--text-sm); 1050 color: var(--text-secondary); 1051 margin: 0; 1052 } 1053 1054 .info-box-inline { 1055 background: var(--bg-card); 1056 border: 1px solid var(--border-color); 1057 border-radius: var(--radius-lg); 1058 padding: var(--space-4); 1059 margin-bottom: var(--space-4); 1060 font-size: var(--text-sm); 1061 } 1062 1063 .info-box-inline strong { 1064 display: block; 1065 margin-bottom: var(--space-2); 1066 } 1067 1068 .info-box-inline ul { 1069 margin: 0; 1070 padding-left: var(--space-5); 1071 color: var(--text-secondary); 1072 } 1073 1074 .info-box-inline li { 1075 margin-bottom: var(--space-1); 1076 } 1077 1078 .info-box-inline p { 1079 margin: 0; 1080 color: var(--text-secondary); 1081 } 1082 1083 .toggle-row { 1084 display: flex; 1085 justify-content: space-between; 1086 align-items: flex-start; 1087 gap: var(--space-4); 1088 padding: var(--space-4); 1089 background: var(--bg-card); 1090 border: 1px solid var(--border-color); 1091 border-radius: var(--radius-lg); 1092 margin-bottom: var(--space-4); 1093 } 1094 1095 .toggle-info { 1096 display: flex; 1097 flex-direction: column; 1098 gap: var(--space-1); 1099 } 1100 1101 .toggle-label { 1102 font-weight: var(--font-medium); 1103 } 1104 1105 .toggle-description { 1106 font-size: var(--text-sm); 1107 color: var(--text-secondary); 1108 } 1109 1110 .toggle-button { 1111 position: relative; 1112 width: 50px; 1113 height: 26px; 1114 padding: 0; 1115 border: none; 1116 border-radius: 13px; 1117 cursor: pointer; 1118 transition: background var(--transition-fast); 1119 flex-shrink: 0; 1120 } 1121 1122 .toggle-button.on { 1123 background: var(--success-text); 1124 } 1125 1126 .toggle-button.off { 1127 background: var(--text-secondary); 1128 } 1129 1130 .toggle-button:disabled { 1131 opacity: 0.6; 1132 cursor: not-allowed; 1133 } 1134 1135 .toggle-slider { 1136 position: absolute; 1137 top: 3px; 1138 width: 20px; 1139 height: 20px; 1140 background: white; 1141 border-radius: 50%; 1142 transition: left var(--transition-fast); 1143 } 1144 1145 .toggle-button.on .toggle-slider { 1146 left: 27px; 1147 } 1148 1149 .toggle-button.off .toggle-slider { 1150 left: 3px; 1151 } 1152 1153 .warning-box { 1154 background: var(--warning-bg); 1155 border: 1px solid var(--warning-border); 1156 border-left: 4px solid var(--warning-text); 1157 border-radius: var(--radius-lg); 1158 padding: var(--space-4); 1159 margin-bottom: var(--space-4); 1160 } 1161 1162 .warning-box strong { 1163 display: block; 1164 margin-bottom: var(--space-2); 1165 color: var(--warning-text); 1166 } 1167 1168 .warning-box p { 1169 margin: 0 0 var(--space-3) 0; 1170 font-size: var(--text-sm); 1171 color: var(--text-primary); 1172 } 1173 1174 .warning-box ol { 1175 margin: 0; 1176 padding-left: var(--space-5); 1177 font-size: var(--text-sm); 1178 } 1179 1180 .warning-box li { 1181 margin-bottom: var(--space-2); 1182 } 1183 1184 .warning-box a { 1185 color: var(--accent); 1186 } 1187 1188 .skeleton-grid { 1189 display: grid; 1190 grid-template-columns: repeat(2, 1fr); 1191 gap: var(--space-6); 1192 } 1193 1194 .skeleton-section { 1195 height: 200px; 1196 background: var(--bg-secondary); 1197 border-radius: var(--radius-xl); 1198 animation: skeleton-pulse 1.5s ease-in-out infinite; 1199 } 1200 1201 @keyframes skeleton-pulse { 1202 0%, 100% { opacity: 1; } 1203 50% { opacity: 0.5; } 1204 } 1205 1206 @media (max-width: 900px) { 1207 .skeleton-grid { 1208 grid-template-columns: 1fr; 1209 } 1210 } 1211</style>