this repo has no description
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
59 let showCreateDelegated = $state(false)
60 let newDelegatedHandle = $state('')
61 let newDelegatedEmail = $state('')
62 let newDelegatedScopes = $state('atproto')
63 let creatingDelegated = $state(false)
64
65 $effect(() => {
66 if (!authLoading && !session) {
67 navigate(routes.login)
68 }
69 })
70
71 $effect(() => {
72 if (session) {
73 loadData()
74 }
75 })
76
77 async function loadData() {
78 loading = true
79 try {
80 await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()])
81 } finally {
82 loading = false
83 }
84 }
85
86 async function loadControllers() {
87 if (!session) return
88 try {
89 const response = await fetch('/xrpc/_delegation.listControllers', {
90 headers: { 'Authorization': `Bearer ${session.accessJwt}` }
91 })
92 if (response.ok) {
93 const data = await response.json()
94 controllers = data.controllers || []
95 }
96 } catch (e) {
97 console.error('Failed to load controllers:', e)
98 }
99 }
100
101 async function loadControlledAccounts() {
102 if (!session) return
103 try {
104 const response = await fetch('/xrpc/_delegation.listControlledAccounts', {
105 headers: { 'Authorization': `Bearer ${session.accessJwt}` }
106 })
107 if (response.ok) {
108 const data = await response.json()
109 controlledAccounts = data.accounts || []
110 }
111 } catch (e) {
112 console.error('Failed to load controlled accounts:', e)
113 }
114 }
115
116 async function loadScopePresets() {
117 try {
118 const response = await fetch('/xrpc/_delegation.getScopePresets')
119 if (response.ok) {
120 const data = await response.json()
121 scopePresets = data.presets || []
122 }
123 } catch (e) {
124 console.error('Failed to load scope presets:', e)
125 }
126 }
127
128 async function addController() {
129 if (!session || !addControllerDid.trim()) return
130 addingController = true
131
132 try {
133 const response = await fetch('/xrpc/_delegation.addController', {
134 method: 'POST',
135 headers: {
136 'Authorization': `Bearer ${session.accessJwt}`,
137 'Content-Type': 'application/json'
138 },
139 body: JSON.stringify({
140 controller_did: addControllerDid.trim(),
141 granted_scopes: addControllerScopes
142 })
143 })
144
145 if (!response.ok) {
146 const data = await response.json()
147 toast.error(data.message || data.error || $_('delegation.failedToAddController'))
148 return
149 }
150
151 toast.success($_('delegation.controllerAdded'))
152 addControllerDid = ''
153 addControllerScopes = 'atproto'
154 showAddController = false
155 await loadControllers()
156 } catch (e) {
157 toast.error($_('delegation.failedToAddController'))
158 } finally {
159 addingController = false
160 }
161 }
162
163 async function removeController(controllerDid: string) {
164 if (!session) return
165 if (!confirm($_('delegation.removeConfirm'))) return
166
167 try {
168 const response = await fetch('/xrpc/_delegation.removeController', {
169 method: 'POST',
170 headers: {
171 'Authorization': `Bearer ${session.accessJwt}`,
172 'Content-Type': 'application/json'
173 },
174 body: JSON.stringify({ controller_did: controllerDid })
175 })
176
177 if (!response.ok) {
178 const data = await response.json()
179 toast.error(data.message || data.error || $_('delegation.failedToRemoveController'))
180 return
181 }
182
183 toast.success($_('delegation.controllerRemoved'))
184 await loadControllers()
185 } catch (e) {
186 toast.error($_('delegation.failedToRemoveController'))
187 }
188 }
189
190 async function createDelegatedAccount() {
191 if (!session || !newDelegatedHandle.trim()) return
192 creatingDelegated = true
193
194 try {
195 const response = await fetch('/xrpc/_delegation.createDelegatedAccount', {
196 method: 'POST',
197 headers: {
198 'Authorization': `Bearer ${session.accessJwt}`,
199 'Content-Type': 'application/json'
200 },
201 body: JSON.stringify({
202 handle: newDelegatedHandle.trim(),
203 email: newDelegatedEmail.trim() || undefined,
204 controllerScopes: newDelegatedScopes
205 })
206 })
207
208 if (!response.ok) {
209 const data = await response.json()
210 toast.error(data.message || data.error || $_('delegation.failedToCreateAccount'))
211 return
212 }
213
214 const data = await response.json()
215 toast.success($_('delegation.accountCreated', { values: { handle: data.handle } }))
216 newDelegatedHandle = ''
217 newDelegatedEmail = ''
218 newDelegatedScopes = 'atproto'
219 showCreateDelegated = false
220 await loadControlledAccounts()
221 } catch (e) {
222 toast.error($_('delegation.failedToCreateAccount'))
223 } finally {
224 creatingDelegated = false
225 }
226 }
227
228 function getScopeLabel(scopes: string): string {
229 const preset = scopePresets.find(p => p.scopes === scopes)
230 if (preset) return preset.label
231 if (scopes === 'atproto') return $_('delegation.scopeOwner')
232 if (scopes === '') return $_('delegation.scopeViewer')
233 return $_('delegation.scopeCustom')
234 }
235</script>
236
237<div class="page">
238 <header>
239 <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
240 <h1>{$_('delegation.title')}</h1>
241 </header>
242
243 {#if loading}
244 <div class="skeleton-list">
245 {#each Array(2) as _}
246 <div class="skeleton-card"></div>
247 {/each}
248 </div>
249 {:else}
250 <section class="section">
251 <div class="section-header">
252 <h2>{$_('delegation.controllers')}</h2>
253 <p class="section-description">{$_('delegation.controllersDesc')}</p>
254 </div>
255
256 {#if controllers.length === 0}
257 <p class="empty">{$_('delegation.noControllers')}</p>
258 {:else}
259 <div class="items-list">
260 {#each controllers as controller}
261 <div class="item-card" class:inactive={!controller.isActive}>
262 <div class="item-info">
263 <div class="item-header">
264 <span class="item-handle">@{controller.handle}</span>
265 <span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span>
266 {#if !controller.isActive}
267 <span class="badge inactive">{$_('delegation.inactive')}</span>
268 {/if}
269 </div>
270 <div class="item-details">
271 <div class="detail">
272 <span class="label">{$_('delegation.did')}</span>
273 <span class="value did">{controller.did}</span>
274 </div>
275 <div class="detail">
276 <span class="label">{$_('delegation.granted')}</span>
277 <span class="value">{formatDateTime(controller.grantedAt)}</span>
278 </div>
279 </div>
280 </div>
281 <div class="item-actions">
282 <button class="danger-outline" onclick={() => removeController(controller.did)}>
283 {$_('delegation.remove')}
284 </button>
285 </div>
286 </div>
287 {/each}
288 </div>
289 {/if}
290
291 {#if !canAddControllers}
292 <div class="constraint-notice">
293 <p>{$_('delegation.cannotAddControllers')}</p>
294 </div>
295 {:else if showAddController}
296 <div class="form-card">
297 <h3>{$_('delegation.addController')}</h3>
298 <div class="field">
299 <label for="controllerDid">{$_('delegation.controllerDid')}</label>
300 <input
301 id="controllerDid"
302 type="text"
303 bind:value={addControllerDid}
304 placeholder="did:plc:..."
305 disabled={addingController}
306 />
307 </div>
308 <div class="field">
309 <label for="controllerScopes">{$_('delegation.accessLevel')}</label>
310 <select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}>
311 {#each scopePresets as preset}
312 <option value={preset.scopes}>{preset.label} - {preset.description}</option>
313 {/each}
314 </select>
315 </div>
316 <div class="form-actions">
317 <button class="ghost" onclick={() => showAddController = false} disabled={addingController}>
318 {$_('common.cancel')}
319 </button>
320 <button onclick={addController} disabled={addingController || !addControllerDid.trim()}>
321 {addingController ? $_('delegation.adding') : $_('delegation.addController')}
322 </button>
323 </div>
324 </div>
325 {:else}
326 <button class="ghost full-width" onclick={() => showAddController = true}>
327 {$_('delegation.addControllerButton')}
328 </button>
329 {/if}
330 </section>
331
332 <section class="section">
333 <div class="section-header">
334 <h2>{$_('delegation.controlledAccounts')}</h2>
335 <p class="section-description">{$_('delegation.controlledAccountsDesc')}</p>
336 </div>
337
338 {#if controlledAccounts.length === 0}
339 <p class="empty">{$_('delegation.noControlledAccounts')}</p>
340 {:else}
341 <div class="items-list">
342 {#each controlledAccounts as account}
343 <div class="item-card">
344 <div class="item-info">
345 <div class="item-header">
346 <span class="item-handle">@{account.handle}</span>
347 <span class="badge scope">{getScopeLabel(account.grantedScopes)}</span>
348 </div>
349 <div class="item-details">
350 <div class="detail">
351 <span class="label">{$_('delegation.did')}</span>
352 <span class="value did">{account.did}</span>
353 </div>
354 <div class="detail">
355 <span class="label">{$_('delegation.granted')}</span>
356 <span class="value">{formatDateTime(account.grantedAt)}</span>
357 </div>
358 </div>
359 </div>
360 <div class="item-actions">
361 <a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
362 {$_('delegation.actAs')}
363 </a>
364 </div>
365 </div>
366 {/each}
367 </div>
368 {/if}
369
370 {#if !canControlAccounts}
371 <div class="constraint-notice">
372 <p>{$_('delegation.cannotControlAccounts')}</p>
373 </div>
374 {:else if showCreateDelegated}
375 <div class="form-card">
376 <h3>{$_('delegation.createDelegatedAccount')}</h3>
377 <div class="field">
378 <label for="delegatedHandle">{$_('delegation.handle')}</label>
379 <input
380 id="delegatedHandle"
381 type="text"
382 bind:value={newDelegatedHandle}
383 placeholder="username"
384 disabled={creatingDelegated}
385 />
386 </div>
387 <div class="field">
388 <label for="delegatedEmail">{$_('delegation.emailOptional')}</label>
389 <input
390 id="delegatedEmail"
391 type="email"
392 bind:value={newDelegatedEmail}
393 placeholder="email@example.com"
394 disabled={creatingDelegated}
395 />
396 </div>
397 <div class="field">
398 <label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label>
399 <select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}>
400 {#each scopePresets as preset}
401 <option value={preset.scopes}>{preset.label} - {preset.description}</option>
402 {/each}
403 </select>
404 </div>
405 <div class="form-actions">
406 <button class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}>
407 {$_('common.cancel')}
408 </button>
409 <button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
410 {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')}
411 </button>
412 </div>
413 </div>
414 {:else}
415 <button class="ghost full-width" onclick={() => showCreateDelegated = true}>
416 {$_('delegation.createDelegatedAccountButton')}
417 </button>
418 {/if}
419 </section>
420
421 <section class="section">
422 <div class="section-header">
423 <h2>{$_('delegation.auditLog')}</h2>
424 <p class="section-description">{$_('delegation.auditLogDesc')}</p>
425 </div>
426 <a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a>
427 </section>
428 {/if}
429</div>
430
431<style>
432 .page {
433 max-width: var(--width-lg);
434 margin: 0 auto;
435 padding: var(--space-7);
436 }
437
438 header {
439 margin-bottom: var(--space-7);
440 }
441
442 .back {
443 color: var(--text-secondary);
444 text-decoration: none;
445 font-size: var(--text-sm);
446 }
447
448 .back:hover {
449 color: var(--accent);
450 }
451
452 h1 {
453 margin: var(--space-2) 0 0 0;
454 }
455
456 .empty {
457 text-align: center;
458 color: var(--text-secondary);
459 padding: var(--space-4);
460 }
461
462 .constraint-notice {
463 background: var(--bg-tertiary);
464 border: 1px solid var(--border-color);
465 border-radius: var(--radius-md);
466 padding: var(--space-4);
467 }
468
469 .constraint-notice p {
470 margin: 0;
471 color: var(--text-secondary);
472 font-size: var(--text-sm);
473 }
474
475 .section {
476 margin-bottom: var(--space-8);
477 }
478
479 .section-header {
480 margin-bottom: var(--space-4);
481 }
482
483 .section-header h2 {
484 margin: 0 0 var(--space-1) 0;
485 font-size: var(--text-lg);
486 }
487
488 .section-description {
489 color: var(--text-secondary);
490 margin: 0;
491 font-size: var(--text-sm);
492 }
493
494 .items-list {
495 display: flex;
496 flex-direction: column;
497 gap: var(--space-4);
498 margin-bottom: var(--space-4);
499 }
500
501 .item-card {
502 background: var(--bg-secondary);
503 border: 1px solid var(--border-color);
504 border-radius: var(--radius-xl);
505 padding: var(--space-4);
506 display: flex;
507 justify-content: space-between;
508 align-items: center;
509 gap: var(--space-4);
510 flex-wrap: wrap;
511 }
512
513 .item-card.inactive {
514 opacity: 0.6;
515 }
516
517 .item-info {
518 flex: 1;
519 min-width: 200px;
520 }
521
522 .item-header {
523 margin-bottom: var(--space-2);
524 display: flex;
525 align-items: center;
526 gap: var(--space-2);
527 flex-wrap: wrap;
528 }
529
530 .item-handle {
531 font-weight: var(--font-semibold);
532 color: var(--text-primary);
533 }
534
535 .badge {
536 display: inline-block;
537 padding: var(--space-1) var(--space-2);
538 border-radius: var(--radius-md);
539 font-size: var(--text-xs);
540 font-weight: var(--font-medium);
541 }
542
543 .badge.scope {
544 background: var(--accent);
545 color: var(--text-inverse);
546 }
547
548 .badge.inactive {
549 background: var(--error-bg);
550 color: var(--error-text);
551 border: 1px solid var(--error-border);
552 }
553
554 .item-details {
555 display: flex;
556 flex-direction: column;
557 gap: var(--space-1);
558 }
559
560 .detail {
561 font-size: var(--text-sm);
562 }
563
564 .detail .label {
565 color: var(--text-secondary);
566 margin-right: var(--space-2);
567 }
568
569 .detail .value {
570 color: var(--text-primary);
571 }
572
573 .detail .value.did {
574 font-family: var(--font-mono);
575 font-size: var(--text-xs);
576 word-break: break-all;
577 }
578
579 .item-actions {
580 display: flex;
581 gap: var(--space-2);
582 }
583
584 .item-actions button {
585 padding: var(--space-2) var(--space-4);
586 font-size: var(--text-sm);
587 }
588
589 .btn-link {
590 display: inline-block;
591 padding: var(--space-2) var(--space-4);
592 border: 1px solid var(--accent);
593 border-radius: var(--radius-md);
594 background: transparent;
595 color: var(--accent);
596 font-size: var(--text-sm);
597 font-weight: var(--font-medium);
598 text-decoration: none;
599 transition: background var(--transition-normal), color var(--transition-normal);
600 }
601
602 .btn-link:hover {
603 background: var(--accent);
604 color: var(--text-inverse);
605 }
606
607 .full-width {
608 width: 100%;
609 }
610
611 .form-card {
612 background: var(--bg-secondary);
613 border: 1px solid var(--border-color);
614 border-radius: var(--radius-xl);
615 padding: var(--space-5);
616 margin-top: var(--space-4);
617 }
618
619 .form-card h3 {
620 margin: 0 0 var(--space-4) 0;
621 }
622
623 .field {
624 margin-bottom: var(--space-4);
625 }
626
627 .field label {
628 display: block;
629 font-size: var(--text-sm);
630 font-weight: var(--font-medium);
631 margin-bottom: var(--space-1);
632 }
633
634 .field input,
635 .field select {
636 width: 100%;
637 padding: var(--space-3);
638 border: 1px solid var(--border-color);
639 border-radius: var(--radius-md);
640 font-size: var(--text-base);
641 background: var(--bg-input);
642 color: var(--text-primary);
643 }
644
645 .field input:focus,
646 .field select:focus {
647 outline: none;
648 border-color: var(--accent);
649 }
650
651 .form-actions {
652 display: flex;
653 gap: var(--space-3);
654 justify-content: flex-end;
655 }
656
657 .form-actions button {
658 padding: var(--space-2) var(--space-4);
659 font-size: var(--text-sm);
660 }
661
662 .skeleton-list {
663 display: flex;
664 flex-direction: column;
665 gap: var(--space-4);
666 }
667
668 .skeleton-card {
669 height: 120px;
670 background: var(--bg-secondary);
671 border: 1px solid var(--border-color);
672 border-radius: var(--radius-xl);
673 animation: skeleton-pulse 1.5s ease-in-out infinite;
674 }
675
676 @keyframes skeleton-pulse {
677 0%, 100% { opacity: 1; }
678 50% { opacity: 0.5; }
679 }
680</style>