Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun
at main 655 lines 19 kB view raw
1<script lang="ts"> 2 import { onMount } from 'svelte' 3 import { _ } from '../../lib/i18n' 4 import { api } from '../../lib/api' 5 import { toast } from '../../lib/toast.svelte' 6 import { formatDateTime } from '../../lib/date' 7 import { routes, getFullUrl } from '../../lib/router.svelte' 8 import type { Session, DelegationController, DelegationControlledAccount, DelegationScopePreset } from '../../lib/types/api' 9 import { unsafeAsDid, unsafeAsScopeSet, unsafeAsHandle, unsafeAsEmail } from '../../lib/types/branded' 10 import type { Did, Handle, ScopeSet } from '../../lib/types/branded' 11 12 interface Props { 13 session: Session 14 } 15 16 let { session }: Props = $props() 17 18 interface Controller { 19 did: Did 20 handle: Handle 21 grantedScopes: ScopeSet 22 grantedAt: string 23 isActive: boolean 24 } 25 26 interface ControlledAccount { 27 did: Did 28 handle: Handle 29 grantedScopes: ScopeSet 30 grantedAt: string 31 } 32 33 interface ScopePreset { 34 name: string 35 label: string 36 description: string 37 scopes: ScopeSet 38 } 39 40 let loading = $state(true) 41 let controllers = $state<Controller[]>([]) 42 let controlledAccounts = $state<ControlledAccount[]>([]) 43 let scopePresets = $state<ScopePreset[]>([]) 44 45 let hasControllers = $derived(controllers.length > 0) 46 let controlsAccounts = $derived(controlledAccounts.length > 0) 47 let canAddControllers = $derived(!controlsAccounts) 48 let canControlAccounts = $derived(!hasControllers) 49 50 let showAddController = $state(false) 51 let addControllerDid = $state('') 52 let addControllerScopes = $state('atproto') 53 let addingController = $state(false) 54 let addControllerConfirmed = $state(false) 55 56 let showCreateDelegated = $state(false) 57 let newDelegatedHandle = $state('') 58 let newDelegatedEmail = $state('') 59 let newDelegatedScopes = $state('atproto') 60 let creatingDelegated = $state(false) 61 62 onMount(async () => { 63 await loadData() 64 }) 65 66 async function loadData() { 67 loading = true 68 await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()]) 69 loading = false 70 } 71 72 async function loadControllers() { 73 const result = await api.listDelegationControllers(session.accessJwt) 74 if (result.ok) { 75 controllers = (result.value.controllers ?? []).map((c: DelegationController) => ({ 76 did: c.did, 77 handle: c.handle, 78 grantedScopes: c.grantedScopes, 79 grantedAt: c.grantedAt, 80 isActive: c.isActive 81 })) 82 } 83 } 84 85 async function loadControlledAccounts() { 86 const result = await api.listDelegationControlledAccounts(session.accessJwt) 87 if (result.ok) { 88 controlledAccounts = (result.value.accounts ?? []).map((a: DelegationControlledAccount) => ({ 89 did: a.did, 90 handle: a.handle, 91 grantedScopes: a.grantedScopes, 92 grantedAt: a.grantedAt 93 })) 94 } 95 } 96 97 async function loadScopePresets() { 98 const result = await api.getDelegationScopePresets() 99 if (result.ok) { 100 scopePresets = (result.value.presets ?? []).map((p: DelegationScopePreset) => ({ 101 name: p.name, 102 label: p.name, 103 description: p.description, 104 scopes: unsafeAsScopeSet(p.scopes) 105 })) 106 } 107 } 108 109 async function addController() { 110 if (!addControllerDid.trim()) return 111 addingController = true 112 113 const controllerDid = unsafeAsDid(addControllerDid.trim()) 114 const scopes = unsafeAsScopeSet(addControllerScopes) 115 const result = await api.addDelegationController(session.accessJwt, controllerDid, scopes) 116 if (result.ok) { 117 toast.success($_('delegation.controllerAdded')) 118 addControllerDid = '' 119 addControllerScopes = 'atproto' 120 addControllerConfirmed = false 121 showAddController = false 122 await loadControllers() 123 } 124 addingController = false 125 } 126 127 async function removeController(controllerDid: Did) { 128 if (!confirm($_('delegation.removeConfirm'))) return 129 130 const result = await api.removeDelegationController(session.accessJwt, controllerDid) 131 if (result.ok) { 132 toast.success($_('delegation.controllerRemoved')) 133 await loadControllers() 134 } 135 } 136 137 async function createDelegatedAccount() { 138 if (!newDelegatedHandle.trim()) return 139 creatingDelegated = true 140 141 const result = await api.createDelegatedAccount( 142 session.accessJwt, 143 unsafeAsHandle(newDelegatedHandle.trim()), 144 newDelegatedEmail.trim() ? unsafeAsEmail(newDelegatedEmail.trim()) : undefined, 145 unsafeAsScopeSet(newDelegatedScopes) 146 ) 147 if (result.ok) { 148 toast.success($_('delegation.accountCreated', { values: { handle: result.value.handle } })) 149 newDelegatedHandle = '' 150 newDelegatedEmail = '' 151 newDelegatedScopes = 'atproto' 152 showCreateDelegated = false 153 await loadControlledAccounts() 154 } 155 creatingDelegated = false 156 } 157 158 function getScopeLabel(scopes: ScopeSet): string { 159 const preset = scopePresets.find(p => p.scopes === scopes) 160 if (preset) return preset.label 161 if ((scopes as string) === 'atproto') return $_('delegation.scopeOwner') 162 if ((scopes as string) === '') return $_('delegation.scopeViewer') 163 return $_('delegation.scopeCustom') 164 } 165</script> 166 167<div class="controllers"> 168 {#if loading} 169 <div class="loading">{$_('common.loading')}</div> 170 {:else} 171 <section class="section"> 172 <div class="section-header"> 173 <h3>{$_('delegation.controllers')}</h3> 174 <p class="section-description">{$_('delegation.controllersDesc')}</p> 175 </div> 176 177 {#if controllers.length === 0} 178 <p class="empty">{$_('delegation.noControllers')}</p> 179 {:else} 180 <div class="items-list"> 181 {#each controllers as controller} 182 <div class="item-card" class:inactive={!controller.isActive}> 183 <div class="item-info"> 184 <div class="item-header"> 185 <span class="item-handle">@{controller.handle || controller.did}</span> 186 <span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span> 187 {#if !controller.isActive} 188 <span class="badge inactive">{$_('delegation.inactive')}</span> 189 {/if} 190 </div> 191 <div class="item-details"> 192 <div class="detail"> 193 <span class="label">{$_('delegation.did')}</span> 194 <span class="value did">{controller.did}</span> 195 </div> 196 <div class="detail"> 197 <span class="label">{$_('delegation.granted')}</span> 198 <span class="value">{formatDateTime(controller.grantedAt)}</span> 199 </div> 200 </div> 201 </div> 202 <div class="item-actions"> 203 <button type="button" class="danger-outline" onclick={() => removeController(controller.did)}> 204 {$_('delegation.remove')} 205 </button> 206 </div> 207 </div> 208 {/each} 209 </div> 210 {/if} 211 212 {#if !canAddControllers} 213 <div class="constraint-notice"> 214 <p>{$_('delegation.cannotAddControllers')}</p> 215 </div> 216 {:else if showAddController} 217 <div class="form-card"> 218 <h4>{$_('delegation.addController')}</h4> 219 220 <div class="warning-box"> 221 <div class="warning-header">{$_('delegation.addControllerWarningTitle')}</div> 222 <p class="warning-text">{$_('delegation.addControllerWarningText')}</p> 223 <ul class="warning-bullets"> 224 <li>{$_('delegation.addControllerWarningBullet1')}</li> 225 <li>{$_('delegation.addControllerWarningBullet2')}</li> 226 <li>{$_('delegation.addControllerWarningBullet3')}</li> 227 </ul> 228 </div> 229 230 <div class="field"> 231 <label for="controllerDid">{$_('delegation.controllerDid')}</label> 232 <input 233 id="controllerDid" 234 type="text" 235 bind:value={addControllerDid} 236 placeholder="did:plc:..." 237 disabled={addingController} 238 /> 239 </div> 240 <div class="field"> 241 <label for="controllerScopes">{$_('delegation.accessLevel')}</label> 242 <select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}> 243 {#each scopePresets as preset} 244 <option value={preset.scopes}>{preset.label} - {preset.description}</option> 245 {/each} 246 </select> 247 </div> 248 <label class="confirm-checkbox"> 249 <input type="checkbox" bind:checked={addControllerConfirmed} disabled={addingController} /> 250 <span>{$_('delegation.addControllerConfirm')}</span> 251 </label> 252 <div class="form-actions"> 253 <button type="button" class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}> 254 {$_('common.cancel')} 255 </button> 256 <button type="button" onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}> 257 {addingController ? $_('delegation.adding') : $_('delegation.addController')} 258 </button> 259 </div> 260 </div> 261 {:else} 262 <button type="button" class="ghost full-width" onclick={() => showAddController = true}> 263 {$_('delegation.addControllerButton')} 264 </button> 265 {/if} 266 </section> 267 268 <section class="section"> 269 <div class="section-header"> 270 <h3>{$_('delegation.controlledAccounts')}</h3> 271 <p class="section-description">{$_('delegation.controlledAccountsDesc')}</p> 272 </div> 273 274 {#if controlledAccounts.length === 0} 275 <p class="empty">{$_('delegation.noControlledAccounts')}</p> 276 {:else} 277 <div class="items-list"> 278 {#each controlledAccounts as account} 279 <div class="item-card"> 280 <div class="item-info"> 281 <div class="item-header"> 282 <span class="item-handle">@{account.handle}</span> 283 <span class="badge scope">{getScopeLabel(account.grantedScopes)}</span> 284 </div> 285 <div class="item-details"> 286 <div class="detail"> 287 <span class="label">{$_('delegation.did')}</span> 288 <span class="value did">{account.did}</span> 289 </div> 290 <div class="detail"> 291 <span class="label">{$_('delegation.granted')}</span> 292 <span class="value">{formatDateTime(account.grantedAt)}</span> 293 </div> 294 </div> 295 </div> 296 <div class="item-actions"> 297 <a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link"> 298 {$_('delegation.actAs')} 299 </a> 300 </div> 301 </div> 302 {/each} 303 </div> 304 {/if} 305 306 {#if !canControlAccounts} 307 <div class="constraint-notice"> 308 <p>{$_('delegation.cannotControlAccounts')}</p> 309 </div> 310 {:else if showCreateDelegated} 311 <div class="form-card"> 312 <h4>{$_('delegation.createDelegatedAccount')}</h4> 313 <div class="field"> 314 <label for="delegatedHandle">{$_('delegation.handle')}</label> 315 <input 316 id="delegatedHandle" 317 type="text" 318 bind:value={newDelegatedHandle} 319 placeholder="username" 320 disabled={creatingDelegated} 321 /> 322 </div> 323 <div class="field"> 324 <label for="delegatedEmail">{$_('delegation.emailOptional')}</label> 325 <input 326 id="delegatedEmail" 327 type="email" 328 bind:value={newDelegatedEmail} 329 placeholder="email@example.com" 330 disabled={creatingDelegated} 331 /> 332 </div> 333 <div class="field"> 334 <label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label> 335 <select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}> 336 {#each scopePresets as preset} 337 <option value={preset.scopes}>{preset.label} - {preset.description}</option> 338 {/each} 339 </select> 340 </div> 341 <div class="form-actions"> 342 <button type="button" class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}> 343 {$_('common.cancel')} 344 </button> 345 <button type="button" onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}> 346 {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')} 347 </button> 348 </div> 349 </div> 350 {:else} 351 <button type="button" class="ghost full-width" onclick={() => showCreateDelegated = true}> 352 {$_('delegation.createDelegatedAccountButton')} 353 </button> 354 {/if} 355 </section> 356 357 <section class="section"> 358 <div class="section-header"> 359 <h3>{$_('delegation.auditLog')}</h3> 360 <p class="section-description">{$_('delegation.auditLogDesc')}</p> 361 </div> 362 <a href={getFullUrl(routes.delegationAudit)} class="btn-link">{$_('delegation.viewAuditLog')}</a> 363 </section> 364 {/if} 365</div> 366 367<style> 368 .controllers { 369 max-width: var(--width-lg); 370 } 371 372 .loading, 373 .empty { 374 color: var(--text-secondary); 375 padding: var(--space-4); 376 text-align: center; 377 } 378 379 .section { 380 background: var(--bg-secondary); 381 padding: var(--space-5); 382 border-radius: var(--radius-lg); 383 margin-bottom: var(--space-5); 384 } 385 386 .section-header { 387 margin-bottom: var(--space-4); 388 } 389 390 .section-header h3 { 391 margin: 0 0 var(--space-1) 0; 392 font-size: var(--text-base); 393 } 394 395 .section-description { 396 color: var(--text-secondary); 397 margin: 0; 398 font-size: var(--text-sm); 399 } 400 401 .constraint-notice { 402 background: var(--bg-tertiary); 403 border: 1px solid var(--border-color); 404 border-radius: var(--radius-md); 405 padding: var(--space-4); 406 } 407 408 .constraint-notice p { 409 margin: 0; 410 color: var(--text-secondary); 411 font-size: var(--text-sm); 412 } 413 414 .items-list { 415 display: flex; 416 flex-direction: column; 417 gap: var(--space-4); 418 margin-bottom: var(--space-4); 419 } 420 421 .item-card { 422 background: var(--bg-card); 423 border: 1px solid var(--border-color); 424 border-radius: var(--radius-lg); 425 padding: var(--space-4); 426 display: flex; 427 justify-content: space-between; 428 align-items: center; 429 gap: var(--space-4); 430 flex-wrap: wrap; 431 } 432 433 .item-card.inactive { 434 opacity: 0.6; 435 } 436 437 .item-info { 438 flex: 1; 439 min-width: 200px; 440 } 441 442 .item-header { 443 margin-bottom: var(--space-2); 444 display: flex; 445 align-items: center; 446 gap: var(--space-2); 447 flex-wrap: wrap; 448 } 449 450 .item-handle { 451 font-weight: var(--font-semibold); 452 color: var(--text-primary); 453 } 454 455 .badge { 456 display: inline-block; 457 padding: var(--space-1) var(--space-2); 458 border-radius: var(--radius-md); 459 font-size: var(--text-xs); 460 font-weight: var(--font-medium); 461 } 462 463 .badge.scope { 464 background: var(--accent); 465 color: var(--text-inverse); 466 } 467 468 .badge.inactive { 469 background: var(--error-bg); 470 color: var(--error-text); 471 border: 1px solid var(--error-border); 472 } 473 474 .item-details { 475 display: flex; 476 flex-direction: column; 477 gap: var(--space-1); 478 } 479 480 .detail { 481 font-size: var(--text-sm); 482 } 483 484 .detail .label { 485 color: var(--text-secondary); 486 margin-right: var(--space-2); 487 } 488 489 .detail .value { 490 color: var(--text-primary); 491 } 492 493 .detail .value.did { 494 font-family: var(--font-mono); 495 font-size: var(--text-xs); 496 word-break: break-all; 497 } 498 499 .item-actions { 500 display: flex; 501 gap: var(--space-2); 502 } 503 504 .btn-link { 505 display: inline-block; 506 padding: var(--space-2) var(--space-4); 507 border: 1px solid var(--accent); 508 border-radius: var(--radius-md); 509 background: transparent; 510 color: var(--accent); 511 font-size: var(--text-sm); 512 font-weight: var(--font-medium); 513 text-decoration: none; 514 } 515 516 .btn-link:hover { 517 background: var(--accent); 518 color: var(--text-inverse); 519 } 520 521 .danger-outline { 522 padding: var(--space-2) var(--space-4); 523 border: 1px solid var(--error-text); 524 border-radius: var(--radius-md); 525 background: transparent; 526 color: var(--error-text); 527 font-size: var(--text-sm); 528 cursor: pointer; 529 } 530 531 .danger-outline:hover { 532 background: var(--error-bg); 533 } 534 535 .ghost { 536 background: transparent; 537 border: 1px solid var(--border-color); 538 color: var(--text-primary); 539 } 540 541 .ghost:hover { 542 border-color: var(--accent); 543 } 544 545 .full-width { 546 width: 100%; 547 } 548 549 .form-card { 550 background: var(--bg-card); 551 border: 1px solid var(--border-color); 552 border-radius: var(--radius-lg); 553 padding: var(--space-5); 554 margin-top: var(--space-4); 555 } 556 557 .form-card h4 { 558 margin: 0 0 var(--space-4) 0; 559 font-size: var(--text-base); 560 } 561 562 .warning-box { 563 background: var(--warning-bg); 564 border: 1px solid var(--warning-border); 565 border-radius: var(--radius-md); 566 padding: var(--space-4); 567 margin-bottom: var(--space-5); 568 } 569 570 .warning-header { 571 font-weight: var(--font-semibold); 572 color: var(--warning-text); 573 margin-bottom: var(--space-2); 574 } 575 576 .warning-text { 577 margin: 0 0 var(--space-3) 0; 578 color: var(--warning-text); 579 font-size: var(--text-sm); 580 line-height: 1.5; 581 } 582 583 .warning-bullets { 584 margin: 0; 585 padding-left: var(--space-5); 586 color: var(--warning-text); 587 font-size: var(--text-sm); 588 line-height: 1.6; 589 } 590 591 .confirm-checkbox { 592 display: flex; 593 align-items: flex-start; 594 gap: var(--space-2); 595 cursor: pointer; 596 padding: var(--space-3); 597 background: var(--bg-tertiary); 598 border: 1px solid var(--border-color); 599 border-radius: var(--radius-md); 600 margin-bottom: var(--space-4); 601 } 602 603 .confirm-checkbox input { 604 width: 18px; 605 height: 18px; 606 flex-shrink: 0; 607 margin-top: 2px; 608 } 609 610 .confirm-checkbox span { 611 font-size: var(--text-sm); 612 font-weight: var(--font-medium); 613 color: var(--text-primary); 614 line-height: 1.4; 615 } 616 617 .field { 618 margin-bottom: var(--space-4); 619 } 620 621 .field label { 622 display: block; 623 font-size: var(--text-sm); 624 font-weight: var(--font-medium); 625 margin-bottom: var(--space-1); 626 } 627 628 .field input, 629 .field select { 630 width: 100%; 631 } 632 633 .form-actions { 634 display: flex; 635 gap: var(--space-3); 636 justify-content: flex-end; 637 } 638 639 @media (max-width: 600px) { 640 .item-card { 641 flex-direction: column; 642 align-items: stretch; 643 } 644 645 .item-actions { 646 width: 100%; 647 } 648 649 .item-actions button, 650 .item-actions a { 651 width: 100%; 652 text-align: center; 653 } 654 } 655</style>