Something went wrong. Try again.
A downstream fork of Tranquil PDS for use in margin.cafe
Something went wrong. Try again.
26 kB · 683 lines
Svelte
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684<script lang="ts"> import { onMount, onDestroy } from 'svelte' import { _ } from '../../lib/i18n' import { api } from '../../lib/api' import { toast } from '../../lib/toast.svelte' import { formatDateTime } from '../../lib/date' import type { Session, DelegationController, DelegationControlledAccount, DelegationScopePreset, DelegationAuditEntry } from '../../lib/types/api' import { unsafeAsDid, unsafeAsScopeSet, unsafeAsHandle, unsafeAsEmail } from '../../lib/types/branded' import type { Did, Handle, ScopeSet } from '../../lib/types/branded' import LoadMoreSentinel from '../LoadMoreSentinel.svelte'
interface Props { session: Session }
let { session }: Props = $props()
interface Controller { did: Did handle?: Handle grantedScopes: ScopeSet grantedAt: string isActive: boolean isLocal: boolean }
interface ControlledAccount { did: Did handle?: Handle grantedScopes: ScopeSet grantedAt: string }
interface ScopePreset { name: string label: string description: string scopes: ScopeSet }
let loading = $state(true) let controllers = $state<Controller[]>([]) let controlledAccounts = $state<ControlledAccount[]>([]) let scopePresets = $state<ScopePreset[]>([]) let defaultScopes = $state('')
let hasControllers = $derived(controllers.length > 0) let controlsAccounts = $derived(controlledAccounts.length > 0) let canAddControllers = $derived(!controlsAccounts) let canControlAccounts = $derived(!hasControllers)
let showAddController = $state(false) let addControllerIdentifier = $state('') let addControllerScopes = $state('') let addingController = $state(false) let addControllerConfirmed = $state(false) let resolvedController = $state<{ did: string; handle?: string; pdsUrl?: string; isLocal: boolean } | null>(null) let resolving = $state(false) let resolveError = $state('') let controllerToRemove = $state<Controller | null>(null) let removeDialog = $state<HTMLDialogElement>() let removingController = $state(false)
let selectedControllerPreset = $derived(scopePresets.find(p => p.scopes === addControllerScopes))
function onControllerInput(value: string) { addControllerIdentifier = value resolvedController = null resolveError = '' }
async function resolveControllerIdentifier() { const identifier = addControllerIdentifier.trim().replace(/^@/, '') if (!identifier) return
resolving = true resolveError = '' resolvedController = null
const result = await api.resolveController(identifier) if (result.ok) { resolvedController = result.value } else { resolveError = $_('delegation.controllerNotFound') } resolving = false }
let showCreateDelegated = $state(false) let newDelegatedHandle = $state('') let newDelegatedEmail = $state('') let newDelegatedScopes = $state('') let creatingDelegated = $state(false) let deletingDid = $state<string | null>(null) let selectedDelegatedPreset = $derived(scopePresets.find(p => p.scopes === newDelegatedScopes))
onMount(async () => { await Promise.all([loadData(), loadAuditLog()]) pollInterval = setInterval(pollAuditLog, 15_000) })
async function loadData() { loading = true await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()]) loading = false }
async function loadControllers() { const result = await api.listDelegationControllers(session.accessJwt) if (result.ok) { controllers = (result.value.controllers ?? []).map((c: DelegationController) => ({ did: c.did, handle: c.handle, grantedScopes: c.grantedScopes, grantedAt: c.grantedAt, isActive: c.isActive, isLocal: c.isLocal })) } }
async function loadControlledAccounts() { const result = await api.listDelegationControlledAccounts(session.accessJwt) if (result.ok) { controlledAccounts = (result.value.accounts ?? []).map((a: DelegationControlledAccount) => ({ did: a.did, handle: a.handle, grantedScopes: a.grantedScopes, grantedAt: a.grantedAt })) } }
async function loadScopePresets() { const result = await api.getDelegationScopePresets() if (result.ok) { scopePresets = (result.value.presets ?? []).map((p: DelegationScopePreset) => ({ name: p.name, label: p.label, description: p.description, scopes: unsafeAsScopeSet(p.scopes) })) defaultScopes = scopePresets.find(p => p.name === 'owner')?.scopes ?? scopePresets[0]?.scopes ?? '' addControllerScopes = defaultScopes newDelegatedScopes = defaultScopes } }
async function addController() { if (!resolvedController) return addingController = true
const controllerDid = unsafeAsDid(resolvedController.did) const scopes = unsafeAsScopeSet(addControllerScopes) const result = await api.addDelegationController(session.accessJwt, controllerDid, scopes) if (result.ok) { toast.success($_('delegation.controllerAdded')) addControllerIdentifier = '' addControllerScopes = defaultScopes addControllerConfirmed = false resolvedController = null showAddController = false await loadControllers() } else { toast.error(result.error.message || $_('common.error')) } addingController = false }
function requestControllerRemoval(controller: Controller) { controllerToRemove = controller }
function cancelControllerRemoval() { if (removingController) return controllerToRemove = null }
$effect(() => { const currentDialog = removeDialog if (!currentDialog) return if (controllerToRemove && !currentDialog.open) { currentDialog.showModal() requestAnimationFrame(() => currentDialog.querySelector<HTMLElement>('.cancel-remove-controller')?.focus()) } else if (!controllerToRemove && currentDialog.open) { currentDialog.close() } })
async function removeController() { if (!controllerToRemove) return const controllerDid = controllerToRemove.did removingController = true
const result = await api.removeDelegationController(session.accessJwt, controllerDid) if (result.ok) { toast.success($_('delegation.controllerRemoved')) controllerToRemove = null await loadControllers() } else { toast.error(result.error.message || $_('common.error')) } removingController = false }
async function createDelegatedAccount() { if (!newDelegatedHandle.trim()) return creatingDelegated = true
const result = await api.createDelegatedAccount( session.accessJwt, unsafeAsHandle(newDelegatedHandle.trim()), newDelegatedEmail.trim() ? unsafeAsEmail(newDelegatedEmail.trim()) : undefined, unsafeAsScopeSet(newDelegatedScopes) ) if (result.ok) { toast.success($_('delegation.accountCreated', { values: { handle: result.value.handle } })) newDelegatedHandle = '' newDelegatedEmail = '' newDelegatedScopes = defaultScopes showCreateDelegated = false await loadControlledAccounts() } else { toast.error(result.error.message || $_('common.error')) } creatingDelegated = false }
async function deleteControlledAccount(account: ControlledAccount) { const label = account.handle ?? account.did if (!confirm($_('delegation.deleteAccountConfirm', { values: { handle: label } }))) { return } deletingDid = account.did const result = await api.deleteControlledAccount(session.accessJwt, account.did) deletingDid = null if (result.ok) { toast.success($_('delegation.accountDeleted')) await loadControlledAccounts() } else { toast.error(result.error.message || $_('delegation.deleteFailed')) } }
function getScopeLabel(scopes: ScopeSet): string { const preset = scopePresets.find(p => p.scopes === scopes) if (preset) return preset.label if ((scopes as string) === '') return $_('delegation.scopeViewer') return $_('delegation.scopeCustom') }
interface AuditEntry { id: string delegatedDid: string actorDid: string actionType: string actionDetails: Record<string, unknown> | null createdAt: string }
let auditLoading = $state(true) let auditLoadingMore = $state(false) let auditEntries = $state<AuditEntry[]>([]) let auditHasMore = $state(true) let auditOffset = $state(0) const auditLimit = 20 let pollInterval: ReturnType<typeof setInterval> | null = null
onDestroy(() => { if (pollInterval) clearInterval(pollInterval) })
async function loadAuditLog() { auditLoading = true auditOffset = 0 try { const result = await api.getDelegationAuditLog(session.accessJwt, auditLimit, 0) if (result.ok && result.value) { const rawEntries = Array.isArray(result.value.entries) ? result.value.entries : [] auditEntries = rawEntries.map(mapAuditEntry) const total = result.value.total ?? 0 auditHasMore = auditEntries.length < total auditOffset = auditEntries.length } else { auditEntries = [] auditHasMore = false } } catch { toast.error($_('delegation.failedToLoadAudit')) auditEntries = [] auditHasMore = false } finally { auditLoading = false } }
async function pollAuditLog() { try { const result = await api.getDelegationAuditLog(session.accessJwt, auditLimit, 0) if (result.ok && result.value) { const rawEntries = Array.isArray(result.value.entries) ? result.value.entries : [] const latest = rawEntries.map(mapAuditEntry) if (latest.length > 0 && (auditEntries.length === 0 || latest[0].id !== auditEntries[0].id)) { auditEntries = latest const total = result.value.total ?? 0 auditHasMore = auditEntries.length < total auditOffset = auditEntries.length } } else if (result.ok === false) { if (pollInterval) { clearInterval(pollInterval); pollInterval = null } } } catch { if (pollInterval) { clearInterval(pollInterval); pollInterval = null } } }
async function loadMoreAuditEntries() { if (auditLoadingMore || !auditHasMore) return auditLoadingMore = true try { const result = await api.getDelegationAuditLog(session.accessJwt, auditLimit, auditOffset) if (result.ok && result.value) { const rawEntries = Array.isArray(result.value.entries) ? result.value.entries : [] const newEntries = rawEntries.map(mapAuditEntry) auditEntries = [...auditEntries, ...newEntries] const total = result.value.total ?? 0 auditHasMore = auditEntries.length < total auditOffset = auditEntries.length } } catch { toast.error($_('delegation.failedToLoadAudit')) } finally { auditLoadingMore = false } }
function mapAuditEntry(e: DelegationAuditEntry): AuditEntry { const parsed: Record<string, unknown> | null = e.details ? (() => { try { return JSON.parse(e.details!) as Record<string, unknown> } catch { return null } })() : null return { id: e.id, delegatedDid: e.target_did ?? '', actorDid: e.actor_did, actionType: e.action, actionDetails: parsed, createdAt: e.created_at, } }
function formatActionType(type: string): string { const labels: Record<string, string> = { 'GrantCreated': $_('delegation.actionGrantCreated'), 'GrantRevoked': $_('delegation.actionGrantRevoked'), 'ScopesModified': $_('delegation.actionScopesModified'), 'TokenIssued': $_('delegation.actionTokenIssued'), 'RepoWrite': $_('delegation.actionRepoWrite'), 'BlobUpload': $_('delegation.actionBlobUpload'), 'AccountAction': $_('delegation.actionAccountAction'), } return labels[type] || type }
function formatActionDetails(details: Record<string, unknown> | null): string { if (!details) return '' return Object.entries(details) .map(([key, value]) => `${key.replace(/_/g, ' ')}: ${JSON.stringify(value)}`) .join(', ') }
</script>
<div class="controllers"> {#if loading} <div class="loading">{$_('common.loading')}</div> {:else} <section class="section"> <div class="section-header"> <h3>{$_('delegation.controllers')}</h3> <p class="section-description">{$_('delegation.controllersDesc')}</p> </div>
{#if controllers.length === 0} <p class="empty">{$_('delegation.noControllers')}</p> {:else} <div class="items-list"> {#each controllers as controller} <div class="item-card" class:inactive={!controller.isActive}> <div class="item-info"> <div class="item-header"> <span class="item-handle">{controller.handle ? `@${controller.handle}` : controller.did}</span> <span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span> {#if !controller.isActive} <span class="badge inactive">{$_('delegation.inactive')}</span> {/if} </div> <div class="item-details"> <div class="detail"> <span class="detail-label">{$_('delegation.did')}</span> <span class="detail-value detail-value-did">{controller.did}</span> </div> <div class="detail"> <span class="detail-label">{$_('delegation.granted')}</span> <span class="detail-value">{formatDateTime(controller.grantedAt)}</span> </div> </div> </div> <div class="item-actions"> <button type="button" class="danger-outline" onclick={() => requestControllerRemoval(controller)}> {$_('delegation.remove')} </button> </div> </div> {/each} </div> {/if}
{#if !canAddControllers} <div class="constraint-notice"> <p>{$_('delegation.cannotAddControllers')}</p> </div> {:else if showAddController} <form class="form-card" onsubmit={(event) => { event.preventDefault(); addController() }}> <h4>{$_('delegation.addController')}</h4>
<div class="warning-box"> <div class="warning-header">{$_('delegation.addControllerWarningTitle')}</div> <p class="warning-text">{$_('delegation.addControllerWarningText')}</p> <ul class="warning-bullets"> <li>{$_('delegation.addControllerWarningBullet1')}</li> <li>{$_('delegation.addControllerWarningBullet2')}</li> <li>{$_('delegation.addControllerWarningBullet3')}</li> </ul> </div>
<div class="field controller-search"> <label for="controllerIdentifier">{$_('delegation.controllerIdentifier')}</label> <div class="controller-lookup-row"> <input id="controllerIdentifier" name="controllerIdentifier" type="text" value={addControllerIdentifier} oninput={(e) => onControllerInput((e.target as HTMLInputElement).value)} placeholder="handle or did:plc:..." disabled={addingController} required aria-invalid={resolveError ? 'true' : undefined} aria-describedby={resolving || resolveError || resolvedController ? 'controllerResolveStatus' : undefined} /> <button type="button" class="ghost" onclick={resolveControllerIdentifier} disabled={addingController || resolving || !addControllerIdentifier.trim()} > {resolving ? $_('common.verifying') : $_('common.verify')} </button> </div> {#if resolving} <span id="controllerResolveStatus" class="resolve-status" role="status">{$_('common.loading')}</span> {:else if resolveError} <span id="controllerResolveStatus" class="resolve-status error" role="alert">{resolveError}</span> {:else if resolvedController} <div id="controllerResolveStatus" class="resolved-info" role="status"> <span class="resolved-did">{resolvedController.did}</span> {#if resolvedController.handle} <span class="resolved-handle">@{resolvedController.handle}</span> {/if} {#if !resolvedController.isLocal && resolvedController.pdsUrl} <span class="badge external">{new URL(resolvedController.pdsUrl).hostname}</span> {/if} </div> {/if} </div> <div class="field"> <label for="controllerScopes">{$_('delegation.accessLevel')}</label> <select id="controllerScopes" name="controllerScopes" bind:value={addControllerScopes} disabled={addingController}> {#each scopePresets as preset} <option value={preset.scopes}>{preset.label}</option> {/each} </select> {#if selectedControllerPreset} <span class="field-help">{selectedControllerPreset.description}</span> {/if} </div> <label class="confirm-checkbox"> <input name="controllerConfirmation" type="checkbox" bind:checked={addControllerConfirmed} disabled={addingController} /> <span>{$_('delegation.addControllerConfirm')}</span> </label> <div class="form-actions"> <button type="button" class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}> {$_('common.cancel')} </button> <button type="submit" disabled={addingController || !resolvedController || !addControllerConfirmed}> {addingController ? $_('delegation.adding') : $_('delegation.addController')} </button> </div> </form> {:else} <button type="button" class="ghost full-width" onclick={() => showAddController = true}> {$_('delegation.addControllerButton')} </button> {/if} </section>
<section class="section"> <div class="section-header"> <h3>{$_('delegation.controlledAccounts')}</h3> <p class="section-description">{$_('delegation.controlledAccountsDesc')}</p> </div>
{#if controlledAccounts.length === 0} <p class="empty">{$_('delegation.noControlledAccounts')}</p> {:else} <div class="items-list"> {#each controlledAccounts as account} <div class="item-card"> <div class="item-info"> <div class="item-header"> <span class="item-handle">{account.handle ? `@${account.handle}` : account.did}</span> <span class="badge scope">{getScopeLabel(account.grantedScopes)}</span> </div> <div class="item-details"> <div class="detail"> <span class="detail-label">{$_('delegation.did')}</span> <span class="detail-value detail-value-did">{account.did}</span> </div> <div class="detail"> <span class="detail-label">{$_('delegation.granted')}</span> <span class="detail-value">{formatDateTime(account.grantedAt)}</span> </div> </div> </div> <div class="item-actions"> <a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link"> {$_('delegation.actAs')} </a> <button type="button" class="danger-outline" disabled={deletingDid === account.did} onclick={() => deleteControlledAccount(account)} > {deletingDid === account.did ? $_('settings.deleting') : $_('delegation.deleteAccount')} </button> </div> </div> {/each} </div> {/if}
{#if !canControlAccounts} <div class="constraint-notice"> <p>{$_('delegation.cannotControlAccounts')}</p> </div> {:else if showCreateDelegated} <form class="form-card" onsubmit={(event) => { event.preventDefault(); createDelegatedAccount() }}> <h4>{$_('delegation.createDelegatedAccount')}</h4> <div class="field"> <label for="delegatedHandle">{$_('delegation.handle')}</label> <input id="delegatedHandle" name="delegatedHandle" type="text" bind:value={newDelegatedHandle} placeholder="username" disabled={creatingDelegated} required /> </div> <div class="field"> <label for="delegatedEmail">{$_('delegation.emailOptional')}</label> <input id="delegatedEmail" name="delegatedEmail" type="email" bind:value={newDelegatedEmail} placeholder="email@example.com" disabled={creatingDelegated} /> </div> <div class="field"> <label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label> <select id="delegatedScopes" name="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}> {#each scopePresets as preset} <option value={preset.scopes}>{preset.label}</option> {/each} </select> {#if selectedDelegatedPreset} <span class="field-help">{selectedDelegatedPreset.description}</span> {/if} </div> <div class="form-actions"> <button type="button" class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}> {$_('common.cancel')} </button> <button type="submit" disabled={creatingDelegated || !newDelegatedHandle.trim()}> {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')} </button> </div> </form> {:else} <button type="button" class="ghost full-width" onclick={() => showCreateDelegated = true}> {$_('delegation.createDelegatedAccountButton')} </button> {/if}
<div class="constraint-notice"> <p>{$_('delegation.controlledAccountsLocalOnly')}</p> </div> </section>
<section class="section"> <div class="section-header"> <h3>{$_('delegation.auditLog')}</h3> <p class="section-description">{$_('delegation.auditLogDesc')}</p> </div>
{#if auditLoading} <div class="loading">{$_('common.loading')}</div> {:else if auditEntries.length === 0} <p class="empty">{$_('delegation.noAuditEntries')}</p> {:else} <div class="audit-entries"> {#each auditEntries as entry} <div class="audit-entry"> <div class="audit-entry-header"> <span class="action-type">{formatActionType(entry.actionType)}</span> <span class="audit-entry-date">{formatDateTime(entry.createdAt)}</span> </div> <div class="audit-entry-details"> <div class="detail"> <span class="detail-label">{$_('delegation.actor')}</span> <span class="detail-value detail-value-did">{entry.actorDid}</span> </div> {#if entry.delegatedDid} <div class="detail"> <span class="detail-label">{$_('delegation.target')}</span> <span class="detail-value detail-value-did">{entry.delegatedDid}</span> </div> {/if} {#if entry.actionDetails} <div class="detail"> <span class="detail-label">{$_('delegation.details')}</span> <span class="detail-value audit-details-value">{formatActionDetails(entry.actionDetails)}</span> </div> {/if} </div> </div> {/each} </div>
<LoadMoreSentinel hasMore={auditHasMore} loading={auditLoadingMore} onLoadMore={loadMoreAuditEntries} /> {/if} </section> {/if}</div>
{#if controllerToRemove} <dialog bind:this={removeDialog} class="modal controller-removal-dialog" aria-labelledby="remove-controller-title" aria-describedby="remove-controller-description" oncancel={(event) => { event.preventDefault(); cancelControllerRemoval() }} onclick={(event) => event.target === removeDialog && cancelControllerRemoval()} > <h2 id="remove-controller-title">{$_('delegation.remove')}</h2> <p id="remove-controller-description">{$_('delegation.removeConfirm')}</p> <p class="controller-removal-identity"> {controllerToRemove.handle ? `@${controllerToRemove.handle}` : controllerToRemove.did} </p> <div class="modal-actions"> <button type="button" class="ghost cancel-remove-controller" onclick={cancelControllerRemoval} disabled={removingController}> {$_('common.cancel')} </button> <button type="button" class="danger" onclick={removeController} disabled={removingController}> {$_('delegation.remove')} </button> </div> </dialog>{/if}