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">← 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 · 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 →
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>