Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

refactor(frontend): rewrite SecurityContent #74

merged opened by oyster.cafe targeting main from refactor/frontend-deletions
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mhg53l5vhd22
+161 -806
Diff #2
+161 -806
frontend/src/components/dashboard/SecurityContent.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte' 3 - import { getValidToken } from '../../lib/auth.svelte' 4 3 import { api, ApiError } from '../../lib/api' 5 4 import { _ } from '../../lib/i18n' 6 5 import { formatDate } from '../../lib/date' ··· 8 7 import { toast } from '../../lib/toast.svelte' 9 8 import ReauthModal from '../ReauthModal.svelte' 10 9 import SsoIcon from '../SsoIcon.svelte' 11 - import { 12 - prepareCreationOptions, 13 - serializeAttestationResponse, 14 - type WebAuthnCreationOptionsResponse, 15 - } from '../../lib/webauthn' 16 - import { 17 - type TotpSetupState, 18 - idleState, 19 - qrState, 20 - verifyState, 21 - backupState, 22 - goBackToQr, 23 - finish, 24 - type TotpQr, 25 - } from '../../lib/types/totp-state' 10 + import PasskeySection from './PasskeySection.svelte' 11 + import TotpSection from './TotpSection.svelte' 12 + import PasswordSection from './PasswordSection.svelte' 26 13 27 14 interface Props { 28 15 session: Session ··· 30 17 31 18 let { session }: Props = $props() 32 19 33 - let loading = $state(true) 34 - let totpEnabled = $state(false) 35 - let hasBackupCodes = $state(false) 36 - let totpSetup = $state<TotpSetupState>(idleState) 37 - let verifyCodeRaw = $state('') 38 - let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, '')) 39 - let verifyLoading = $state(false) 40 - 41 - interface Passkey { 42 - id: string 43 - credentialId: string 44 - friendlyName: string | null 45 - createdAt: string 46 - lastUsed: string | null 47 - } 48 - let passkeys = $state<Passkey[]>([]) 49 - let passkeysLoading = $state(true) 50 - let addingPasskey = $state(false) 51 - let newPasskeyName = $state('') 52 - let editingPasskeyId = $state<string | null>(null) 53 - let editPasskeyName = $state('') 54 - 55 20 let hasPassword = $state(true) 56 - let passwordLoading = $state(true) 57 - let showRemovePasswordForm = $state(false) 58 - let removePasswordLoading = $state(false) 59 - 60 - let showChangePasswordForm = $state(false) 61 - let currentPassword = $state('') 62 - let newPassword = $state('') 63 - let confirmNewPassword = $state('') 64 - let changePasswordLoading = $state(false) 65 - 66 - let showSetPasswordForm = $state(false) 67 - let setNewPassword = $state('') 68 - let setConfirmPassword = $state('') 69 - let setPasswordLoading = $state(false) 70 - 71 - let disablePassword = $state('') 72 - let disableCode = $state('') 73 - let disableLoading = $state(false) 74 - let showDisableForm = $state(false) 75 - 76 - let regenPassword = $state('') 77 - let regenCode = $state('') 78 - let regenLoading = $state(false) 79 - let showRegenForm = $state(false) 21 + let passkeyCount = $state(0) 22 + let totpEnabled = $state(false) 80 23 81 24 interface SsoProvider { 82 25 provider: string ··· 122 65 let reauthMethods = $state<string[]>(['password']) 123 66 let pendingAction = $state<(() => Promise<void>) | null>(null) 124 67 68 + function handleReauthRequired(methods: string[], retryAction: () => Promise<void>) { 69 + reauthMethods = methods 70 + pendingAction = retryAction 71 + showReauthModal = true 72 + } 73 + 125 74 function handleReauthSuccess() { 126 75 if (pendingAction) { 127 76 pendingAction() ··· 146 95 } 147 96 148 97 await Promise.all([ 149 - loadTotpStatus(), 150 - loadPasskeys(), 151 - loadPasswordStatus(), 152 98 loadSsoProviders(), 153 99 loadLinkedAccounts(), 154 100 loadLegacyLoginPreference(), 155 101 loadTrustedDevices() 156 102 ]) 157 - loading = false 158 103 }) 159 104 160 - async function loadTotpStatus() { 161 - try { 162 - const status = await api.getTotpStatus(session.accessJwt) 163 - totpEnabled = status.enabled 164 - hasBackupCodes = status.hasBackupCodes 165 - } catch { 166 - toast.error($_('security.failedToLoadTotpStatus')) 167 - } 168 - } 169 - 170 - async function loadPasskeys() { 171 - passkeysLoading = true 172 - try { 173 - const result = await api.listPasskeys(session.accessJwt) 174 - passkeys = result.passkeys 175 - } catch { 176 - toast.error($_('security.failedToLoadPasskeys')) 177 - } finally { 178 - passkeysLoading = false 179 - } 180 - } 181 - 182 - async function loadPasswordStatus() { 183 - passwordLoading = true 184 - try { 185 - const status = await api.getPasswordStatus(session.accessJwt) 186 - hasPassword = status.hasPassword 187 - } catch { 188 - hasPassword = true 189 - } finally { 190 - passwordLoading = false 191 - } 192 - } 193 - 194 105 async function loadSsoProviders() { 195 106 try { 196 107 const response = await fetch('/oauth/sso/providers') ··· 293 204 } catch (e) { 294 205 if (e instanceof ApiError) { 295 206 if (e.error === 'ReauthRequired') { 296 - reauthMethods = e.reauthMethods || ['password'] 297 - pendingAction = () => handleLinkAccount(provider) 298 - showReauthModal = true 207 + handleReauthRequired(e.reauthMethods || ['password'], () => handleLinkAccount(provider)) 299 208 } else { 300 209 toast.error(e.message || $_('oauth.sso.linkFailed')) 301 210 } ··· 318 227 } catch (e) { 319 228 if (e instanceof ApiError) { 320 229 if (e.error === 'ReauthRequired') { 321 - reauthMethods = e.reauthMethods || ['password'] 322 - pendingAction = () => handleUnlinkAccount(id) 323 - showReauthModal = true 230 + handleReauthRequired(e.reauthMethods || ['password'], () => handleUnlinkAccount(id)) 324 231 } else { 325 232 toast.error(e.message || $_('oauth.sso.unlinkFailed')) 326 233 } ··· 357 264 } catch (e) { 358 265 if (e instanceof ApiError) { 359 266 if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') { 360 - reauthMethods = e.reauthMethods || ['password'] 361 - pendingAction = handleToggleLegacyLogin 362 - showReauthModal = true 267 + handleReauthRequired(e.reauthMethods || ['password'], handleToggleLegacyLogin) 363 268 } else { 364 269 toast.error(e.message) 365 270 } ··· 370 275 legacyLoginUpdating = false 371 276 } 372 277 } 373 - 374 - async function handleRemovePassword() { 375 - removePasswordLoading = true 376 - try { 377 - const token = await getValidToken() 378 - if (!token) { 379 - toast.error($_('security.sessionExpired')) 380 - return 381 - } 382 - await api.removePassword(token) 383 - hasPassword = false 384 - showRemovePasswordForm = false 385 - toast.success($_('security.passwordRemoved')) 386 - } catch (e) { 387 - if (e instanceof ApiError) { 388 - if (e.error === 'ReauthRequired') { 389 - reauthMethods = e.reauthMethods || ['password'] 390 - pendingAction = handleRemovePassword 391 - showReauthModal = true 392 - } else { 393 - toast.error(e.message) 394 - } 395 - } else { 396 - toast.error($_('security.failedToRemovePassword')) 397 - } 398 - } finally { 399 - removePasswordLoading = false 400 - } 401 - } 402 - 403 - async function handleChangePassword(e: Event) { 404 - e.preventDefault() 405 - if (!currentPassword || !newPassword || !confirmNewPassword) return 406 - if (newPassword !== confirmNewPassword) { 407 - toast.error($_('security.passwordsDoNotMatch')) 408 - return 409 - } 410 - if (newPassword.length < 8) { 411 - toast.error($_('security.passwordTooShort')) 412 - return 413 - } 414 - changePasswordLoading = true 415 - try { 416 - await api.changePassword(session.accessJwt, currentPassword, newPassword) 417 - toast.success($_('security.passwordChanged')) 418 - currentPassword = '' 419 - newPassword = '' 420 - confirmNewPassword = '' 421 - showChangePasswordForm = false 422 - } catch (e) { 423 - if (e instanceof ApiError) { 424 - if (e.error === 'ReauthRequired') { 425 - reauthMethods = e.reauthMethods || ['password'] 426 - pendingAction = () => handleChangePassword(new Event('submit')) 427 - showReauthModal = true 428 - } else { 429 - toast.error(e.message) 430 - } 431 - } else { 432 - toast.error($_('security.failedToChangePassword')) 433 - } 434 - } finally { 435 - changePasswordLoading = false 436 - } 437 - } 438 - 439 - async function handleSetPassword(e: Event) { 440 - e.preventDefault() 441 - if (!setNewPassword || !setConfirmPassword) return 442 - if (setNewPassword !== setConfirmPassword) { 443 - toast.error($_('security.passwordsDoNotMatch')) 444 - return 445 - } 446 - if (setNewPassword.length < 8) { 447 - toast.error($_('security.passwordTooShort')) 448 - return 449 - } 450 - setPasswordLoading = true 451 - try { 452 - await api.setPassword(session.accessJwt, setNewPassword) 453 - hasPassword = true 454 - toast.success($_('security.passwordSet')) 455 - setNewPassword = '' 456 - setConfirmPassword = '' 457 - showSetPasswordForm = false 458 - } catch (e) { 459 - if (e instanceof ApiError) { 460 - if (e.error === 'ReauthRequired') { 461 - reauthMethods = e.reauthMethods || ['passkey'] 462 - pendingAction = () => handleSetPassword(new Event('submit')) 463 - showReauthModal = true 464 - } else { 465 - toast.error(e.message) 466 - } 467 - } else { 468 - toast.error($_('security.failedToSetPassword')) 469 - } 470 - } finally { 471 - setPasswordLoading = false 472 - } 473 - } 474 - 475 - async function handleStartTotpSetup() { 476 - verifyLoading = true 477 - try { 478 - const result = await api.createTotpSecret(session.accessJwt) 479 - totpSetup = qrState(result.qrBase64, result.uri) 480 - } catch (e) { 481 - toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret') 482 - } finally { 483 - verifyLoading = false 484 - } 485 - } 486 - 487 - async function handleVerifyTotp(e: Event) { 488 - e.preventDefault() 489 - if (!verifyCode || totpSetup.step !== 'verify') return 490 - verifyLoading = true 491 - try { 492 - const result = await api.enableTotp(session.accessJwt, verifyCode) 493 - totpSetup = backupState(totpSetup, result.backupCodes) 494 - totpEnabled = true 495 - hasBackupCodes = true 496 - verifyCodeRaw = '' 497 - } catch (e) { 498 - toast.error(e instanceof ApiError ? e.message : 'Invalid code') 499 - } finally { 500 - verifyLoading = false 501 - } 502 - } 503 - 504 - function handleFinishSetup() { 505 - if (totpSetup.step !== 'backup') return 506 - totpSetup = finish(totpSetup) 507 - toast.success($_('security.totpEnabledSuccess')) 508 - } 509 - 510 - function copyBackupCodes() { 511 - if (totpSetup.step !== 'backup') return 512 - navigator.clipboard.writeText(totpSetup.backupCodes.join('\n')) 513 - toast.success($_('security.backupCodesCopied')) 514 - } 515 - 516 - async function handleDisableTotp(e: Event) { 517 - e.preventDefault() 518 - if (!disablePassword || !disableCode) return 519 - disableLoading = true 520 - try { 521 - await api.disableTotp(session.accessJwt, disablePassword, disableCode) 522 - totpEnabled = false 523 - hasBackupCodes = false 524 - showDisableForm = false 525 - disablePassword = '' 526 - disableCode = '' 527 - toast.success($_('security.totpDisabledSuccess')) 528 - } catch (e) { 529 - toast.error(e instanceof ApiError ? e.message : $_('security.failedToDisableTotp')) 530 - } finally { 531 - disableLoading = false 532 - } 533 - } 534 - 535 - async function handleRegenerateBackupCodes(e: Event) { 536 - e.preventDefault() 537 - if (!regenPassword || !regenCode) return 538 - regenLoading = true 539 - try { 540 - const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode) 541 - const dummyVerify = verifyState(qrState('', '')) 542 - totpSetup = backupState(dummyVerify, result.backupCodes) 543 - showRegenForm = false 544 - regenPassword = '' 545 - regenCode = '' 546 - } catch (e) { 547 - toast.error(e instanceof ApiError ? e.message : $_('security.failedToRegenerateBackupCodes')) 548 - } finally { 549 - regenLoading = false 550 - } 551 - } 552 - 553 - async function handleAddPasskey() { 554 - if (!window.PublicKeyCredential) { 555 - toast.error($_('security.passkeysNotSupported')) 556 - return 557 - } 558 - addingPasskey = true 559 - try { 560 - const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined) 561 - const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse) 562 - const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }) 563 - if (!credential) { 564 - toast.error($_('security.passkeyCreationCancelled')) 565 - return 566 - } 567 - const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential) 568 - await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined) 569 - await loadPasskeys() 570 - newPasskeyName = '' 571 - toast.success($_('security.passkeyAddedSuccess')) 572 - } catch (e) { 573 - if (e instanceof DOMException && e.name === 'NotAllowedError') { 574 - toast.error($_('security.passkeyCreationCancelled')) 575 - } else { 576 - toast.error(e instanceof ApiError ? e.message : 'Failed to add passkey') 577 - } 578 - } finally { 579 - addingPasskey = false 580 - } 581 - } 582 - 583 - async function handleDeletePasskey(id: string) { 584 - const passkey = passkeys.find(p => p.id === id) 585 - if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return 586 - try { 587 - await api.deletePasskey(session.accessJwt, id) 588 - await loadPasskeys() 589 - toast.success($_('security.passkeyDeleted')) 590 - } catch (e) { 591 - toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey') 592 - } 593 - } 594 - 595 - async function handleSavePasskeyName() { 596 - if (!editingPasskeyId || !editPasskeyName.trim()) return 597 - try { 598 - await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim()) 599 - await loadPasskeys() 600 - editingPasskeyId = null 601 - editPasskeyName = '' 602 - toast.success($_('security.passkeyRenamed')) 603 - } catch (e) { 604 - toast.error(e instanceof ApiError ? e.message : 'Failed to rename passkey') 605 - } 606 - } 607 - 608 - function startEditPasskey(passkey: Passkey) { 609 - editingPasskeyId = passkey.id 610 - editPasskeyName = passkey.friendlyName || '' 611 - } 612 - 613 - function cancelEditPasskey() { 614 - editingPasskeyId = null 615 - editPasskeyName = '' 616 - } 617 278 </script> 618 279 619 280 <div class="security"> 620 - {#if loading} 621 - <div class="loading">{$_('common.loading')}</div> 622 - {:else} 281 + <PasskeySection 282 + {session} 283 + {hasPassword} 284 + onPasskeysChanged={(count) => passkeyCount = count} 285 + /> 286 + 287 + <TotpSection 288 + {session} 289 + onStatusChanged={(enabled, _hasBackup) => totpEnabled = enabled} 290 + /> 291 + 292 + <PasswordSection 293 + {session} 294 + {passkeyCount} 295 + onPasswordChanged={(has) => hasPassword = has} 296 + onReauthRequired={handleReauthRequired} 297 + /> 298 + 299 + {#if ssoProviders.length > 0} 623 300 <section> 624 - <h3>{$_('security.passkeys')}</h3> 301 + <h3>{$_('oauth.sso.linkedAccounts')}</h3> 625 302 626 - {#if !passkeysLoading} 627 - {#if passkeys.length > 0} 628 - <ul class="passkey-list"> 629 - {#each passkeys as passkey} 630 - <li class="passkey-item"> 631 - {#if editingPasskeyId === passkey.id} 632 - <div class="passkey-edit"> 633 - <input type="text" bind:value={editPasskeyName} placeholder={$_('security.passkeyName')} /> 634 - <button type="button" class="sm" onclick={handleSavePasskeyName}>{$_('common.save')}</button> 635 - <button type="button" class="sm secondary" onclick={cancelEditPasskey}>{$_('common.cancel')}</button> 636 - </div> 637 - {:else} 638 - <div class="passkey-info"> 639 - <span class="passkey-name">{passkey.friendlyName || $_('security.unnamedPasskey')}</span> 640 - <span class="passkey-meta"> 641 - {$_('security.added')} {formatDate(passkey.createdAt)} 642 - {#if passkey.lastUsed} 643 - - {$_('security.lastUsed')} {formatDate(passkey.lastUsed)} 644 - {/if} 645 - </span> 646 - </div> 647 - <div class="passkey-actions"> 648 - <button type="button" class="sm secondary" onclick={() => startEditPasskey(passkey)}>{$_('security.rename')}</button> 649 - {#if hasPassword || passkeys.length > 1} 650 - <button type="button" class="sm danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>{$_('security.deletePasskey')}</button> 651 - {/if} 652 - </div> 653 - {/if} 303 + {#if linkedAccountsLoading} 304 + <div class="loading">{$_('common.loading')}</div> 305 + {:else} 306 + {#if linkedAccounts.length > 0} 307 + <ul class="sso-list"> 308 + {#each linkedAccounts as account} 309 + <li class="sso-item"> 310 + <div class="sso-info"> 311 + <span class="sso-provider">{account.provider_name}</span> 312 + <span class="sso-id">{account.provider_username}</span> 313 + <span class="sso-meta">{$_('oauth.sso.linkedAt')} {formatDate(account.created_at)}</span> 314 + </div> 315 + <button 316 + type="button" 317 + class="sm danger-outline" 318 + onclick={() => handleUnlinkAccount(account.id)} 319 + disabled={unlinkingId === account.id} 320 + > 321 + {unlinkingId === account.id ? $_('common.loading') : $_('oauth.sso.unlink')} 322 + </button> 654 323 </li> 655 324 {/each} 656 325 </ul> 657 326 {:else} 658 - <div class="status warning">{$_('security.noPasskeys')}</div> 327 + <p class="empty">{$_('oauth.sso.noLinkedAccounts')}</p> 659 328 {/if} 660 329 661 - <div class="add-passkey"> 662 - <input type="text" bind:value={newPasskeyName} placeholder={$_('security.passkeyNamePlaceholder')} disabled={addingPasskey} /> 663 - <button onclick={handleAddPasskey} disabled={addingPasskey}> 664 - {addingPasskey ? $_('security.adding') : $_('security.addPasskey')} 665 - </button> 330 + <div class="sso-providers"> 331 + <h4>{$_('oauth.sso.linkNewAccount')}</h4> 332 + <div class="provider-buttons"> 333 + {#each ssoProviders as provider} 334 + {@const isLinked = linkedAccounts.some(a => a.provider === provider.provider)} 335 + <button 336 + type="button" 337 + class="provider-btn" 338 + onclick={() => handleLinkAccount(provider.provider)} 339 + disabled={linkingProvider === provider.provider || isLinked} 340 + > 341 + <SsoIcon provider={provider.provider} /> 342 + <span class="provider-name">{linkingProvider === provider.provider ? $_('common.loading') : provider.name}</span> 343 + {#if isLinked} 344 + <span class="linked-badge">{$_('oauth.sso.linked')}</span> 345 + {/if} 346 + </button> 347 + {/each} 348 + </div> 666 349 </div> 667 - 668 350 {/if} 669 351 </section> 352 + {/if} 670 353 354 + {#if hasMfa} 671 355 <section> 672 - <h3>{$_('security.totp')}</h3> 673 - 674 - {#if totpSetup.step === 'idle'} 675 - {#if totpEnabled} 676 - <div class="status success">{$_('security.totpEnabled')}</div> 677 - 678 - {#if !showDisableForm && !showRegenForm} 679 - <div class="totp-actions"> 680 - <button type="button" class="secondary" onclick={() => showRegenForm = true}> 681 - {$_('security.regenerateBackupCodes')} 682 - </button> 683 - <button type="button" class="danger-outline" onclick={() => showDisableForm = true}> 684 - {$_('security.disableTotp')} 685 - </button> 686 - </div> 687 - {/if} 688 - 689 - {#if showRegenForm} 690 - <form class="inline-form" onsubmit={handleRegenerateBackupCodes}> 691 - <h4>{$_('security.regenerateBackupCodes')}</h4> 692 - <p class="warning-text">{$_('security.regenerateConfirm')}</p> 693 - <div> 694 - <label for="regen-password">{$_('security.password')}</label> 695 - <input 696 - id="regen-password" 697 - type="password" 698 - bind:value={regenPassword} 699 - placeholder={$_('security.enterPassword')} 700 - disabled={regenLoading} 701 - required 702 - /> 703 - </div> 704 - <div> 705 - <label for="regen-code">{$_('security.totpCode')}</label> 706 - <input 707 - id="regen-code" 708 - type="text" 709 - bind:value={regenCode} 710 - placeholder={$_('security.totpCodePlaceholder')} 711 - disabled={regenLoading} 712 - required 713 - maxlength="6" 714 - inputmode="numeric" 715 - /> 716 - </div> 717 - <div class="actions"> 718 - <button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}> 719 - {$_('common.cancel')} 720 - </button> 721 - <button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}> 722 - {regenLoading ? $_('security.regenerating') : $_('security.regenerateBackupCodes')} 723 - </button> 724 - </div> 725 - </form> 726 - {/if} 727 - 728 - {#if showDisableForm} 729 - <form class="inline-form danger-form" onsubmit={handleDisableTotp}> 730 - <h4>{$_('security.disableTotp')}</h4> 731 - <p class="warning-text">{$_('security.disableTotpWarning')}</p> 732 - <div> 733 - <label for="disable-password">{$_('security.password')}</label> 734 - <input 735 - id="disable-password" 736 - type="password" 737 - bind:value={disablePassword} 738 - placeholder={$_('security.enterPassword')} 739 - disabled={disableLoading} 740 - required 741 - /> 742 - </div> 743 - <div> 744 - <label for="disable-code">{$_('security.totpCode')}</label> 745 - <input 746 - id="disable-code" 747 - type="text" 748 - bind:value={disableCode} 749 - placeholder={$_('security.totpCodePlaceholder')} 750 - disabled={disableLoading} 751 - required 752 - maxlength="6" 753 - inputmode="numeric" 754 - /> 755 - </div> 756 - <div class="actions"> 757 - <button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}> 758 - {$_('common.cancel')} 759 - </button> 760 - <button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}> 761 - {disableLoading ? $_('security.disabling') : $_('security.disableTotp')} 762 - </button> 763 - </div> 764 - </form> 765 - {/if} 766 - {:else} 767 - <div class="status warning">{$_('security.totpDisabled')}</div> 768 - <button onclick={handleStartTotpSetup} disabled={verifyLoading}> 769 - {$_('security.enableTotp')} 770 - </button> 771 - {/if} 772 - {:else if totpSetup.step === 'qr'} 773 - {@const qrData = totpSetup as TotpQr} 774 - <div class="setup-step"> 775 - <p>{$_('security.totpSetupInstructions')}</p> 776 - <div class="qr-container"> 777 - <img src="data:image/png;base64,{qrData.qrBase64}" alt="TOTP QR Code" class="qr-code" /> 356 + <h3>{$_('security.appCompatibility')}</h3> 357 + <p class="section-description">{$_('security.legacyLoginDescription')}</p> 358 + 359 + {#if !legacyLoginLoading} 360 + <div class="toggle-row"> 361 + <div class="toggle-info"> 362 + <span class="toggle-label">{$_('security.legacyLogin')}</span> 363 + <span class="toggle-description"> 364 + {#if allowLegacyLogin} 365 + {$_('security.legacyLoginOn')} 366 + {:else} 367 + {$_('security.legacyLoginOff')} 368 + {/if} 369 + </span> 778 370 </div> 779 - <details class="manual-entry"> 780 - <summary>{$_('security.cantScan')}</summary> 781 - <code class="secret-code">{qrData.totpUri.split('secret=')[1]?.split('&')[0] || ''}</code> 782 - </details> 783 - <button onclick={() => totpSetup = verifyState(qrData)}>{$_('security.next')}</button> 784 - </div> 785 - {:else if totpSetup.step === 'verify'} 786 - {@const verifyData = totpSetup} 787 - <div class="setup-step"> 788 - <p>{$_('security.totpCodePlaceholder')}</p> 789 - <form onsubmit={handleVerifyTotp}> 790 - <input type="text" bind:value={verifyCodeRaw} placeholder="000000" class="code-input" inputmode="numeric" disabled={verifyLoading} /> 791 - <div class="actions"> 792 - <button type="button" class="secondary" onclick={() => totpSetup = goBackToQr(verifyData)}>{$_('common.back')}</button> 793 - <button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>{$_('security.verifyAndEnable')}</button> 794 - </div> 795 - </form> 371 + <button 372 + type="button" 373 + class="toggle-button {allowLegacyLogin ? 'on' : 'off'}" 374 + onclick={handleToggleLegacyLogin} 375 + disabled={legacyLoginUpdating} 376 + aria-label={allowLegacyLogin ? $_('security.disableLegacyLogin') : $_('security.enableLegacyLogin')} 377 + > 378 + <span class="toggle-slider"></span> 379 + </button> 796 380 </div> 797 - {:else if totpSetup.step === 'backup'} 798 - <div class="setup-step"> 799 - <h4>{$_('security.backupCodes')}</h4> 800 - <p class="warning-text">{$_('security.backupCodesDescription')}</p> 801 - <div class="backup-codes"> 802 - {#each totpSetup.backupCodes as code} 803 - <code class="backup-code">{code}</code> 804 - {/each} 805 - </div> 806 - <div class="actions"> 807 - <button type="button" class="secondary" onclick={copyBackupCodes}>{$_('security.copyToClipboard')}</button> 808 - <button onclick={handleFinishSetup}>{$_('security.savedMyCodes')}</button> 381 + 382 + {#if totpEnabled && allowLegacyLogin} 383 + <div class="warning-box"> 384 + <strong>{$_('security.legacyLoginWarning')}</strong> 809 385 </div> 386 + {/if} 387 + 388 + <div class="info-box"> 389 + <strong>{$_('security.legacyAppsTitle')}</strong> 390 + <p>{$_('security.legacyAppsDescription')}</p> 810 391 </div> 811 392 {/if} 812 393 </section> 394 + {/if} 813 395 814 - <section> 815 - <h3>{$_('security.password')}</h3> 816 - {#if !passwordLoading} 817 - {#if hasPassword} 818 - <div class="status success">{$_('security.passwordStatus')}</div> 819 - 820 - {#if !showChangePasswordForm && !showRemovePasswordForm} 821 - <div class="password-actions"> 822 - <button type="button" onclick={() => showChangePasswordForm = true}> 823 - {$_('security.changePassword')} 824 - </button> 825 - {#if passkeys.length > 0} 826 - <button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}> 827 - {$_('security.removePassword')} 828 - </button> 829 - {/if} 830 - </div> 831 - {/if} 832 - 833 - {#if showChangePasswordForm} 834 - <form class="inline-form" onsubmit={handleChangePassword}> 835 - <h4>{$_('security.changePassword')}</h4> 836 - <div> 837 - <label for="current-password">{$_('security.currentPassword')}</label> 396 + <section> 397 + <h3>{$_('security.trustedDevices')}</h3> 398 + <p class="section-description">{$_('security.trustedDevicesDescription')}</p> 399 + 400 + {#if trustedDevicesLoading} 401 + <div class="loading">{$_('common.loading')}</div> 402 + {:else if trustedDevices.length === 0} 403 + <p class="empty-hint">{$_('trustedDevices.noDevices')}</p> 404 + <p class="hint-text">{$_('trustedDevices.noDevicesHint')}</p> 405 + {:else} 406 + <div class="device-list"> 407 + {#each trustedDevices as device} 408 + <div class="device-card"> 409 + <div class="device-header"> 410 + {#if editingDeviceId === device.id} 838 411 <input 839 - id="current-password" 840 - type="password" 841 - bind:value={currentPassword} 842 - placeholder={$_('security.currentPasswordPlaceholder')} 843 - disabled={changePasswordLoading} 844 - required 845 - /> 846 - </div> 847 - <div> 848 - <label for="new-password">{$_('security.newPassword')}</label> 849 - <input 850 - id="new-password" 851 - type="password" 852 - bind:value={newPassword} 853 - placeholder={$_('security.newPasswordPlaceholder')} 854 - disabled={changePasswordLoading} 855 - required 856 - minlength="8" 857 - /> 858 - </div> 859 - <div> 860 - <label for="confirm-password">{$_('security.confirmPassword')}</label> 861 - <input 862 - id="confirm-password" 863 - type="password" 864 - bind:value={confirmNewPassword} 865 - placeholder={$_('security.confirmPasswordPlaceholder')} 866 - disabled={changePasswordLoading} 867 - required 868 - minlength="8" 412 + type="text" 413 + class="edit-name-input" 414 + bind:value={editDeviceName} 415 + placeholder={$_('trustedDevices.deviceNamePlaceholder')} 869 416 /> 870 - </div> 871 - <div class="actions"> 872 - <button type="button" class="secondary" onclick={() => { showChangePasswordForm = false; currentPassword = ''; newPassword = ''; confirmNewPassword = '' }}> 873 - {$_('common.cancel')} 874 - </button> 875 - <button type="submit" disabled={changePasswordLoading || !currentPassword || !newPassword || !confirmNewPassword}> 876 - {changePasswordLoading ? $_('security.changing') : $_('security.changePassword')} 877 - </button> 878 - </div> 879 - </form> 880 - {/if} 881 - 882 - {#if showRemovePasswordForm} 883 - <div class="remove-password-form"> 884 - <p class="warning-text">{$_('security.removePasswordWarning')}</p> 885 - <div class="actions"> 886 - <button type="button" class="ghost sm" onclick={() => showRemovePasswordForm = false}> 887 - {$_('common.cancel')} 417 + <div class="edit-actions"> 418 + <button type="button" class="sm" onclick={handleSaveDeviceName}>{$_('common.save')}</button> 419 + <button type="button" class="sm ghost" onclick={cancelEditDevice}>{$_('common.cancel')}</button> 420 + </div> 421 + {:else} 422 + <span class="device-name">{device.friendlyName || parseUserAgent(device.userAgent)}</span> 423 + <button type="button" class="icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}> 424 + &#9998; 888 425 </button> 889 - <button type="button" class="danger sm" onclick={handleRemovePassword} disabled={removePasswordLoading}> 890 - {removePasswordLoading ? $_('security.removing') : $_('security.removePassword')} 891 - </button> 892 - </div> 426 + {/if} 893 427 </div> 894 - {/if} 895 - {:else} 896 - <div class="status info">{$_('security.noPassword')}</div> 897 - 898 - {#if !showSetPasswordForm} 899 - <button type="button" onclick={() => showSetPasswordForm = true}> 900 - {$_('security.setPassword')} 901 - </button> 902 - {:else} 903 - <form class="inline-form" onsubmit={handleSetPassword}> 904 - <h4>{$_('security.setPassword')}</h4> 905 - <div> 906 - <label for="set-new-password">{$_('security.newPassword')}</label> 907 - <input 908 - id="set-new-password" 909 - type="password" 910 - bind:value={setNewPassword} 911 - placeholder={$_('security.newPasswordPlaceholder')} 912 - disabled={setPasswordLoading} 913 - required 914 - minlength="8" 915 - /> 916 - </div> 917 - <div> 918 - <label for="set-confirm-password">{$_('security.confirmPassword')}</label> 919 - <input 920 - id="set-confirm-password" 921 - type="password" 922 - bind:value={setConfirmPassword} 923 - placeholder={$_('security.confirmPasswordPlaceholder')} 924 - disabled={setPasswordLoading} 925 - required 926 - minlength="8" 927 - /> 928 - </div> 929 - <div class="actions"> 930 - <button type="button" class="secondary" onclick={() => { showSetPasswordForm = false; setNewPassword = ''; setConfirmPassword = '' }}> 931 - {$_('common.cancel')} 932 - </button> 933 - <button type="submit" disabled={setPasswordLoading || !setNewPassword || !setConfirmPassword}> 934 - {setPasswordLoading ? $_('security.setting') : $_('security.setPassword')} 935 - </button> 936 - </div> 937 - </form> 938 - {/if} 939 - {/if} 940 - {/if} 941 - </section> 942 - 943 - {#if ssoProviders.length > 0} 944 - <section> 945 - <h3>{$_('oauth.sso.linkedAccounts')}</h3> 946 428 947 - {#if linkedAccountsLoading} 948 - <div class="loading">{$_('common.loading')}</div> 949 - {:else} 950 - {#if linkedAccounts.length > 0} 951 - <ul class="sso-list"> 952 - {#each linkedAccounts as account} 953 - <li class="sso-item"> 954 - <div class="sso-info"> 955 - <span class="sso-provider">{account.provider_name}</span> 956 - <span class="sso-id">{account.provider_username}</span> 957 - <span class="sso-meta">{$_('oauth.sso.linkedAt')} {formatDate(account.created_at)}</span> 958 - </div> 959 - <button 960 - type="button" 961 - class="sm danger-outline" 962 - onclick={() => handleUnlinkAccount(account.id)} 963 - disabled={unlinkingId === account.id} 964 - > 965 - {unlinkingId === account.id ? $_('common.loading') : $_('oauth.sso.unlink')} 966 - </button> 967 - </li> 968 - {/each} 969 - </ul> 970 - {:else} 971 - <p class="empty">{$_('oauth.sso.noLinkedAccounts')}</p> 972 - {/if} 973 - 974 - <div class="sso-providers"> 975 - <h4>{$_('oauth.sso.linkNewAccount')}</h4> 976 - <div class="provider-buttons"> 977 - {#each ssoProviders as provider} 978 - {@const isLinked = linkedAccounts.some(a => a.provider === provider.provider)} 979 - <button 980 - type="button" 981 - class="provider-btn" 982 - onclick={() => handleLinkAccount(provider.provider)} 983 - disabled={linkingProvider === provider.provider || isLinked} 984 - > 985 - <SsoIcon provider={provider.provider} /> 986 - <span class="provider-name">{linkingProvider === provider.provider ? $_('common.loading') : provider.name}</span> 987 - {#if isLinked} 988 - <span class="linked-badge">{$_('oauth.sso.linked')}</span> 429 + <div class="device-details"> 430 + {#if device.userAgent} 431 + <span class="detail">{parseUserAgent(device.userAgent)}</span> 432 + {/if} 433 + {#if device.trustedAt} 434 + <span class="detail">{$_('trustedDevices.trustedSince')} {formatDate(device.trustedAt)}</span> 435 + {/if} 436 + <span class="detail">{$_('trustedDevices.lastSeen')} {formatDate(device.lastSeenAt)}</span> 437 + {#if device.trustedUntil} 438 + {@const daysRemaining = getDaysRemaining(device.trustedUntil)} 439 + <span class="detail" class:expiring-soon={daysRemaining <= 7}> 440 + {#if daysRemaining <= 0} 441 + {$_('trustedDevices.expired')} 442 + {:else if daysRemaining === 1} 443 + {$_('trustedDevices.tomorrow')} 444 + {:else} 445 + {$_('trustedDevices.inDays', { values: { days: daysRemaining } })} 989 446 {/if} 990 - </button> 991 - {/each} 447 + </span> 448 + {/if} 992 449 </div> 993 - </div> 994 - {/if} 995 - </section> 996 - {/if} 997 - 998 - {#if hasMfa} 999 - <section> 1000 - <h3>{$_('security.appCompatibility')}</h3> 1001 - <p class="section-description">{$_('security.legacyLoginDescription')}</p> 1002 450 1003 - {#if !legacyLoginLoading} 1004 - <div class="toggle-row"> 1005 - <div class="toggle-info"> 1006 - <span class="toggle-label">{$_('security.legacyLogin')}</span> 1007 - <span class="toggle-description"> 1008 - {#if allowLegacyLogin} 1009 - {$_('security.legacyLoginOn')} 1010 - {:else} 1011 - {$_('security.legacyLoginOff')} 1012 - {/if} 1013 - </span> 1014 - </div> 1015 - <button 1016 - type="button" 1017 - class="toggle-button {allowLegacyLogin ? 'on' : 'off'}" 1018 - onclick={handleToggleLegacyLogin} 1019 - disabled={legacyLoginUpdating} 1020 - aria-label={allowLegacyLogin ? $_('security.disableLegacyLogin') : $_('security.enableLegacyLogin')} 1021 - > 1022 - <span class="toggle-slider"></span> 451 + <button type="button" class="sm danger-outline" onclick={() => handleRevokeDevice(device.id)}> 452 + {$_('trustedDevices.revoke')} 1023 453 </button> 1024 454 </div> 1025 - 1026 - {#if totpEnabled && allowLegacyLogin} 1027 - <div class="warning-box"> 1028 - <strong>{$_('security.legacyLoginWarning')}</strong> 1029 - </div> 1030 - {/if} 1031 - 1032 - <div class="info-box"> 1033 - <strong>{$_('security.legacyAppsTitle')}</strong> 1034 - <p>{$_('security.legacyAppsDescription')}</p> 1035 - </div> 1036 - {/if} 1037 - </section> 455 + {/each} 456 + </div> 1038 457 {/if} 1039 - 1040 - <section> 1041 - <h3>{$_('security.trustedDevices')}</h3> 1042 - <p class="section-description">{$_('security.trustedDevicesDescription')}</p> 1043 - 1044 - {#if trustedDevicesLoading} 1045 - <div class="loading">{$_('common.loading')}</div> 1046 - {:else if trustedDevices.length === 0} 1047 - <p class="empty-hint">{$_('trustedDevices.noDevices')}</p> 1048 - <p class="hint-text">{$_('trustedDevices.noDevicesHint')}</p> 1049 - {:else} 1050 - <div class="device-list"> 1051 - {#each trustedDevices as device} 1052 - <div class="device-card"> 1053 - <div class="device-header"> 1054 - {#if editingDeviceId === device.id} 1055 - <input 1056 - type="text" 1057 - class="edit-name-input" 1058 - bind:value={editDeviceName} 1059 - placeholder={$_('trustedDevices.deviceNamePlaceholder')} 1060 - /> 1061 - <div class="edit-actions"> 1062 - <button type="button" class="sm" onclick={handleSaveDeviceName}>{$_('common.save')}</button> 1063 - <button type="button" class="sm ghost" onclick={cancelEditDevice}>{$_('common.cancel')}</button> 1064 - </div> 1065 - {:else} 1066 - <span class="device-name">{device.friendlyName || parseUserAgent(device.userAgent)}</span> 1067 - <button type="button" class="icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}> 1068 - &#9998; 1069 - </button> 1070 - {/if} 1071 - </div> 1072 - 1073 - <div class="device-details"> 1074 - {#if device.userAgent} 1075 - <span class="detail">{parseUserAgent(device.userAgent)}</span> 1076 - {/if} 1077 - {#if device.trustedAt} 1078 - <span class="detail">{$_('trustedDevices.trustedSince')} {formatDate(device.trustedAt)}</span> 1079 - {/if} 1080 - <span class="detail">{$_('trustedDevices.lastSeen')} {formatDate(device.lastSeenAt)}</span> 1081 - {#if device.trustedUntil} 1082 - {@const daysRemaining = getDaysRemaining(device.trustedUntil)} 1083 - <span class="detail" class:expiring-soon={daysRemaining <= 7}> 1084 - {#if daysRemaining <= 0} 1085 - {$_('trustedDevices.expired')} 1086 - {:else if daysRemaining === 1} 1087 - {$_('trustedDevices.tomorrow')} 1088 - {:else} 1089 - {$_('trustedDevices.inDays', { values: { days: daysRemaining } })} 1090 - {/if} 1091 - </span> 1092 - {/if} 1093 - </div> 1094 - 1095 - <button type="button" class="sm danger-outline" onclick={() => handleRevokeDevice(device.id)}> 1096 - {$_('trustedDevices.revoke')} 1097 - </button> 1098 - </div> 1099 - {/each} 1100 - </div> 1101 - {/if} 1102 - </section> 1103 - {/if} 458 + </section> 1104 459 </div> 1105 460 1106 461 <ReauthModal

History

3 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
refactor(frontend): rewrite SecurityContent
expand 0 comments
pull request successfully merged
1 commit
expand
refactor(frontend): rewrite SecurityContent
expand 0 comments
1 commit
expand
refactor(frontend): rewrite SecurityContent
expand 0 comments