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

refactor(frontend): extract dashboard sections #73

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/3mhg53l5vdr22
+765 -218
Diff #2
-60
frontend/src/components/dashboard/AccountOverview.svelte
··· 1 - <script lang="ts"> 2 - import { _ } from '../../lib/i18n' 3 - import type { Session } from '../../lib/types/api' 4 - 5 - interface Props { 6 - session: Session 7 - } 8 - 9 - let { session }: Props = $props() 10 - </script> 11 - 12 - <div class="overview"> 13 - <dl> 14 - <dt>{$_('dashboard.handle')}</dt> 15 - <dd> 16 - @{session.handle} 17 - {#if session.isAdmin} 18 - <span class="badge admin">{$_('dashboard.admin')}</span> 19 - {/if} 20 - {#if session.accountKind === 'migrated'} 21 - <span class="badge migrated">{$_('dashboard.migrated')}</span> 22 - {:else if session.accountKind === 'deactivated'} 23 - <span class="badge deactivated">{$_('dashboard.deactivated')}</span> 24 - {/if} 25 - </dd> 26 - <dt>{$_('dashboard.did')}</dt> 27 - <dd class="mono">{session.did}</dd> 28 - {#if session.contactKind === 'channel'} 29 - <dt>{$_('dashboard.primaryContact')}</dt> 30 - <dd> 31 - {#if session.preferredChannel === 'email'} 32 - {session.email || $_('register.email')} 33 - {:else if session.preferredChannel === 'discord'} 34 - {$_('register.discord')} 35 - {:else if session.preferredChannel === 'telegram'} 36 - {$_('register.telegram')} 37 - {:else if session.preferredChannel === 'signal'} 38 - {$_('register.signal')} 39 - {:else} 40 - {session.preferredChannel} 41 - {/if} 42 - {#if session.preferredChannelVerified} 43 - <span class="badge success">{$_('dashboard.verified')}</span> 44 - {:else} 45 - <span class="badge warning">{$_('dashboard.unverified')}</span> 46 - {/if} 47 - </dd> 48 - {:else if session.contactKind === 'email'} 49 - <dt>{$_('register.email')}</dt> 50 - <dd> 51 - {session.email} 52 - {#if session.emailConfirmed} 53 - <span class="badge success">{$_('dashboard.verified')}</span> 54 - {:else} 55 - <span class="badge warning">{$_('dashboard.unverified')}</span> 56 - {/if} 57 - </dd> 58 - {/if} 59 - </dl> 60 - </div>
+20 -57
frontend/src/components/dashboard/AdminContent.svelte
··· 11 11 setColors as setGlobalColors, 12 12 setHasLogo as setGlobalHasLogo 13 13 } from '../../lib/serverConfig.svelte' 14 + import LoadMoreSentinel from '../LoadMoreSentinel.svelte' 15 + import { portal } from '../../lib/portal' 14 16 15 17 interface Props { 16 18 session: Session ··· 32 34 indexedAt: string 33 35 emailConfirmedAt?: string 34 36 deactivatedAt?: string 35 - invitesDisabled?: boolean 36 37 } 37 38 38 39 let stats = $state<ServerStats | null>(null) ··· 41 42 let usersLoading = $state(false) 42 43 let searchQuery = $state('') 43 44 let usersCursor = $state<string | undefined>(undefined) 45 + let usersHasMore = $state(true) 46 + let searchDebounce: ReturnType<typeof setTimeout> | null = null 44 47 45 48 let selectedUser = $state<User | null>(null) 46 49 let userActionLoading = $state(false) ··· 63 66 let serverConfigLoading = $state(false) 64 67 65 68 onMount(async () => { 66 - await Promise.all([loadStats(), loadServerConfig()]) 69 + await Promise.all([loadStats(), loadServerConfig(), loadUsers(true)]) 67 70 }) 68 71 69 72 async function loadStats() { ··· 82 85 if (reset) { 83 86 users = [] 84 87 usersCursor = undefined 88 + usersHasMore = true 85 89 } 86 90 try { 87 91 const result = await api.searchAccounts(session.accessJwt, { ··· 91 95 }) 92 96 users = reset ? result.accounts : [...users, ...result.accounts] 93 97 usersCursor = result.cursor 98 + usersHasMore = !!result.cursor 94 99 } catch { 95 100 toast.error($_('admin.failedToLoadUsers')) 96 101 } finally { ··· 98 103 } 99 104 } 100 105 101 - function handleSearch(e: Event) { 102 - e.preventDefault() 103 - loadUsers(true) 106 + function onSearchInput(value: string) { 107 + searchQuery = value 108 + if (searchDebounce) clearTimeout(searchDebounce) 109 + searchDebounce = setTimeout(() => loadUsers(true), 300) 104 110 } 105 111 106 112 function formatBytes(bytes: number): string { ··· 216 222 indexedAt: details.indexedAt, 217 223 emailConfirmedAt: details.emailConfirmedAt, 218 224 deactivatedAt: details.deactivatedAt, 219 - invitesDisabled: details.invitesDisabled 220 225 } 221 226 } catch { 222 227 } finally { ··· 228 233 selectedUser = null 229 234 } 230 235 231 - async function toggleUserInvites() { 232 - if (!selectedUser) return 233 - userActionLoading = true 234 - try { 235 - if (selectedUser.invitesDisabled) { 236 - await api.enableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did)) 237 - selectedUser = { ...selectedUser, invitesDisabled: false } 238 - toast.success($_('admin.invitesEnabled')) 239 - } else { 240 - await api.disableAccountInvites(session.accessJwt, unsafeAsDid(selectedUser.did)) 241 - selectedUser = { ...selectedUser, invitesDisabled: true } 242 - toast.success($_('admin.invitesDisabled')) 243 - } 244 - } catch (e) { 245 - toast.error(e instanceof ApiError ? e.message : $_('admin.failedToToggleInvites')) 246 - } finally { 247 - userActionLoading = false 248 - } 249 - } 250 - 251 236 async function deleteUserAccount() { 252 237 if (!selectedUser) return 253 238 if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return ··· 372 357 <section class="users-section"> 373 358 <h3>{$_('admin.userManagement')}</h3> 374 359 375 - <form class="search-bar" onsubmit={handleSearch}> 376 - <input 377 - type="text" 378 - bind:value={searchQuery} 379 - placeholder={$_('admin.searchPlaceholder')} 380 - /> 381 - <button type="submit" disabled={usersLoading}> 382 - {usersLoading ? $_('common.loading') : $_('admin.search')} 383 - </button> 384 - </form> 360 + <input 361 + type="text" 362 + value={searchQuery} 363 + oninput={(e) => onSearchInput(e.currentTarget.value)} 364 + placeholder={$_('admin.searchPlaceholder')} 365 + /> 385 366 386 367 {#if users.length === 0 && !usersLoading} 387 368 <p class="empty">{$_('admin.searchToSeeUsers')}</p> ··· 412 393 </li> 413 394 {/each} 414 395 </ul> 415 - {#if usersCursor} 416 - <button type="button" class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}> 417 - {usersLoading ? $_('common.loading') : $_('admin.loadMore')} 418 - </button> 419 - {/if} 396 + <LoadMoreSentinel hasMore={usersHasMore} loading={usersLoading} onLoadMore={() => loadUsers(false)} /> 420 397 {/if} 421 398 </section> 422 399 423 400 </div> 424 401 425 402 {#if selectedUser} 426 - <div class="modal-backdrop" onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation"> 403 + <div class="modal-backdrop" use:portal onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation"> 427 404 <div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1"> 428 405 <div class="modal-header"> 429 406 <h2>{$_('admin.userDetails')}</h2> ··· 437 414 <dt>{$_('admin.handle')}</dt> 438 415 <dd>@{selectedUser.handle}</dd> 439 416 <dt>{$_('admin.did')}</dt> 440 - <dd class="mono">{selectedUser.did}</dd> 417 + <dd class="definition-mono">{selectedUser.did}</dd> 441 418 <dt>{$_('admin.email')}</dt> 442 419 <dd>{selectedUser.email || '-'}</dd> 443 420 <dt>{$_('admin.created')}</dt> ··· 452 429 <span class="badge unverified">{$_('admin.unverified')}</span> 453 430 {/if} 454 431 </dd> 455 - <dt>{$_('admin.invites')}</dt> 456 - <dd> 457 - {#if selectedUser.invitesDisabled} 458 - <span class="badge deactivated">{$_('admin.disabled')}</span> 459 - {:else} 460 - <span class="badge verified">{$_('admin.enabled')}</span> 461 - {/if} 462 - </dd> 463 432 </dl> 464 433 <div class="modal-actions"> 465 - <button 466 - onclick={toggleUserInvites} 467 - disabled={userActionLoading} 468 - > 469 - {selectedUser.invitesDisabled ? $_('admin.enableInvites') : $_('admin.disableInvites')} 470 - </button> 471 434 <button 472 435 class="danger" 473 436 onclick={deleteUserAccount}
+14 -14
frontend/src/components/dashboard/ControllersContent.svelte
··· 376 376 </div> 377 377 <div class="item-details"> 378 378 <div class="detail"> 379 - <span class="label">{$_('delegation.did')}</span> 380 - <span class="value did">{controller.did}</span> 379 + <span class="detail-label">{$_('delegation.did')}</span> 380 + <span class="detail-value detail-value-did">{controller.did}</span> 381 381 </div> 382 382 <div class="detail"> 383 - <span class="label">{$_('delegation.granted')}</span> 384 - <span class="value">{formatDateTime(controller.grantedAt)}</span> 383 + <span class="detail-label">{$_('delegation.granted')}</span> 384 + <span class="detail-value">{formatDateTime(controller.grantedAt)}</span> 385 385 </div> 386 386 </div> 387 387 </div> ··· 507 507 </div> 508 508 <div class="item-details"> 509 509 <div class="detail"> 510 - <span class="label">{$_('delegation.did')}</span> 511 - <span class="value did">{account.did}</span> 510 + <span class="detail-label">{$_('delegation.did')}</span> 511 + <span class="detail-value detail-value-did">{account.did}</span> 512 512 </div> 513 513 <div class="detail"> 514 - <span class="label">{$_('delegation.granted')}</span> 515 - <span class="value">{formatDateTime(account.grantedAt)}</span> 514 + <span class="detail-label">{$_('delegation.granted')}</span> 515 + <span class="detail-value">{formatDateTime(account.grantedAt)}</span> 516 516 </div> 517 517 </div> 518 518 </div> ··· 601 601 </div> 602 602 <div class="audit-entry-details"> 603 603 <div class="detail"> 604 - <span class="label">{$_('delegation.actor')}</span> 605 - <span class="value did">{entry.actorDid}</span> 604 + <span class="detail-label">{$_('delegation.actor')}</span> 605 + <span class="detail-value detail-value-did">{entry.actorDid}</span> 606 606 </div> 607 607 {#if entry.delegatedDid} 608 608 <div class="detail"> 609 - <span class="label">{$_('delegation.target')}</span> 610 - <span class="value did">{entry.delegatedDid}</span> 609 + <span class="detail-label">{$_('delegation.target')}</span> 610 + <span class="detail-value detail-value-did">{entry.delegatedDid}</span> 611 611 </div> 612 612 {/if} 613 613 {#if entry.actionDetails} 614 614 <div class="detail"> 615 - <span class="label">{$_('delegation.details')}</span> 616 - <span class="value audit-details-value">{formatActionDetails(entry.actionDetails)}</span> 615 + <span class="detail-label">{$_('delegation.details')}</span> 616 + <span class="detail-value audit-details-value">{formatActionDetails(entry.actionDetails)}</span> 617 617 </div> 618 618 {/if} 619 619 </div>
-53
frontend/src/components/dashboard/MigrationContent.svelte
··· 1 - <script lang="ts"> 2 - import { _ } from '../../lib/i18n' 3 - import { navigate, routes } from '../../lib/router.svelte' 4 - import type { Session } from '../../lib/types/api' 5 - 6 - interface Props { 7 - session: Session 8 - } 9 - 10 - let { session }: Props = $props() 11 - 12 - function startMigration(type: 'inbound' | 'offline') { 13 - const url = type === 'offline' 14 - ? `${routes.migrate}?flow=offline` 15 - : routes.migrate 16 - navigate(url as typeof routes.migrate) 17 - } 18 - </script> 19 - 20 - <div class="migration"> 21 - <section> 22 - <h3>{$_('migration.migrateHere')}</h3> 23 - <p class="description">{$_('migration.migrateHereDesc')}</p> 24 - <ul class="feature-list"> 25 - <li>{$_('migration.bringDid')}</li> 26 - <li>{$_('migration.transferData')}</li> 27 - <li>{$_('migration.keepFollowers')}</li> 28 - </ul> 29 - <button onclick={() => startMigration('inbound')}> 30 - {$_('migration.inbound.review.startMigration')} 31 - </button> 32 - </section> 33 - 34 - <section> 35 - <h3>{$_('migration.offlineRestore')}</h3> 36 - <p class="description">{$_('migration.offlineRestoreDesc')}</p> 37 - <ul class="feature-list"> 38 - <li>{$_('migration.offlineFeature1')}</li> 39 - <li>{$_('migration.offlineFeature2')}</li> 40 - <li>{$_('migration.offlineFeature3')}</li> 41 - </ul> 42 - <button class="secondary" onclick={() => startMigration('offline')}> 43 - {$_('migration.offlineRestore')} 44 - </button> 45 - </section> 46 - 47 - {#if session.accountKind === 'migrated'} 48 - <section class="info-section"> 49 - <h3>{$_('dashboard.migratedTitle')}</h3> 50 - <p>{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}</p> 51 - </section> 52 - {/if} 53 - </div>
+166
frontend/src/components/dashboard/PasskeySection.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte' 3 + import { api, ApiError } from '../../lib/api' 4 + import { _ } from '../../lib/i18n' 5 + import { formatDate } from '../../lib/date' 6 + import { toast } from '../../lib/toast.svelte' 7 + import type { Session } from '../../lib/types/api' 8 + import { 9 + prepareCreationOptions, 10 + serializeAttestationResponse, 11 + type WebAuthnCreationOptionsResponse, 12 + } from '../../lib/webauthn' 13 + 14 + interface Props { 15 + session: Session 16 + hasPassword: boolean 17 + onPasskeysChanged?: (count: number) => void 18 + } 19 + 20 + let { session, hasPassword, onPasskeysChanged }: Props = $props() 21 + 22 + interface Passkey { 23 + id: string 24 + credentialId: string 25 + friendlyName: string | null 26 + createdAt: string 27 + lastUsed: string | null 28 + } 29 + 30 + let passkeys = $state<Passkey[]>([]) 31 + let loading = $state(true) 32 + let addingPasskey = $state(false) 33 + let newPasskeyName = $state('') 34 + let editingPasskeyId = $state<string | null>(null) 35 + let editPasskeyName = $state('') 36 + 37 + onMount(async () => { 38 + await loadPasskeys() 39 + }) 40 + 41 + async function loadPasskeys() { 42 + loading = true 43 + try { 44 + const result = await api.listPasskeys(session.accessJwt) 45 + passkeys = result.passkeys 46 + onPasskeysChanged?.(passkeys.length) 47 + } catch { 48 + toast.error($_('security.failedToLoadPasskeys')) 49 + } finally { 50 + loading = false 51 + } 52 + } 53 + 54 + async function handleAddPasskey() { 55 + if (!window.PublicKeyCredential) { 56 + toast.error($_('security.passkeysNotSupported')) 57 + return 58 + } 59 + addingPasskey = true 60 + try { 61 + const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined) 62 + const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse) 63 + const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }) 64 + if (!credential) { 65 + toast.error($_('security.passkeyCreationCancelled')) 66 + return 67 + } 68 + const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential) 69 + await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined) 70 + await loadPasskeys() 71 + newPasskeyName = '' 72 + toast.success($_('security.passkeyAddedSuccess')) 73 + } catch (e) { 74 + if (e instanceof DOMException && e.name === 'NotAllowedError') { 75 + toast.error($_('security.passkeyCreationCancelled')) 76 + } else { 77 + toast.error(e instanceof ApiError ? e.message : 'Failed to add passkey') 78 + } 79 + } finally { 80 + addingPasskey = false 81 + } 82 + } 83 + 84 + async function handleDeletePasskey(id: string) { 85 + const passkey = passkeys.find(p => p.id === id) 86 + if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return 87 + try { 88 + await api.deletePasskey(session.accessJwt, id) 89 + await loadPasskeys() 90 + toast.success($_('security.passkeyDeleted')) 91 + } catch (e) { 92 + toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey') 93 + } 94 + } 95 + 96 + async function handleSavePasskeyName() { 97 + if (!editingPasskeyId || !editPasskeyName.trim()) return 98 + try { 99 + await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim()) 100 + await loadPasskeys() 101 + editingPasskeyId = null 102 + editPasskeyName = '' 103 + toast.success($_('security.passkeyRenamed')) 104 + } catch (e) { 105 + toast.error(e instanceof ApiError ? e.message : 'Failed to rename passkey') 106 + } 107 + } 108 + 109 + function startEditPasskey(passkey: Passkey) { 110 + editingPasskeyId = passkey.id 111 + editPasskeyName = passkey.friendlyName || '' 112 + } 113 + 114 + function cancelEditPasskey() { 115 + editingPasskeyId = null 116 + editPasskeyName = '' 117 + } 118 + </script> 119 + 120 + <section> 121 + <h3>{$_('security.passkeys')}</h3> 122 + 123 + {#if !loading} 124 + {#if passkeys.length > 0} 125 + <ul class="passkey-list"> 126 + {#each passkeys as passkey} 127 + <li class="passkey-item"> 128 + {#if editingPasskeyId === passkey.id} 129 + <div class="passkey-edit"> 130 + <input type="text" bind:value={editPasskeyName} placeholder={$_('security.passkeyName')} /> 131 + <button type="button" class="sm" onclick={handleSavePasskeyName}>{$_('common.save')}</button> 132 + <button type="button" class="sm secondary" onclick={cancelEditPasskey}>{$_('common.cancel')}</button> 133 + </div> 134 + {:else} 135 + <div class="passkey-info"> 136 + <span class="passkey-name">{passkey.friendlyName || $_('security.unnamedPasskey')}</span> 137 + <span class="passkey-meta"> 138 + {$_('security.added')} {formatDate(passkey.createdAt)} 139 + {#if passkey.lastUsed} 140 + - {$_('security.lastUsed')} {formatDate(passkey.lastUsed)} 141 + {/if} 142 + </span> 143 + </div> 144 + <div class="passkey-actions"> 145 + <button type="button" class="sm secondary" onclick={() => startEditPasskey(passkey)}>{$_('security.rename')}</button> 146 + {#if hasPassword || passkeys.length > 1} 147 + <button type="button" class="sm danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>{$_('security.deletePasskey')}</button> 148 + {/if} 149 + </div> 150 + {/if} 151 + </li> 152 + {/each} 153 + </ul> 154 + {:else} 155 + <div class="status warning">{$_('security.noPasskeys')}</div> 156 + {/if} 157 + 158 + <div class="add-passkey"> 159 + <input type="text" bind:value={newPasskeyName} placeholder={$_('security.passkeyNamePlaceholder')} disabled={addingPasskey} /> 160 + <button onclick={handleAddPasskey} disabled={addingPasskey}> 161 + {addingPasskey ? $_('security.adding') : $_('security.addPasskey')} 162 + </button> 163 + </div> 164 + 165 + {/if} 166 + </section>
+265
frontend/src/components/dashboard/PasswordSection.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte' 3 + import { getValidToken } from '../../lib/auth.svelte' 4 + import { api, ApiError } from '../../lib/api' 5 + import { _ } from '../../lib/i18n' 6 + import { toast } from '../../lib/toast.svelte' 7 + import type { Session } from '../../lib/types/api' 8 + 9 + interface Props { 10 + session: Session 11 + passkeyCount: number 12 + onPasswordChanged?: (hasPassword: boolean) => void 13 + onReauthRequired: (methods: string[], retryAction: () => Promise<void>) => void 14 + } 15 + 16 + let { session, passkeyCount, onPasswordChanged, onReauthRequired }: Props = $props() 17 + 18 + let hasPassword = $state(true) 19 + let loading = $state(true) 20 + 21 + let showChangePasswordForm = $state(false) 22 + let currentPassword = $state('') 23 + let newPassword = $state('') 24 + let confirmNewPassword = $state('') 25 + let changePasswordLoading = $state(false) 26 + 27 + let showSetPasswordForm = $state(false) 28 + let setNewPassword = $state('') 29 + let setConfirmPassword = $state('') 30 + let setPasswordLoading = $state(false) 31 + 32 + let showRemovePasswordForm = $state(false) 33 + let removePasswordLoading = $state(false) 34 + 35 + onMount(async () => { 36 + await loadPasswordStatus() 37 + }) 38 + 39 + async function loadPasswordStatus() { 40 + loading = true 41 + try { 42 + const status = await api.getPasswordStatus(session.accessJwt) 43 + hasPassword = status.hasPassword 44 + onPasswordChanged?.(hasPassword) 45 + } catch { 46 + hasPassword = true 47 + } finally { 48 + loading = false 49 + } 50 + } 51 + 52 + function handleReauthError(e: unknown, fallback: string, retryAction: () => Promise<void>) { 53 + if (e instanceof ApiError) { 54 + if (e.error === 'ReauthRequired') { 55 + onReauthRequired(e.reauthMethods || ['password'], retryAction) 56 + } else { 57 + toast.error(e.message) 58 + } 59 + } else { 60 + toast.error(fallback) 61 + } 62 + } 63 + 64 + async function handleChangePassword(e: Event) { 65 + e.preventDefault() 66 + if (!currentPassword || !newPassword || !confirmNewPassword) return 67 + if (newPassword !== confirmNewPassword) { 68 + toast.error($_('security.passwordsDoNotMatch')) 69 + return 70 + } 71 + if (newPassword.length < 8) { 72 + toast.error($_('security.passwordTooShort')) 73 + return 74 + } 75 + changePasswordLoading = true 76 + try { 77 + await api.changePassword(session.accessJwt, currentPassword, newPassword) 78 + toast.success($_('security.passwordChanged')) 79 + currentPassword = '' 80 + newPassword = '' 81 + confirmNewPassword = '' 82 + showChangePasswordForm = false 83 + } catch (e) { 84 + handleReauthError(e, $_('security.failedToChangePassword'), () => handleChangePassword(new Event('submit'))) 85 + } finally { 86 + changePasswordLoading = false 87 + } 88 + } 89 + 90 + async function handleSetPassword(e: Event) { 91 + e.preventDefault() 92 + if (!setNewPassword || !setConfirmPassword) return 93 + if (setNewPassword !== setConfirmPassword) { 94 + toast.error($_('security.passwordsDoNotMatch')) 95 + return 96 + } 97 + if (setNewPassword.length < 8) { 98 + toast.error($_('security.passwordTooShort')) 99 + return 100 + } 101 + setPasswordLoading = true 102 + try { 103 + await api.setPassword(session.accessJwt, setNewPassword) 104 + hasPassword = true 105 + toast.success($_('security.passwordSet')) 106 + setNewPassword = '' 107 + setConfirmPassword = '' 108 + showSetPasswordForm = false 109 + onPasswordChanged?.(true) 110 + } catch (e) { 111 + handleReauthError(e, $_('security.failedToSetPassword'), () => handleSetPassword(new Event('submit'))) 112 + } finally { 113 + setPasswordLoading = false 114 + } 115 + } 116 + 117 + async function handleRemovePassword() { 118 + removePasswordLoading = true 119 + try { 120 + const token = await getValidToken() 121 + if (!token) { 122 + toast.error($_('security.sessionExpired')) 123 + return 124 + } 125 + await api.removePassword(token) 126 + hasPassword = false 127 + showRemovePasswordForm = false 128 + toast.success($_('security.passwordRemoved')) 129 + onPasswordChanged?.(false) 130 + } catch (e) { 131 + handleReauthError(e, $_('security.failedToRemovePassword'), handleRemovePassword) 132 + } finally { 133 + removePasswordLoading = false 134 + } 135 + } 136 + </script> 137 + 138 + <section> 139 + <h3>{$_('security.password')}</h3> 140 + {#if !loading} 141 + {#if hasPassword} 142 + <div class="status success">{$_('security.passwordStatus')}</div> 143 + 144 + {#if !showChangePasswordForm && !showRemovePasswordForm} 145 + <div class="password-actions"> 146 + <button type="button" onclick={() => showChangePasswordForm = true}> 147 + {$_('security.changePassword')} 148 + </button> 149 + {#if passkeyCount > 0} 150 + <button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}> 151 + {$_('security.removePassword')} 152 + </button> 153 + {/if} 154 + </div> 155 + {/if} 156 + 157 + {#if showChangePasswordForm} 158 + <form class="inline-form" onsubmit={handleChangePassword}> 159 + <h4>{$_('security.changePassword')}</h4> 160 + <div> 161 + <label for="current-password">{$_('security.currentPassword')}</label> 162 + <input 163 + id="current-password" 164 + type="password" 165 + bind:value={currentPassword} 166 + placeholder={$_('security.currentPasswordPlaceholder')} 167 + disabled={changePasswordLoading} 168 + required 169 + /> 170 + </div> 171 + <div> 172 + <label for="new-password">{$_('security.newPassword')}</label> 173 + <input 174 + id="new-password" 175 + type="password" 176 + bind:value={newPassword} 177 + placeholder={$_('security.newPasswordPlaceholder')} 178 + disabled={changePasswordLoading} 179 + required 180 + minlength="8" 181 + /> 182 + </div> 183 + <div> 184 + <label for="confirm-password">{$_('security.confirmPassword')}</label> 185 + <input 186 + id="confirm-password" 187 + type="password" 188 + bind:value={confirmNewPassword} 189 + placeholder={$_('security.confirmPasswordPlaceholder')} 190 + disabled={changePasswordLoading} 191 + required 192 + minlength="8" 193 + /> 194 + </div> 195 + <div class="actions"> 196 + <button type="button" class="secondary" onclick={() => { showChangePasswordForm = false; currentPassword = ''; newPassword = ''; confirmNewPassword = '' }}> 197 + {$_('common.cancel')} 198 + </button> 199 + <button type="submit" disabled={changePasswordLoading || !currentPassword || !newPassword || !confirmNewPassword}> 200 + {changePasswordLoading ? $_('security.changing') : $_('security.changePassword')} 201 + </button> 202 + </div> 203 + </form> 204 + {/if} 205 + 206 + {#if showRemovePasswordForm} 207 + <div class="remove-password-form"> 208 + <p class="warning-text">{$_('security.removePasswordWarning')}</p> 209 + <div class="actions"> 210 + <button type="button" class="ghost sm" onclick={() => showRemovePasswordForm = false}> 211 + {$_('common.cancel')} 212 + </button> 213 + <button type="button" class="danger sm" onclick={handleRemovePassword} disabled={removePasswordLoading}> 214 + {removePasswordLoading ? $_('security.removing') : $_('security.removePassword')} 215 + </button> 216 + </div> 217 + </div> 218 + {/if} 219 + {:else} 220 + <div class="status info">{$_('security.noPassword')}</div> 221 + 222 + {#if !showSetPasswordForm} 223 + <button type="button" onclick={() => showSetPasswordForm = true}> 224 + {$_('security.setPassword')} 225 + </button> 226 + {:else} 227 + <form class="inline-form" onsubmit={handleSetPassword}> 228 + <h4>{$_('security.setPassword')}</h4> 229 + <div> 230 + <label for="set-new-password">{$_('security.newPassword')}</label> 231 + <input 232 + id="set-new-password" 233 + type="password" 234 + bind:value={setNewPassword} 235 + placeholder={$_('security.newPasswordPlaceholder')} 236 + disabled={setPasswordLoading} 237 + required 238 + minlength="8" 239 + /> 240 + </div> 241 + <div> 242 + <label for="set-confirm-password">{$_('security.confirmPassword')}</label> 243 + <input 244 + id="set-confirm-password" 245 + type="password" 246 + bind:value={setConfirmPassword} 247 + placeholder={$_('security.confirmPasswordPlaceholder')} 248 + disabled={setPasswordLoading} 249 + required 250 + minlength="8" 251 + /> 252 + </div> 253 + <div class="actions"> 254 + <button type="button" class="secondary" onclick={() => { showSetPasswordForm = false; setNewPassword = ''; setConfirmPassword = '' }}> 255 + {$_('common.cancel')} 256 + </button> 257 + <button type="submit" disabled={setPasswordLoading || !setNewPassword || !setConfirmPassword}> 258 + {setPasswordLoading ? $_('security.setting') : $_('security.setPassword')} 259 + </button> 260 + </div> 261 + </form> 262 + {/if} 263 + {/if} 264 + {/if} 265 + </section>
+4 -4
frontend/src/components/dashboard/SessionsContent.svelte
··· 116 116 </div> 117 117 <div class="session-details"> 118 118 <div class="detail"> 119 - <span class="label">{$_('sessions.created')}</span> 120 - <span class="value">{timeAgo(s.createdAt)}</span> 119 + <span class="detail-label">{$_('sessions.created')}</span> 120 + <span class="detail-value">{timeAgo(s.createdAt)}</span> 121 121 </div> 122 122 <div class="detail"> 123 - <span class="label">{$_('sessions.expires')}</span> 124 - <span class="value">{formatDate(s.expiresAt)}</span> 123 + <span class="detail-label">{$_('sessions.expires')}</span> 124 + <span class="detail-value">{formatDate(s.expiresAt)}</span> 125 125 </div> 126 126 </div> 127 127 </div>
+279
frontend/src/components/dashboard/TotpSection.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte' 3 + import { api, ApiError } from '../../lib/api' 4 + import { _ } from '../../lib/i18n' 5 + import { toast } from '../../lib/toast.svelte' 6 + import type { Session } from '../../lib/types/api' 7 + import { 8 + type TotpSetupState, 9 + idleState, 10 + qrState, 11 + verifyState, 12 + backupState, 13 + goBackToQr, 14 + finish, 15 + type TotpQr, 16 + } from '../../lib/types/totp-state' 17 + 18 + interface Props { 19 + session: Session 20 + onStatusChanged?: (enabled: boolean, hasBackupCodes: boolean) => void 21 + } 22 + 23 + let { session, onStatusChanged }: Props = $props() 24 + 25 + let totpEnabled = $state(false) 26 + let hasBackupCodes = $state(false) 27 + let totpSetup = $state<TotpSetupState>(idleState) 28 + let verifyCodeRaw = $state('') 29 + let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, '')) 30 + let verifyLoading = $state(false) 31 + 32 + let disablePassword = $state('') 33 + let disableCode = $state('') 34 + let disableLoading = $state(false) 35 + let showDisableForm = $state(false) 36 + 37 + let regenPassword = $state('') 38 + let regenCode = $state('') 39 + let regenLoading = $state(false) 40 + let showRegenForm = $state(false) 41 + 42 + onMount(async () => { 43 + await loadTotpStatus() 44 + }) 45 + 46 + async function loadTotpStatus() { 47 + try { 48 + const status = await api.getTotpStatus(session.accessJwt) 49 + totpEnabled = status.enabled 50 + hasBackupCodes = status.hasBackupCodes 51 + onStatusChanged?.(totpEnabled, hasBackupCodes) 52 + } catch { 53 + toast.error($_('security.failedToLoadTotpStatus')) 54 + } 55 + } 56 + 57 + async function handleStartTotpSetup() { 58 + verifyLoading = true 59 + try { 60 + const result = await api.createTotpSecret(session.accessJwt) 61 + totpSetup = qrState(result.qrBase64, result.uri) 62 + } catch (e) { 63 + toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret') 64 + } finally { 65 + verifyLoading = false 66 + } 67 + } 68 + 69 + async function handleVerifyTotp(e: Event) { 70 + e.preventDefault() 71 + if (!verifyCode || totpSetup.step !== 'verify') return 72 + verifyLoading = true 73 + try { 74 + const result = await api.enableTotp(session.accessJwt, verifyCode) 75 + totpSetup = backupState(totpSetup, result.backupCodes) 76 + totpEnabled = true 77 + hasBackupCodes = true 78 + verifyCodeRaw = '' 79 + onStatusChanged?.(true, true) 80 + } catch (e) { 81 + toast.error(e instanceof ApiError ? e.message : 'Invalid code') 82 + } finally { 83 + verifyLoading = false 84 + } 85 + } 86 + 87 + function handleFinishSetup() { 88 + if (totpSetup.step !== 'backup') return 89 + totpSetup = finish(totpSetup) 90 + toast.success($_('security.totpEnabledSuccess')) 91 + } 92 + 93 + function copyBackupCodes() { 94 + if (totpSetup.step !== 'backup') return 95 + navigator.clipboard.writeText(totpSetup.backupCodes.join('\n')) 96 + toast.success($_('security.backupCodesCopied')) 97 + } 98 + 99 + async function handleDisableTotp(e: Event) { 100 + e.preventDefault() 101 + if (!disablePassword || !disableCode) return 102 + disableLoading = true 103 + try { 104 + await api.disableTotp(session.accessJwt, disablePassword, disableCode) 105 + totpEnabled = false 106 + hasBackupCodes = false 107 + showDisableForm = false 108 + disablePassword = '' 109 + disableCode = '' 110 + toast.success($_('security.totpDisabledSuccess')) 111 + onStatusChanged?.(false, false) 112 + } catch (e) { 113 + toast.error(e instanceof ApiError ? e.message : $_('security.failedToDisableTotp')) 114 + } finally { 115 + disableLoading = false 116 + } 117 + } 118 + 119 + async function handleRegenerateBackupCodes(e: Event) { 120 + e.preventDefault() 121 + if (!regenPassword || !regenCode) return 122 + regenLoading = true 123 + try { 124 + const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode) 125 + const dummyVerify = verifyState(qrState('', '')) 126 + totpSetup = backupState(dummyVerify, result.backupCodes) 127 + showRegenForm = false 128 + regenPassword = '' 129 + regenCode = '' 130 + } catch (e) { 131 + toast.error(e instanceof ApiError ? e.message : $_('security.failedToRegenerateBackupCodes')) 132 + } finally { 133 + regenLoading = false 134 + } 135 + } 136 + </script> 137 + 138 + <section> 139 + <h3>{$_('security.totp')}</h3> 140 + 141 + {#if totpSetup.step === 'idle'} 142 + {#if totpEnabled} 143 + <div class="status success">{$_('security.totpEnabled')}</div> 144 + 145 + {#if !showDisableForm && !showRegenForm} 146 + <div class="totp-actions"> 147 + <button type="button" class="secondary" onclick={() => showRegenForm = true}> 148 + {$_('security.regenerateBackupCodes')} 149 + </button> 150 + <button type="button" class="danger-outline" onclick={() => showDisableForm = true}> 151 + {$_('security.disableTotp')} 152 + </button> 153 + </div> 154 + {/if} 155 + 156 + {#if showRegenForm} 157 + <form class="inline-form" onsubmit={handleRegenerateBackupCodes}> 158 + <h4>{$_('security.regenerateBackupCodes')}</h4> 159 + <p class="warning-text">{$_('security.regenerateConfirm')}</p> 160 + <div> 161 + <label for="regen-password">{$_('security.password')}</label> 162 + <input 163 + id="regen-password" 164 + type="password" 165 + bind:value={regenPassword} 166 + placeholder={$_('security.enterPassword')} 167 + disabled={regenLoading} 168 + required 169 + /> 170 + </div> 171 + <div> 172 + <label for="regen-code">{$_('security.totpCode')}</label> 173 + <input 174 + id="regen-code" 175 + type="text" 176 + bind:value={regenCode} 177 + placeholder={$_('security.totpCodePlaceholder')} 178 + disabled={regenLoading} 179 + required 180 + maxlength="6" 181 + inputmode="numeric" 182 + /> 183 + </div> 184 + <div class="actions"> 185 + <button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}> 186 + {$_('common.cancel')} 187 + </button> 188 + <button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}> 189 + {regenLoading ? $_('security.regenerating') : $_('security.regenerateBackupCodes')} 190 + </button> 191 + </div> 192 + </form> 193 + {/if} 194 + 195 + {#if showDisableForm} 196 + <form class="inline-form danger-form" onsubmit={handleDisableTotp}> 197 + <h4>{$_('security.disableTotp')}</h4> 198 + <p class="warning-text">{$_('security.disableTotpWarning')}</p> 199 + <div> 200 + <label for="disable-password">{$_('security.password')}</label> 201 + <input 202 + id="disable-password" 203 + type="password" 204 + bind:value={disablePassword} 205 + placeholder={$_('security.enterPassword')} 206 + disabled={disableLoading} 207 + required 208 + /> 209 + </div> 210 + <div> 211 + <label for="disable-code">{$_('security.totpCode')}</label> 212 + <input 213 + id="disable-code" 214 + type="text" 215 + bind:value={disableCode} 216 + placeholder={$_('security.totpCodePlaceholder')} 217 + disabled={disableLoading} 218 + required 219 + maxlength="6" 220 + inputmode="numeric" 221 + /> 222 + </div> 223 + <div class="actions"> 224 + <button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}> 225 + {$_('common.cancel')} 226 + </button> 227 + <button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}> 228 + {disableLoading ? $_('security.disabling') : $_('security.disableTotp')} 229 + </button> 230 + </div> 231 + </form> 232 + {/if} 233 + {:else} 234 + <div class="status warning">{$_('security.totpDisabled')}</div> 235 + <button onclick={handleStartTotpSetup} disabled={verifyLoading}> 236 + {$_('security.enableTotp')} 237 + </button> 238 + {/if} 239 + {:else if totpSetup.step === 'qr'} 240 + {@const qrData = totpSetup as TotpQr} 241 + <div class="setup-step"> 242 + <p>{$_('security.totpSetupInstructions')}</p> 243 + <div class="qr-container"> 244 + <img src="data:image/png;base64,{qrData.qrBase64}" alt="TOTP QR Code" class="qr-code" /> 245 + </div> 246 + <details class="manual-entry"> 247 + <summary>{$_('security.cantScan')}</summary> 248 + <code class="secret-code">{qrData.totpUri.split('secret=')[1]?.split('&')[0] || ''}</code> 249 + </details> 250 + <button onclick={() => totpSetup = verifyState(qrData)}>{$_('security.next')}</button> 251 + </div> 252 + {:else if totpSetup.step === 'verify'} 253 + {@const verifyData = totpSetup} 254 + <div class="setup-step"> 255 + <p>{$_('security.totpCodePlaceholder')}</p> 256 + <form onsubmit={handleVerifyTotp}> 257 + <input type="text" bind:value={verifyCodeRaw} placeholder="000000" class="code-input" inputmode="numeric" disabled={verifyLoading} /> 258 + <div class="actions"> 259 + <button type="button" class="secondary" onclick={() => totpSetup = goBackToQr(verifyData)}>{$_('common.back')}</button> 260 + <button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>{$_('security.verifyAndEnable')}</button> 261 + </div> 262 + </form> 263 + </div> 264 + {:else if totpSetup.step === 'backup'} 265 + <div class="setup-step"> 266 + <h4>{$_('security.backupCodes')}</h4> 267 + <p class="warning-text">{$_('security.backupCodesDescription')}</p> 268 + <div class="backup-codes"> 269 + {#each totpSetup.backupCodes as code} 270 + <code class="backup-code">{code}</code> 271 + {/each} 272 + </div> 273 + <div class="actions"> 274 + <button type="button" class="secondary" onclick={copyBackupCodes}>{$_('security.copyToClipboard')}</button> 275 + <button onclick={handleFinishSetup}>{$_('security.savedMyCodes')}</button> 276 + </div> 277 + </div> 278 + {/if} 279 + </section>
+17 -30
frontend/src/styles/dashboard.css
··· 279 279 display: flex; 280 280 align-items: center; 281 281 gap: var(--space-1); 282 - padding: 0; 282 + padding: var(--space-2) var(--space-3); 283 283 background: transparent; 284 284 border: none; 285 - color: var(--secondary); 285 + color: var(--text-secondary); 286 286 font-size: var(--text-base); 287 287 cursor: pointer; 288 288 margin-bottom: var(--space-2); 289 + min-height: 44px; 290 + } 291 + 292 + .back-button:hover:not(:disabled) { 293 + background: var(--bg-secondary); 294 + color: var(--text-primary); 289 295 } 290 296 291 297 .back-arrow { ··· 376 382 } 377 383 } 378 384 379 - .overview { 380 - background: var(--bg-secondary); 381 - padding: var(--space-6); 382 - } 383 - 384 385 .overview dl { 385 386 display: grid; 386 387 grid-template-columns: auto 1fr; ··· 429 430 } 430 431 } 431 432 432 - 433 433 .current { 434 434 color: var(--text-secondary); 435 435 font-size: var(--text-sm); ··· 607 607 } 608 608 609 609 .password-actions, 610 - .totp-actions, 610 + .totp-actions { 611 + display: flex; 612 + gap: var(--space-3); 613 + } 611 614 612 615 .remove-password-form { 613 616 background: var(--error-bg); ··· 949 952 font-size: var(--text-sm); 950 953 } 951 954 952 - .sessions .detail .label { 955 + .detail-label { 953 956 color: var(--text-secondary); 954 957 margin-right: var(--space-2); 955 958 } 956 959 957 - .sessions .detail .value { 960 + .detail-value { 958 961 color: var(--text-primary); 959 962 } 960 963 ··· 1688 1691 font-size: var(--text-sm); 1689 1692 } 1690 1693 1691 - .controllers .detail .label { 1692 - color: var(--text-secondary); 1693 - margin-right: var(--space-2); 1694 - } 1695 - 1696 - .controllers .detail .value { 1697 - color: var(--text-primary); 1698 - } 1699 - 1700 - .controllers .detail .value.did { 1694 + .detail-value-did { 1701 1695 font-family: var(--font-mono); 1702 1696 font-size: var(--text-xs); 1703 1697 word-break: break-all; ··· 2092 2086 gap: var(--space-3); 2093 2087 } 2094 2088 2095 - 2096 - 2097 2089 .item-id { 2098 2090 font-weight: var(--font-medium); 2099 2091 font-family: var(--font-mono); ··· 2174 2166 font-size: var(--text-base); 2175 2167 } 2176 2168 2177 - .feature-list { 2178 - list-style: none; 2179 - padding: 0; 2180 - margin: 0 0 var(--space-4) 0; 2181 - } 2182 - 2183 2169 .feature-list li { 2184 2170 padding: var(--space-2) 0; 2185 2171 padding-left: var(--space-4); ··· 2263 2249 } 2264 2250 2265 2251 button.user-item-btn:hover { 2252 + background: var(--bg-secondary); 2266 2253 border-color: var(--secondary); 2267 2254 } 2268 2255 ··· 2406 2393 margin-bottom: var(--space-5); 2407 2394 } 2408 2395 2409 - .admin .definition-list .mono { 2396 + .definition-mono { 2410 2397 font-family: var(--font-mono); 2411 2398 font-size: var(--text-xs); 2412 2399 word-break: break-all;

History

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