Our Personal Data Server from scratch!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 4e2986199080ec4e4cf264540f3e2193dbc5bf49 777 lines 22 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 let addControllerConfirmed = $state(false) 59 60 let showCreateDelegated = $state(false) 61 let newDelegatedHandle = $state('') 62 let newDelegatedEmail = $state('') 63 let newDelegatedScopes = $state('atproto') 64 let creatingDelegated = $state(false) 65 66 $effect(() => { 67 if (!authLoading && !session) { 68 navigate(routes.login) 69 } 70 }) 71 72 $effect(() => { 73 if (session) { 74 loadData() 75 } 76 }) 77 78 async function loadData() { 79 loading = true 80 try { 81 await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()]) 82 } finally { 83 loading = false 84 } 85 } 86 87 async function loadControllers() { 88 if (!session) return 89 try { 90 const response = await fetch('/xrpc/_delegation.listControllers', { 91 headers: { 'Authorization': `Bearer ${session.accessJwt}` } 92 }) 93 if (response.ok) { 94 const data = await response.json() 95 controllers = data.controllers || [] 96 } 97 } catch (e) { 98 console.error('Failed to load controllers:', e) 99 } 100 } 101 102 async function loadControlledAccounts() { 103 if (!session) return 104 try { 105 const response = await fetch('/xrpc/_delegation.listControlledAccounts', { 106 headers: { 'Authorization': `Bearer ${session.accessJwt}` } 107 }) 108 if (response.ok) { 109 const data = await response.json() 110 controlledAccounts = data.accounts || [] 111 } 112 } catch (e) { 113 console.error('Failed to load controlled accounts:', e) 114 } 115 } 116 117 async function loadScopePresets() { 118 try { 119 const response = await fetch('/xrpc/_delegation.getScopePresets') 120 if (response.ok) { 121 const data = await response.json() 122 scopePresets = data.presets || [] 123 } 124 } catch (e) { 125 console.error('Failed to load scope presets:', e) 126 } 127 } 128 129 async function addController() { 130 if (!session || !addControllerDid.trim()) return 131 addingController = true 132 133 try { 134 const response = await fetch('/xrpc/_delegation.addController', { 135 method: 'POST', 136 headers: { 137 'Authorization': `Bearer ${session.accessJwt}`, 138 'Content-Type': 'application/json' 139 }, 140 body: JSON.stringify({ 141 controller_did: addControllerDid.trim(), 142 granted_scopes: addControllerScopes 143 }) 144 }) 145 146 if (!response.ok) { 147 const data = await response.json() 148 toast.error(data.message || data.error || $_('delegation.failedToAddController')) 149 return 150 } 151 152 toast.success($_('delegation.controllerAdded')) 153 addControllerDid = '' 154 addControllerScopes = 'atproto' 155 addControllerConfirmed = false 156 showAddController = false 157 await loadControllers() 158 } catch (e) { 159 toast.error($_('delegation.failedToAddController')) 160 } finally { 161 addingController = false 162 } 163 } 164 165 async function removeController(controllerDid: string) { 166 if (!session) return 167 if (!confirm($_('delegation.removeConfirm'))) return 168 169 try { 170 const response = await fetch('/xrpc/_delegation.removeController', { 171 method: 'POST', 172 headers: { 173 'Authorization': `Bearer ${session.accessJwt}`, 174 'Content-Type': 'application/json' 175 }, 176 body: JSON.stringify({ controller_did: controllerDid }) 177 }) 178 179 if (!response.ok) { 180 const data = await response.json() 181 toast.error(data.message || data.error || $_('delegation.failedToRemoveController')) 182 return 183 } 184 185 toast.success($_('delegation.controllerRemoved')) 186 await loadControllers() 187 } catch (e) { 188 toast.error($_('delegation.failedToRemoveController')) 189 } 190 } 191 192 async function createDelegatedAccount() { 193 if (!session || !newDelegatedHandle.trim()) return 194 creatingDelegated = true 195 196 try { 197 const response = await fetch('/xrpc/_delegation.createDelegatedAccount', { 198 method: 'POST', 199 headers: { 200 'Authorization': `Bearer ${session.accessJwt}`, 201 'Content-Type': 'application/json' 202 }, 203 body: JSON.stringify({ 204 handle: newDelegatedHandle.trim(), 205 email: newDelegatedEmail.trim() || undefined, 206 controllerScopes: newDelegatedScopes 207 }) 208 }) 209 210 if (!response.ok) { 211 const data = await response.json() 212 toast.error(data.message || data.error || $_('delegation.failedToCreateAccount')) 213 return 214 } 215 216 const data = await response.json() 217 toast.success($_('delegation.accountCreated', { values: { handle: data.handle } })) 218 newDelegatedHandle = '' 219 newDelegatedEmail = '' 220 newDelegatedScopes = 'atproto' 221 showCreateDelegated = false 222 await loadControlledAccounts() 223 } catch (e) { 224 toast.error($_('delegation.failedToCreateAccount')) 225 } finally { 226 creatingDelegated = false 227 } 228 } 229 230 function getScopeLabel(scopes: string): string { 231 const preset = scopePresets.find(p => p.scopes === scopes) 232 if (preset) return preset.label 233 if (scopes === 'atproto') return $_('delegation.scopeOwner') 234 if (scopes === '') return $_('delegation.scopeViewer') 235 return $_('delegation.scopeCustom') 236 } 237</script> 238 239<div class="page"> 240 <header> 241 <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 242 <h1>{$_('delegation.title')}</h1> 243 </header> 244 245 {#if loading} 246 <div class="skeleton-list"> 247 {#each Array(2) as _} 248 <div class="skeleton-card"></div> 249 {/each} 250 </div> 251 {:else} 252 <section class="section"> 253 <div class="section-header"> 254 <h2>{$_('delegation.controllers')}</h2> 255 <p class="section-description">{$_('delegation.controllersDesc')}</p> 256 </div> 257 258 {#if controllers.length === 0} 259 <p class="empty">{$_('delegation.noControllers')}</p> 260 {:else} 261 <div class="items-list"> 262 {#each controllers as controller} 263 <div class="item-card" class:inactive={!controller.isActive}> 264 <div class="item-info"> 265 <div class="item-header"> 266 <span class="item-handle">@{controller.handle}</span> 267 <span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span> 268 {#if !controller.isActive} 269 <span class="badge inactive">{$_('delegation.inactive')}</span> 270 {/if} 271 </div> 272 <div class="item-details"> 273 <div class="detail"> 274 <span class="label">{$_('delegation.did')}</span> 275 <span class="value did">{controller.did}</span> 276 </div> 277 <div class="detail"> 278 <span class="label">{$_('delegation.granted')}</span> 279 <span class="value">{formatDateTime(controller.grantedAt)}</span> 280 </div> 281 </div> 282 </div> 283 <div class="item-actions"> 284 <button class="danger-outline" onclick={() => removeController(controller.did)}> 285 {$_('delegation.remove')} 286 </button> 287 </div> 288 </div> 289 {/each} 290 </div> 291 {/if} 292 293 {#if !canAddControllers} 294 <div class="constraint-notice"> 295 <p>{$_('delegation.cannotAddControllers')}</p> 296 </div> 297 {:else if showAddController} 298 <div class="form-card"> 299 <h3>{$_('delegation.addController')}</h3> 300 301 <div class="warning-box"> 302 <div class="warning-header"> 303 <svg class="warning-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 304 <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path> 305 <line x1="12" y1="9" x2="12" y2="13"></line> 306 <line x1="12" y1="17" x2="12.01" y2="17"></line> 307 </svg> 308 <span>{$_('delegation.addControllerWarningTitle')}</span> 309 </div> 310 <p class="warning-text">{$_('delegation.addControllerWarningText')}</p> 311 <ul class="warning-bullets"> 312 <li>{$_('delegation.addControllerWarningBullet1')}</li> 313 <li>{$_('delegation.addControllerWarningBullet2')}</li> 314 <li>{$_('delegation.addControllerWarningBullet3')}</li> 315 </ul> 316 </div> 317 318 <div class="field"> 319 <label for="controllerDid">{$_('delegation.controllerDid')}</label> 320 <input 321 id="controllerDid" 322 type="text" 323 bind:value={addControllerDid} 324 placeholder="did:plc:..." 325 disabled={addingController} 326 /> 327 </div> 328 <div class="field"> 329 <label for="controllerScopes">{$_('delegation.accessLevel')}</label> 330 <select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}> 331 {#each scopePresets as preset} 332 <option value={preset.scopes}>{preset.label} - {preset.description}</option> 333 {/each} 334 </select> 335 </div> 336 <label class="confirm-checkbox"> 337 <input type="checkbox" bind:checked={addControllerConfirmed} disabled={addingController} /> 338 <span>{$_('delegation.addControllerConfirm')}</span> 339 </label> 340 <div class="form-actions"> 341 <button class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}> 342 {$_('common.cancel')} 343 </button> 344 <button onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}> 345 {addingController ? $_('delegation.adding') : $_('delegation.addController')} 346 </button> 347 </div> 348 </div> 349 {:else} 350 <button class="ghost full-width" onclick={() => showAddController = true}> 351 {$_('delegation.addControllerButton')} 352 </button> 353 {/if} 354 </section> 355 356 <section class="section"> 357 <div class="section-header"> 358 <h2>{$_('delegation.controlledAccounts')}</h2> 359 <p class="section-description">{$_('delegation.controlledAccountsDesc')}</p> 360 </div> 361 362 {#if controlledAccounts.length === 0} 363 <p class="empty">{$_('delegation.noControlledAccounts')}</p> 364 {:else} 365 <div class="items-list"> 366 {#each controlledAccounts as account} 367 <div class="item-card"> 368 <div class="item-info"> 369 <div class="item-header"> 370 <span class="item-handle">@{account.handle}</span> 371 <span class="badge scope">{getScopeLabel(account.grantedScopes)}</span> 372 </div> 373 <div class="item-details"> 374 <div class="detail"> 375 <span class="label">{$_('delegation.did')}</span> 376 <span class="value did">{account.did}</span> 377 </div> 378 <div class="detail"> 379 <span class="label">{$_('delegation.granted')}</span> 380 <span class="value">{formatDateTime(account.grantedAt)}</span> 381 </div> 382 </div> 383 </div> 384 <div class="item-actions"> 385 <a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link"> 386 {$_('delegation.actAs')} 387 </a> 388 </div> 389 </div> 390 {/each} 391 </div> 392 {/if} 393 394 {#if !canControlAccounts} 395 <div class="constraint-notice"> 396 <p>{$_('delegation.cannotControlAccounts')}</p> 397 </div> 398 {:else if showCreateDelegated} 399 <div class="form-card"> 400 <h3>{$_('delegation.createDelegatedAccount')}</h3> 401 <div class="field"> 402 <label for="delegatedHandle">{$_('delegation.handle')}</label> 403 <input 404 id="delegatedHandle" 405 type="text" 406 bind:value={newDelegatedHandle} 407 placeholder="username" 408 disabled={creatingDelegated} 409 /> 410 </div> 411 <div class="field"> 412 <label for="delegatedEmail">{$_('delegation.emailOptional')}</label> 413 <input 414 id="delegatedEmail" 415 type="email" 416 bind:value={newDelegatedEmail} 417 placeholder="email@example.com" 418 disabled={creatingDelegated} 419 /> 420 </div> 421 <div class="field"> 422 <label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label> 423 <select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}> 424 {#each scopePresets as preset} 425 <option value={preset.scopes}>{preset.label} - {preset.description}</option> 426 {/each} 427 </select> 428 </div> 429 <div class="form-actions"> 430 <button class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}> 431 {$_('common.cancel')} 432 </button> 433 <button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}> 434 {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')} 435 </button> 436 </div> 437 </div> 438 {:else} 439 <button class="ghost full-width" onclick={() => showCreateDelegated = true}> 440 {$_('delegation.createDelegatedAccountButton')} 441 </button> 442 {/if} 443 </section> 444 445 <section class="section"> 446 <div class="section-header"> 447 <h2>{$_('delegation.auditLog')}</h2> 448 <p class="section-description">{$_('delegation.auditLogDesc')}</p> 449 </div> 450 <a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a> 451 </section> 452 {/if} 453</div> 454 455<style> 456 .page { 457 max-width: var(--width-lg); 458 margin: 0 auto; 459 padding: var(--space-7); 460 } 461 462 header { 463 margin-bottom: var(--space-7); 464 } 465 466 .back { 467 color: var(--text-secondary); 468 text-decoration: none; 469 font-size: var(--text-sm); 470 } 471 472 .back:hover { 473 color: var(--accent); 474 } 475 476 h1 { 477 margin: var(--space-2) 0 0 0; 478 } 479 480 .empty { 481 text-align: center; 482 color: var(--text-secondary); 483 padding: var(--space-4); 484 } 485 486 .constraint-notice { 487 background: var(--bg-tertiary); 488 border: 1px solid var(--border-color); 489 border-radius: var(--radius-md); 490 padding: var(--space-4); 491 } 492 493 .constraint-notice p { 494 margin: 0; 495 color: var(--text-secondary); 496 font-size: var(--text-sm); 497 } 498 499 .section { 500 margin-bottom: var(--space-8); 501 } 502 503 .section-header { 504 margin-bottom: var(--space-4); 505 } 506 507 .section-header h2 { 508 margin: 0 0 var(--space-1) 0; 509 font-size: var(--text-lg); 510 } 511 512 .section-description { 513 color: var(--text-secondary); 514 margin: 0; 515 font-size: var(--text-sm); 516 } 517 518 .items-list { 519 display: flex; 520 flex-direction: column; 521 gap: var(--space-4); 522 margin-bottom: var(--space-4); 523 } 524 525 .item-card { 526 background: var(--bg-secondary); 527 border: 1px solid var(--border-color); 528 border-radius: var(--radius-xl); 529 padding: var(--space-4); 530 display: flex; 531 justify-content: space-between; 532 align-items: center; 533 gap: var(--space-4); 534 flex-wrap: wrap; 535 } 536 537 .item-card.inactive { 538 opacity: 0.6; 539 } 540 541 .item-info { 542 flex: 1; 543 min-width: 200px; 544 } 545 546 .item-header { 547 margin-bottom: var(--space-2); 548 display: flex; 549 align-items: center; 550 gap: var(--space-2); 551 flex-wrap: wrap; 552 } 553 554 .item-handle { 555 font-weight: var(--font-semibold); 556 color: var(--text-primary); 557 } 558 559 .badge { 560 display: inline-block; 561 padding: var(--space-1) var(--space-2); 562 border-radius: var(--radius-md); 563 font-size: var(--text-xs); 564 font-weight: var(--font-medium); 565 } 566 567 .badge.scope { 568 background: var(--accent); 569 color: var(--text-inverse); 570 } 571 572 .badge.inactive { 573 background: var(--error-bg); 574 color: var(--error-text); 575 border: 1px solid var(--error-border); 576 } 577 578 .item-details { 579 display: flex; 580 flex-direction: column; 581 gap: var(--space-1); 582 } 583 584 .detail { 585 font-size: var(--text-sm); 586 } 587 588 .detail .label { 589 color: var(--text-secondary); 590 margin-right: var(--space-2); 591 } 592 593 .detail .value { 594 color: var(--text-primary); 595 } 596 597 .detail .value.did { 598 font-family: var(--font-mono); 599 font-size: var(--text-xs); 600 word-break: break-all; 601 } 602 603 .item-actions { 604 display: flex; 605 gap: var(--space-2); 606 } 607 608 .item-actions button { 609 padding: var(--space-2) var(--space-4); 610 font-size: var(--text-sm); 611 } 612 613 .btn-link { 614 display: inline-block; 615 padding: var(--space-2) var(--space-4); 616 border: 1px solid var(--accent); 617 border-radius: var(--radius-md); 618 background: transparent; 619 color: var(--accent); 620 font-size: var(--text-sm); 621 font-weight: var(--font-medium); 622 text-decoration: none; 623 transition: background var(--transition-normal), color var(--transition-normal); 624 } 625 626 .btn-link:hover { 627 background: var(--accent); 628 color: var(--text-inverse); 629 } 630 631 .full-width { 632 width: 100%; 633 } 634 635 .form-card { 636 background: var(--bg-secondary); 637 border: 1px solid var(--border-color); 638 border-radius: var(--radius-xl); 639 padding: var(--space-5); 640 margin-top: var(--space-4); 641 } 642 643 .form-card h3 { 644 margin: 0 0 var(--space-4) 0; 645 } 646 647 .warning-box { 648 background: var(--warning-bg, #fef3c7); 649 border: 1px solid var(--warning-border, #f59e0b); 650 border-radius: var(--radius-md); 651 padding: var(--space-4); 652 margin-bottom: var(--space-5); 653 } 654 655 .warning-header { 656 display: flex; 657 align-items: center; 658 gap: var(--space-2); 659 font-weight: var(--font-semibold); 660 color: var(--warning-text, #92400e); 661 margin-bottom: var(--space-2); 662 } 663 664 .warning-icon { 665 width: 20px; 666 height: 20px; 667 flex-shrink: 0; 668 stroke: var(--warning-text, #92400e); 669 } 670 671 .warning-text { 672 margin: 0 0 var(--space-3) 0; 673 color: var(--warning-text, #92400e); 674 font-size: var(--text-sm); 675 line-height: 1.5; 676 } 677 678 .warning-bullets { 679 margin: 0; 680 padding-left: var(--space-5); 681 color: var(--warning-text, #92400e); 682 font-size: var(--text-sm); 683 line-height: 1.6; 684 } 685 686 .warning-bullets li { 687 margin-bottom: var(--space-1); 688 } 689 690 .warning-bullets li:last-child { 691 margin-bottom: 0; 692 } 693 694 .confirm-checkbox { 695 display: flex; 696 align-items: flex-start; 697 gap: var(--space-2); 698 cursor: pointer; 699 padding: var(--space-3); 700 background: var(--bg-tertiary); 701 border: 1px solid var(--border-color); 702 border-radius: var(--radius-md); 703 margin-bottom: var(--space-4); 704 } 705 706 .confirm-checkbox input { 707 width: 18px; 708 height: 18px; 709 flex-shrink: 0; 710 margin-top: 2px; 711 } 712 713 .confirm-checkbox span { 714 font-size: var(--text-sm); 715 font-weight: var(--font-medium); 716 color: var(--text-primary); 717 line-height: 1.4; 718 } 719 720 .field { 721 margin-bottom: var(--space-4); 722 } 723 724 .field label { 725 display: block; 726 font-size: var(--text-sm); 727 font-weight: var(--font-medium); 728 margin-bottom: var(--space-1); 729 } 730 731 .field input, 732 .field select { 733 width: 100%; 734 padding: var(--space-3); 735 border: 1px solid var(--border-color); 736 border-radius: var(--radius-md); 737 font-size: var(--text-base); 738 background: var(--bg-input); 739 color: var(--text-primary); 740 } 741 742 .field input:focus, 743 .field select:focus { 744 outline: none; 745 border-color: var(--accent); 746 } 747 748 .form-actions { 749 display: flex; 750 gap: var(--space-3); 751 justify-content: flex-end; 752 } 753 754 .form-actions button { 755 padding: var(--space-2) var(--space-4); 756 font-size: var(--text-sm); 757 } 758 759 .skeleton-list { 760 display: flex; 761 flex-direction: column; 762 gap: var(--space-4); 763 } 764 765 .skeleton-card { 766 height: 120px; 767 background: var(--bg-secondary); 768 border: 1px solid var(--border-color); 769 border-radius: var(--radius-xl); 770 animation: skeleton-pulse 1.5s ease-in-out infinite; 771 } 772 773 @keyframes skeleton-pulse { 774 0%, 100% { opacity: 1; } 775 50% { opacity: 0.5; } 776 } 777</style>