this repo has no description
1<script lang="ts">
2 import { getAuthState } from '../lib/auth.svelte'
3 import { navigate } from '../lib/router.svelte'
4 import { _ } from '../lib/i18n'
5 import { formatDateTime } from '../lib/date'
6
7 interface Controller {
8 did: string
9 handle: string
10 grantedScopes: string
11 grantedAt: string
12 isActive: boolean
13 }
14
15 interface ControlledAccount {
16 did: string
17 handle: string
18 grantedScopes: string
19 grantedAt: string
20 }
21
22 interface ScopePreset {
23 name: string
24 label: string
25 description: string
26 scopes: string
27 }
28
29 const auth = getAuthState()
30 let loading = $state(true)
31 let error = $state<string | null>(null)
32 let success = $state<string | null>(null)
33 let controllers = $state<Controller[]>([])
34 let controlledAccounts = $state<ControlledAccount[]>([])
35 let scopePresets = $state<ScopePreset[]>([])
36
37 let hasControllers = $derived(controllers.length > 0)
38 let controlsAccounts = $derived(controlledAccounts.length > 0)
39 let canAddControllers = $derived(!controlsAccounts)
40 let canControlAccounts = $derived(!hasControllers)
41
42 let showAddController = $state(false)
43 let addControllerDid = $state('')
44 let addControllerScopes = $state('atproto')
45 let addingController = $state(false)
46
47 let showCreateDelegated = $state(false)
48 let newDelegatedHandle = $state('')
49 let newDelegatedEmail = $state('')
50 let newDelegatedScopes = $state('atproto')
51 let creatingDelegated = $state(false)
52
53 $effect(() => {
54 if (!auth.loading && !auth.session) {
55 navigate('/login')
56 }
57 })
58
59 $effect(() => {
60 if (auth.session) {
61 loadData()
62 }
63 })
64
65 async function loadData() {
66 loading = true
67 error = null
68 try {
69 await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()])
70 } finally {
71 loading = false
72 }
73 }
74
75 async function loadControllers() {
76 if (!auth.session) return
77 try {
78 const response = await fetch('/xrpc/com.tranquil.delegation.listControllers', {
79 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
80 })
81 if (response.ok) {
82 const data = await response.json()
83 controllers = data.controllers || []
84 }
85 } catch (e) {
86 console.error('Failed to load controllers:', e)
87 }
88 }
89
90 async function loadControlledAccounts() {
91 if (!auth.session) return
92 try {
93 const response = await fetch('/xrpc/com.tranquil.delegation.listControlledAccounts', {
94 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
95 })
96 if (response.ok) {
97 const data = await response.json()
98 controlledAccounts = data.accounts || []
99 }
100 } catch (e) {
101 console.error('Failed to load controlled accounts:', e)
102 }
103 }
104
105 async function loadScopePresets() {
106 try {
107 const response = await fetch('/xrpc/com.tranquil.delegation.getScopePresets')
108 if (response.ok) {
109 const data = await response.json()
110 scopePresets = data.presets || []
111 }
112 } catch (e) {
113 console.error('Failed to load scope presets:', e)
114 }
115 }
116
117 async function addController() {
118 if (!auth.session || !addControllerDid.trim()) return
119 addingController = true
120 error = null
121 success = null
122
123 try {
124 const response = await fetch('/xrpc/com.tranquil.delegation.addController', {
125 method: 'POST',
126 headers: {
127 'Authorization': `Bearer ${auth.session.accessJwt}`,
128 'Content-Type': 'application/json'
129 },
130 body: JSON.stringify({
131 controller_did: addControllerDid.trim(),
132 granted_scopes: addControllerScopes
133 })
134 })
135
136 if (!response.ok) {
137 const data = await response.json()
138 error = data.message || data.error || $_('delegation.failedToAddController')
139 return
140 }
141
142 success = $_('delegation.controllerAdded')
143 addControllerDid = ''
144 addControllerScopes = 'atproto'
145 showAddController = false
146 await loadControllers()
147 } catch (e) {
148 error = $_('delegation.failedToAddController')
149 } finally {
150 addingController = false
151 }
152 }
153
154 async function removeController(controllerDid: string) {
155 if (!auth.session) return
156 if (!confirm($_('delegation.removeConfirm'))) return
157
158 error = null
159 success = null
160
161 try {
162 const response = await fetch('/xrpc/com.tranquil.delegation.removeController', {
163 method: 'POST',
164 headers: {
165 'Authorization': `Bearer ${auth.session.accessJwt}`,
166 'Content-Type': 'application/json'
167 },
168 body: JSON.stringify({ controller_did: controllerDid })
169 })
170
171 if (!response.ok) {
172 const data = await response.json()
173 error = data.message || data.error || $_('delegation.failedToRemoveController')
174 return
175 }
176
177 success = $_('delegation.controllerRemoved')
178 await loadControllers()
179 } catch (e) {
180 error = $_('delegation.failedToRemoveController')
181 }
182 }
183
184 async function createDelegatedAccount() {
185 if (!auth.session || !newDelegatedHandle.trim()) return
186 creatingDelegated = true
187 error = null
188 success = null
189
190 try {
191 const response = await fetch('/xrpc/com.tranquil.delegation.createDelegatedAccount', {
192 method: 'POST',
193 headers: {
194 'Authorization': `Bearer ${auth.session.accessJwt}`,
195 'Content-Type': 'application/json'
196 },
197 body: JSON.stringify({
198 handle: newDelegatedHandle.trim(),
199 email: newDelegatedEmail.trim() || undefined,
200 controllerScopes: newDelegatedScopes
201 })
202 })
203
204 if (!response.ok) {
205 const data = await response.json()
206 error = data.message || data.error || $_('delegation.failedToCreateAccount')
207 return
208 }
209
210 const data = await response.json()
211 success = $_('delegation.accountCreated', { values: { handle: data.handle } })
212 newDelegatedHandle = ''
213 newDelegatedEmail = ''
214 newDelegatedScopes = 'atproto'
215 showCreateDelegated = false
216 await loadControlledAccounts()
217 } catch (e) {
218 error = $_('delegation.failedToCreateAccount')
219 } finally {
220 creatingDelegated = false
221 }
222 }
223
224 function getScopeLabel(scopes: string): string {
225 const preset = scopePresets.find(p => p.scopes === scopes)
226 if (preset) return preset.label
227 if (scopes === 'atproto') return $_('delegation.scopeOwner')
228 if (scopes === '') return $_('delegation.scopeViewer')
229 return $_('delegation.scopeCustom')
230 }
231</script>
232
233<div class="page">
234 <header>
235 <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
236 <h1>{$_('delegation.title')}</h1>
237 </header>
238
239 {#if loading}
240 <p class="loading">{$_('delegation.loading')}</p>
241 {:else}
242 {#if error}
243 <div class="message error">{error}</div>
244 {/if}
245
246 {#if success}
247 <div class="message success">{success}</div>
248 {/if}
249
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="/#/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 ? $_('delegation.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="#/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</style>