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>