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 .loading,
457 .empty {
458 text-align: center;
459 color: var(--text-secondary);
460 padding: var(--space-4);
461 }
462
463 .message {
464 padding: var(--space-3);
465 border-radius: var(--radius-md);
466 margin-bottom: var(--space-4);
467 }
468
469 .message.error {
470 background: var(--error-bg);
471 border: 1px solid var(--error-border);
472 color: var(--error-text);
473 }
474
475 .message.success {
476 background: var(--success-bg);
477 border: 1px solid var(--success-border);
478 color: var(--success-text);
479 }
480
481 .constraint-notice {
482 background: var(--bg-tertiary);
483 border: 1px solid var(--border-color);
484 border-radius: var(--radius-md);
485 padding: var(--space-4);
486 }
487
488 .constraint-notice p {
489 margin: 0;
490 color: var(--text-secondary);
491 font-size: var(--text-sm);
492 }
493
494 .section {
495 margin-bottom: var(--space-8);
496 }
497
498 .section-header {
499 margin-bottom: var(--space-4);
500 }
501
502 .section-header h2 {
503 margin: 0 0 var(--space-1) 0;
504 font-size: var(--text-lg);
505 }
506
507 .section-description {
508 color: var(--text-secondary);
509 margin: 0;
510 font-size: var(--text-sm);
511 }
512
513 .items-list {
514 display: flex;
515 flex-direction: column;
516 gap: var(--space-4);
517 margin-bottom: var(--space-4);
518 }
519
520 .item-card {
521 background: var(--bg-secondary);
522 border: 1px solid var(--border-color);
523 border-radius: var(--radius-xl);
524 padding: var(--space-4);
525 display: flex;
526 justify-content: space-between;
527 align-items: center;
528 gap: var(--space-4);
529 flex-wrap: wrap;
530 }
531
532 .item-card.inactive {
533 opacity: 0.6;
534 }
535
536 .item-info {
537 flex: 1;
538 min-width: 200px;
539 }
540
541 .item-header {
542 margin-bottom: var(--space-2);
543 display: flex;
544 align-items: center;
545 gap: var(--space-2);
546 flex-wrap: wrap;
547 }
548
549 .item-handle {
550 font-weight: var(--font-semibold);
551 color: var(--text-primary);
552 }
553
554 .badge {
555 display: inline-block;
556 padding: var(--space-1) var(--space-2);
557 border-radius: var(--radius-md);
558 font-size: var(--text-xs);
559 font-weight: var(--font-medium);
560 }
561
562 .badge.scope {
563 background: var(--accent);
564 color: var(--text-inverse);
565 }
566
567 .badge.inactive {
568 background: var(--error-bg);
569 color: var(--error-text);
570 border: 1px solid var(--error-border);
571 }
572
573 .item-details {
574 display: flex;
575 flex-direction: column;
576 gap: var(--space-1);
577 }
578
579 .detail {
580 font-size: var(--text-sm);
581 }
582
583 .detail .label {
584 color: var(--text-secondary);
585 margin-right: var(--space-2);
586 }
587
588 .detail .value {
589 color: var(--text-primary);
590 }
591
592 .detail .value.did {
593 font-family: var(--font-mono);
594 font-size: var(--text-xs);
595 word-break: break-all;
596 }
597
598 .item-actions {
599 display: flex;
600 gap: var(--space-2);
601 }
602
603 .item-actions button {
604 padding: var(--space-2) var(--space-4);
605 font-size: var(--text-sm);
606 }
607
608 .btn-link {
609 display: inline-block;
610 padding: var(--space-2) var(--space-4);
611 border: 1px solid var(--accent);
612 border-radius: var(--radius-md);
613 background: transparent;
614 color: var(--accent);
615 font-size: var(--text-sm);
616 font-weight: var(--font-medium);
617 text-decoration: none;
618 transition: background var(--transition-normal), color var(--transition-normal);
619 }
620
621 .btn-link:hover {
622 background: var(--accent);
623 color: var(--text-inverse);
624 }
625
626 .full-width {
627 width: 100%;
628 }
629
630 .form-card {
631 background: var(--bg-secondary);
632 border: 1px solid var(--border-color);
633 border-radius: var(--radius-xl);
634 padding: var(--space-5);
635 margin-top: var(--space-4);
636 }
637
638 .form-card h3 {
639 margin: 0 0 var(--space-4) 0;
640 }
641
642 .field {
643 margin-bottom: var(--space-4);
644 }
645
646 .field label {
647 display: block;
648 font-size: var(--text-sm);
649 font-weight: var(--font-medium);
650 margin-bottom: var(--space-1);
651 }
652
653 .field input,
654 .field select {
655 width: 100%;
656 padding: var(--space-3);
657 border: 1px solid var(--border-color);
658 border-radius: var(--radius-md);
659 font-size: var(--text-base);
660 background: var(--bg-input);
661 color: var(--text-primary);
662 }
663
664 .field input:focus,
665 .field select:focus {
666 outline: none;
667 border-color: var(--accent);
668 }
669
670 .form-actions {
671 display: flex;
672 gap: var(--space-3);
673 justify-content: flex-end;
674 }
675
676 .form-actions button {
677 padding: var(--space-2) var(--space-4);
678 font-size: var(--text-sm);
679 }
680
681 .skeleton-list {
682 display: flex;
683 flex-direction: column;
684 gap: var(--space-4);
685 }
686
687 .skeleton-card {
688 height: 120px;
689 background: var(--bg-secondary);
690 border: 1px solid var(--border-color);
691 border-radius: var(--radius-xl);
692 animation: skeleton-pulse 1.5s ease-in-out infinite;
693 }
694
695 @keyframes skeleton-pulse {
696 0%, 100% { opacity: 1; }
697 50% { opacity: 0.5; }
698 }
699</style>