this repo has no description
at main 19 kB view raw
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>