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