this repo has no description
1<script lang="ts"> 2 import { 3 getAuthState, 4 logout, 5 switchAccount, 6 type SavedAccount, 7 } from '../lib/auth.svelte' 8 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 9 import { _ } from '../lib/i18n' 10 import { api } from '../lib/api' 11 import { isOk } from '../lib/types/result' 12 import { unsafeAsDid, type Did } from '../lib/types/branded' 13 import type { Session } from '../lib/types/api' 14 import { onMount } from 'svelte' 15 16 const auth = $derived(getAuthState()) 17 let dropdownOpen = $state(false) 18 let switching = $state(false) 19 let inviteCodesEnabled = $state(false) 20 21 function getSession(): Session | null { 22 return auth.kind === 'authenticated' ? auth.session : null 23 } 24 25 function getSavedAccounts(): readonly SavedAccount[] { 26 return auth.savedAccounts 27 } 28 29 function isLoading(): boolean { 30 return auth.kind === 'loading' 31 } 32 33 const session = $derived(getSession()) 34 const savedAccounts = $derived(getSavedAccounts()) 35 const loading = $derived(isLoading()) 36 const isDidWeb = $derived(session?.did?.startsWith('did:web:') ?? false) 37 const otherAccounts = $derived(savedAccounts.filter(a => a.did !== session?.did)) 38 39 onMount(async () => { 40 try { 41 const serverInfo = await api.describeServer() 42 inviteCodesEnabled = serverInfo.inviteCodeRequired 43 } catch { 44 inviteCodesEnabled = false 45 } 46 }) 47 48 $effect(() => { 49 if (!loading && !session) { 50 navigate(routes.login) 51 } 52 }) 53 54 async function handleLogout() { 55 await logout() 56 navigate(routes.login) 57 } 58 59 async function handleSwitchAccount(did: Did) { 60 switching = true 61 dropdownOpen = false 62 const result = await switchAccount(did) 63 if (!isOk(result)) { 64 navigate(routes.login) 65 } 66 switching = false 67 } 68 69 function toggleDropdown() { 70 dropdownOpen = !dropdownOpen 71 } 72 73 function closeDropdown(e: MouseEvent) { 74 const target = e.target as HTMLElement 75 if (!target.closest('.account-dropdown')) { 76 dropdownOpen = false 77 } 78 } 79 80 $effect(() => { 81 if (dropdownOpen) { 82 document.addEventListener('click', closeDropdown) 83 } 84 return () => { 85 if (dropdownOpen) { 86 document.removeEventListener('click', closeDropdown) 87 } 88 } 89 }) 90</script> 91 92{#if session} 93 <div class="dashboard"> 94 <header> 95 <h1>{$_('dashboard.title')}</h1> 96 <div class="account-dropdown"> 97 <button class="account-trigger" onclick={toggleDropdown} disabled={switching}> 98 <span class="account-handle">@{session.handle}</span> 99 <span class="dropdown-arrow">{dropdownOpen ? '▲' : '▼'}</span> 100 </button> 101 {#if dropdownOpen} 102 <div class="dropdown-menu"> 103 {#if otherAccounts.length > 0} 104 <div class="dropdown-section"> 105 <span class="dropdown-label">{$_('dashboard.switchAccount')}</span> 106 {#each otherAccounts as account} 107 <button type="button" class="dropdown-item" onclick={() => handleSwitchAccount(account.did)}> 108 @{account.handle} 109 </button> 110 {/each} 111 </div> 112 <div class="dropdown-divider"></div> 113 {/if} 114 <button type="button" class="dropdown-item" onclick={() => { dropdownOpen = false; navigate(routes.login) }}> 115 {$_('dashboard.addAnotherAccount')} 116 </button> 117 <div class="dropdown-divider"></div> 118 <button type="button" class="dropdown-item logout-item" onclick={handleLogout}> 119 {$_('dashboard.signOut', { values: { handle: session.handle } })} 120 </button> 121 </div> 122 {/if} 123 </div> 124 </header> 125 126 {#if session.status === 'migrated'} 127 <div class="migrated-banner"> 128 <strong>{$_('dashboard.migratedTitle')}</strong> 129 <p>{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}</p> 130 </div> 131 {:else if session.status === 'deactivated' || session.active === false} 132 <div class="deactivated-banner"> 133 <strong>{$_('dashboard.deactivatedTitle')}</strong> 134 <p>{$_('dashboard.deactivatedMessage')}</p> 135 </div> 136 {/if} 137 138 <section class="account-overview"> 139 <h2>{$_('dashboard.accountOverview')}</h2> 140 <dl> 141 <dt>{$_('dashboard.handle')}</dt> 142 <dd> 143 @{session.handle} 144 {#if session.isAdmin} 145 <span class="badge admin">{$_('dashboard.admin')}</span> 146 {/if} 147 {#if session.status === 'migrated'} 148 <span class="badge migrated">{$_('dashboard.migrated')}</span> 149 {:else if session.status === 'deactivated' || session.active === false} 150 <span class="badge deactivated">{$_('dashboard.deactivated')}</span> 151 {/if} 152 </dd> 153 <dt>{$_('dashboard.did')}</dt> 154 <dd class="mono">{session.did}</dd> 155 {#if session.preferredChannel} 156 <dt>{$_('dashboard.primaryContact')}</dt> 157 <dd> 158 {#if session.preferredChannel === 'email'} 159 {session.email || $_('register.email')} 160 {:else if session.preferredChannel === 'discord'} 161 {$_('register.discord')} 162 {:else if session.preferredChannel === 'telegram'} 163 {$_('register.telegram')} 164 {:else if session.preferredChannel === 'signal'} 165 {$_('register.signal')} 166 {:else} 167 {session.preferredChannel} 168 {/if} 169 {#if session.preferredChannelVerified} 170 <span class="badge success">{$_('dashboard.verified')}</span> 171 {:else} 172 <span class="badge warning">{$_('dashboard.unverified')}</span> 173 {/if} 174 </dd> 175 {:else if session.email} 176 <dt>{$_('register.email')}</dt> 177 <dd> 178 {session.email} 179 {#if session.emailConfirmed} 180 <span class="badge success">{$_('dashboard.verified')}</span> 181 {:else} 182 <span class="badge warning">{$_('dashboard.unverified')}</span> 183 {/if} 184 </dd> 185 {/if} 186 </dl> 187 </section> 188 189 <nav class="nav-grid"> 190 {#if session.status === 'migrated'} 191 <a href={getFullUrl(routes.didDocument)} class="nav-card migrated-card"> 192 <h3>{$_('dashboard.navDidDocument')}</h3> 193 <p>{$_('dashboard.navDidDocumentDesc')}</p> 194 </a> 195 <a href={getFullUrl(routes.sessions)} class="nav-card"> 196 <h3>{$_('dashboard.navSessions')}</h3> 197 <p>{$_('dashboard.navSessionsDesc')}</p> 198 </a> 199 <a href={getFullUrl(routes.security)} class="nav-card"> 200 <h3>{$_('dashboard.navSecurity')}</h3> 201 <p>{$_('dashboard.navSecurityDesc')}</p> 202 </a> 203 <a href={getFullUrl(routes.settings)} class="nav-card"> 204 <h3>{$_('dashboard.navSettings')}</h3> 205 <p>{$_('dashboard.navSettingsDesc')}</p> 206 </a> 207 <a href={getFullUrl(routes.migrate)} class="nav-card"> 208 <h3>{$_('dashboard.navMigrateAgain')}</h3> 209 <p>{$_('dashboard.navMigrateAgainDesc')}</p> 210 </a> 211 {:else} 212 <a href={getFullUrl(routes.appPasswords)} class="nav-card"> 213 <h3>{$_('dashboard.navAppPasswords')}</h3> 214 <p>{$_('dashboard.navAppPasswordsDesc')}</p> 215 </a> 216 <a href={getFullUrl(routes.sessions)} class="nav-card"> 217 <h3>{$_('dashboard.navSessions')}</h3> 218 <p>{$_('dashboard.navSessionsDesc')}</p> 219 </a> 220 {#if inviteCodesEnabled && session.isAdmin} 221 <a href={getFullUrl(routes.inviteCodes)} class="nav-card"> 222 <h3>{$_('dashboard.navInviteCodes')}</h3> 223 <p>{$_('dashboard.navInviteCodesDesc')}</p> 224 </a> 225 {/if} 226 <a href={getFullUrl(routes.settings)} class="nav-card"> 227 <h3>{$_('dashboard.navSettings')}</h3> 228 <p>{$_('dashboard.navSettingsDesc')}</p> 229 </a> 230 <a href={getFullUrl(routes.security)} class="nav-card"> 231 <h3>{$_('dashboard.navSecurity')}</h3> 232 <p>{$_('dashboard.navSecurityDesc')}</p> 233 </a> 234 <a href={getFullUrl(routes.comms)} class="nav-card"> 235 <h3>{$_('dashboard.navComms')}</h3> 236 <p>{$_('dashboard.navCommsDesc')}</p> 237 </a> 238 <a href={getFullUrl(routes.repo)} class="nav-card"> 239 <h3>{$_('dashboard.navRepo')}</h3> 240 <p>{$_('dashboard.navRepoDesc')}</p> 241 </a> 242 <a href={getFullUrl(routes.controllers)} class="nav-card"> 243 <h3>{$_('dashboard.navDelegation')}</h3> 244 <p>{$_('dashboard.navDelegationDesc')}</p> 245 </a> 246 {#if isDidWeb} 247 <a href={getFullUrl(routes.didDocument)} class="nav-card did-web-card"> 248 <h3>{$_('dashboard.navDidDocument')}</h3> 249 <p>{$_('dashboard.navDidDocumentDescActive')}</p> 250 </a> 251 {/if} 252 <a href={getFullUrl(routes.migrate)} class="nav-card"> 253 <h3>{$_('migration.navTitle')}</h3> 254 <p>{$_('migration.navDesc')}</p> 255 </a> 256 {#if session.isAdmin} 257 <a href={getFullUrl(routes.admin)} class="nav-card admin-card"> 258 <h3>{$_('dashboard.navAdmin')}</h3> 259 <p>{$_('dashboard.navAdminDesc')}</p> 260 </a> 261 {/if} 262 {/if} 263 </nav> 264 </div> 265{:else if loading} 266 <div class="dashboard"> 267 <div class="skeleton-section"></div> 268 <nav class="nav-grid"> 269 {#each Array(6) as _} 270 <div class="skeleton-card"></div> 271 {/each} 272 </nav> 273 </div> 274{/if} 275 276<style> 277 .dashboard { 278 max-width: var(--width-xl); 279 margin: 0 auto; 280 padding: var(--space-7); 281 } 282 283 header { 284 display: flex; 285 justify-content: space-between; 286 align-items: center; 287 margin-bottom: var(--space-7); 288 } 289 290 header h1 { 291 margin: 0; 292 } 293 294 .account-dropdown { 295 position: relative; 296 } 297 298 .account-trigger { 299 display: flex; 300 align-items: center; 301 gap: var(--space-3); 302 padding: var(--space-3) var(--space-5); 303 background: transparent; 304 border: 1px solid var(--border-color); 305 border-radius: var(--radius-md); 306 cursor: pointer; 307 color: var(--text-primary); 308 } 309 310 .account-trigger:hover:not(:disabled) { 311 background: var(--bg-secondary); 312 } 313 314 .account-trigger:disabled { 315 opacity: 0.6; 316 cursor: not-allowed; 317 } 318 319 .account-trigger .account-handle { 320 font-weight: var(--font-medium); 321 } 322 323 .dropdown-arrow { 324 font-size: 0.625rem; 325 color: var(--text-secondary); 326 } 327 328 .dropdown-menu { 329 position: absolute; 330 top: 100%; 331 right: 0; 332 margin-top: var(--space-2); 333 min-width: 200px; 334 background: var(--bg-card); 335 border: 1px solid var(--border-color); 336 border-radius: var(--radius-xl); 337 box-shadow: var(--shadow-lg); 338 z-index: 100; 339 overflow: hidden; 340 } 341 342 .dropdown-section { 343 padding: var(--space-3) 0; 344 } 345 346 .dropdown-label { 347 display: block; 348 padding: var(--space-2) var(--space-5); 349 font-size: var(--text-xs); 350 color: var(--text-muted); 351 text-transform: uppercase; 352 letter-spacing: 0.05em; 353 } 354 355 .dropdown-item { 356 display: block; 357 width: 100%; 358 padding: var(--space-4) var(--space-5); 359 background: transparent; 360 border: none; 361 text-align: left; 362 cursor: pointer; 363 color: var(--text-primary); 364 font-size: var(--text-sm); 365 } 366 367 .dropdown-item:hover { 368 background: var(--bg-secondary); 369 } 370 371 .dropdown-item.logout-item { 372 color: var(--error-text); 373 } 374 375 .dropdown-divider { 376 height: 1px; 377 background: var(--border-color); 378 margin: 0; 379 } 380 381 section { 382 background: var(--bg-secondary); 383 padding: var(--space-6); 384 border-radius: var(--radius-xl); 385 margin-bottom: var(--space-7); 386 } 387 388 section h2 { 389 margin: 0 0 var(--space-4) 0; 390 font-size: var(--text-xl); 391 } 392 393 dl { 394 display: grid; 395 grid-template-columns: auto 1fr; 396 gap: var(--space-3) var(--space-5); 397 margin: 0; 398 } 399 400 dt { 401 font-weight: var(--font-medium); 402 color: var(--text-secondary); 403 } 404 405 dd { 406 margin: 0; 407 } 408 409 .mono { 410 font-family: ui-monospace, monospace; 411 font-size: var(--text-sm); 412 word-break: break-all; 413 } 414 415 .badge { 416 display: inline-block; 417 padding: var(--space-1) var(--space-3); 418 border-radius: var(--radius-md); 419 font-size: var(--text-xs); 420 margin-left: var(--space-3); 421 } 422 423 .badge.success { 424 background: var(--success-bg); 425 color: var(--success-text); 426 } 427 428 .badge.warning { 429 background: var(--warning-bg); 430 color: var(--warning-text); 431 } 432 433 .badge.admin { 434 background: var(--accent); 435 color: var(--text-inverse); 436 } 437 438 .badge.deactivated { 439 background: var(--warning-bg); 440 color: var(--warning-text); 441 border: 1px solid var(--warning-border); 442 } 443 444 .badge.migrated { 445 background: var(--info-bg, #e0f2fe); 446 color: var(--info-text, #0369a1); 447 border: 1px solid var(--info-border, #7dd3fc); 448 } 449 450 .nav-grid { 451 display: grid; 452 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 453 gap: var(--space-4); 454 } 455 456 .nav-card { 457 display: block; 458 padding: var(--space-6); 459 background: var(--bg-card); 460 border: 1px solid var(--border-color); 461 border-radius: var(--radius-xl); 462 text-decoration: none; 463 color: inherit; 464 transition: border-color var(--transition-normal), box-shadow var(--transition-normal); 465 } 466 467 .nav-card:hover { 468 border-color: var(--accent); 469 box-shadow: 0 2px 8px var(--accent-muted); 470 } 471 472 .nav-card h3 { 473 margin: 0 0 var(--space-3) 0; 474 color: var(--accent); 475 } 476 477 .nav-card p { 478 margin: 0; 479 color: var(--text-secondary); 480 font-size: var(--text-sm); 481 } 482 483 .nav-card.admin-card { 484 border-color: var(--accent); 485 background: linear-gradient(135deg, var(--bg-card) 0%, var(--accent-muted) 100%); 486 } 487 488 .nav-card.admin-card:hover { 489 box-shadow: 0 2px 12px var(--accent-muted); 490 } 491 492 .skeleton-section { 493 height: 140px; 494 background: var(--bg-secondary); 495 border-radius: var(--radius-xl); 496 margin-bottom: var(--space-7); 497 animation: skeleton-pulse 1.5s ease-in-out infinite; 498 } 499 500 .skeleton-card { 501 height: 100px; 502 background: var(--bg-tertiary); 503 border: 1px solid var(--border-color); 504 border-radius: var(--radius-xl); 505 animation: skeleton-pulse 1.5s ease-in-out infinite; 506 } 507 508 @keyframes skeleton-pulse { 509 0%, 100% { opacity: 1; } 510 50% { opacity: 0.5; } 511 } 512 513 .deactivated-banner { 514 background: var(--warning-bg); 515 border: 1px solid var(--warning-border); 516 border-radius: var(--radius-xl); 517 padding: var(--space-5) var(--space-6); 518 margin-bottom: var(--space-7); 519 } 520 521 .deactivated-banner strong { 522 color: var(--warning-text); 523 font-size: var(--text-base); 524 } 525 526 .deactivated-banner p { 527 margin: var(--space-3) 0 0 0; 528 color: var(--warning-text); 529 font-size: var(--text-sm); 530 } 531 532 .migrated-banner { 533 background: var(--info-bg, #e0f2fe); 534 border: 1px solid var(--info-border, #7dd3fc); 535 border-radius: var(--radius-xl); 536 padding: var(--space-5) var(--space-6); 537 margin-bottom: var(--space-7); 538 } 539 540 .migrated-banner strong { 541 color: var(--info-text, #0369a1); 542 font-size: var(--text-base); 543 } 544 545 .migrated-banner p { 546 margin: var(--space-3) 0 0 0; 547 color: var(--info-text, #0369a1); 548 font-size: var(--text-sm); 549 } 550 551 .nav-card.migrated-card { 552 border-color: var(--info-border, #7dd3fc); 553 background: linear-gradient(135deg, var(--bg-card) 0%, var(--info-bg, #e0f2fe) 100%); 554 } 555 556 .nav-card.migrated-card:hover { 557 box-shadow: 0 2px 12px var(--info-bg, #e0f2fe); 558 } 559 560 .nav-card.migrated-card h3 { 561 color: var(--info-text, #0369a1); 562 } 563 564 .nav-card.did-web-card { 565 border-color: var(--accent); 566 background: linear-gradient(135deg, var(--bg-card) 0%, var(--accent-muted) 100%); 567 } 568 569 .nav-card.did-web-card:hover { 570 box-shadow: 0 2px 12px var(--accent-muted); 571 } 572</style>