rspace-online/modules/rnetwork/components/folk-delegation-manager.ts

534 lines
18 KiB
TypeScript

/**
* <folk-delegation-manager> — 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<string, string> = {
"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<string, string> {
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<string, unknown>) {
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 `
<div class="authority-row">
<div class="authority-header">
<span class="authority-icon">${icon}</span>
<span class="authority-name">${authority}</span>
<span class="authority-pct">${pct}% delegated</span>
${inboundCount > 0 ? `<span class="authority-inbound">${inboundCount} received</span>` : ""}
<button class="btn-add" data-add-authority="${authority}" title="Add delegation">+</button>
</div>
<div class="authority-bar-track">
<div class="authority-bar-fill" style="width:${pct}%;background:${pct > 90 ? '#ef4444' : '#a78bfa'}"></div>
</div>
${delegations.length > 0 ? `
<div class="delegation-list">
${delegations.map(d => `
<div class="delegation-item ${d.state === 'paused' ? 'paused' : ''}">
<span class="delegation-name">${this.esc(this.getUserName(d.delegateDid))}</span>
<span class="delegation-weight">${Math.round(d.weight * 100)}%</span>
<span class="delegation-state">${d.state}</span>
${d.state === 'active' ? `
<button class="btn-sm btn-pause" data-pause="${d.id}" title="Pause">||</button>
` : d.state === 'paused' ? `
<button class="btn-sm btn-resume" data-resume="${d.id}" title="Resume">\u25B6</button>
` : ""}
<button class="btn-sm btn-revoke" data-revoke="${d.id}" title="Revoke">\u2715</button>
</div>`).join("")}
</div>` : ""}
</div>`;
}
private renderModal(): string {
if (!this.showModal) return "";
const currentTotal = this.getWeightForAuthority(this.modalAuthority);
const maxWeight = Math.round((1.0 - currentTotal) * 100);
return `
<div class="modal-overlay" id="modal-overlay">
<div class="modal">
<div class="modal-header">
<span class="modal-title">${this.editingId ? "Edit" : "New"} Delegation</span>
<button class="modal-close" id="modal-close">\u2715</button>
</div>
<div class="modal-body">
<label class="field-label">Authority</label>
<select class="field-select" id="modal-authority">
${AUTHORITIES.map(a => `<option value="${a}" ${this.modalAuthority === a ? "selected" : ""}>${AUTHORITY_ICONS[a]} ${a}</option>`).join("")}
</select>
<label class="field-label">Delegate</label>
<select class="field-select" id="modal-delegate">
<option value="">Select a member...</option>
${this.users.map(u => `<option value="${u.did}" ${this.modalDelegate === u.did ? "selected" : ""}>${this.esc(u.displayName || u.username)}</option>`).join("")}
</select>
<label class="field-label">Weight: ${this.modalWeight}%</label>
<input type="range" min="1" max="${Math.max(maxWeight, 1)}" value="${this.modalWeight}" class="field-slider" id="modal-weight">
<div class="field-hint">Available: ${maxWeight}% of ${this.modalAuthority}</div>
<label class="field-label">Re-delegation depth: ${this.modalMaxDepth}</label>
<input type="range" min="0" max="5" value="${this.modalMaxDepth}" class="field-slider" id="modal-depth">
<label class="field-check">
<input type="checkbox" id="modal-retain" ${this.modalRetainAuthority ? "checked" : ""}>
Retain authority alongside delegate
</label>
${this.error ? `<div class="modal-error">${this.esc(this.error)}</div>` : ""}
</div>
<div class="modal-footer">
<button class="btn-cancel" id="modal-cancel">Cancel</button>
<button class="btn-confirm" id="modal-confirm">${this.editingId ? "Update" : "Delegate"}</button>
</div>
</div>
</div>`;
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
* { box-sizing: border-box; }
.dm-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.dm-title { font-size: 15px; font-weight: 600; }
.dm-subtitle { font-size: 12px; color: var(--rs-text-muted); }
.authority-row { margin-bottom: 16px; }
.authority-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
}
.authority-icon { font-size: 16px; }
.authority-name { font-size: 13px; font-weight: 600; text-transform: capitalize; flex: 1; }
.authority-pct { font-size: 12px; color: var(--rs-text-muted); }
.authority-inbound {
font-size: 11px; color: #a78bfa; background: rgba(167,139,250,0.1);
padding: 2px 6px; border-radius: 4px;
}
.btn-add {
width: 24px; height: 24px; border: 1px solid var(--rs-input-border); border-radius: 6px;
background: var(--rs-input-bg); color: var(--rs-text-primary); cursor: pointer;
font-size: 14px; display: flex; align-items: center; justify-content: center;
}
.btn-add:hover { border-color: #a78bfa; color: #a78bfa; }
.authority-bar-track {
height: 6px; background: var(--rs-bg-surface-raised, #1a1a2e); border-radius: 3px;
overflow: hidden; margin-bottom: 8px;
}
.authority-bar-fill {
height: 100%; border-radius: 3px; transition: width 0.3s;
}
.delegation-list { display: flex; flex-direction: column; gap: 4px; }
.delegation-item {
display: flex; align-items: center; gap: 8px; padding: 6px 10px;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px;
font-size: 12px;
}
.delegation-item.paused { opacity: 0.6; }
.delegation-name { flex: 1; font-weight: 500; }
.delegation-weight { color: #a78bfa; font-weight: 600; min-width: 36px; text-align: right; }
.delegation-state {
font-size: 10px; text-transform: uppercase; color: var(--rs-text-muted);
padding: 1px 5px; border-radius: 3px; background: var(--rs-bg-surface-raised, #1a1a2e);
}
.btn-sm {
width: 22px; height: 22px; border: none; border-radius: 4px;
background: transparent; color: var(--rs-text-muted); cursor: pointer;
font-size: 11px; display: flex; align-items: center; justify-content: center;
}
.btn-sm:hover { background: var(--rs-bg-hover); }
.btn-revoke:hover { color: #ef4444; }
.btn-pause:hover { color: #f59e0b; }
.btn-resume:hover { color: #22c55e; }
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100;
display: flex; align-items: center; justify-content: center;
}
.modal {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 12px; width: 380px; max-width: 95vw;
}
.modal-header {
display: flex; align-items: center; padding: 14px 16px;
border-bottom: 1px solid var(--rs-border);
}
.modal-title { font-size: 14px; font-weight: 600; flex: 1; }
.modal-close {
background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 16px;
}
.modal-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.modal-footer {
display: flex; gap: 8px; justify-content: flex-end; padding: 12px 16px;
border-top: 1px solid var(--rs-border);
}
.field-label { font-size: 12px; font-weight: 600; color: var(--rs-text-muted); }
.field-select, .field-input {
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border);
border-radius: 8px; background: var(--rs-input-bg); color: var(--rs-input-text);
font-size: 13px; outline: none;
}
.field-select:focus, .field-input:focus { border-color: #a78bfa; }
.field-slider {
width: 100%; accent-color: #a78bfa;
}
.field-hint { font-size: 11px; color: var(--rs-text-muted); }
.field-check {
display: flex; align-items: center; gap: 8px; font-size: 12px;
color: var(--rs-text-secondary); cursor: pointer;
}
.field-check input { accent-color: #a78bfa; }
.modal-error { font-size: 12px; color: #ef4444; padding: 6px 10px; background: rgba(239,68,68,0.1); border-radius: 6px; }
.btn-cancel {
padding: 6px 14px; border: 1px solid var(--rs-border); border-radius: 8px;
background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 13px;
}
.btn-confirm {
padding: 6px 14px; border: none; border-radius: 8px;
background: #a78bfa; color: #fff; cursor: pointer; font-size: 13px; font-weight: 600;
}
.btn-confirm:hover { background: #8b5cf6; }
.loading { text-align: center; color: var(--rs-text-muted); padding: 40px; font-size: 13px; }
.error-msg { font-size: 12px; color: #ef4444; margin-bottom: 10px; }
.section-title {
font-size: 11px; font-weight: 600; color: var(--rs-text-muted); text-transform: uppercase;
letter-spacing: 0.05em; margin: 16px 0 8px;
}
</style>
${this.loading ? `<div class="loading">Loading delegations...</div>` : `
<div class="dm-header">
<span class="dm-title">My Delegations</span>
<span class="dm-subtitle">Outbound</span>
</div>
${this.error && !this.showModal ? `<div class="error-msg">${this.esc(this.error)}</div>` : ""}
${AUTHORITIES.map(a => this.renderAuthorityBar(a)).join("")}
${this.inbound.length > 0 ? `
<div class="section-title">Received Delegations</div>
${this.inbound.filter(d => d.state === "active").map(d => `
<div class="delegation-item">
<span class="delegation-name">${this.esc(this.getUserName(d.delegatorDid))}</span>
<span class="delegation-weight">${Math.round(d.weight * 100)}%</span>
<span class="delegation-state">${d.authority}</span>
</div>
`).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);