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', 'Session expired. Please log in again.') 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 <section> 418 <h2>{$_('security.totp')}</h2> 419 <p class="description"> 420 {$_('security.totpDescription')} 421 </p> 422 423 {#if setupStep === 'idle'} 424 {#if totpEnabled} 425 <div class="status enabled"> 426 <span>{$_('security.totpEnabled')}</span> 427 </div> 428 429 {#if !showDisableForm && !showRegenForm} 430 <div class="totp-actions"> 431 <button type="button" class="secondary" onclick={() => showRegenForm = true}> 432 {$_('security.regenerateBackupCodes')} 433 </button> 434 <button type="button" class="danger-outline" onclick={() => showDisableForm = true}> 435 {$_('security.disableTotp')} 436 </button> 437 </div> 438 {/if} 439 440 {#if showRegenForm} 441 <form onsubmit={handleRegenerate} class="inline-form"> 442 <h3>{$_('security.regenerateBackupCodes')}</h3> 443 <p class="warning-text">{$_('security.regenerateConfirm')}</p> 444 <div class="field"> 445 <label for="regen-password">{$_('security.password')}</label> 446 <input 447 id="regen-password" 448 type="password" 449 bind:value={regenPassword} 450 placeholder={$_('security.enterPassword')} 451 disabled={regenLoading} 452 required 453 /> 454 </div> 455 <div class="field"> 456 <label for="regen-code">{$_('security.totpCode')}</label> 457 <input 458 id="regen-code" 459 type="text" 460 bind:value={regenCode} 461 placeholder="{$_('security.totpCodePlaceholder')}" 462 disabled={regenLoading} 463 required 464 maxlength="6" 465 pattern="[0-9]{6}" 466 inputmode="numeric" 467 /> 468 </div> 469 <div class="actions"> 470 <button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}> 471 {$_('common.cancel')} 472 </button> 473 <button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}> 474 {regenLoading ? $_('security.regenerating') : $_('security.regenerateBackupCodes')} 475 </button> 476 </div> 477 </form> 478 {/if} 479 480 {#if showDisableForm} 481 <form onsubmit={handleDisable} class="inline-form danger-form"> 482 <h3>{$_('security.disableTotp')}</h3> 483 <p class="warning-text">{$_('security.disableTotpWarning')}</p> 484 <div class="field"> 485 <label for="disable-password">{$_('security.password')}</label> 486 <input 487 id="disable-password" 488 type="password" 489 bind:value={disablePassword} 490 placeholder={$_('security.enterPassword')} 491 disabled={disableLoading} 492 required 493 /> 494 </div> 495 <div class="field"> 496 <label for="disable-code">{$_('security.totpCode')}</label> 497 <input 498 id="disable-code" 499 type="text" 500 bind:value={disableCode} 501 placeholder="{$_('security.totpCodePlaceholder')}" 502 disabled={disableLoading} 503 required 504 maxlength="6" 505 pattern="[0-9]{6}" 506 inputmode="numeric" 507 /> 508 </div> 509 <div class="actions"> 510 <button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}> 511 {$_('common.cancel')} 512 </button> 513 <button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}> 514 {disableLoading ? $_('security.disabling') : $_('security.disableTotp')} 515 </button> 516 </div> 517 </form> 518 {/if} 519 {:else} 520 <div class="status disabled"> 521 <span>{$_('security.totpDisabled')}</span> 522 </div> 523 <button onclick={handleStartSetup} disabled={verifyLoading}> 524 {$_('security.enableTotp')} 525 </button> 526 {/if} 527 {:else if setupStep === 'qr'} 528 <div class="setup-step"> 529 <h3>{$_('security.totpSetup')}</h3> 530 <p>{$_('security.totpSetupInstructions')}</p> 531 <div class="qr-container"> 532 <img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" /> 533 </div> 534 <details class="manual-entry"> 535 <summary>{$_('security.cantScan')}</summary> 536 <code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code> 537 </details> 538 <button onclick={() => setupStep = 'verify'}> 539 {$_('security.next')} 540 </button> 541 </div> 542 {:else if setupStep === 'verify'} 543 <div class="setup-step"> 544 <h3>{$_('security.totpSetup')}</h3> 545 <p>{$_('security.totpCodePlaceholder')}</p> 546 <form onsubmit={handleVerifySetup}> 547 <div class="field"> 548 <input 549 type="text" 550 bind:value={verifyCodeRaw} 551 placeholder="000000" 552 disabled={verifyLoading} 553 inputmode="numeric" 554 class="code-input" 555 /> 556 </div> 557 <div class="actions"> 558 <button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}> 559 {$_('common.back')} 560 </button> 561 <button type="submit" disabled={verifyLoading || verifyCode.length !== 6}> 562 {$_('security.verifyAndEnable')} 563 </button> 564 </div> 565 </form> 566 </div> 567 {:else if setupStep === 'backup'} 568 <div class="setup-step"> 569 <h3>{$_('security.backupCodes')}</h3> 570 <p class="warning-text"> 571 {$_('security.backupCodesDescription')} 572 </p> 573 <div class="backup-codes"> 574 {#each backupCodes as code} 575 <code class="backup-code">{code}</code> 576 {/each} 577 </div> 578 <div class="actions"> 579 <button type="button" class="secondary" onclick={copyBackupCodes}> 580 {$_('security.copyToClipboard')} 581 </button> 582 <button onclick={handleFinishSetup}> 583 {$_('security.savedMyCodes')} 584 </button> 585 </div> 586 </div> 587 {/if} 588 </section> 589 590 <section> 591 <h2>{$_('security.passkeys')}</h2> 592 <p class="description"> 593 {$_('security.passkeysDescription')} 594 </p> 595 596 {#if passkeysLoading} 597 <div class="loading">{$_('security.loadingPasskeys')}</div> 598 {:else} 599 {#if passkeys.length > 0} 600 <div class="passkey-list"> 601 {#each passkeys as passkey} 602 <div class="passkey-item"> 603 {#if editingPasskeyId === passkey.id} 604 <div class="passkey-edit"> 605 <input 606 type="text" 607 bind:value={editPasskeyName} 608 placeholder="{$_('security.passkeyName')}" 609 class="passkey-name-input" 610 /> 611 <div class="passkey-edit-actions"> 612 <button type="button" class="small" onclick={handleSavePasskeyName}>{$_('common.save')}</button> 613 <button type="button" class="small secondary" onclick={cancelEditPasskey}>{$_('common.cancel')}</button> 614 </div> 615 </div> 616 {:else} 617 <div class="passkey-info"> 618 <span class="passkey-name">{passkey.friendlyName || $_('security.unnamedPasskey')}</span> 619 <span class="passkey-meta"> 620 {$_('security.added')} {formatDate(passkey.createdAt)} 621 {#if passkey.lastUsed} 622 &middot; {$_('security.lastUsed')} {formatDate(passkey.lastUsed)} 623 {/if} 624 </span> 625 </div> 626 <div class="passkey-actions"> 627 <button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}> 628 {$_('security.rename')} 629 </button> 630 {#if hasPassword || passkeys.length > 1} 631 <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}> 632 {$_('security.deletePasskey')} 633 </button> 634 {/if} 635 </div> 636 {/if} 637 </div> 638 {/each} 639 </div> 640 {:else} 641 <div class="status disabled"> 642 <span>{$_('security.noPasskeys')}</span> 643 </div> 644 {/if} 645 646 <div class="add-passkey"> 647 <div class="field"> 648 <label for="passkey-name">{$_('security.passkeyName')}</label> 649 <input 650 id="passkey-name" 651 type="text" 652 bind:value={newPasskeyName} 653 placeholder="{$_('security.passkeyNamePlaceholder')}" 654 disabled={addingPasskey} 655 /> 656 </div> 657 <button onclick={handleAddPasskey} disabled={addingPasskey}> 658 {addingPasskey ? $_('security.adding') : $_('security.addPasskey')} 659 </button> 660 </div> 661 {/if} 662 </section> 663 664 <section> 665 <h2>{$_('security.password')}</h2> 666 <p class="description"> 667 {$_('security.passwordDescription')} 668 </p> 669 670 {#if passwordLoading} 671 <div class="loading">{$_('common.loading')}</div> 672 {:else if hasPassword} 673 <div class="status enabled"> 674 <span>{$_('security.passwordStatus')}</span> 675 </div> 676 677 {#if passkeys.length > 0} 678 {#if !showRemovePasswordForm} 679 <button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}> 680 {$_('security.removePassword')} 681 </button> 682 {:else} 683 <div class="inline-form danger-form"> 684 <h3>{$_('security.removePassword')}</h3> 685 <p class="warning-text"> 686 {$_('security.removePasswordWarning')} 687 </p> 688 <div class="info-box-inline"> 689 <strong>{$_('security.beforeProceeding')}</strong> 690 <ul> 691 <li>{$_('security.beforeProceedingItem1')}</li> 692 <li>{$_('security.beforeProceedingItem2')}</li> 693 <li>{$_('security.beforeProceedingItem3')}</li> 694 </ul> 695 </div> 696 <div class="actions"> 697 <button type="button" class="secondary" onclick={() => showRemovePasswordForm = false}> 698 {$_('common.cancel')} 699 </button> 700 <button type="button" class="danger" onclick={handleRemovePassword} disabled={removePasswordLoading}> 701 {removePasswordLoading ? $_('security.removing') : $_('security.removePassword')} 702 </button> 703 </div> 704 </div> 705 {/if} 706 {:else} 707 <p class="hint">{$_('security.addPasskeyFirst')}</p> 708 {/if} 709 {:else} 710 <div class="status passkey-only"> 711 <span>{$_('security.noPassword')}</span> 712 </div> 713 <p class="hint"> 714 {$_('security.passkeyOnlyHint')} 715 </p> 716 {/if} 717 </section> 718 719 <section> 720 <h2>{$_('security.trustedDevices')}</h2> 721 <p class="description"> 722 {$_('security.trustedDevicesDescription')} 723 </p> 724 <a href="#/trusted-devices" class="section-link"> 725 {$_('security.manageTrustedDevices')} &rarr; 726 </a> 727 </section> 728 729 {#if hasMfa} 730 <section> 731 <h2>{$_('security.appCompatibility')}</h2> 732 <p class="description"> 733 {$_('security.legacyLoginDescription')} 734 </p> 735 736 {#if legacyLoginLoading} 737 <div class="loading">{$_('common.loading')}</div> 738 {:else} 739 <div class="toggle-row"> 740 <div class="toggle-info"> 741 <span class="toggle-label">{$_('security.legacyLogin')}</span> 742 <span class="toggle-description"> 743 {#if allowLegacyLogin} 744 {$_('security.legacyLoginOn')} 745 {:else} 746 {$_('security.legacyLoginOff')} 747 {/if} 748 </span> 749 </div> 750 <button 751 type="button" 752 class="toggle-button {allowLegacyLogin ? 'on' : 'off'}" 753 onclick={handleToggleLegacyLogin} 754 disabled={legacyLoginUpdating} 755 aria-label={allowLegacyLogin ? $_('security.disableLegacyLogin') : $_('security.enableLegacyLogin')} 756 > 757 <span class="toggle-slider"></span> 758 </button> 759 </div> 760 761 {#if totpEnabled} 762 <div class="warning-box"> 763 <strong>{$_('security.legacyLoginWarning')}</strong> 764 <p>{$_('security.totpPasswordWarning')}</p> 765 <ol> 766 <li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href="#/settings">{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li> 767 <li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href="#/settings">{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li> 768 </ol> 769 </div> 770 {/if} 771 772 <div class="info-box-inline"> 773 <strong>{$_('security.legacyAppsTitle')}</strong> 774 <p>{$_('security.legacyAppsDescription')}</p> 775 </div> 776 {/if} 777 </section> 778 {/if} 779 {/if} 780</div> 781 782<ReauthModal 783 bind:show={showReauthModal} 784 availableMethods={reauthMethods} 785 onSuccess={handleReauthSuccess} 786 onCancel={handleReauthCancel} 787/> 788 789<style> 790 .page { 791 max-width: var(--width-md); 792 margin: 0 auto; 793 padding: var(--space-7); 794 } 795 796 header { 797 margin-bottom: var(--space-7); 798 } 799 800 .back { 801 color: var(--text-secondary); 802 text-decoration: none; 803 font-size: var(--text-sm); 804 } 805 806 .back:hover { 807 color: var(--accent); 808 } 809 810 h1 { 811 margin: var(--space-2) 0 0 0; 812 } 813 814 .loading { 815 text-align: center; 816 color: var(--text-secondary); 817 padding: var(--space-7); 818 } 819 820 section { 821 padding: var(--space-6); 822 background: var(--bg-secondary); 823 border-radius: var(--radius-xl); 824 margin-bottom: var(--space-6); 825 } 826 827 section h2 { 828 margin: 0 0 var(--space-2) 0; 829 font-size: var(--text-lg); 830 } 831 832 .description { 833 color: var(--text-secondary); 834 font-size: var(--text-sm); 835 margin-bottom: var(--space-6); 836 } 837 838 .status { 839 display: flex; 840 align-items: center; 841 gap: var(--space-2); 842 padding: var(--space-3); 843 border-radius: var(--radius-md); 844 margin-bottom: var(--space-4); 845 } 846 847 .status.enabled { 848 background: var(--success-bg); 849 border: 1px solid var(--success-border); 850 color: var(--success-text); 851 } 852 853 .status.disabled { 854 background: var(--warning-bg); 855 border: 1px solid var(--border-color); 856 color: var(--warning-text); 857 } 858 859 .status.passkey-only { 860 background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15)); 861 border: 1px solid var(--accent); 862 color: var(--accent); 863 } 864 865 .totp-actions { 866 display: flex; 867 gap: var(--space-2); 868 flex-wrap: wrap; 869 } 870 871 .code-input { 872 font-size: var(--text-2xl); 873 letter-spacing: 0.5em; 874 text-align: center; 875 max-width: 200px; 876 margin: 0 auto; 877 display: block; 878 } 879 880 .actions { 881 display: flex; 882 gap: var(--space-2); 883 margin-top: var(--space-4); 884 } 885 886 .inline-form { 887 margin-top: var(--space-4); 888 padding: var(--space-4); 889 background: var(--bg-card); 890 border: 1px solid var(--border-color); 891 border-radius: var(--radius-lg); 892 } 893 894 .inline-form h3 { 895 margin: 0 0 var(--space-2) 0; 896 font-size: var(--text-base); 897 } 898 899 .danger-form { 900 border-color: var(--error-border); 901 background: var(--error-bg); 902 } 903 904 .warning-text { 905 color: var(--error-text); 906 font-size: var(--text-sm); 907 margin-bottom: var(--space-4); 908 } 909 910 .setup-step { 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 .setup-step h3 { 918 margin: 0 0 var(--space-2) 0; 919 } 920 921 .setup-step p { 922 color: var(--text-secondary); 923 font-size: var(--text-sm); 924 margin-bottom: var(--space-4); 925 } 926 927 .qr-container { 928 display: flex; 929 justify-content: center; 930 margin: var(--space-6) 0; 931 } 932 933 .qr-code { 934 width: 200px; 935 height: 200px; 936 image-rendering: pixelated; 937 } 938 939 .manual-entry { 940 margin-bottom: var(--space-4); 941 font-size: var(--text-sm); 942 } 943 944 .manual-entry summary { 945 cursor: pointer; 946 color: var(--accent); 947 } 948 949 .secret-code { 950 display: block; 951 margin-top: var(--space-2); 952 padding: var(--space-2); 953 background: var(--bg-input); 954 border-radius: var(--radius-md); 955 word-break: break-all; 956 font-size: var(--text-xs); 957 } 958 959 .backup-codes { 960 display: grid; 961 grid-template-columns: repeat(2, 1fr); 962 gap: var(--space-2); 963 margin: var(--space-4) 0; 964 } 965 966 .backup-code { 967 padding: var(--space-2); 968 background: var(--bg-input); 969 border-radius: var(--radius-md); 970 text-align: center; 971 font-size: var(--text-sm); 972 font-family: ui-monospace, monospace; 973 } 974 975 .passkey-list { 976 display: flex; 977 flex-direction: column; 978 gap: var(--space-2); 979 margin-bottom: var(--space-4); 980 } 981 982 .passkey-item { 983 display: flex; 984 justify-content: space-between; 985 align-items: center; 986 padding: var(--space-3); 987 background: var(--bg-card); 988 border: 1px solid var(--border-color); 989 border-radius: var(--radius-lg); 990 gap: var(--space-4); 991 } 992 993 .passkey-info { 994 display: flex; 995 flex-direction: column; 996 gap: var(--space-1); 997 flex: 1; 998 min-width: 0; 999 } 1000 1001 .passkey-name { 1002 font-weight: var(--font-medium); 1003 overflow: hidden; 1004 text-overflow: ellipsis; 1005 white-space: nowrap; 1006 } 1007 1008 .passkey-meta { 1009 font-size: var(--text-xs); 1010 color: var(--text-secondary); 1011 } 1012 1013 .passkey-actions { 1014 display: flex; 1015 gap: var(--space-2); 1016 flex-shrink: 0; 1017 } 1018 1019 .passkey-edit { 1020 display: flex; 1021 flex: 1; 1022 gap: var(--space-2); 1023 align-items: center; 1024 } 1025 1026 .passkey-name-input { 1027 flex: 1; 1028 padding: var(--space-2); 1029 font-size: var(--text-sm); 1030 } 1031 1032 .passkey-edit-actions { 1033 display: flex; 1034 gap: var(--space-1); 1035 } 1036 1037 button.small { 1038 padding: var(--space-2) var(--space-3); 1039 font-size: var(--text-xs); 1040 } 1041 1042 .add-passkey { 1043 margin-top: var(--space-4); 1044 padding-top: var(--space-4); 1045 border-top: 1px solid var(--border-color); 1046 } 1047 1048 .add-passkey .field { 1049 margin-bottom: var(--space-3); 1050 } 1051 1052 .section-link { 1053 display: inline-block; 1054 color: var(--accent); 1055 text-decoration: none; 1056 font-weight: var(--font-medium); 1057 } 1058 1059 .section-link:hover { 1060 text-decoration: underline; 1061 } 1062 1063 .hint { 1064 font-size: var(--text-sm); 1065 color: var(--text-secondary); 1066 margin: 0; 1067 } 1068 1069 .info-box-inline { 1070 background: var(--bg-card); 1071 border: 1px solid var(--border-color); 1072 border-radius: var(--radius-lg); 1073 padding: var(--space-4); 1074 margin-bottom: var(--space-4); 1075 font-size: var(--text-sm); 1076 } 1077 1078 .info-box-inline strong { 1079 display: block; 1080 margin-bottom: var(--space-2); 1081 } 1082 1083 .info-box-inline ul { 1084 margin: 0; 1085 padding-left: var(--space-5); 1086 color: var(--text-secondary); 1087 } 1088 1089 .info-box-inline li { 1090 margin-bottom: var(--space-1); 1091 } 1092 1093 .info-box-inline p { 1094 margin: 0; 1095 color: var(--text-secondary); 1096 } 1097 1098 .toggle-row { 1099 display: flex; 1100 justify-content: space-between; 1101 align-items: flex-start; 1102 gap: var(--space-4); 1103 padding: var(--space-4); 1104 background: var(--bg-card); 1105 border: 1px solid var(--border-color); 1106 border-radius: var(--radius-lg); 1107 margin-bottom: var(--space-4); 1108 } 1109 1110 .toggle-info { 1111 display: flex; 1112 flex-direction: column; 1113 gap: var(--space-1); 1114 } 1115 1116 .toggle-label { 1117 font-weight: var(--font-medium); 1118 } 1119 1120 .toggle-description { 1121 font-size: var(--text-sm); 1122 color: var(--text-secondary); 1123 } 1124 1125 .toggle-button { 1126 position: relative; 1127 width: 50px; 1128 height: 26px; 1129 padding: 0; 1130 border: none; 1131 border-radius: 13px; 1132 cursor: pointer; 1133 transition: background var(--transition-fast); 1134 flex-shrink: 0; 1135 } 1136 1137 .toggle-button.on { 1138 background: var(--success-text); 1139 } 1140 1141 .toggle-button.off { 1142 background: var(--text-secondary); 1143 } 1144 1145 .toggle-button:disabled { 1146 opacity: 0.6; 1147 cursor: not-allowed; 1148 } 1149 1150 .toggle-slider { 1151 position: absolute; 1152 top: 3px; 1153 width: 20px; 1154 height: 20px; 1155 background: white; 1156 border-radius: 50%; 1157 transition: left var(--transition-fast); 1158 } 1159 1160 .toggle-button.on .toggle-slider { 1161 left: 27px; 1162 } 1163 1164 .toggle-button.off .toggle-slider { 1165 left: 3px; 1166 } 1167 1168 .warning-box { 1169 background: var(--warning-bg); 1170 border: 1px solid var(--warning-border); 1171 border-left: 4px solid var(--warning-text); 1172 border-radius: var(--radius-lg); 1173 padding: var(--space-4); 1174 margin-bottom: var(--space-4); 1175 } 1176 1177 .warning-box strong { 1178 display: block; 1179 margin-bottom: var(--space-2); 1180 color: var(--warning-text); 1181 } 1182 1183 .warning-box p { 1184 margin: 0 0 var(--space-3) 0; 1185 font-size: var(--text-sm); 1186 color: var(--text-primary); 1187 } 1188 1189 .warning-box ol { 1190 margin: 0; 1191 padding-left: var(--space-5); 1192 font-size: var(--text-sm); 1193 } 1194 1195 .warning-box li { 1196 margin-bottom: var(--space-2); 1197 } 1198 1199 .warning-box a { 1200 color: var(--accent); 1201 } 1202</style>