diff --git a/modules/rnetwork/components/folk-crm-view.ts b/modules/rnetwork/components/folk-crm-view.ts
index 9ed42e0..b900feb 100644
--- a/modules/rnetwork/components/folk-crm-view.ts
+++ b/modules/rnetwork/components/folk-crm-view.ts
@@ -48,7 +48,7 @@ interface CrmGraphEdge {
type: "works_at" | "point_of_contact";
}
-type Tab = "pipeline" | "contacts" | "companies" | "graph";
+type Tab = "pipeline" | "contacts" | "companies" | "graph" | "delegations";
const PIPELINE_STAGES = [
"INCOMING",
@@ -109,6 +109,7 @@ class FolkCrmView extends HTMLElement {
{ target: '[data-tab="contacts"]', title: "Contacts", message: "View and search your contact directory. Click a contact to see their details.", advanceOnClick: true },
{ target: '#crm-search', title: "Search", message: "Search across contacts and companies by name, email, or city. Results filter in real time.", advanceOnClick: false },
{ target: '[data-tab="graph"]', title: "Relationship Graph", message: "Visualise connections between people and companies as an interactive network graph.", advanceOnClick: true },
+ { target: '[data-tab="delegations"]', title: "Delegations", message: "Manage delegative trust — assign voting, moderation, and other authority to community members. View flows as a Sankey diagram.", advanceOnClick: true },
];
constructor() {
@@ -651,6 +652,19 @@ class FolkCrmView extends HTMLElement {
`;
}
+ // ── Delegations tab ──
+ private renderDelegations(): string {
+ return `
+
`;
+ }
+
// ── Main render ──
private render() {
const tabs: { id: Tab; label: string; count: number }[] = [
@@ -658,6 +672,7 @@ class FolkCrmView extends HTMLElement {
{ id: "contacts", label: "Contacts", count: this.people.length },
{ id: "companies", label: "Companies", count: this.companies.length },
{ id: "graph", label: "Graph", count: 0 },
+ { id: "delegations", label: "Delegations", count: 0 },
];
let content = "";
@@ -671,6 +686,7 @@ class FolkCrmView extends HTMLElement {
case "contacts": content = this.renderContacts(); break;
case "companies": content = this.renderCompanies(); break;
case "graph": content = this.renderGraphTab(); break;
+ case "delegations": content = this.renderDelegations(); break;
}
}
@@ -792,7 +808,14 @@ class FolkCrmView extends HTMLElement {
.graph-legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--rs-text-muted); }
.graph-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
+ /* ── Delegations tab ── */
+ .delegations-layout {
+ display: grid; grid-template-columns: 1fr 1fr; gap: 20px;
+ }
+ .delegations-col { min-width: 0; }
+
@media (max-width: 900px) {
+ .delegations-layout { grid-template-columns: 1fr; }
.pipeline-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
diff --git a/modules/rnetwork/components/folk-delegation-manager.ts b/modules/rnetwork/components/folk-delegation-manager.ts
new file mode 100644
index 0000000..c37d41b
--- /dev/null
+++ b/modules/rnetwork/components/folk-delegation-manager.ts
@@ -0,0 +1,535 @@
+/**
+ * — per-vertical delegation management UI.
+ *
+ * Shows bars for each authority vertical (voting, moderation, curation, treasury, membership)
+ * 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 = ["voting", "moderation", "curation", "treasury", "membership"] as const;
+const AUTHORITY_ICONS: Record = {
+ voting: "\u{1F5F3}\uFE0F",
+ moderation: "\u{1F6E1}\uFE0F",
+ curation: "\u2728",
+ treasury: "\u{1F4B0}",
+ membership: "\u{1F465}",
+};
+
+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 `
+
+
+
+ ${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 `
+
+
+
+
+
+
+
+
+
+
+
+
+
Available: ${maxWeight}% of ${this.modalAuthority}
+
+
+
+
+
+
+ ${this.error ? `
${this.esc(this.error)}
` : ""}
+
+
+
+
`;
+ }
+
+ private render() {
+ this.shadow.innerHTML = `
+
+
+ ${this.loading ? `Loading delegations...
` : `
+
+
+ ${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);
diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts
index 5a53626..f23229a 100644
--- a/modules/rnetwork/components/folk-graph-viewer.ts
+++ b/modules/rnetwork/components/folk-graph-viewer.ts
@@ -9,11 +9,12 @@
interface GraphNode {
id: string;
name: string;
- type: "person" | "company" | "opportunity";
+ type: "person" | "company" | "opportunity" | "rspace_user";
workspace: string;
role?: string;
location?: string;
description?: string;
+ trustScore?: number;
}
interface GraphEdge {
@@ -21,8 +22,12 @@ interface GraphEdge {
target: string;
type: string;
label?: string;
+ weight?: number;
}
+const DELEGATION_AUTHORITIES = ["voting", "moderation", "curation", "treasury", "membership"] as const;
+type DelegationAuthority = typeof DELEGATION_AUTHORITIES[number];
+
class FolkGraphViewer extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
@@ -30,10 +35,12 @@ class FolkGraphViewer extends HTMLElement {
private info: any = null;
private nodes: GraphNode[] = [];
private edges: GraphEdge[] = [];
- private filter: "all" | "person" | "company" | "opportunity" = "all";
+ private filter: "all" | "person" | "company" | "opportunity" | "rspace_user" = "all";
private searchQuery = "";
private error = "";
private selectedNode: GraphNode | null = null;
+ private trustMode = false;
+ private authority: DelegationAuthority = "voting";
// Canvas state
private canvasZoom = 1;
@@ -75,10 +82,11 @@ class FolkGraphViewer extends HTMLElement {
private async loadData() {
const base = this.getApiBase();
try {
+ const trustParam = this.trustMode ? `?trust=true&authority=${this.authority}` : "";
const [wsRes, infoRes, graphRes] = await Promise.all([
fetch(`${base}/api/workspaces`),
fetch(`${base}/api/info`),
- fetch(`${base}/api/graph`),
+ fetch(`${base}/api/graph${trustParam}`),
]);
if (wsRes.ok) this.workspaces = await wsRes.json();
if (infoRes.ok) this.info = await infoRes.json();
@@ -92,6 +100,14 @@ class FolkGraphViewer extends HTMLElement {
requestAnimationFrame(() => this.fitView());
}
+ /** Reload graph with trust data for selected authority */
+ private async reloadWithAuthority(authority: DelegationAuthority) {
+ this.authority = authority;
+ this.trustMode = true;
+ this.layoutDirty = true;
+ await this.loadData();
+ }
+
/** Map server /api/graph response to client GraphNode/GraphEdge format */
private importGraph(graph: { nodes?: any[]; edges?: any[] }) {
if (!graph.nodes?.length) return;
@@ -268,9 +284,21 @@ class FolkGraphViewer extends HTMLElement {
}
private getTrustScore(nodeId: string): number {
+ const node = this.nodes.find(n => n.id === nodeId);
+ if (node?.trustScore != null) return Math.round(node.trustScore * 100);
+ // Fallback: edge-count heuristic
return Math.min(100, this.edges.filter(e => e.source === nodeId || e.target === nodeId).length * 20);
}
+ /** Get node radius based on trust score (8px min, 30px max for users) */
+ private getNodeRadius(node: GraphNode): number {
+ if (node.type === "company") return 22;
+ if (node.trustScore != null && this.trustMode) {
+ return 8 + (node.trustScore * 22); // 8px min → 30px max
+ }
+ return 12;
+ }
+
private getConnectedNodes(nodeId: string): GraphNode[] {
const connIds = new Set();
for (const e of this.edges) {
@@ -471,13 +499,25 @@ class FolkGraphViewer extends HTMLElement {
}
}
+ // Render delegation edges (purple, with arrows)
+ for (const edge of this.edges) {
+ if (edge.type !== "delegates_to") continue;
+ const sp = positions[edge.source];
+ const tp = positions[edge.target];
+ if (!sp || !tp) continue;
+ if (!filteredIds.has(edge.source) || !filteredIds.has(edge.target)) continue;
+ const strokeWidth = 1 + (edge.weight || 0.5) * 3;
+ edgesSvg.push(``);
+ }
+
// Render nodes
const nodesSvg = filtered.map(node => {
const pos = positions[node.id];
if (!pos) return "";
const isOrg = node.type === "company";
- const color = isOrg ? (orgColors[node.id] || "#22c55e") : "#3b82f6";
- const radius = isOrg ? 22 : 12;
+ const isUser = node.type === "rspace_user";
+ const color = isOrg ? (orgColors[node.id] || "#22c55e") : isUser ? "#a78bfa" : "#3b82f6";
+ const radius = this.getNodeRadius(node);
const isSelected = this.selectedNode?.id === node.id;
let label = this.esc(node.name);
@@ -509,6 +549,11 @@ class FolkGraphViewer extends HTMLElement {
return `