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>