forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
1<script lang="ts">
2 import { getAuthState } from '../lib/auth.svelte'
3 import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4 import { _ } from '../lib/i18n'
5 import { formatDateTime } from '../lib/date'
6 import type { Session } from '../lib/types/api'
7 import { toast } from '../lib/toast.svelte'
8
9 interface Controller {
10 did: string
11 handle: string
12 grantedScopes: string
13 grantedAt: string
14 isActive: boolean
15 }
16
17 interface ControlledAccount {
18 did: string
19 handle: string
20 grantedScopes: string
21 grantedAt: string
22 }
23
24 interface ScopePreset {
25 name: string
26 label: string
27 description: string
28 scopes: string
29 }
30
31 const auth = $derived(getAuthState())
32
33 function getSession(): Session | null {
34 return auth.kind === 'authenticated' ? auth.session : null
35 }
36
37 function isLoading(): boolean {
38 return auth.kind === 'loading'
39 }
40
41 const session = $derived(getSession())
42 const authLoading = $derived(isLoading())
43
44 let loading = $state(true)
45 let controllers = $state<Controller[]>([])
46 let controlledAccounts = $state<ControlledAccount[]>([])
47 let scopePresets = $state<ScopePreset[]>([])
48
49 let hasControllers = $derived(controllers.length > 0)
50 let controlsAccounts = $derived(controlledAccounts.length > 0)
51 let canAddControllers = $derived(!controlsAccounts)
52 let canControlAccounts = $derived(!hasControllers)
53
54 let showAddController = $state(false)
55 let addControllerDid = $state('')
56 let addControllerScopes = $state('atproto')
57 let addingController = $state(false)
58 let addControllerConfirmed = $state(false)
59
60 let showCreateDelegated = $state(false)
61 let newDelegatedHandle = $state('')
62 let newDelegatedEmail = $state('')
63 let newDelegatedScopes = $state('atproto')
64 let creatingDelegated = $state(false)
65
66 $effect(() => {
67 if (!authLoading && !session) {
68 navigate(routes.login)
69 }
70 })
71
72 $effect(() => {
73 if (session) {
74 loadData()
75 }
76 })
77
78 async function loadData() {
79 loading = true
80 try {
81 await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()])
82 } finally {
83 loading = false
84 }
85 }
86
87 async function loadControllers() {
88 if (!session) return
89 try {
90 const response = await fetch('/xrpc/_delegation.listControllers', {
91 headers: { 'Authorization': `Bearer ${session.accessJwt}` }
92 })
93 if (response.ok) {
94 const data = await response.json()
95 controllers = data.controllers || []
96 }
97 } catch (e) {
98 console.error('Failed to load controllers:', e)
99 }
100 }
101
102 async function loadControlledAccounts() {
103 if (!session) return
104 try {
105 const response = await fetch('/xrpc/_delegation.listControlledAccounts', {
106 headers: { 'Authorization': `Bearer ${session.accessJwt}` }
107 })
108 if (response.ok) {
109 const data = await response.json()
110 controlledAccounts = data.accounts || []
111 }
112 } catch (e) {
113 console.error('Failed to load controlled accounts:', e)
114 }
115 }
116
117 async function loadScopePresets() {
118 try {
119 const response = await fetch('/xrpc/_delegation.getScopePresets')
120 if (response.ok) {
121 const data = await response.json()
122 scopePresets = data.presets || []
123 }
124 } catch (e) {
125 console.error('Failed to load scope presets:', e)
126 }
127 }
128
129 async function addController() {
130 if (!session || !addControllerDid.trim()) return
131 addingController = true
132
133 try {
134 const response = await fetch('/xrpc/_delegation.addController', {
135 method: 'POST',
136 headers: {
137 'Authorization': `Bearer ${session.accessJwt}`,
138 'Content-Type': 'application/json'
139 },
140 body: JSON.stringify({
141 controller_did: addControllerDid.trim(),
142 granted_scopes: addControllerScopes
143 })
144 })
145
146 if (!response.ok) {
147 const data = await response.json()
148 toast.error(data.message || data.error || $_('delegation.failedToAddController'))
149 return
150 }
151
152 toast.success($_('delegation.controllerAdded'))
153 addControllerDid = ''
154 addControllerScopes = 'atproto'
155 addControllerConfirmed = false
156 showAddController = false
157 await loadControllers()
158 } catch (e) {
159 toast.error($_('delegation.failedToAddController'))
160 } finally {
161 addingController = false
162 }
163 }
164
165 async function removeController(controllerDid: string) {
166 if (!session) return
167 if (!confirm($_('delegation.removeConfirm'))) return
168
169 try {
170 const response = await fetch('/xrpc/_delegation.removeController', {
171 method: 'POST',
172 headers: {
173 'Authorization': `Bearer ${session.accessJwt}`,
174 'Content-Type': 'application/json'
175 },
176 body: JSON.stringify({ controller_did: controllerDid })
177 })
178
179 if (!response.ok) {
180 const data = await response.json()
181 toast.error(data.message || data.error || $_('delegation.failedToRemoveController'))
182 return
183 }
184
185 toast.success($_('delegation.controllerRemoved'))
186 await loadControllers()
187 } catch (e) {
188 toast.error($_('delegation.failedToRemoveController'))
189 }
190 }
191
192 async function createDelegatedAccount() {
193 if (!session || !newDelegatedHandle.trim()) return
194 creatingDelegated = true
195
196 try {
197 const response = await fetch('/xrpc/_delegation.createDelegatedAccount', {
198 method: 'POST',
199 headers: {
200 'Authorization': `Bearer ${session.accessJwt}`,
201 'Content-Type': 'application/json'
202 },
203 body: JSON.stringify({
204 handle: newDelegatedHandle.trim(),
205 email: newDelegatedEmail.trim() || undefined,
206 controllerScopes: newDelegatedScopes
207 })
208 })
209
210 if (!response.ok) {
211 const data = await response.json()
212 toast.error(data.message || data.error || $_('delegation.failedToCreateAccount'))
213 return
214 }
215
216 const data = await response.json()
217 toast.success($_('delegation.accountCreated', { values: { handle: data.handle } }))
218 newDelegatedHandle = ''
219 newDelegatedEmail = ''
220 newDelegatedScopes = 'atproto'
221 showCreateDelegated = false
222 await loadControlledAccounts()
223 } catch (e) {
224 toast.error($_('delegation.failedToCreateAccount'))
225 } finally {
226 creatingDelegated = false
227 }
228 }
229
230 function getScopeLabel(scopes: string): string {
231 const preset = scopePresets.find(p => p.scopes === scopes)
232 if (preset) return preset.label
233 if (scopes === 'atproto') return $_('delegation.scopeOwner')
234 if (scopes === '') return $_('delegation.scopeViewer')
235 return $_('delegation.scopeCustom')
236 }
237</script>
238
239<div class="page">
240 <header>
241 <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
242 <h1>{$_('delegation.title')}</h1>
243 </header>
244
245 {#if loading}
246 <div class="skeleton-list">
247 {#each Array(2) as _}
248 <div class="skeleton-card"></div>
249 {/each}
250 </div>
251 {:else}
252 <section class="section">
253 <div class="section-header">
254 <h2>{$_('delegation.controllers')}</h2>
255 <p class="section-description">{$_('delegation.controllersDesc')}</p>
256 </div>
257
258 {#if controllers.length === 0}
259 <p class="empty">{$_('delegation.noControllers')}</p>
260 {:else}
261 <div class="items-list">
262 {#each controllers as controller}
263 <div class="item-card" class:inactive={!controller.isActive}>
264 <div class="item-info">
265 <div class="item-header">
266 <span class="item-handle">@{controller.handle}</span>
267 <span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span>
268 {#if !controller.isActive}
269 <span class="badge inactive">{$_('delegation.inactive')}</span>
270 {/if}
271 </div>
272 <div class="item-details">
273 <div class="detail">
274 <span class="label">{$_('delegation.did')}</span>
275 <span class="value did">{controller.did}</span>
276 </div>
277 <div class="detail">
278 <span class="label">{$_('delegation.granted')}</span>
279 <span class="value">{formatDateTime(controller.grantedAt)}</span>
280 </div>
281 </div>
282 </div>
283 <div class="item-actions">
284 <button class="danger-outline" onclick={() => removeController(controller.did)}>
285 {$_('delegation.remove')}
286 </button>
287 </div>
288 </div>
289 {/each}
290 </div>
291 {/if}
292
293 {#if !canAddControllers}
294 <div class="constraint-notice">
295 <p>{$_('delegation.cannotAddControllers')}</p>
296 </div>
297 {:else if showAddController}
298 <div class="form-card">
299 <h3>{$_('delegation.addController')}</h3>
300
301 <div class="warning-box">
302 <div class="warning-header">
303 <svg class="warning-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
304 <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
305 <line x1="12" y1="9" x2="12" y2="13"></line>
306 <line x1="12" y1="17" x2="12.01" y2="17"></line>
307 </svg>
308 <span>{$_('delegation.addControllerWarningTitle')}</span>
309 </div>
310 <p class="warning-text">{$_('delegation.addControllerWarningText')}</p>
311 <ul class="warning-bullets">
312 <li>{$_('delegation.addControllerWarningBullet1')}</li>
313 <li>{$_('delegation.addControllerWarningBullet2')}</li>
314 <li>{$_('delegation.addControllerWarningBullet3')}</li>
315 </ul>
316 </div>
317
318 <div class="field">
319 <label for="controllerDid">{$_('delegation.controllerDid')}</label>
320 <input
321 id="controllerDid"
322 type="text"
323 bind:value={addControllerDid}
324 placeholder="did:plc:..."
325 disabled={addingController}
326 />
327 </div>
328 <div class="field">
329 <label for="controllerScopes">{$_('delegation.accessLevel')}</label>
330 <select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}>
331 {#each scopePresets as preset}
332 <option value={preset.scopes}>{preset.label} - {preset.description}</option>
333 {/each}
334 </select>
335 </div>
336 <label class="confirm-checkbox">
337 <input type="checkbox" bind:checked={addControllerConfirmed} disabled={addingController} />
338 <span>{$_('delegation.addControllerConfirm')}</span>
339 </label>
340 <div class="form-actions">
341 <button class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}>
342 {$_('common.cancel')}
343 </button>
344 <button onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}>
345 {addingController ? $_('delegation.adding') : $_('delegation.addController')}
346 </button>
347 </div>
348 </div>
349 {:else}
350 <button class="ghost full-width" onclick={() => showAddController = true}>
351 {$_('delegation.addControllerButton')}
352 </button>
353 {/if}
354 </section>
355
356 <section class="section">
357 <div class="section-header">
358 <h2>{$_('delegation.controlledAccounts')}</h2>
359 <p class="section-description">{$_('delegation.controlledAccountsDesc')}</p>
360 </div>
361
362 {#if controlledAccounts.length === 0}
363 <p class="empty">{$_('delegation.noControlledAccounts')}</p>
364 {:else}
365 <div class="items-list">
366 {#each controlledAccounts as account}
367 <div class="item-card">
368 <div class="item-info">
369 <div class="item-header">
370 <span class="item-handle">@{account.handle}</span>
371 <span class="badge scope">{getScopeLabel(account.grantedScopes)}</span>
372 </div>
373 <div class="item-details">
374 <div class="detail">
375 <span class="label">{$_('delegation.did')}</span>
376 <span class="value did">{account.did}</span>
377 </div>
378 <div class="detail">
379 <span class="label">{$_('delegation.granted')}</span>
380 <span class="value">{formatDateTime(account.grantedAt)}</span>
381 </div>
382 </div>
383 </div>
384 <div class="item-actions">
385 <a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
386 {$_('delegation.actAs')}
387 </a>
388 </div>
389 </div>
390 {/each}
391 </div>
392 {/if}
393
394 {#if !canControlAccounts}
395 <div class="constraint-notice">
396 <p>{$_('delegation.cannotControlAccounts')}</p>
397 </div>
398 {:else if showCreateDelegated}
399 <div class="form-card">
400 <h3>{$_('delegation.createDelegatedAccount')}</h3>
401 <div class="field">
402 <label for="delegatedHandle">{$_('delegation.handle')}</label>
403 <input
404 id="delegatedHandle"
405 type="text"
406 bind:value={newDelegatedHandle}
407 placeholder="username"
408 disabled={creatingDelegated}
409 />
410 </div>
411 <div class="field">
412 <label for="delegatedEmail">{$_('delegation.emailOptional')}</label>
413 <input
414 id="delegatedEmail"
415 type="email"
416 bind:value={newDelegatedEmail}
417 placeholder="email@example.com"
418 disabled={creatingDelegated}
419 />
420 </div>
421 <div class="field">
422 <label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label>
423 <select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}>
424 {#each scopePresets as preset}
425 <option value={preset.scopes}>{preset.label} - {preset.description}</option>
426 {/each}
427 </select>
428 </div>
429 <div class="form-actions">
430 <button class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}>
431 {$_('common.cancel')}
432 </button>
433 <button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
434 {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')}
435 </button>
436 </div>
437 </div>
438 {:else}
439 <button class="ghost full-width" onclick={() => showCreateDelegated = true}>
440 {$_('delegation.createDelegatedAccountButton')}
441 </button>
442 {/if}
443 </section>
444
445 <section class="section">
446 <div class="section-header">
447 <h2>{$_('delegation.auditLog')}</h2>
448 <p class="section-description">{$_('delegation.auditLogDesc')}</p>
449 </div>
450 <a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a>
451 </section>
452 {/if}
453</div>
454
455<style>
456 .page {
457 max-width: var(--width-lg);
458 margin: 0 auto;
459 padding: var(--space-7);
460 }
461
462 header {
463 margin-bottom: var(--space-7);
464 }
465
466 .back {
467 color: var(--text-secondary);
468 text-decoration: none;
469 font-size: var(--text-sm);
470 }
471
472 .back:hover {
473 color: var(--accent);
474 }
475
476 h1 {
477 margin: var(--space-2) 0 0 0;
478 }
479
480 .empty {
481 text-align: center;
482 color: var(--text-secondary);
483 padding: var(--space-4);
484 }
485
486 .constraint-notice {
487 background: var(--bg-tertiary);
488 border: 1px solid var(--border-color);
489 border-radius: var(--radius-md);
490 padding: var(--space-4);
491 }
492
493 .constraint-notice p {
494 margin: 0;
495 color: var(--text-secondary);
496 font-size: var(--text-sm);
497 }
498
499 .section {
500 margin-bottom: var(--space-8);
501 }
502
503 .section-header {
504 margin-bottom: var(--space-4);
505 }
506
507 .section-header h2 {
508 margin: 0 0 var(--space-1) 0;
509 font-size: var(--text-lg);
510 }
511
512 .section-description {
513 color: var(--text-secondary);
514 margin: 0;
515 font-size: var(--text-sm);
516 }
517
518 .items-list {
519 display: flex;
520 flex-direction: column;
521 gap: var(--space-4);
522 margin-bottom: var(--space-4);
523 }
524
525 .item-card {
526 background: var(--bg-secondary);
527 border: 1px solid var(--border-color);
528 border-radius: var(--radius-xl);
529 padding: var(--space-4);
530 display: flex;
531 justify-content: space-between;
532 align-items: center;
533 gap: var(--space-4);
534 flex-wrap: wrap;
535 }
536
537 .item-card.inactive {
538 opacity: 0.6;
539 }
540
541 .item-info {
542 flex: 1;
543 min-width: 200px;
544 }
545
546 .item-header {
547 margin-bottom: var(--space-2);
548 display: flex;
549 align-items: center;
550 gap: var(--space-2);
551 flex-wrap: wrap;
552 }
553
554 .item-handle {
555 font-weight: var(--font-semibold);
556 color: var(--text-primary);
557 }
558
559 .badge {
560 display: inline-block;
561 padding: var(--space-1) var(--space-2);
562 border-radius: var(--radius-md);
563 font-size: var(--text-xs);
564 font-weight: var(--font-medium);
565 }
566
567 .badge.scope {
568 background: var(--accent);
569 color: var(--text-inverse);
570 }
571
572 .badge.inactive {
573 background: var(--error-bg);
574 color: var(--error-text);
575 border: 1px solid var(--error-border);
576 }
577
578 .item-details {
579 display: flex;
580 flex-direction: column;
581 gap: var(--space-1);
582 }
583
584 .detail {
585 font-size: var(--text-sm);
586 }
587
588 .detail .label {
589 color: var(--text-secondary);
590 margin-right: var(--space-2);
591 }
592
593 .detail .value {
594 color: var(--text-primary);
595 }
596
597 .detail .value.did {
598 font-family: var(--font-mono);
599 font-size: var(--text-xs);
600 word-break: break-all;
601 }
602
603 .item-actions {
604 display: flex;
605 gap: var(--space-2);
606 }
607
608 .item-actions button {
609 padding: var(--space-2) var(--space-4);
610 font-size: var(--text-sm);
611 }
612
613 .btn-link {
614 display: inline-block;
615 padding: var(--space-2) var(--space-4);
616 border: 1px solid var(--accent);
617 border-radius: var(--radius-md);
618 background: transparent;
619 color: var(--accent);
620 font-size: var(--text-sm);
621 font-weight: var(--font-medium);
622 text-decoration: none;
623 transition: background var(--transition-normal), color var(--transition-normal);
624 }
625
626 .btn-link:hover {
627 background: var(--accent);
628 color: var(--text-inverse);
629 }
630
631 .full-width {
632 width: 100%;
633 }
634
635 .form-card {
636 background: var(--bg-secondary);
637 border: 1px solid var(--border-color);
638 border-radius: var(--radius-xl);
639 padding: var(--space-5);
640 margin-top: var(--space-4);
641 }
642
643 .form-card h3 {
644 margin: 0 0 var(--space-4) 0;
645 }
646
647 .warning-box {
648 background: var(--warning-bg, #fef3c7);
649 border: 1px solid var(--warning-border, #f59e0b);
650 border-radius: var(--radius-md);
651 padding: var(--space-4);
652 margin-bottom: var(--space-5);
653 }
654
655 .warning-header {
656 display: flex;
657 align-items: center;
658 gap: var(--space-2);
659 font-weight: var(--font-semibold);
660 color: var(--warning-text, #92400e);
661 margin-bottom: var(--space-2);
662 }
663
664 .warning-icon {
665 width: 20px;
666 height: 20px;
667 flex-shrink: 0;
668 stroke: var(--warning-text, #92400e);
669 }
670
671 .warning-text {
672 margin: 0 0 var(--space-3) 0;
673 color: var(--warning-text, #92400e);
674 font-size: var(--text-sm);
675 line-height: 1.5;
676 }
677
678 .warning-bullets {
679 margin: 0;
680 padding-left: var(--space-5);
681 color: var(--warning-text, #92400e);
682 font-size: var(--text-sm);
683 line-height: 1.6;
684 }
685
686 .warning-bullets li {
687 margin-bottom: var(--space-1);
688 }
689
690 .warning-bullets li:last-child {
691 margin-bottom: 0;
692 }
693
694 .confirm-checkbox {
695 display: flex;
696 align-items: flex-start;
697 gap: var(--space-2);
698 cursor: pointer;
699 padding: var(--space-3);
700 background: var(--bg-tertiary);
701 border: 1px solid var(--border-color);
702 border-radius: var(--radius-md);
703 margin-bottom: var(--space-4);
704 }
705
706 .confirm-checkbox input {
707 width: 18px;
708 height: 18px;
709 flex-shrink: 0;
710 margin-top: 2px;
711 }
712
713 .confirm-checkbox span {
714 font-size: var(--text-sm);
715 font-weight: var(--font-medium);
716 color: var(--text-primary);
717 line-height: 1.4;
718 }
719
720 .field {
721 margin-bottom: var(--space-4);
722 }
723
724 .field label {
725 display: block;
726 font-size: var(--text-sm);
727 font-weight: var(--font-medium);
728 margin-bottom: var(--space-1);
729 }
730
731 .field input,
732 .field select {
733 width: 100%;
734 padding: var(--space-3);
735 border: 1px solid var(--border-color);
736 border-radius: var(--radius-md);
737 font-size: var(--text-base);
738 background: var(--bg-input);
739 color: var(--text-primary);
740 }
741
742 .field input:focus,
743 .field select:focus {
744 outline: none;
745 border-color: var(--accent);
746 }
747
748 .form-actions {
749 display: flex;
750 gap: var(--space-3);
751 justify-content: flex-end;
752 }
753
754 .form-actions button {
755 padding: var(--space-2) var(--space-4);
756 font-size: var(--text-sm);
757 }
758
759 .skeleton-list {
760 display: flex;
761 flex-direction: column;
762 gap: var(--space-4);
763 }
764
765 .skeleton-card {
766 height: 120px;
767 background: var(--bg-secondary);
768 border: 1px solid var(--border-color);
769 border-radius: var(--radius-xl);
770 animation: skeleton-pulse 1.5s ease-in-out infinite;
771 }
772
773 @keyframes skeleton-pulse {
774 0%, 100% { opacity: 1; }
775 50% { opacity: 0.5; }
776 }
777</style>