this repo has no description
at main 16 kB view raw
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 gap: var(--space-4); 289 } 290 291 @media (max-width: 500px) { 292 header { 293 flex-direction: column-reverse; 294 align-items: flex-start; 295 } 296 } 297 298 header h1 { 299 margin: 0; 300 min-width: 0; 301 } 302 303 .account-dropdown { 304 position: relative; 305 max-width: 100%; 306 } 307 308 .account-trigger { 309 display: flex; 310 align-items: center; 311 gap: var(--space-3); 312 padding: var(--space-3) var(--space-5); 313 background: transparent; 314 border: 1px solid var(--border-color); 315 border-radius: var(--radius-md); 316 cursor: pointer; 317 color: var(--text-primary); 318 max-width: 100%; 319 } 320 321 .account-trigger .account-handle { 322 font-weight: var(--font-medium); 323 overflow: hidden; 324 text-overflow: ellipsis; 325 white-space: nowrap; 326 } 327 328 .account-trigger:hover:not(:disabled) { 329 background: var(--bg-secondary); 330 } 331 332 .account-trigger:disabled { 333 opacity: 0.6; 334 cursor: not-allowed; 335 } 336 337 .dropdown-arrow { 338 font-size: 0.625rem; 339 color: var(--text-secondary); 340 } 341 342 .dropdown-menu { 343 position: absolute; 344 top: 100%; 345 right: 0; 346 margin-top: var(--space-2); 347 min-width: 200px; 348 background: var(--bg-card); 349 border: 1px solid var(--border-color); 350 border-radius: var(--radius-xl); 351 box-shadow: var(--shadow-lg); 352 z-index: 100; 353 overflow: hidden; 354 } 355 356 .dropdown-section { 357 padding: var(--space-3) 0; 358 } 359 360 .dropdown-label { 361 display: block; 362 padding: var(--space-2) var(--space-5); 363 font-size: var(--text-xs); 364 color: var(--text-muted); 365 text-transform: uppercase; 366 letter-spacing: 0.05em; 367 } 368 369 .dropdown-item { 370 display: block; 371 width: 100%; 372 padding: var(--space-4) var(--space-5); 373 background: transparent; 374 border: none; 375 text-align: left; 376 cursor: pointer; 377 color: var(--text-primary); 378 font-size: var(--text-sm); 379 } 380 381 .dropdown-item:hover { 382 background: var(--bg-secondary); 383 } 384 385 .dropdown-item.logout-item { 386 color: var(--error-text); 387 } 388 389 .dropdown-divider { 390 height: 1px; 391 background: var(--border-color); 392 margin: 0; 393 } 394 395 section { 396 background: var(--bg-secondary); 397 padding: var(--space-6); 398 border-radius: var(--radius-xl); 399 margin-bottom: var(--space-7); 400 overflow: hidden; 401 min-width: 0; 402 } 403 404 section h2 { 405 margin: 0 0 var(--space-4) 0; 406 font-size: var(--text-xl); 407 } 408 409 dl { 410 display: grid; 411 grid-template-columns: auto 1fr; 412 gap: var(--space-3) var(--space-5); 413 margin: 0; 414 } 415 416 dt { 417 font-weight: var(--font-medium); 418 color: var(--text-secondary); 419 max-width: 6rem; 420 } 421 422 dd { 423 margin: 0; 424 min-width: 0; 425 } 426 427 .mono { 428 font-family: ui-monospace, monospace; 429 font-size: var(--text-sm); 430 word-break: break-all; 431 } 432 433 .badge { 434 display: inline-block; 435 padding: var(--space-1) var(--space-3); 436 border-radius: var(--radius-md); 437 font-size: var(--text-xs); 438 margin-left: var(--space-3); 439 } 440 441 .badge.success { 442 background: var(--success-bg); 443 color: var(--success-text); 444 } 445 446 .badge.warning { 447 background: var(--warning-bg); 448 color: var(--warning-text); 449 } 450 451 .badge.admin { 452 background: var(--accent); 453 color: var(--text-inverse); 454 } 455 456 .badge.deactivated { 457 background: var(--warning-bg); 458 color: var(--warning-text); 459 border: 1px solid var(--warning-border); 460 } 461 462 .badge.migrated { 463 background: var(--info-bg, #e0f2fe); 464 color: var(--info-text, #0369a1); 465 border: 1px solid var(--info-border, #7dd3fc); 466 } 467 468 .nav-grid { 469 display: grid; 470 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 471 gap: var(--space-4); 472 } 473 474 .nav-card { 475 display: block; 476 padding: var(--space-6); 477 background: var(--bg-card); 478 border: 1px solid var(--border-color); 479 border-radius: var(--radius-xl); 480 text-decoration: none; 481 color: inherit; 482 transition: border-color var(--transition-normal), box-shadow var(--transition-normal); 483 } 484 485 .nav-card:hover { 486 border-color: var(--accent); 487 box-shadow: 0 2px 8px var(--accent-muted); 488 } 489 490 .nav-card h3 { 491 margin: 0 0 var(--space-3) 0; 492 color: var(--accent); 493 } 494 495 .nav-card p { 496 margin: 0; 497 color: var(--text-secondary); 498 font-size: var(--text-sm); 499 } 500 501 .nav-card.admin-card { 502 border-color: var(--accent); 503 background: linear-gradient(135deg, var(--bg-card) 0%, var(--accent-muted) 100%); 504 } 505 506 .nav-card.admin-card:hover { 507 box-shadow: 0 2px 12px var(--accent-muted); 508 } 509 510 .skeleton-section { 511 height: 140px; 512 background: var(--bg-secondary); 513 border-radius: var(--radius-xl); 514 margin-bottom: var(--space-7); 515 animation: skeleton-pulse 1.5s ease-in-out infinite; 516 } 517 518 .skeleton-card { 519 height: 100px; 520 background: var(--bg-tertiary); 521 border: 1px solid var(--border-color); 522 border-radius: var(--radius-xl); 523 animation: skeleton-pulse 1.5s ease-in-out infinite; 524 } 525 526 @keyframes skeleton-pulse { 527 0%, 100% { opacity: 1; } 528 50% { opacity: 0.5; } 529 } 530 531 .deactivated-banner { 532 background: var(--warning-bg); 533 border: 1px solid var(--warning-border); 534 border-radius: var(--radius-xl); 535 padding: var(--space-5) var(--space-6); 536 margin-bottom: var(--space-7); 537 } 538 539 .deactivated-banner strong { 540 color: var(--warning-text); 541 font-size: var(--text-base); 542 } 543 544 .deactivated-banner p { 545 margin: var(--space-3) 0 0 0; 546 color: var(--warning-text); 547 font-size: var(--text-sm); 548 } 549 550 .migrated-banner { 551 background: var(--info-bg, #e0f2fe); 552 border: 1px solid var(--info-border, #7dd3fc); 553 border-radius: var(--radius-xl); 554 padding: var(--space-5) var(--space-6); 555 margin-bottom: var(--space-7); 556 } 557 558 .migrated-banner strong { 559 color: var(--info-text, #0369a1); 560 font-size: var(--text-base); 561 } 562 563 .migrated-banner p { 564 margin: var(--space-3) 0 0 0; 565 color: var(--info-text, #0369a1); 566 font-size: var(--text-sm); 567 } 568 569 .nav-card.migrated-card { 570 border-color: var(--info-border, #7dd3fc); 571 background: linear-gradient(135deg, var(--bg-card) 0%, var(--info-bg, #e0f2fe) 100%); 572 } 573 574 .nav-card.migrated-card:hover { 575 box-shadow: 0 2px 12px var(--info-bg, #e0f2fe); 576 } 577 578 .nav-card.migrated-card h3 { 579 color: var(--info-text, #0369a1); 580 } 581 582 .nav-card.did-web-card { 583 border-color: var(--accent); 584 background: linear-gradient(135deg, var(--bg-card) 0%, var(--accent-muted) 100%); 585 } 586 587 .nav-card.did-web-card:hover { 588 box-shadow: 0 2px 12px var(--accent-muted); 589 } 590</style>