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