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>