534 lines
18 KiB
TypeScript
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);
|