/** * — per-vertical delegation management UI. * * Shows bars for each authority vertical (gov-ops, fin-ops, dev-ops) * with percentage allocated and delegate avatars. Supports create, edit, revoke. * Weight sum validated client-side before submission. */ interface Delegation { id: string; delegatorDid: string; delegateDid: string; authority: string; weight: number; maxDepth: number; retainAuthority: boolean; spaceSlug: string; state: string; customScope: string | null; expiresAt: number | null; createdAt: number; updatedAt: number; } interface SpaceUser { did: string; username: string; displayName: string | null; avatarUrl: string | null; role: string; } const AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const; const AUTHORITY_ICONS: Record = { "gov-ops": "\u{1F3DB}\uFE0F", "fin-ops": "\u{1F4B0}", "dev-ops": "\u{1F528}", }; class FolkDelegationManager extends HTMLElement { private shadow: ShadowRoot; private space = ""; private outbound: Delegation[] = []; private inbound: Delegation[] = []; private users: SpaceUser[] = []; private loading = true; private error = ""; private showModal = false; private modalAuthority = "voting"; private modalDelegate = ""; private modalWeight = 50; private modalMaxDepth = 3; private modalRetainAuthority = true; private editingId: string | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.render(); this.loadData(); } private getAuthBase(): string { return this.getAttribute("auth-url") || ""; } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rnetwork/); return match ? match[0] : ""; } private getAuthHeaders(): Record { const token = localStorage.getItem("encryptid_session"); if (!token) return {}; return { Authorization: `Bearer ${token}` }; } private async loadData() { const authBase = this.getAuthBase(); const apiBase = this.getApiBase(); const headers = this.getAuthHeaders(); try { const [outRes, inRes, usersRes] = await Promise.all([ fetch(`${authBase}/api/delegations/from?space=${encodeURIComponent(this.space)}`, { headers }), fetch(`${authBase}/api/delegations/to?space=${encodeURIComponent(this.space)}`, { headers }), fetch(`${apiBase}/api/users?space=${encodeURIComponent(this.space)}`), ]); if (outRes.ok) { const d = await outRes.json(); this.outbound = d.delegations || []; } if (inRes.ok) { const d = await inRes.json(); this.inbound = d.delegations || []; } if (usersRes.ok) { const d = await usersRes.json(); this.users = d.users || []; } } catch { this.error = "Failed to load delegation data"; } this.loading = false; this.render(); } private getWeightForAuthority(authority: string): number { return this.outbound .filter(d => d.authority === authority && d.state === "active") .reduce((sum, d) => sum + d.weight, 0); } private getDelegationsForAuthority(authority: string): Delegation[] { return this.outbound.filter(d => d.authority === authority && d.state !== "revoked"); } private getInboundForAuthority(authority: string): Delegation[] { return this.inbound.filter(d => d.authority === authority && d.state === "active"); } private getUserName(did: string): string { const u = this.users.find(u => u.did === did); return u?.displayName || u?.username || did.slice(0, 16) + "..."; } private async createDelegation() { if (!this.modalDelegate) return; const headers = { ...this.getAuthHeaders(), "Content-Type": "application/json" }; const authBase = this.getAuthBase(); try { const body = JSON.stringify({ delegateDid: this.modalDelegate, authority: this.modalAuthority, weight: this.modalWeight / 100, spaceSlug: this.space, maxDepth: this.modalMaxDepth, retainAuthority: this.modalRetainAuthority, }); const res = await fetch(`${authBase}/api/delegations`, { method: "POST", headers, body }); const data = await res.json(); if (!res.ok) { this.error = data.error || "Failed to create delegation"; this.render(); return; } this.showModal = false; this.editingId = null; this.error = ""; await this.loadData(); } catch { this.error = "Network error creating delegation"; this.render(); } } private async updateDelegation(id: string, updates: Record) { const headers = { ...this.getAuthHeaders(), "Content-Type": "application/json" }; const authBase = this.getAuthBase(); try { const res = await fetch(`${authBase}/api/delegations/${id}`, { method: "PATCH", headers, body: JSON.stringify(updates), }); if (!res.ok) { const data = await res.json(); this.error = data.error || "Failed to update delegation"; this.render(); return; } await this.loadData(); } catch { this.error = "Network error"; this.render(); } } private async revokeDelegation(id: string) { const headers = this.getAuthHeaders(); const authBase = this.getAuthBase(); try { const res = await fetch(`${authBase}/api/delegations/${id}`, { method: "DELETE", headers }); if (!res.ok) { const data = await res.json(); this.error = data.error || "Failed to revoke delegation"; this.render(); return; } await this.loadData(); } catch { this.error = "Network error"; this.render(); } } private renderAuthorityBar(authority: string): string { const total = this.getWeightForAuthority(authority); const pct = Math.round(total * 100); const delegations = this.getDelegationsForAuthority(authority); const inboundCount = this.getInboundForAuthority(authority).length; const icon = AUTHORITY_ICONS[authority] || ""; return `
${icon} ${authority} ${pct}% delegated ${inboundCount > 0 ? `${inboundCount} received` : ""}
${delegations.length > 0 ? `
${delegations.map(d => `
${this.esc(this.getUserName(d.delegateDid))} ${Math.round(d.weight * 100)}% ${d.state} ${d.state === 'active' ? ` ` : d.state === 'paused' ? ` ` : ""}
`).join("")}
` : ""}
`; } private renderModal(): string { if (!this.showModal) return ""; const currentTotal = this.getWeightForAuthority(this.modalAuthority); const maxWeight = Math.round((1.0 - currentTotal) * 100); return ` `; } private render() { this.shadow.innerHTML = ` ${this.loading ? `
Loading delegations...
` : `
My Delegations Outbound
${this.error && !this.showModal ? `
${this.esc(this.error)}
` : ""} ${AUTHORITIES.map(a => this.renderAuthorityBar(a)).join("")} ${this.inbound.length > 0 ? `
Received Delegations
${this.inbound.filter(d => d.state === "active").map(d => `
${this.esc(this.getUserName(d.delegatorDid))} ${Math.round(d.weight * 100)}% ${d.authority}
`).join("")} ` : ""} `} ${this.renderModal()} `; this.attachListeners(); } private attachListeners() { // Add delegation buttons this.shadow.querySelectorAll("[data-add-authority]").forEach(el => { el.addEventListener("click", () => { this.modalAuthority = (el as HTMLElement).dataset.addAuthority!; this.modalDelegate = ""; this.modalWeight = 50; this.modalMaxDepth = 3; this.modalRetainAuthority = true; this.editingId = null; this.error = ""; // Clamp default weight to available const currentTotal = this.getWeightForAuthority(this.modalAuthority); const max = Math.round((1.0 - currentTotal) * 100); this.modalWeight = Math.min(50, max); this.showModal = true; this.render(); }); }); // Pause/Resume/Revoke buttons this.shadow.querySelectorAll("[data-pause]").forEach(el => { el.addEventListener("click", () => this.updateDelegation((el as HTMLElement).dataset.pause!, { state: "paused" })); }); this.shadow.querySelectorAll("[data-resume]").forEach(el => { el.addEventListener("click", () => this.updateDelegation((el as HTMLElement).dataset.resume!, { state: "active" })); }); this.shadow.querySelectorAll("[data-revoke]").forEach(el => { el.addEventListener("click", () => { if (confirm("Revoke this delegation?")) { this.revokeDelegation((el as HTMLElement).dataset.revoke!); } }); }); // Modal listeners if (this.showModal) { this.shadow.getElementById("modal-close")?.addEventListener("click", () => { this.showModal = false; this.error = ""; this.render(); }); this.shadow.getElementById("modal-overlay")?.addEventListener("click", (e) => { if ((e.target as HTMLElement).id === "modal-overlay") { this.showModal = false; this.error = ""; this.render(); } }); this.shadow.getElementById("modal-cancel")?.addEventListener("click", () => { this.showModal = false; this.error = ""; this.render(); }); this.shadow.getElementById("modal-confirm")?.addEventListener("click", () => { this.createDelegation(); }); this.shadow.getElementById("modal-authority")?.addEventListener("change", (e) => { this.modalAuthority = (e.target as HTMLSelectElement).value; const currentTotal = this.getWeightForAuthority(this.modalAuthority); const max = Math.round((1.0 - currentTotal) * 100); this.modalWeight = Math.min(this.modalWeight, max); this.render(); }); this.shadow.getElementById("modal-delegate")?.addEventListener("change", (e) => { this.modalDelegate = (e.target as HTMLSelectElement).value; }); this.shadow.getElementById("modal-weight")?.addEventListener("input", (e) => { this.modalWeight = parseInt((e.target as HTMLInputElement).value); const label = this.shadow.querySelector('.field-label:nth-of-type(3)'); // live update shown via next render }); this.shadow.getElementById("modal-depth")?.addEventListener("input", (e) => { this.modalMaxDepth = parseInt((e.target as HTMLInputElement).value); }); this.shadow.getElementById("modal-retain")?.addEventListener("change", (e) => { this.modalRetainAuthority = (e.target as HTMLInputElement).checked; }); } } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-delegation-manager", FolkDelegationManager);