Our Personal Data Server from scratch!
tranquil.farm
oauth
atproto
pds
rust
postgresql
objectstorage
fun
1<script lang="ts">
2 import { onMount } from 'svelte'
3 import { _ } from '../../lib/i18n'
4 import { api } from '../../lib/api'
5 import { toast } from '../../lib/toast.svelte'
6 import { formatDateTime } from '../../lib/date'
7 import { routes, getFullUrl } from '../../lib/router.svelte'
8 import type { Session, DelegationController, DelegationControlledAccount, DelegationScopePreset } from '../../lib/types/api'
9 import { unsafeAsDid, unsafeAsScopeSet, unsafeAsHandle, unsafeAsEmail } from '../../lib/types/branded'
10 import type { Did, Handle, ScopeSet } from '../../lib/types/branded'
11
12 interface Props {
13 session: Session
14 }
15
16 let { session }: Props = $props()
17
18 interface Controller {
19 did: Did
20 handle: Handle
21 grantedScopes: ScopeSet
22 grantedAt: string
23 isActive: boolean
24 }
25
26 interface ControlledAccount {
27 did: Did
28 handle: Handle
29 grantedScopes: ScopeSet
30 grantedAt: string
31 }
32
33 interface ScopePreset {
34 name: string
35 label: string
36 description: string
37 scopes: ScopeSet
38 }
39
40 let loading = $state(true)
41 let controllers = $state<Controller[]>([])
42 let controlledAccounts = $state<ControlledAccount[]>([])
43 let scopePresets = $state<ScopePreset[]>([])
44
45 let hasControllers = $derived(controllers.length > 0)
46 let controlsAccounts = $derived(controlledAccounts.length > 0)
47 let canAddControllers = $derived(!controlsAccounts)
48 let canControlAccounts = $derived(!hasControllers)
49
50 let showAddController = $state(false)
51 let addControllerDid = $state('')
52 let addControllerScopes = $state('atproto')
53 let addingController = $state(false)
54 let addControllerConfirmed = $state(false)
55
56 let showCreateDelegated = $state(false)
57 let newDelegatedHandle = $state('')
58 let newDelegatedEmail = $state('')
59 let newDelegatedScopes = $state('atproto')
60 let creatingDelegated = $state(false)
61
62 onMount(async () => {
63 await loadData()
64 })
65
66 async function loadData() {
67 loading = true
68 await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()])
69 loading = false
70 }
71
72 async function loadControllers() {
73 const result = await api.listDelegationControllers(session.accessJwt)
74 if (result.ok) {
75 controllers = (result.value.controllers ?? []).map((c: DelegationController) => ({
76 did: c.did,
77 handle: c.handle,
78 grantedScopes: c.grantedScopes,
79 grantedAt: c.grantedAt,
80 isActive: c.isActive
81 }))
82 }
83 }
84
85 async function loadControlledAccounts() {
86 const result = await api.listDelegationControlledAccounts(session.accessJwt)
87 if (result.ok) {
88 controlledAccounts = (result.value.accounts ?? []).map((a: DelegationControlledAccount) => ({
89 did: a.did,
90 handle: a.handle,
91 grantedScopes: a.grantedScopes,
92 grantedAt: a.grantedAt
93 }))
94 }
95 }
96
97 async function loadScopePresets() {
98 const result = await api.getDelegationScopePresets()
99 if (result.ok) {
100 scopePresets = (result.value.presets ?? []).map((p: DelegationScopePreset) => ({
101 name: p.name,
102 label: p.name,
103 description: p.description,
104 scopes: unsafeAsScopeSet(p.scopes)
105 }))
106 }
107 }
108
109 async function addController() {
110 if (!addControllerDid.trim()) return
111 addingController = true
112
113 const controllerDid = unsafeAsDid(addControllerDid.trim())
114 const scopes = unsafeAsScopeSet(addControllerScopes)
115 const result = await api.addDelegationController(session.accessJwt, controllerDid, scopes)
116 if (result.ok) {
117 toast.success($_('delegation.controllerAdded'))
118 addControllerDid = ''
119 addControllerScopes = 'atproto'
120 addControllerConfirmed = false
121 showAddController = false
122 await loadControllers()
123 }
124 addingController = false
125 }
126
127 async function removeController(controllerDid: Did) {
128 if (!confirm($_('delegation.removeConfirm'))) return
129
130 const result = await api.removeDelegationController(session.accessJwt, controllerDid)
131 if (result.ok) {
132 toast.success($_('delegation.controllerRemoved'))
133 await loadControllers()
134 }
135 }
136
137 async function createDelegatedAccount() {
138 if (!newDelegatedHandle.trim()) return
139 creatingDelegated = true
140
141 const result = await api.createDelegatedAccount(
142 session.accessJwt,
143 unsafeAsHandle(newDelegatedHandle.trim()),
144 newDelegatedEmail.trim() ? unsafeAsEmail(newDelegatedEmail.trim()) : undefined,
145 unsafeAsScopeSet(newDelegatedScopes)
146 )
147 if (result.ok) {
148 toast.success($_('delegation.accountCreated', { values: { handle: result.value.handle } }))
149 newDelegatedHandle = ''
150 newDelegatedEmail = ''
151 newDelegatedScopes = 'atproto'
152 showCreateDelegated = false
153 await loadControlledAccounts()
154 }
155 creatingDelegated = false
156 }
157
158 function getScopeLabel(scopes: ScopeSet): string {
159 const preset = scopePresets.find(p => p.scopes === scopes)
160 if (preset) return preset.label
161 if ((scopes as string) === 'atproto') return $_('delegation.scopeOwner')
162 if ((scopes as string) === '') return $_('delegation.scopeViewer')
163 return $_('delegation.scopeCustom')
164 }
165</script>
166
167<div class="controllers">
168 {#if loading}
169 <div class="loading">{$_('common.loading')}</div>
170 {:else}
171 <section class="section">
172 <div class="section-header">
173 <h3>{$_('delegation.controllers')}</h3>
174 <p class="section-description">{$_('delegation.controllersDesc')}</p>
175 </div>
176
177 {#if controllers.length === 0}
178 <p class="empty">{$_('delegation.noControllers')}</p>
179 {:else}
180 <div class="items-list">
181 {#each controllers as controller}
182 <div class="item-card" class:inactive={!controller.isActive}>
183 <div class="item-info">
184 <div class="item-header">
185 <span class="item-handle">@{controller.handle || controller.did}</span>
186 <span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span>
187 {#if !controller.isActive}
188 <span class="badge inactive">{$_('delegation.inactive')}</span>
189 {/if}
190 </div>
191 <div class="item-details">
192 <div class="detail">
193 <span class="label">{$_('delegation.did')}</span>
194 <span class="value did">{controller.did}</span>
195 </div>
196 <div class="detail">
197 <span class="label">{$_('delegation.granted')}</span>
198 <span class="value">{formatDateTime(controller.grantedAt)}</span>
199 </div>
200 </div>
201 </div>
202 <div class="item-actions">
203 <button type="button" class="danger-outline" onclick={() => removeController(controller.did)}>
204 {$_('delegation.remove')}
205 </button>
206 </div>
207 </div>
208 {/each}
209 </div>
210 {/if}
211
212 {#if !canAddControllers}
213 <div class="constraint-notice">
214 <p>{$_('delegation.cannotAddControllers')}</p>
215 </div>
216 {:else if showAddController}
217 <div class="form-card">
218 <h4>{$_('delegation.addController')}</h4>
219
220 <div class="warning-box">
221 <div class="warning-header">{$_('delegation.addControllerWarningTitle')}</div>
222 <p class="warning-text">{$_('delegation.addControllerWarningText')}</p>
223 <ul class="warning-bullets">
224 <li>{$_('delegation.addControllerWarningBullet1')}</li>
225 <li>{$_('delegation.addControllerWarningBullet2')}</li>
226 <li>{$_('delegation.addControllerWarningBullet3')}</li>
227 </ul>
228 </div>
229
230 <div class="field">
231 <label for="controllerDid">{$_('delegation.controllerDid')}</label>
232 <input
233 id="controllerDid"
234 type="text"
235 bind:value={addControllerDid}
236 placeholder="did:plc:..."
237 disabled={addingController}
238 />
239 </div>
240 <div class="field">
241 <label for="controllerScopes">{$_('delegation.accessLevel')}</label>
242 <select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}>
243 {#each scopePresets as preset}
244 <option value={preset.scopes}>{preset.label} - {preset.description}</option>
245 {/each}
246 </select>
247 </div>
248 <label class="confirm-checkbox">
249 <input type="checkbox" bind:checked={addControllerConfirmed} disabled={addingController} />
250 <span>{$_('delegation.addControllerConfirm')}</span>
251 </label>
252 <div class="form-actions">
253 <button type="button" class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}>
254 {$_('common.cancel')}
255 </button>
256 <button type="button" onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}>
257 {addingController ? $_('delegation.adding') : $_('delegation.addController')}
258 </button>
259 </div>
260 </div>
261 {:else}
262 <button type="button" class="ghost full-width" onclick={() => showAddController = true}>
263 {$_('delegation.addControllerButton')}
264 </button>
265 {/if}
266 </section>
267
268 <section class="section">
269 <div class="section-header">
270 <h3>{$_('delegation.controlledAccounts')}</h3>
271 <p class="section-description">{$_('delegation.controlledAccountsDesc')}</p>
272 </div>
273
274 {#if controlledAccounts.length === 0}
275 <p class="empty">{$_('delegation.noControlledAccounts')}</p>
276 {:else}
277 <div class="items-list">
278 {#each controlledAccounts as account}
279 <div class="item-card">
280 <div class="item-info">
281 <div class="item-header">
282 <span class="item-handle">@{account.handle}</span>
283 <span class="badge scope">{getScopeLabel(account.grantedScopes)}</span>
284 </div>
285 <div class="item-details">
286 <div class="detail">
287 <span class="label">{$_('delegation.did')}</span>
288 <span class="value did">{account.did}</span>
289 </div>
290 <div class="detail">
291 <span class="label">{$_('delegation.granted')}</span>
292 <span class="value">{formatDateTime(account.grantedAt)}</span>
293 </div>
294 </div>
295 </div>
296 <div class="item-actions">
297 <a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
298 {$_('delegation.actAs')}
299 </a>
300 </div>
301 </div>
302 {/each}
303 </div>
304 {/if}
305
306 {#if !canControlAccounts}
307 <div class="constraint-notice">
308 <p>{$_('delegation.cannotControlAccounts')}</p>
309 </div>
310 {:else if showCreateDelegated}
311 <div class="form-card">
312 <h4>{$_('delegation.createDelegatedAccount')}</h4>
313 <div class="field">
314 <label for="delegatedHandle">{$_('delegation.handle')}</label>
315 <input
316 id="delegatedHandle"
317 type="text"
318 bind:value={newDelegatedHandle}
319 placeholder="username"
320 disabled={creatingDelegated}
321 />
322 </div>
323 <div class="field">
324 <label for="delegatedEmail">{$_('delegation.emailOptional')}</label>
325 <input
326 id="delegatedEmail"
327 type="email"
328 bind:value={newDelegatedEmail}
329 placeholder="email@example.com"
330 disabled={creatingDelegated}
331 />
332 </div>
333 <div class="field">
334 <label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label>
335 <select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}>
336 {#each scopePresets as preset}
337 <option value={preset.scopes}>{preset.label} - {preset.description}</option>
338 {/each}
339 </select>
340 </div>
341 <div class="form-actions">
342 <button type="button" class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}>
343 {$_('common.cancel')}
344 </button>
345 <button type="button" onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
346 {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')}
347 </button>
348 </div>
349 </div>
350 {:else}
351 <button type="button" class="ghost full-width" onclick={() => showCreateDelegated = true}>
352 {$_('delegation.createDelegatedAccountButton')}
353 </button>
354 {/if}
355 </section>
356
357 <section class="section">
358 <div class="section-header">
359 <h3>{$_('delegation.auditLog')}</h3>
360 <p class="section-description">{$_('delegation.auditLogDesc')}</p>
361 </div>
362 <a href={getFullUrl(routes.delegationAudit)} class="btn-link">{$_('delegation.viewAuditLog')}</a>
363 </section>
364 {/if}
365</div>
366
367<style>
368 .controllers {
369 max-width: var(--width-lg);
370 }
371
372 .loading,
373 .empty {
374 color: var(--text-secondary);
375 padding: var(--space-4);
376 text-align: center;
377 }
378
379 .section {
380 background: var(--bg-secondary);
381 padding: var(--space-5);
382 border-radius: var(--radius-lg);
383 margin-bottom: var(--space-5);
384 }
385
386 .section-header {
387 margin-bottom: var(--space-4);
388 }
389
390 .section-header h3 {
391 margin: 0 0 var(--space-1) 0;
392 font-size: var(--text-base);
393 }
394
395 .section-description {
396 color: var(--text-secondary);
397 margin: 0;
398 font-size: var(--text-sm);
399 }
400
401 .constraint-notice {
402 background: var(--bg-tertiary);
403 border: 1px solid var(--border-color);
404 border-radius: var(--radius-md);
405 padding: var(--space-4);
406 }
407
408 .constraint-notice p {
409 margin: 0;
410 color: var(--text-secondary);
411 font-size: var(--text-sm);
412 }
413
414 .items-list {
415 display: flex;
416 flex-direction: column;
417 gap: var(--space-4);
418 margin-bottom: var(--space-4);
419 }
420
421 .item-card {
422 background: var(--bg-card);
423 border: 1px solid var(--border-color);
424 border-radius: var(--radius-lg);
425 padding: var(--space-4);
426 display: flex;
427 justify-content: space-between;
428 align-items: center;
429 gap: var(--space-4);
430 flex-wrap: wrap;
431 }
432
433 .item-card.inactive {
434 opacity: 0.6;
435 }
436
437 .item-info {
438 flex: 1;
439 min-width: 200px;
440 }
441
442 .item-header {
443 margin-bottom: var(--space-2);
444 display: flex;
445 align-items: center;
446 gap: var(--space-2);
447 flex-wrap: wrap;
448 }
449
450 .item-handle {
451 font-weight: var(--font-semibold);
452 color: var(--text-primary);
453 }
454
455 .badge {
456 display: inline-block;
457 padding: var(--space-1) var(--space-2);
458 border-radius: var(--radius-md);
459 font-size: var(--text-xs);
460 font-weight: var(--font-medium);
461 }
462
463 .badge.scope {
464 background: var(--accent);
465 color: var(--text-inverse);
466 }
467
468 .badge.inactive {
469 background: var(--error-bg);
470 color: var(--error-text);
471 border: 1px solid var(--error-border);
472 }
473
474 .item-details {
475 display: flex;
476 flex-direction: column;
477 gap: var(--space-1);
478 }
479
480 .detail {
481 font-size: var(--text-sm);
482 }
483
484 .detail .label {
485 color: var(--text-secondary);
486 margin-right: var(--space-2);
487 }
488
489 .detail .value {
490 color: var(--text-primary);
491 }
492
493 .detail .value.did {
494 font-family: var(--font-mono);
495 font-size: var(--text-xs);
496 word-break: break-all;
497 }
498
499 .item-actions {
500 display: flex;
501 gap: var(--space-2);
502 }
503
504 .btn-link {
505 display: inline-block;
506 padding: var(--space-2) var(--space-4);
507 border: 1px solid var(--accent);
508 border-radius: var(--radius-md);
509 background: transparent;
510 color: var(--accent);
511 font-size: var(--text-sm);
512 font-weight: var(--font-medium);
513 text-decoration: none;
514 }
515
516 .btn-link:hover {
517 background: var(--accent);
518 color: var(--text-inverse);
519 }
520
521 .danger-outline {
522 padding: var(--space-2) var(--space-4);
523 border: 1px solid var(--error-text);
524 border-radius: var(--radius-md);
525 background: transparent;
526 color: var(--error-text);
527 font-size: var(--text-sm);
528 cursor: pointer;
529 }
530
531 .danger-outline:hover {
532 background: var(--error-bg);
533 }
534
535 .ghost {
536 background: transparent;
537 border: 1px solid var(--border-color);
538 color: var(--text-primary);
539 }
540
541 .ghost:hover {
542 border-color: var(--accent);
543 }
544
545 .full-width {
546 width: 100%;
547 }
548
549 .form-card {
550 background: var(--bg-card);
551 border: 1px solid var(--border-color);
552 border-radius: var(--radius-lg);
553 padding: var(--space-5);
554 margin-top: var(--space-4);
555 }
556
557 .form-card h4 {
558 margin: 0 0 var(--space-4) 0;
559 font-size: var(--text-base);
560 }
561
562 .warning-box {
563 background: var(--warning-bg);
564 border: 1px solid var(--warning-border);
565 border-radius: var(--radius-md);
566 padding: var(--space-4);
567 margin-bottom: var(--space-5);
568 }
569
570 .warning-header {
571 font-weight: var(--font-semibold);
572 color: var(--warning-text);
573 margin-bottom: var(--space-2);
574 }
575
576 .warning-text {
577 margin: 0 0 var(--space-3) 0;
578 color: var(--warning-text);
579 font-size: var(--text-sm);
580 line-height: 1.5;
581 }
582
583 .warning-bullets {
584 margin: 0;
585 padding-left: var(--space-5);
586 color: var(--warning-text);
587 font-size: var(--text-sm);
588 line-height: 1.6;
589 }
590
591 .confirm-checkbox {
592 display: flex;
593 align-items: flex-start;
594 gap: var(--space-2);
595 cursor: pointer;
596 padding: var(--space-3);
597 background: var(--bg-tertiary);
598 border: 1px solid var(--border-color);
599 border-radius: var(--radius-md);
600 margin-bottom: var(--space-4);
601 }
602
603 .confirm-checkbox input {
604 width: 18px;
605 height: 18px;
606 flex-shrink: 0;
607 margin-top: 2px;
608 }
609
610 .confirm-checkbox span {
611 font-size: var(--text-sm);
612 font-weight: var(--font-medium);
613 color: var(--text-primary);
614 line-height: 1.4;
615 }
616
617 .field {
618 margin-bottom: var(--space-4);
619 }
620
621 .field label {
622 display: block;
623 font-size: var(--text-sm);
624 font-weight: var(--font-medium);
625 margin-bottom: var(--space-1);
626 }
627
628 .field input,
629 .field select {
630 width: 100%;
631 }
632
633 .form-actions {
634 display: flex;
635 gap: var(--space-3);
636 justify-content: flex-end;
637 }
638
639 @media (max-width: 600px) {
640 .item-card {
641 flex-direction: column;
642 align-items: stretch;
643 }
644
645 .item-actions {
646 width: 100%;
647 }
648
649 .item-actions button,
650 .item-actions a {
651 width: 100%;
652 text-align: center;
653 }
654 }
655</style>