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 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>