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