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 7 const auth = getAuthState() 8 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) 9 let loading = $state(true) 10 let totpEnabled = $state(false) 11 let hasBackupCodes = $state(false) 12 let setupStep = $state<'idle' | 'qr' | 'verify' | 'backup'>('idle') 13 let qrBase64 = $state('') 14 let totpUri = $state('') 15 let verifyCodeRaw = $state('') 16 let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, '')) 17 let verifyLoading = $state(false) 18 let backupCodes = $state<string[]>([]) 19 let disablePassword = $state('') 20 let disableCode = $state('') 21 let disableLoading = $state(false) 22 let showDisableForm = $state(false) 23 let regenPassword = $state('') 24 let regenCode = $state('') 25 let regenLoading = $state(false) 26 let showRegenForm = $state(false) 27 28 interface Passkey { 29 id: string 30 credentialId: string 31 friendlyName: string | null 32 createdAt: string 33 lastUsed: string | null 34 } 35 let passkeys = $state<Passkey[]>([]) 36 let passkeysLoading = $state(true) 37 let addingPasskey = $state(false) 38 let newPasskeyName = $state('') 39 let editingPasskeyId = $state<string | null>(null) 40 let editPasskeyName = $state('') 41 42 let hasPassword = $state(true) 43 let passwordLoading = $state(true) 44 let showRemovePasswordForm = $state(false) 45 let removePasswordLoading = $state(false) 46 47 let allowLegacyLogin = $state(true) 48 let hasMfa = $state(false) 49 let legacyLoginLoading = $state(true) 50 let legacyLoginUpdating = $state(false) 51 52 let showReauthModal = $state(false) 53 let reauthMethods = $state<string[]>(['password']) 54 let pendingAction = $state<(() => Promise<void>) | null>(null) 55 56 $effect(() => { 57 if (!auth.loading && !auth.session) { 58 navigate('/login') 59 } 60 }) 61 62 $effect(() => { 63 if (auth.session) { 64 loadTotpStatus() 65 loadPasskeys() 66 loadPasswordStatus() 67 loadLegacyLoginPreference() 68 } 69 }) 70 71 async function loadPasswordStatus() { 72 if (!auth.session) return 73 passwordLoading = true 74 try { 75 const status = await api.getPasswordStatus(auth.session.accessJwt) 76 hasPassword = status.hasPassword 77 } catch { 78 hasPassword = true 79 } finally { 80 passwordLoading = false 81 } 82 } 83 84 async function loadLegacyLoginPreference() { 85 if (!auth.session) return 86 legacyLoginLoading = true 87 try { 88 const pref = await api.getLegacyLoginPreference(auth.session.accessJwt) 89 allowLegacyLogin = pref.allowLegacyLogin 90 hasMfa = pref.hasMfa 91 } catch { 92 allowLegacyLogin = true 93 hasMfa = false 94 } finally { 95 legacyLoginLoading = false 96 } 97 } 98 99 async function handleToggleLegacyLogin() { 100 if (!auth.session) return 101 legacyLoginUpdating = true 102 try { 103 const result = await api.updateLegacyLoginPreference(auth.session.accessJwt, !allowLegacyLogin) 104 allowLegacyLogin = result.allowLegacyLogin 105 showMessage('success', allowLegacyLogin 106 ? 'Legacy app login enabled' 107 : 'Legacy app login disabled - only OAuth apps can sign in') 108 } catch (e) { 109 if (e instanceof ApiError) { 110 if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') { 111 reauthMethods = e.reauthMethods || ['password'] 112 pendingAction = handleToggleLegacyLogin 113 showReauthModal = true 114 } else { 115 showMessage('error', e.message) 116 } 117 } else { 118 showMessage('error', 'Failed to update preference') 119 } 120 } finally { 121 legacyLoginUpdating = false 122 } 123 } 124 125 async function handleRemovePassword() { 126 if (!auth.session) return 127 removePasswordLoading = true 128 try { 129 await api.removePassword(auth.session.accessJwt) 130 hasPassword = false 131 showRemovePasswordForm = false 132 showMessage('success', 'Password removed. Your account is now passkey-only.') 133 } catch (e) { 134 if (e instanceof ApiError) { 135 if (e.error === 'ReauthRequired') { 136 reauthMethods = e.reauthMethods || ['password'] 137 pendingAction = handleRemovePassword 138 showReauthModal = true 139 } else { 140 showMessage('error', e.message) 141 } 142 } else { 143 showMessage('error', 'Failed to remove password') 144 } 145 } finally { 146 removePasswordLoading = false 147 } 148 } 149 150 function handleReauthSuccess() { 151 if (pendingAction) { 152 pendingAction() 153 pendingAction = null 154 } 155 } 156 157 function handleReauthCancel() { 158 pendingAction = null 159 } 160 161 async function loadTotpStatus() { 162 if (!auth.session) return 163 loading = true 164 try { 165 const status = await api.getTotpStatus(auth.session.accessJwt) 166 totpEnabled = status.enabled 167 hasBackupCodes = status.hasBackupCodes 168 } catch { 169 showMessage('error', 'Failed to load TOTP status') 170 } finally { 171 loading = false 172 } 173 } 174 175 function showMessage(type: 'success' | 'error', text: string) { 176 message = { type, text } 177 setTimeout(() => { 178 if (message?.text === text) message = null 179 }, 5000) 180 } 181 182 async function handleStartSetup() { 183 if (!auth.session) return 184 verifyLoading = true 185 try { 186 const result = await api.createTotpSecret(auth.session.accessJwt) 187 qrBase64 = result.qrBase64 188 totpUri = result.uri 189 setupStep = 'qr' 190 } catch (e) { 191 showMessage('error', e instanceof ApiError ? e.message : 'Failed to generate TOTP secret') 192 } finally { 193 verifyLoading = false 194 } 195 } 196 197 async function handleVerifySetup(e: Event) { 198 e.preventDefault() 199 if (!auth.session || !verifyCode) return 200 verifyLoading = true 201 try { 202 const result = await api.enableTotp(auth.session.accessJwt, verifyCode) 203 backupCodes = result.backupCodes 204 setupStep = 'backup' 205 totpEnabled = true 206 hasBackupCodes = true 207 verifyCodeRaw = '' 208 } catch (e) { 209 showMessage('error', e instanceof ApiError ? e.message : 'Invalid code. Please try again.') 210 } finally { 211 verifyLoading = false 212 } 213 } 214 215 function handleFinishSetup() { 216 setupStep = 'idle' 217 backupCodes = [] 218 qrBase64 = '' 219 totpUri = '' 220 showMessage('success', 'Two-factor authentication enabled successfully') 221 } 222 223 async function handleDisable(e: Event) { 224 e.preventDefault() 225 if (!auth.session || !disablePassword || !disableCode) return 226 disableLoading = true 227 try { 228 await api.disableTotp(auth.session.accessJwt, disablePassword, disableCode) 229 totpEnabled = false 230 hasBackupCodes = false 231 showDisableForm = false 232 disablePassword = '' 233 disableCode = '' 234 showMessage('success', 'Two-factor authentication disabled') 235 } catch (e) { 236 showMessage('error', e instanceof ApiError ? e.message : 'Failed to disable TOTP') 237 } finally { 238 disableLoading = false 239 } 240 } 241 242 async function handleRegenerate(e: Event) { 243 e.preventDefault() 244 if (!auth.session || !regenPassword || !regenCode) return 245 regenLoading = true 246 try { 247 const result = await api.regenerateBackupCodes(auth.session.accessJwt, regenPassword, regenCode) 248 backupCodes = result.backupCodes 249 setupStep = 'backup' 250 showRegenForm = false 251 regenPassword = '' 252 regenCode = '' 253 } catch (e) { 254 showMessage('error', e instanceof ApiError ? e.message : 'Failed to regenerate backup codes') 255 } finally { 256 regenLoading = false 257 } 258 } 259 260 function copyBackupCodes() { 261 const text = backupCodes.join('\n') 262 navigator.clipboard.writeText(text) 263 showMessage('success', 'Backup codes copied to clipboard') 264 } 265 266 async function loadPasskeys() { 267 if (!auth.session) return 268 passkeysLoading = true 269 try { 270 const result = await api.listPasskeys(auth.session.accessJwt) 271 passkeys = result.passkeys 272 } catch { 273 showMessage('error', 'Failed to load passkeys') 274 } finally { 275 passkeysLoading = false 276 } 277 } 278 279 async function handleAddPasskey() { 280 if (!auth.session) return 281 if (!window.PublicKeyCredential) { 282 showMessage('error', 'Passkeys are not supported in this browser') 283 return 284 } 285 addingPasskey = true 286 try { 287 const { options } = await api.startPasskeyRegistration(auth.session.accessJwt, newPasskeyName || undefined) 288 const publicKeyOptions = preparePublicKeyOptions(options) 289 const credential = await navigator.credentials.create({ 290 publicKey: publicKeyOptions 291 }) 292 if (!credential) { 293 showMessage('error', 'Passkey creation was cancelled') 294 return 295 } 296 const credentialResponse = { 297 id: credential.id, 298 type: credential.type, 299 rawId: arrayBufferToBase64Url((credential as PublicKeyCredential).rawId), 300 response: { 301 clientDataJSON: arrayBufferToBase64Url((credential as PublicKeyCredential).response.clientDataJSON), 302 attestationObject: arrayBufferToBase64Url(((credential as PublicKeyCredential).response as AuthenticatorAttestationResponse).attestationObject), 303 }, 304 } 305 await api.finishPasskeyRegistration(auth.session.accessJwt, credentialResponse, newPasskeyName || undefined) 306 await loadPasskeys() 307 newPasskeyName = '' 308 showMessage('success', 'Passkey added successfully') 309 } catch (e) { 310 if (e instanceof DOMException && e.name === 'NotAllowedError') { 311 showMessage('error', 'Passkey creation was cancelled') 312 } else { 313 showMessage('error', e instanceof ApiError ? e.message : 'Failed to add passkey') 314 } 315 } finally { 316 addingPasskey = false 317 } 318 } 319 320 async function handleDeletePasskey(id: string) { 321 if (!auth.session) return 322 if (!confirm('Are you sure you want to delete this passkey?')) return 323 try { 324 await api.deletePasskey(auth.session.accessJwt, id) 325 await loadPasskeys() 326 showMessage('success', 'Passkey deleted') 327 } catch (e) { 328 showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete passkey') 329 } 330 } 331 332 async function handleSavePasskeyName() { 333 if (!auth.session || !editingPasskeyId || !editPasskeyName.trim()) return 334 try { 335 await api.updatePasskey(auth.session.accessJwt, editingPasskeyId, editPasskeyName.trim()) 336 await loadPasskeys() 337 editingPasskeyId = null 338 editPasskeyName = '' 339 showMessage('success', 'Passkey renamed') 340 } catch (e) { 341 showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename passkey') 342 } 343 } 344 345 function startEditPasskey(passkey: Passkey) { 346 editingPasskeyId = passkey.id 347 editPasskeyName = passkey.friendlyName || '' 348 } 349 350 function cancelEditPasskey() { 351 editingPasskeyId = null 352 editPasskeyName = '' 353 } 354 355 function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 356 const bytes = new Uint8Array(buffer) 357 let binary = '' 358 for (let i = 0; i < bytes.byteLength; i++) { 359 binary += String.fromCharCode(bytes[i]) 360 } 361 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 362 } 363 364 function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 365 const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 366 const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 367 const binary = atob(padded) 368 const bytes = new Uint8Array(binary.length) 369 for (let i = 0; i < binary.length; i++) { 370 bytes[i] = binary.charCodeAt(i) 371 } 372 return bytes.buffer 373 } 374 375 function preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions { 376 return { 377 ...options.publicKey, 378 challenge: base64UrlToArrayBuffer(options.publicKey.challenge), 379 user: { 380 ...options.publicKey.user, 381 id: base64UrlToArrayBuffer(options.publicKey.user.id) 382 }, 383 excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({ 384 ...cred, 385 id: base64UrlToArrayBuffer(cred.id) 386 })) || [] 387 } 388 } 389 390 function formatDate(dateStr: string): string { 391 return new Date(dateStr).toLocaleDateString() 392 } 393</script> 394 395<div class="page"> 396 <header> 397 <a href="#/dashboard" class="back">&larr; Dashboard</a> 398 <h1>Security Settings</h1> 399 </header> 400 401 {#if message} 402 <div class="message {message.type}">{message.text}</div> 403 {/if} 404 405 {#if loading} 406 <div class="loading">Loading...</div> 407 {:else} 408 <section> 409 <h2>Two-Factor Authentication</h2> 410 <p class="description"> 411 Add an extra layer of security to your account using an authenticator app like Google Authenticator, Authy, or 1Password. 412 </p> 413 414 {#if setupStep === 'idle'} 415 {#if totpEnabled} 416 <div class="status enabled"> 417 <span>Two-factor authentication is <strong>enabled</strong></span> 418 </div> 419 420 {#if !showDisableForm && !showRegenForm} 421 <div class="totp-actions"> 422 <button type="button" class="secondary" onclick={() => showRegenForm = true}> 423 Regenerate Backup Codes 424 </button> 425 <button type="button" class="danger-outline" onclick={() => showDisableForm = true}> 426 Disable 2FA 427 </button> 428 </div> 429 {/if} 430 431 {#if showRegenForm} 432 <form onsubmit={handleRegenerate} class="inline-form"> 433 <h3>Regenerate Backup Codes</h3> 434 <p class="warning-text">This will invalidate all existing backup codes.</p> 435 <div class="field"> 436 <label for="regen-password">Password</label> 437 <input 438 id="regen-password" 439 type="password" 440 bind:value={regenPassword} 441 placeholder="Enter your password" 442 disabled={regenLoading} 443 required 444 /> 445 </div> 446 <div class="field"> 447 <label for="regen-code">Authenticator Code</label> 448 <input 449 id="regen-code" 450 type="text" 451 bind:value={regenCode} 452 placeholder="6-digit code" 453 disabled={regenLoading} 454 required 455 maxlength="6" 456 pattern="[0-9]{6}" 457 inputmode="numeric" 458 /> 459 </div> 460 <div class="actions"> 461 <button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}> 462 Cancel 463 </button> 464 <button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}> 465 {regenLoading ? 'Regenerating...' : 'Regenerate'} 466 </button> 467 </div> 468 </form> 469 {/if} 470 471 {#if showDisableForm} 472 <form onsubmit={handleDisable} class="inline-form danger-form"> 473 <h3>Disable Two-Factor Authentication</h3> 474 <p class="warning-text">This will make your account less secure.</p> 475 <div class="field"> 476 <label for="disable-password">Password</label> 477 <input 478 id="disable-password" 479 type="password" 480 bind:value={disablePassword} 481 placeholder="Enter your password" 482 disabled={disableLoading} 483 required 484 /> 485 </div> 486 <div class="field"> 487 <label for="disable-code">Authenticator Code</label> 488 <input 489 id="disable-code" 490 type="text" 491 bind:value={disableCode} 492 placeholder="6-digit code" 493 disabled={disableLoading} 494 required 495 maxlength="6" 496 pattern="[0-9]{6}" 497 inputmode="numeric" 498 /> 499 </div> 500 <div class="actions"> 501 <button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}> 502 Cancel 503 </button> 504 <button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}> 505 {disableLoading ? 'Disabling...' : 'Disable 2FA'} 506 </button> 507 </div> 508 </form> 509 {/if} 510 {:else} 511 <div class="status disabled"> 512 <span>Two-factor authentication is <strong>not enabled</strong></span> 513 </div> 514 <button onclick={handleStartSetup} disabled={verifyLoading}> 515 {verifyLoading ? 'Setting up...' : 'Set Up Two-Factor Authentication'} 516 </button> 517 {/if} 518 {:else if setupStep === 'qr'} 519 <div class="setup-step"> 520 <h3>Step 1: Scan QR Code</h3> 521 <p>Scan this QR code with your authenticator app:</p> 522 <div class="qr-container"> 523 <img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" /> 524 </div> 525 <details class="manual-entry"> 526 <summary>Can't scan? Enter manually</summary> 527 <code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code> 528 </details> 529 <button onclick={() => setupStep = 'verify'}> 530 Next: Verify Code 531 </button> 532 </div> 533 {:else if setupStep === 'verify'} 534 <div class="setup-step"> 535 <h3>Step 2: Verify Setup</h3> 536 <p>Enter the 6-digit code from your authenticator app:</p> 537 <form onsubmit={handleVerifySetup}> 538 <div class="field"> 539 <input 540 type="text" 541 bind:value={verifyCodeRaw} 542 placeholder="000000" 543 disabled={verifyLoading} 544 inputmode="numeric" 545 class="code-input" 546 /> 547 </div> 548 <div class="actions"> 549 <button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}> 550 Back 551 </button> 552 <button type="submit" disabled={verifyLoading || verifyCode.length !== 6}> 553 {verifyLoading ? 'Verifying...' : 'Verify & Enable'} 554 </button> 555 </div> 556 </form> 557 </div> 558 {:else if setupStep === 'backup'} 559 <div class="setup-step"> 560 <h3>Step 3: Save Backup Codes</h3> 561 <p class="warning-text"> 562 Save these backup codes in a secure location. Each code can only be used once. 563 If you lose access to your authenticator app, you'll need these to sign in. 564 </p> 565 <div class="backup-codes"> 566 {#each backupCodes as code} 567 <code class="backup-code">{code}</code> 568 {/each} 569 </div> 570 <div class="actions"> 571 <button type="button" class="secondary" onclick={copyBackupCodes}> 572 Copy to Clipboard 573 </button> 574 <button onclick={handleFinishSetup}> 575 I've Saved My Codes 576 </button> 577 </div> 578 </div> 579 {/if} 580 </section> 581 582 <section> 583 <h2>Passkeys</h2> 584 <p class="description"> 585 Passkeys are a secure, passwordless way to sign in using biometrics (fingerprint or face), a security key, or your device's screen lock. 586 </p> 587 588 {#if passkeysLoading} 589 <div class="loading">Loading passkeys...</div> 590 {:else} 591 {#if passkeys.length > 0} 592 <div class="passkey-list"> 593 {#each passkeys as passkey} 594 <div class="passkey-item"> 595 {#if editingPasskeyId === passkey.id} 596 <div class="passkey-edit"> 597 <input 598 type="text" 599 bind:value={editPasskeyName} 600 placeholder="Passkey name" 601 class="passkey-name-input" 602 /> 603 <div class="passkey-edit-actions"> 604 <button type="button" class="small" onclick={handleSavePasskeyName}>Save</button> 605 <button type="button" class="small secondary" onclick={cancelEditPasskey}>Cancel</button> 606 </div> 607 </div> 608 {:else} 609 <div class="passkey-info"> 610 <span class="passkey-name">{passkey.friendlyName || 'Unnamed passkey'}</span> 611 <span class="passkey-meta"> 612 Added {formatDate(passkey.createdAt)} 613 {#if passkey.lastUsed} 614 &middot; Last used {formatDate(passkey.lastUsed)} 615 {/if} 616 </span> 617 </div> 618 <div class="passkey-actions"> 619 <button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}> 620 Rename 621 </button> 622 {#if hasPassword || passkeys.length > 1} 623 <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}> 624 Delete 625 </button> 626 {/if} 627 </div> 628 {/if} 629 </div> 630 {/each} 631 </div> 632 {:else} 633 <div class="status disabled"> 634 <span>No passkeys registered</span> 635 </div> 636 {/if} 637 638 <div class="add-passkey"> 639 <div class="field"> 640 <label for="passkey-name">Passkey Name (optional)</label> 641 <input 642 id="passkey-name" 643 type="text" 644 bind:value={newPasskeyName} 645 placeholder="e.g., MacBook Touch ID" 646 disabled={addingPasskey} 647 /> 648 </div> 649 <button onclick={handleAddPasskey} disabled={addingPasskey}> 650 {addingPasskey ? 'Adding Passkey...' : 'Add a Passkey'} 651 </button> 652 </div> 653 {/if} 654 </section> 655 656 <section> 657 <h2>Password</h2> 658 <p class="description"> 659 Manage your account password. If you have passkeys set up, you can optionally remove your password for a fully passwordless experience. 660 </p> 661 662 {#if passwordLoading} 663 <div class="loading">Loading...</div> 664 {:else if hasPassword} 665 <div class="status enabled"> 666 <span>Password authentication is <strong>enabled</strong></span> 667 </div> 668 669 {#if passkeys.length > 0} 670 {#if !showRemovePasswordForm} 671 <button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}> 672 Remove Password 673 </button> 674 {:else} 675 <div class="inline-form danger-form"> 676 <h3>Remove Password</h3> 677 <p class="warning-text"> 678 This will make your account passkey-only. You'll only be able to sign in using your registered passkeys. 679 If you lose access to all your passkeys, you can recover your account using your notification channel. 680 </p> 681 <div class="info-box-inline"> 682 <strong>Before proceeding:</strong> 683 <ul> 684 <li>Make sure you have at least one reliable passkey registered</li> 685 <li>Consider registering passkeys on multiple devices</li> 686 <li>Ensure your recovery notification channel is up to date</li> 687 </ul> 688 </div> 689 <div class="actions"> 690 <button type="button" class="secondary" onclick={() => showRemovePasswordForm = false}> 691 Cancel 692 </button> 693 <button type="button" class="danger" onclick={handleRemovePassword} disabled={removePasswordLoading}> 694 {removePasswordLoading ? 'Removing...' : 'Remove Password'} 695 </button> 696 </div> 697 </div> 698 {/if} 699 {:else} 700 <p class="hint">Add at least one passkey before you can remove your password.</p> 701 {/if} 702 {:else} 703 <div class="status passkey-only"> 704 <span>Your account is <strong>passkey-only</strong></span> 705 </div> 706 <p class="hint"> 707 You sign in using passkeys only. If you ever lose access to your passkeys, 708 you can recover your account using the "Lost passkey?" link on the login page. 709 </p> 710 {/if} 711 </section> 712 713 <section> 714 <h2>Trusted Devices</h2> 715 <p class="description"> 716 Manage devices that can skip two-factor authentication when signing in. Trust is granted for 30 days and automatically extends when you use the device. 717 </p> 718 <a href="#/trusted-devices" class="section-link"> 719 Manage Trusted Devices &rarr; 720 </a> 721 </section> 722 723 {#if hasMfa} 724 <section> 725 <h2>App Compatibility</h2> 726 <p class="description"> 727 Control whether apps that don't support modern authentication (like the official Bluesky app) can sign in to your account. 728 </p> 729 730 {#if legacyLoginLoading} 731 <div class="loading">Loading...</div> 732 {:else} 733 <div class="toggle-row"> 734 <div class="toggle-info"> 735 <span class="toggle-label">Allow legacy app login</span> 736 <span class="toggle-description"> 737 {#if allowLegacyLogin} 738 Legacy apps can sign in with just your password, but sensitive actions (like changing your password) will require MFA verification. 739 {:else} 740 Only OAuth-compatible apps can sign in. Legacy apps will be blocked. 741 {/if} 742 </span> 743 </div> 744 <button 745 type="button" 746 class="toggle-button {allowLegacyLogin ? 'on' : 'off'}" 747 onclick={handleToggleLegacyLogin} 748 disabled={legacyLoginUpdating} 749 > 750 <span class="toggle-slider"></span> 751 </button> 752 </div> 753 754 {#if totpEnabled} 755 <div class="warning-box"> 756 <strong>Important: Password changes in Bluesky app will fail</strong> 757 <p> 758 With TOTP enabled, changing your password from the Bluesky app (or other legacy apps) will be blocked. 759 To change your password, you have two options: 760 </p> 761 <ol> 762 <li><strong>Change it here:</strong> Use this website's <a href="#/settings">Settings page</a> where you can verify with your authenticator app.</li> 763 <li><strong>Verify your session first:</strong> Use the <a href="#/settings">re-authenticate option</a> to verify your Bluesky session with TOTP, then password changes will work temporarily.</li> 764 </ol> 765 </div> 766 {/if} 767 768 <div class="info-box-inline"> 769 <strong>What are legacy apps?</strong> 770 <p> 771 Some apps (like the official Bluesky app) use older authentication that only requires your password. 772 When you have MFA enabled, these apps bypass your second factor. 773 Disabling legacy login forces all apps to use OAuth, which properly enforces MFA. 774 </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: 600px; 792 margin: 0 auto; 793 padding: 2rem; 794 } 795 796 header { 797 margin-bottom: 2rem; 798 } 799 800 .back { 801 color: var(--text-secondary); 802 text-decoration: none; 803 font-size: 0.875rem; 804 } 805 806 .back:hover { 807 color: var(--accent); 808 } 809 810 h1 { 811 margin: 0.5rem 0 0 0; 812 } 813 814 .message { 815 padding: 0.75rem; 816 border-radius: 4px; 817 margin-bottom: 1rem; 818 } 819 820 .message.success { 821 background: var(--success-bg); 822 border: 1px solid var(--success-border); 823 color: var(--success-text); 824 } 825 826 .message.error { 827 background: var(--error-bg); 828 border: 1px solid var(--error-border); 829 color: var(--error-text); 830 } 831 832 .loading { 833 text-align: center; 834 color: var(--text-secondary); 835 padding: 2rem; 836 } 837 838 section { 839 padding: 1.5rem; 840 background: var(--bg-secondary); 841 border-radius: 8px; 842 margin-bottom: 1.5rem; 843 } 844 845 section h2 { 846 margin: 0 0 0.5rem 0; 847 font-size: 1.125rem; 848 } 849 850 .description { 851 color: var(--text-secondary); 852 font-size: 0.875rem; 853 margin-bottom: 1.5rem; 854 } 855 856 .status { 857 display: flex; 858 align-items: center; 859 gap: 0.5rem; 860 padding: 0.75rem; 861 border-radius: 4px; 862 margin-bottom: 1rem; 863 } 864 865 .status.enabled { 866 background: var(--success-bg); 867 border: 1px solid var(--success-border); 868 color: var(--success-text); 869 } 870 871 .status.disabled { 872 background: var(--warning-bg); 873 border: 1px solid var(--border-color); 874 color: var(--warning-text); 875 } 876 877 .totp-actions { 878 display: flex; 879 gap: 0.5rem; 880 flex-wrap: wrap; 881 } 882 883 .field { 884 margin-bottom: 1rem; 885 } 886 887 label { 888 display: block; 889 font-size: 0.875rem; 890 font-weight: 500; 891 margin-bottom: 0.25rem; 892 } 893 894 input { 895 width: 100%; 896 padding: 0.75rem; 897 border: 1px solid var(--border-color-light); 898 border-radius: 4px; 899 font-size: 1rem; 900 box-sizing: border-box; 901 background: var(--bg-input); 902 color: var(--text-primary); 903 } 904 905 input:focus { 906 outline: none; 907 border-color: var(--accent); 908 } 909 910 .code-input { 911 font-size: 1.5rem; 912 letter-spacing: 0.5em; 913 text-align: center; 914 max-width: 200px; 915 margin: 0 auto; 916 display: block; 917 } 918 919 button { 920 padding: 0.75rem 1.5rem; 921 background: var(--accent); 922 color: white; 923 border: none; 924 border-radius: 4px; 925 cursor: pointer; 926 font-size: 1rem; 927 } 928 929 button:hover:not(:disabled) { 930 background: var(--accent-hover); 931 } 932 933 button:disabled { 934 opacity: 0.6; 935 cursor: not-allowed; 936 } 937 938 button.secondary { 939 background: transparent; 940 color: var(--text-secondary); 941 border: 1px solid var(--border-color-light); 942 } 943 944 button.secondary:hover:not(:disabled) { 945 background: var(--bg-card); 946 } 947 948 button.danger { 949 background: var(--error-text); 950 } 951 952 button.danger:hover:not(:disabled) { 953 background: #900; 954 } 955 956 button.danger-outline { 957 background: transparent; 958 color: var(--error-text); 959 border: 1px solid var(--error-border); 960 } 961 962 button.danger-outline:hover:not(:disabled) { 963 background: var(--error-bg); 964 } 965 966 .actions { 967 display: flex; 968 gap: 0.5rem; 969 margin-top: 1rem; 970 } 971 972 .inline-form { 973 margin-top: 1rem; 974 padding: 1rem; 975 background: var(--bg-card); 976 border: 1px solid var(--border-color-light); 977 border-radius: 6px; 978 } 979 980 .inline-form h3 { 981 margin: 0 0 0.5rem 0; 982 font-size: 1rem; 983 } 984 985 .danger-form { 986 border-color: var(--error-border); 987 background: var(--error-bg); 988 } 989 990 .warning-text { 991 color: var(--error-text); 992 font-size: 0.875rem; 993 margin-bottom: 1rem; 994 } 995 996 .setup-step { 997 padding: 1rem; 998 background: var(--bg-card); 999 border: 1px solid var(--border-color-light); 1000 border-radius: 6px; 1001 } 1002 1003 .setup-step h3 { 1004 margin: 0 0 0.5rem 0; 1005 } 1006 1007 .setup-step p { 1008 color: var(--text-secondary); 1009 font-size: 0.875rem; 1010 margin-bottom: 1rem; 1011 } 1012 1013 .qr-container { 1014 display: flex; 1015 justify-content: center; 1016 margin: 1.5rem 0; 1017 } 1018 1019 .qr-code { 1020 width: 200px; 1021 height: 200px; 1022 image-rendering: pixelated; 1023 } 1024 1025 .manual-entry { 1026 margin-bottom: 1rem; 1027 font-size: 0.875rem; 1028 } 1029 1030 .manual-entry summary { 1031 cursor: pointer; 1032 color: var(--accent); 1033 } 1034 1035 .secret-code { 1036 display: block; 1037 margin-top: 0.5rem; 1038 padding: 0.5rem; 1039 background: var(--bg-input); 1040 border-radius: 4px; 1041 word-break: break-all; 1042 font-size: 0.75rem; 1043 } 1044 1045 .backup-codes { 1046 display: grid; 1047 grid-template-columns: repeat(2, 1fr); 1048 gap: 0.5rem; 1049 margin: 1rem 0; 1050 } 1051 1052 .backup-code { 1053 padding: 0.5rem; 1054 background: var(--bg-input); 1055 border-radius: 4px; 1056 text-align: center; 1057 font-size: 0.875rem; 1058 font-family: monospace; 1059 } 1060 1061 .passkey-list { 1062 display: flex; 1063 flex-direction: column; 1064 gap: 0.5rem; 1065 margin-bottom: 1rem; 1066 } 1067 1068 .passkey-item { 1069 display: flex; 1070 justify-content: space-between; 1071 align-items: center; 1072 padding: 0.75rem; 1073 background: var(--bg-card); 1074 border: 1px solid var(--border-color-light); 1075 border-radius: 6px; 1076 gap: 1rem; 1077 } 1078 1079 .passkey-info { 1080 display: flex; 1081 flex-direction: column; 1082 gap: 0.25rem; 1083 flex: 1; 1084 min-width: 0; 1085 } 1086 1087 .passkey-name { 1088 font-weight: 500; 1089 overflow: hidden; 1090 text-overflow: ellipsis; 1091 white-space: nowrap; 1092 } 1093 1094 .passkey-meta { 1095 font-size: 0.75rem; 1096 color: var(--text-secondary); 1097 } 1098 1099 .passkey-actions { 1100 display: flex; 1101 gap: 0.5rem; 1102 flex-shrink: 0; 1103 } 1104 1105 .passkey-edit { 1106 display: flex; 1107 flex: 1; 1108 gap: 0.5rem; 1109 align-items: center; 1110 } 1111 1112 .passkey-name-input { 1113 flex: 1; 1114 padding: 0.5rem; 1115 font-size: 0.875rem; 1116 } 1117 1118 .passkey-edit-actions { 1119 display: flex; 1120 gap: 0.25rem; 1121 } 1122 1123 button.small { 1124 padding: 0.375rem 0.75rem; 1125 font-size: 0.75rem; 1126 } 1127 1128 .add-passkey { 1129 margin-top: 1rem; 1130 padding-top: 1rem; 1131 border-top: 1px solid var(--border-color-light); 1132 } 1133 1134 .add-passkey .field { 1135 margin-bottom: 0.75rem; 1136 } 1137 1138 .section-link { 1139 display: inline-block; 1140 color: var(--accent); 1141 text-decoration: none; 1142 font-weight: 500; 1143 } 1144 1145 .section-link:hover { 1146 text-decoration: underline; 1147 } 1148 1149 .status.passkey-only { 1150 background: var(--accent); 1151 background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15)); 1152 border: 1px solid var(--accent); 1153 color: var(--accent); 1154 } 1155 1156 .hint { 1157 font-size: 0.875rem; 1158 color: var(--text-secondary); 1159 margin: 0; 1160 } 1161 1162 .info-box-inline { 1163 background: var(--bg-card); 1164 border: 1px solid var(--border-color); 1165 border-radius: 6px; 1166 padding: 1rem; 1167 margin-bottom: 1rem; 1168 font-size: 0.875rem; 1169 } 1170 1171 .info-box-inline strong { 1172 display: block; 1173 margin-bottom: 0.5rem; 1174 } 1175 1176 .info-box-inline ul { 1177 margin: 0; 1178 padding-left: 1.25rem; 1179 color: var(--text-secondary); 1180 } 1181 1182 .info-box-inline li { 1183 margin-bottom: 0.25rem; 1184 } 1185 1186 .info-box-inline p { 1187 margin: 0; 1188 color: var(--text-secondary); 1189 } 1190 1191 .toggle-row { 1192 display: flex; 1193 justify-content: space-between; 1194 align-items: flex-start; 1195 gap: 1rem; 1196 padding: 1rem; 1197 background: var(--bg-card); 1198 border: 1px solid var(--border-color-light); 1199 border-radius: 6px; 1200 margin-bottom: 1rem; 1201 } 1202 1203 .toggle-info { 1204 display: flex; 1205 flex-direction: column; 1206 gap: 0.25rem; 1207 } 1208 1209 .toggle-label { 1210 font-weight: 500; 1211 } 1212 1213 .toggle-description { 1214 font-size: 0.875rem; 1215 color: var(--text-secondary); 1216 } 1217 1218 .toggle-button { 1219 position: relative; 1220 width: 50px; 1221 height: 26px; 1222 padding: 0; 1223 border: none; 1224 border-radius: 13px; 1225 cursor: pointer; 1226 transition: background 0.2s; 1227 flex-shrink: 0; 1228 } 1229 1230 .toggle-button.on { 1231 background: var(--success-text); 1232 } 1233 1234 .toggle-button.off { 1235 background: var(--text-secondary); 1236 } 1237 1238 .toggle-button:disabled { 1239 opacity: 0.6; 1240 cursor: not-allowed; 1241 } 1242 1243 .toggle-slider { 1244 position: absolute; 1245 top: 3px; 1246 width: 20px; 1247 height: 20px; 1248 background: white; 1249 border-radius: 50%; 1250 transition: left 0.2s; 1251 } 1252 1253 .toggle-button.on .toggle-slider { 1254 left: 27px; 1255 } 1256 1257 .toggle-button.off .toggle-slider { 1258 left: 3px; 1259 } 1260 1261 .warning-box { 1262 background: var(--warning-bg); 1263 border: 1px solid var(--warning-border, var(--border-color)); 1264 border-left: 4px solid var(--warning-text); 1265 border-radius: 6px; 1266 padding: 1rem; 1267 margin-bottom: 1rem; 1268 } 1269 1270 .warning-box strong { 1271 display: block; 1272 margin-bottom: 0.5rem; 1273 color: var(--warning-text); 1274 } 1275 1276 .warning-box p { 1277 margin: 0 0 0.75rem 0; 1278 font-size: 0.875rem; 1279 color: var(--text-primary); 1280 } 1281 1282 .warning-box ol { 1283 margin: 0; 1284 padding-left: 1.25rem; 1285 font-size: 0.875rem; 1286 } 1287 1288 .warning-box li { 1289 margin-bottom: 0.5rem; 1290 } 1291 1292 .warning-box a { 1293 color: var(--accent); 1294 } 1295</style>