From 079daa7d6613b353859043b8b1a42588643f59a9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 18 May 2026 12:43:45 -0500 Subject: [PATCH] Polish EE permissions plugin UX --- .../paperclip-ee-permissions/src/ui/app.tsx | 752 ++++++++++++------ .../paperclip-ee-permissions/src/worker.ts | 10 +- ui/src/plugins/bridge-init.ts | 130 ++- 3 files changed, 657 insertions(+), 235 deletions(-) diff --git a/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx b/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx index 2eff30b0ec4..08b20ecf106 100644 --- a/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx +++ b/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx @@ -1,9 +1,20 @@ -import { useEffect, useMemo, useState, type CSSProperties } from "react"; +import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; import { + AssigneePicker, + DataTable, + ErrorBoundary, + JsonTree, + KeyValueList, + MetricCard, + ProjectPicker, + Spinner, + StatusBadge, useHostContext, usePluginAction, usePluginData, + type DataTableColumn, type PluginCompanySettingsPageProps, + type StatusBadgeVariant, } from "@paperclipai/plugin-sdk/ui"; type HumanCompanyMembershipRole = "owner" | "admin" | "operator" | "viewer"; @@ -64,10 +75,19 @@ type MemberRecord = { grants: Array<{ permissionKey: string; scope: Record | null }>; }; +type AgentRecord = { + id: string; + name: string; + role?: string | null; + title?: string | null; + status?: string | null; +}; + type MemberAccessData = { companyId: string; warnings: Array<{ code: string; message: string }>; members: MemberRecord[]; + agents?: AgentRecord[]; }; type LicenseState = { @@ -86,13 +106,6 @@ type PolicySummary = { advancedPolicyAvailable: false; }; -type AgentRecord = { - id: string; - name: string; - role?: string | null; - status?: string | null; -}; - type IssueRecord = { id: string; title: string; @@ -118,7 +131,19 @@ type Overview = { warnings: Array<{ code: string; message: string }>; }; +type AuditEntry = { + id: string; + actorType: string; + actorId: string; + action: string; + entityType: string; + entityId: string; + details: Record | null; + createdAt: string; +}; + type AdvancedPolicyData = { + companyId: string; summary: PolicySummary | null; warnings: Overview["warnings"]; agents: AgentRecord[]; @@ -141,16 +166,7 @@ type AdvancedPolicyData = { }>; preview: DecisionRecord | null; explanation: DecisionRecord | null; - auditEntries: Array<{ - id: string; - actorType: string; - actorId: string; - action: string; - entityType: string; - entityId: string; - details: Record | null; - createdAt: string; - }>; + auditEntries: AuditEntry[]; }; const layoutStack: CSSProperties = { @@ -160,32 +176,34 @@ const layoutStack: CSSProperties = { }; const cardStyle: CSSProperties = { - border: "1px solid rgba(148, 163, 184, 0.28)", + border: "1px solid var(--border, #e2e8f0)", borderRadius: "8px", - padding: "16px 20px", + padding: "16px", display: "grid", - gap: "10px", + gap: "12px", + background: "var(--card, #ffffff)", }; const subtleCardStyle: CSSProperties = { - border: "1px solid rgba(148, 163, 184, 0.22)", + border: "1px solid var(--border, #e2e8f0)", borderRadius: "8px", - padding: "14px", + padding: "12px", display: "grid", - gap: "10px", + gap: "8px", + background: "var(--background, transparent)", }; const mutedTextStyle: CSSProperties = { - color: "rgba(100, 116, 139, 0.95)", + color: "var(--muted-foreground, #64748b)", fontSize: "0.9rem", lineHeight: 1.5, }; const sectionHeadingStyle: CSSProperties = { fontSize: "0.75rem", - letterSpacing: "0.08em", + letterSpacing: "0.04em", textTransform: "uppercase", - color: "rgba(100, 116, 139, 0.95)", + color: "var(--muted-foreground, #64748b)", }; const rowStyle: CSSProperties = { @@ -203,10 +221,10 @@ const gridStyle: CSSProperties = { const inputStyle: CSSProperties = { width: "100%", - border: "1px solid rgba(148, 163, 184, 0.35)", + border: "1px solid var(--border, #cbd5e1)", borderRadius: "6px", padding: "8px 10px", - background: "transparent", + background: "var(--background, transparent)", color: "inherit", fontSize: "0.85rem", }; @@ -214,66 +232,95 @@ const inputStyle: CSSProperties = { const buttonStyle: CSSProperties = { padding: "7px 12px", borderRadius: "6px", - border: "1px solid rgba(148, 163, 184, 0.4)", - background: "transparent", + border: "1px solid var(--border, #cbd5e1)", + background: "var(--background, transparent)", color: "inherit", cursor: "pointer", }; const primaryButtonStyle: CSSProperties = { ...buttonStyle, - background: "currentColor", - color: "Canvas", -}; - -const codeStyle: CSSProperties = { - margin: 0, - maxHeight: "240px", - overflow: "auto", - border: "1px solid rgba(148, 163, 184, 0.18)", - borderRadius: "6px", - padding: "10px", - fontSize: "0.75rem", - lineHeight: 1.45, + background: "var(--foreground, #0f172a)", + color: "var(--background, #ffffff)", }; const warningStyle: CSSProperties = { - border: "1px solid rgba(234, 179, 8, 0.4)", - background: "rgba(234, 179, 8, 0.08)", + border: "1px solid var(--warning-border, #facc15)", + background: "var(--warning-muted, #fefce8)", borderRadius: "8px", padding: "10px 12px", - color: "rgba(120, 88, 0, 0.95)", + color: "var(--warning-foreground, #713f12)", }; -function JsonBlock({ value }: { value: unknown }) { - return
{JSON.stringify(value, null, 2)}
; -} - -function Pill({ label, tone = "neutral" }: { label: string; tone?: "neutral" | "allow" | "deny" }) { - const colors = tone === "allow" - ? { border: "rgba(22, 163, 74, 0.5)", background: "rgba(22, 163, 74, 0.1)" } - : tone === "deny" - ? { border: "rgba(220, 38, 38, 0.5)", background: "rgba(220, 38, 38, 0.08)" } - : { border: "rgba(148, 163, 184, 0.35)", background: "transparent" }; - return ( - - {label} - - ); -} +const fieldStyle: CSSProperties = { + display: "grid", + gap: "6px", +}; function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function statusForDecision(allowed: boolean): StatusBadgeVariant { + return allowed ? "ok" : "error"; +} + +function decisionLabel(allowed: boolean): string { + return allowed ? "✓ Allowed" : "✕ Denied"; +} + +function membershipStatusVariant(status: string): StatusBadgeVariant { + if (status === "active") return "ok"; + if (status === "pending") return "pending"; + if (status === "suspended" || status === "archived") return "warning"; + return "info"; +} + +function formatPermission(permissionKey: string): string { + return PERMISSION_LABELS[permissionKey as PermissionKey] ?? permissionKey; +} + +function formatScope(scope: Record | null | undefined): string { + if (!scope || Object.keys(scope).length === 0) return "Any scope"; + const parts = Object.entries(scope).map(([key, value]) => `${key}: ${String(value)}`); + return parts.join(", "); +} + +function formatMode(value: string | null | undefined): string { + if (!value) return "Not set"; + return value.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); +} + +function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +function getPolicySection(policy: Record | null | undefined, key: string): Record { + const value = policy?.[key]; + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function getPolicyString(policy: Record | null | undefined, section: string, key: string, fallback: string) { + const value = getPolicySection(policy, section)[key]; + return typeof value === "string" ? value : fallback; +} + +function getPolicyBoolean(policy: Record | null | undefined, section: string, key: string, fallback: boolean) { + const value = getPolicySection(policy, section)[key]; + return typeof value === "boolean" ? value : fallback; +} + +function RawDisclosure({ label = "Raw response", data }: { label?: string; data: unknown }) { + return ( +
+ {label} + +
+ ); +} + function CapabilityWarning({ warnings }: { warnings: Overview["warnings"] }) { if (warnings.length === 0) return null; const denied = warnings.some((warning) => warning.code === "CAPABILITY_DENIED"); @@ -296,6 +343,15 @@ function CapabilityWarning({ warnings }: { warnings: Overview["warnings"] }) { ); } +function LoadingState({ label }: { label: string }) { + return ( +
+ + {label} +
+ ); +} + function MissingCompanyState() { return (
@@ -327,31 +383,56 @@ function UnlicensedState({
-
- Company: {companyId} -
+ {companyId} }]} /> ); } -function getPolicySection(policy: Record | null | undefined, key: string): Record { - const value = policy?.[key]; - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +type PrincipalProfile = { + label: string; + secondary: string; +}; + +function profileForPrincipal(member: MemberRecord, agents: AgentRecord[]): PrincipalProfile { + if (member.principalType === "agent") { + const agent = agents.find((entry) => entry.id === member.principalId); + if (agent) { + return { + label: agent.name, + secondary: [agent.title || agent.role, agent.status, agent.id].filter(Boolean).join(" / "), + }; + } + return { + label: "Agent", + secondary: member.principalId, + }; + } + if (member.principalId.includes("@")) { + const [localPart] = member.principalId.split("@"); + return { + label: localPart.split(/[._-]/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ") || member.principalId, + secondary: member.principalId, + }; + } + return { + label: "Board user", + secondary: member.principalId, + }; } -function getPolicyString(policy: Record | null | undefined, section: string, key: string, fallback: string) { - const value = getPolicySection(policy, section)[key]; - return typeof value === "string" ? value : fallback; -} - -function getPolicyBoolean(policy: Record | null | undefined, section: string, key: string, fallback: boolean) { - const value = getPolicySection(policy, section)[key]; - return typeof value === "boolean" ? value : fallback; -} +type MemberTableRow = { + id: string; + member: MemberRecord; + principal: ReactNode; + role: ReactNode; + status: ReactNode; + grants: ReactNode; + action: ReactNode; +}; function MembersPanel({ companyId }: { companyId: string }) { const query = usePluginData("memberAccess", { companyId }); @@ -364,10 +445,12 @@ function MembersPanel({ companyId }: { companyId: string }) { const [error, setError] = useState(null); const members = query.data?.members ?? []; + const agents = query.data?.agents ?? []; const editingMember = useMemo( () => members.find((member) => member.id === editingMemberId) ?? null, [members, editingMemberId], ); + const editingProfile = editingMember ? profileForPrincipal(editingMember, agents) : null; const implicitGrantKeys = useMemo( () => (draftRole ? IMPLICIT_ROLE_GRANTS[draftRole] : []), [draftRole], @@ -387,7 +470,7 @@ function MembersPanel({ companyId }: { companyId: string }) { }, [editingMember]); if (query.loading && !query.data) { - return
Loading members...
; + return ; } if (query.error) { @@ -399,9 +482,10 @@ function MembersPanel({ companyId }: { companyId: string }) { ); } - const humanMembers = members.filter((member) => member.principalType === "user"); + const pendingHumans = members.filter((member) => member.principalType === "user" && member.status === "pending"); + const activeHumans = members.filter((member) => member.principalType === "user" && member.status !== "pending"); const agentMembers = members.filter((member) => member.principalType === "agent"); - const closeDialog = () => { + const closeEditor = () => { setEditingMemberId(null); setError(null); }; @@ -432,32 +516,53 @@ function MembersPanel({ companyId }: { companyId: string }) {
-
-
Members
+
+
+
Members
+ Company access +
- Roles, status, and explicit permission grants for users and agents in this company. Saves through capability-gated plugin SDK calls; data lives in core. + Roles, membership status, and explicit permission grants for humans and agents in this company.
- {humanMembers.length === 0 && agentMembers.length === 0 ? ( + {members.length === 0 ? (
No company members yet.
) : null} - - + + +
- {editingMember ? ( -
+ {editingMember && editingProfile ? ( +
-
Edit member
- -
-
- Principal {editingMember.principalId} +
Editing member
+
+ {editingProfile.label} +
{editingProfile.secondary}
-