"use client";
import { useEffect, useMemo, useState } from "react";
import { AlertCircle, CheckCircle2, Loader2, Save } from "lucide-react";
import { fetchAdminResources, fetchUserGrant, saveUserGrant } from "../api";
import type { GrantPayload, McpToolOption, MultiUserResources } from "../types";
type SaveState = "idle" | "saving" | "saved" | "error";
function emptyGrant(userId: string): GrantPayload {
return {
version: 2,
user_id: userId,
models: { llm: [] },
knowledge_bases: [],
skills: [],
partners: [],
enabled_tools: null,
mcp_tools: null,
exec_enabled: null,
};
}
function hasModel(grant: GrantPayload, profileId: string, modelId?: string) {
return grant.models.llm.some((item) => {
if (item.profile_id !== profileId) return false;
if (!modelId) return true;
return Array.isArray(item.model_ids) && item.model_ids.includes(modelId);
});
}
function grantFingerprint(grant: GrantPayload): string {
return JSON.stringify(grant);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
function CheckRow({
label,
description,
checked,
disabled,
onToggle,
}: {
label: string;
description?: string;
checked: boolean;
disabled: boolean;
onToggle: () => void;
}) {
return (
);
}
/**
* Default-vs-custom switch for a whitelist field. ``null`` selects the
* "default" mode; what that resolves to server-side depends on the field —
* built-in tools default to *all*, MCP tools default to *none* (deny until
* explicitly granted) — so the default-mode label is caller-supplied.
*/
function ModeSwitch({
isCustom,
disabled,
onDefault,
onCustom,
defaultLabel = "Default · all",
}: {
isCustom: boolean;
disabled: boolean;
onDefault: () => void;
onCustom: () => void;
defaultLabel?: string;
}) {
const base =
"rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-45";
return (
);
}
export function GrantEditor({ userId }: { userId: string }) {
const [resources, setResources] = useState(null);
const [grant, setGrant] = useState(() => emptyGrant(userId));
const [loading, setLoading] = useState(true);
const [savedFingerprint, setSavedFingerprint] = useState("");
const [saveState, setSaveState] = useState("idle");
const [message, setMessage] = useState("");
useEffect(() => {
let cancelled = false;
Promise.all([fetchAdminResources(), fetchUserGrant(userId)])
.then(([nextResources, nextGrant]) => {
if (cancelled) return;
setResources(nextResources);
setGrant(nextGrant);
setSavedFingerprint(grantFingerprint(nextGrant));
})
.catch((error) => {
setSaveState("error");
setMessage(
error instanceof Error ? error.message : "Failed to load grants",
);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [userId]);
const currentFingerprint = useMemo(() => grantFingerprint(grant), [grant]);
const dirty =
Boolean(savedFingerprint) && currentFingerprint !== savedFingerprint;
const kbIds = useMemo(
() =>
new Set(
grant.knowledge_bases.map((item) =>
String(item.resource_id || item.id || ""),
),
),
[grant.knowledge_bases],
);
const skillIds = useMemo(
() =>
new Set(
grant.skills.map((item) => String(item.skill_id || item.id || "")),
),
[grant.skills],
);
const partnerIds = useMemo(
() =>
new Set(
grant.partners.map((item) => String(item.partner_id || item.id || "")),
),
[grant.partners],
);
const selectedModelCount = useMemo(
() =>
grant.models.llm.reduce((total, item) => {
if (Array.isArray(item.model_ids)) return total + item.model_ids.length;
return total + 1;
}, 0),
[grant.models.llm],
);
const saving = saveState === "saving";
const controlsDisabled = loading || saving;
function toggleModel(profileId: string, modelId: string) {
setGrant((current) => {
const next = structuredClone(current) as GrantPayload;
const items = next.models.llm;
const existing = items.find((item) => item.profile_id === profileId);
if (!existing) {
items.push({
profile_id: profileId,
model_ids: [modelId],
source: "admin",
});
return next;
}
const modelIds = new Set(
Array.isArray(existing.model_ids) ? existing.model_ids : [],
);
if (modelIds.has(modelId)) modelIds.delete(modelId);
else modelIds.add(modelId);
existing.model_ids = Array.from(modelIds);
next.models.llm = items.filter((item) =>
Array.isArray(item.model_ids) ? item.model_ids.length > 0 : true,
);
return next;
});
}
function toggleKb(resourceId: string, name: string) {
setGrant((current) => {
const next = structuredClone(current) as GrantPayload;
const exists = kbIds.has(resourceId);
next.knowledge_bases = exists
? next.knowledge_bases.filter(
(item) => String(item.resource_id || item.id || "") !== resourceId,
)
: [
...next.knowledge_bases,
{ resource_id: resourceId, name, access: "read", source: "admin" },
];
return next;
});
}
function toggleSkill(name: string) {
setGrant((current) => {
const next = structuredClone(current) as GrantPayload;
const exists = skillIds.has(name);
next.skills = exists
? next.skills.filter(
(item) => String(item.skill_id || item.id || "") !== name,
)
: [...next.skills, { skill_id: name, access: "use", source: "admin" }];
return next;
});
}
function togglePartner(partnerId: string, name: string) {
setGrant((current) => {
const next = structuredClone(current) as GrantPayload;
const exists = partnerIds.has(partnerId);
next.partners = exists
? next.partners.filter(
(item) => String(item.partner_id || item.id || "") !== partnerId,
)
: [...next.partners, { partner_id: partnerId, name, source: "admin" }];
return next;
});
}
function setToolList(
key: "enabled_tools" | "mcp_tools",
value: string[] | null,
) {
setGrant((current) => ({ ...current, [key]: value }));
}
function toggleToolName(key: "enabled_tools" | "mcp_tools", name: string) {
setGrant((current) => {
const list = current[key];
if (list === null) return current;
const next = list.includes(name)
? list.filter((item) => item !== name)
: [...list, name];
return { ...current, [key]: next };
});
}
async function save() {
setSaveState("saving");
setMessage("");
try {
const saved = await saveUserGrant(userId, grant);
setGrant(saved);
setSavedFingerprint(grantFingerprint(saved));
setSaveState("saved");
setMessage("Saved just now");
} catch (error) {
setSaveState("error");
setMessage(error instanceof Error ? error.message : "Failed to save");
}
}
const status = loading
? "Loading assignments..."
: saveState === "saving"
? "Saving changes..."
: saveState === "error"
? message || "Failed to save"
: saveState === "saved" && !dirty
? message || "Saved just now"
: dirty
? "Unsaved changes"
: "Ready";
const statusTone =
saveState === "error"
? "text-red-600 dark:text-red-400"
: saveState === "saved" && !dirty
? "text-emerald-700 dark:text-emerald-300"
: "text-[var(--muted-foreground)]";
const toolsSummary =
grant.enabled_tools === null
? "all tools"
: `${grant.enabled_tools.length} tools`;
// MCP tools deny-by-default for non-admin users: ``null`` grants none until
// the admin switches to Custom and picks specific tool names.
const mcpSummary =
grant.mcp_tools === null ? "no MCP" : `${grant.mcp_tools.length} MCP`;
const mcpByServer = useMemo(() => {
const groups = new Map();
for (const tool of resources?.mcp_tools || []) {
const key = tool.server || "other";
groups.set(key, [...(groups.get(key) ?? []), tool]);
}
return groups;
}, [resources?.mcp_tools]);
if (loading && !resources) {
return (
);
}
return (
Assign access
Admin resources stay linked server-side; users only receive
allowed access.
{selectedModelCount} models
{kbIds.size} KBs
{skillIds.size} skills
{partnerIds.size} partners
{toolsSummary}
{mcpSummary}
Models
{(resources?.models.llm || []).map((profile) => (
{profile.name}
{(profile.models || []).map((model) => (
))}
))}
Knowledge
{(resources?.knowledge_bases || []).map((kb) => (
toggleKb(kb.resource_id, kb.name)}
/>
))}
Skills
{(resources?.skills || []).map((skill) => (
toggleSkill(skill.name)}
/>
))}
Partners
{(resources?.partners || []).length === 0 ? (
No partners yet. Create one under Partners to assign it.
) : (
(resources?.partners || []).map((partner) => (
togglePartner(
partner.partner_id,
partner.name || partner.partner_id,
)
}
/>
))
)}
System tools
setToolList("enabled_tools", null)}
onCustom={() =>
setToolList(
"enabled_tools",
(resources?.tools || []).map((tool) => tool.name),
)
}
/>
{grant.enabled_tools !== null && (
{(resources?.tools || []).map((tool) => (
toggleToolName("enabled_tools", tool.name)
}
/>
))}
)}
MCP tools
setToolList("mcp_tools", null)}
onCustom={() =>
setToolList(
"mcp_tools",
(resources?.mcp_tools || []).map((tool) => tool.name),
)
}
/>
{grant.mcp_tools === null ? (
MCP tools proxy host-side capabilities, so they stay denied by
default. Switch to Custom to grant specific tools.
) : null}
{grant.mcp_tools !== null &&
(resources?.mcp_tools?.length ? (
{[...mcpByServer.entries()].map(([server, tools]) => (
{server}
{tools.map((tool) => (
toggleToolName("mcp_tools", tool.name)
}
/>
))}
))}
) : (
No MCP servers configured.
))}
Code execution
setGrant((current) => ({
...current,
exec_enabled:
current.exec_enabled === false ? null : false,
}))
}
/>
{saveState === "error" ? (
) : saveState === "saved" && !dirty ? (
) : null}
{status}
);
}