Review fix round 1. - Secret box v2 tokens: one HKDF key per purpose (byok, server-api-key, mcp) and AES-GCM AAD bound to the row (user id + provider; server id + field). MCP env and headers are sealed per field. open() refuses v1 tokens; the data upgrade re-seals v1 keys, v1 plaintext MCP values and the draft secretCipher, and marks ai_secrets_v2 only when nothing legacy is left (retried until the previous secret is back). - Unreadable secrets are reported (admin: unreadableSecrets with server and field; user: byok.keyUnreadable), logged at startup, and kept on save unless replaced or cleared (clearApiKey, clearSecrets). The admin and account pages show the warnings. - allowedOrigins and provider resolution no longer open MCP secrets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
12 KiB
TypeScript
199 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Pill } from "@stc/ui";
|
|
import { api, ApiError } from "./api";
|
|
import { Field, PageHead, UnreadableSecretsWarning, type UnreadableSecretItem } from "./components";
|
|
|
|
type Kind = "" | "anthropic" | "openai";
|
|
interface ProviderOut { kind: "anthropic" | "openai"; model?: string; baseUrl?: string; effort?: string; hasApiKey: boolean }
|
|
interface McpServer { id: string; name: string; transport: "stdio" | "http"; command?: string; args?: string[]; env?: Record<string, string>; url?: string; headers?: Record<string, string>; courseIds?: string[]; clearSecrets?: ("env" | "headers")[] }
|
|
interface Config { provider: ProviderOut | null; mcpServers: McpServer[]; allowedBaseUrls: string[]; unreadableSecrets?: UnreadableSecretItem[] }
|
|
|
|
type ServerDraft = { id: string; name: string; transport: "stdio" | "http"; command: string; args: string; env: string; url: string; headers?: Record<string, string>; courses: string; test?: string; clearSecrets?: ("env" | "headers")[] };
|
|
const toDraft = (s: McpServer): ServerDraft => ({
|
|
id: s.id, name: s.name, transport: s.transport, command: s.command ?? "", args: (s.args ?? []).join(" "),
|
|
env: Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join("\n"), url: s.url ?? "", headers: s.headers, courses: (s.courseIds ?? []).join(", "),
|
|
});
|
|
const parseEnv = (text: string): Record<string, string> =>
|
|
Object.fromEntries(text.split("\n").map((l) => l.trim()).filter((l) => l.includes("=")).map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1)]));
|
|
const fromDraft = (d: ServerDraft): McpServer => ({
|
|
id: d.id, name: d.name || d.id, transport: d.transport,
|
|
...(d.transport === "stdio" ? { command: d.command, args: d.args.trim() ? d.args.trim().split(/\s+/) : [], env: parseEnv(d.env) } : { url: d.url, ...(d.headers ? { headers: d.headers } : {}) }),
|
|
courseIds: d.courses.split(",").map((s) => s.trim()).filter(Boolean),
|
|
...(d.clearSecrets?.length ? { clearSecrets: d.clearSecrets } : {}),
|
|
});
|
|
|
|
export default function AdminAiPage() {
|
|
const [loaded, setLoaded] = useState<Config | null>(null);
|
|
const [denied, setDenied] = useState<string | null>(null);
|
|
const [kind, setKind] = useState<Kind>("");
|
|
const [model, setModel] = useState("");
|
|
const [baseUrl, setBaseUrl] = useState("");
|
|
const [effort, setEffort] = useState("");
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [clearKey, setClearKey] = useState(false);
|
|
const [servers, setServers] = useState<ServerDraft[]>([]);
|
|
const [allowed, setAllowed] = useState("");
|
|
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
|
|
|
const apply = (c: Config) => {
|
|
setLoaded(c);
|
|
setKind(c.provider?.kind ?? "");
|
|
setModel(c.provider?.model ?? "");
|
|
setBaseUrl(c.provider?.baseUrl ?? "");
|
|
setEffort(c.provider?.effort ?? "");
|
|
setApiKey("");
|
|
setClearKey(false);
|
|
setServers(c.mcpServers.map(toDraft));
|
|
setAllowed((c.allowedBaseUrls ?? []).join("\n"));
|
|
};
|
|
useEffect(() => {
|
|
api<Config>("/api/ai/admin/config").then(apply, (e: ApiError) => setDenied(e.status === 401 ? "Sign in as the owner to change AI settings." : e.message));
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
setMsg(null);
|
|
try {
|
|
const provider = kind ? { kind, model, baseUrl, effort, apiKey: apiKey || undefined, clearApiKey: clearKey } : null;
|
|
apply(await api<Config>("/api/ai/admin/config", { method: "PUT", body: { provider, mcpServers: servers.map(fromDraft), allowedBaseUrls: allowed.split(/[\s,]+/).filter(Boolean) } }));
|
|
setMsg({ ok: true, text: kind ? "Saved. The assistant is available to learners." : "Saved. AI is off for everyone without their own key." });
|
|
} catch (e) {
|
|
setMsg({ ok: false, text: (e as Error).message });
|
|
}
|
|
};
|
|
|
|
const setServer = (i: number, patch: Partial<ServerDraft>) => setServers((s) => s.map((x, j) => (j === i ? { ...x, ...patch } : x)));
|
|
const testServer = async (i: number) => {
|
|
setServer(i, { test: "Connecting…" });
|
|
try {
|
|
const r = await api<{ ok: boolean; tools?: { name: string }[]; error?: string }>("/api/ai/admin/mcp/test", { method: "POST", body: fromDraft(servers[i]!) });
|
|
setServer(i, { test: r.ok ? `Connected. Tools: ${r.tools!.map((t) => t.name).join(", ") || "none"}` : `Failed: ${r.error}` });
|
|
} catch (e) {
|
|
setServer(i, { test: `Failed: ${(e as Error).message}` });
|
|
}
|
|
};
|
|
|
|
if (denied) return <div className="ai-page"><PageHead title="AI settings" /><p className="ai-note">{denied}</p></div>;
|
|
if (!loaded) return <div className="ai-page"><PageHead title="AI settings" /></div>;
|
|
|
|
return (
|
|
<div className="ai-page">
|
|
<PageHead title="AI settings">
|
|
<p>AI is off until you choose a provider here. Learners and authors can also add their own key, which is used only for their own requests. Keys are encrypted on the server and never shown again.</p>
|
|
</PageHead>
|
|
|
|
<UnreadableSecretsWarning
|
|
items={loaded.unreadableSecrets ?? []}
|
|
action={(item) => {
|
|
if (item.kind !== "mcp") return <span className="ai-hint">Paste the key again below, or tick "Remove the saved key".</span>;
|
|
const i = servers.findIndex((x) => x.id === item.serverId);
|
|
const marked = i >= 0 && !!servers[i]!.clearSecrets?.includes(item.field);
|
|
if (i < 0) return null;
|
|
return marked ? <span className="ai-hint">Will be discarded when you save.</span> : (
|
|
<Pill variant="ghost" onClick={() => setServer(i, { clearSecrets: [...(servers[i]!.clearSecrets ?? []), item.field] })}>Discard stored value</Pill>
|
|
);
|
|
}}
|
|
/>
|
|
|
|
<form onSubmit={(e) => { e.preventDefault(); save(); }}>
|
|
<section className="ai-section">
|
|
<h2>Server provider</h2>
|
|
<span className="ai-hint">Used for everyone who has not added their own key.</span>
|
|
<div className="ai-grid">
|
|
<Field label="Provider">
|
|
<select className="ai-select" value={kind} onChange={(e) => setKind(e.target.value as Kind)}>
|
|
<option value="">None (AI off)</option>
|
|
<option value="anthropic">Anthropic</option>
|
|
<option value="openai">OpenAI-compatible (OpenAI, Ollama, vLLM …)</option>
|
|
</select>
|
|
</Field>
|
|
{kind ? (
|
|
<Field label="Model" hint={kind === "anthropic" ? "Leave empty for claude-opus-5-5." : "For example llama3.1 on Ollama."}>
|
|
<input className="ai-input" value={model} onChange={(e) => setModel(e.target.value)} placeholder={kind === "anthropic" ? "claude-opus-5-5" : "model name"} />
|
|
</Field>
|
|
) : null}
|
|
</div>
|
|
{kind ? (
|
|
<>
|
|
<Field label={kind === "anthropic" ? "Base URL (optional)" : "Base URL"} hint={kind === "openai" ? "The address that ends before /chat/completions, for example http://ollama:11434/v1." : "Leave empty for api.anthropic.com."}>
|
|
<input className="ai-input ai-mono" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={kind === "openai" ? "http://localhost:11434/v1" : "https://api.anthropic.com"} />
|
|
</Field>
|
|
<div className="ai-grid">
|
|
<Field label="API key" hint={loaded.provider?.hasApiKey ? "A key is saved. Leave empty to keep it." : kind === "openai" ? "Optional for a local Ollama." : undefined}>
|
|
<input className="ai-input ai-mono" type="password" autoComplete="off" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={loaded.provider?.hasApiKey ? "••••••••" : "paste key"} />
|
|
</Field>
|
|
{kind === "anthropic" ? (
|
|
<Field label="Effort" hint="How hard the model thinks. Empty uses the model's default.">
|
|
<select className="ai-select" value={effort} onChange={(e) => setEffort(e.target.value)}>
|
|
<option value="">Default</option>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
</select>
|
|
</Field>
|
|
) : null}
|
|
</div>
|
|
{loaded.provider?.hasApiKey ? (
|
|
<label className="ai-check"><input type="checkbox" checked={clearKey} onChange={(e) => setClearKey(e.target.checked)} /> Remove the saved key</label>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="ai-section">
|
|
<h2>Endpoints for learners' own keys</h2>
|
|
<span className="ai-hint">Own keys go to api.anthropic.com or api.openai.com only. To let people use another endpoint, such as a shared Ollama, list its origin here (one per line, for example http://ollama.internal:11434). STC_AI_ALLOWED_BASE_URLS adds more.</span>
|
|
<Field label="Allowed origins">
|
|
<textarea className="ai-input ai-mono" rows={2} value={allowed} onChange={(e) => setAllowed(e.target.value)} placeholder="http://ollama.internal:11434" />
|
|
</Field>
|
|
</section>
|
|
|
|
<section className="ai-section">
|
|
<h2>MCP servers</h2>
|
|
<span className="ai-hint">Extra tools for the assistant. Leave "Courses" empty to offer a server in every course.</span>
|
|
{servers.map((s, i) => (
|
|
<div className="ai-server" key={i}>
|
|
<div className="ai-grid">
|
|
<Field label="Id"><input className="ai-input ai-mono" value={s.id} onChange={(e) => setServer(i, { id: e.target.value })} /></Field>
|
|
<Field label="Name"><input className="ai-input" value={s.name} onChange={(e) => setServer(i, { name: e.target.value })} /></Field>
|
|
<Field label="Transport">
|
|
<select className="ai-select" value={s.transport} onChange={(e) => setServer(i, { transport: e.target.value as "stdio" | "http" })}>
|
|
<option value="http">Streamable HTTP</option>
|
|
<option value="stdio">Local command (stdio)</option>
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
{s.transport === "http" ? (
|
|
<Field label="URL"><input className="ai-input ai-mono" value={s.url} onChange={(e) => setServer(i, { url: e.target.value })} placeholder="https://mcp.example.com/mcp" /></Field>
|
|
) : (
|
|
<div className="ai-grid">
|
|
<Field label="Command"><input className="ai-input ai-mono" value={s.command} onChange={(e) => setServer(i, { command: e.target.value })} placeholder="bunx" /></Field>
|
|
<Field label="Arguments"><input className="ai-input ai-mono" value={s.args} onChange={(e) => setServer(i, { args: e.target.value })} placeholder="some-mcp-server --flag" /></Field>
|
|
</div>
|
|
)}
|
|
{s.transport === "stdio" ? (
|
|
<Field label="Environment" hint="One NAME=value per line. The command gets only PATH, HOME, LANG and these; server secrets are never passed on. Values are stored encrypted and shown masked; leave a masked value as it is to keep it.">
|
|
<textarea className="ai-input ai-mono" rows={2} value={s.env} onChange={(e) => setServer(i, { env: e.target.value })} placeholder="API_TOKEN=…" />
|
|
</Field>
|
|
) : null}
|
|
<Field label="Courses" hint="Course ids, separated by commas."><input className="ai-input ai-mono" value={s.courses} onChange={(e) => setServer(i, { courses: e.target.value })} /></Field>
|
|
<div className="ai-footer" style={{ margin: "0 0 10px" }}>
|
|
<Pill onClick={() => testServer(i)}>Test connection</Pill>
|
|
<Pill variant="ghost" onClick={() => setServers((x) => x.filter((_, j) => j !== i))}>Remove</Pill>
|
|
{s.test ? <span className="ai-hint">{s.test}</span> : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="ai-footer">
|
|
<Pill onClick={() => setServers((x) => [...x, { id: `server-${x.length + 1}`, name: "", transport: "http", command: "", args: "", env: "", url: "", courses: "" }])}>Add MCP server</Pill>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="ai-footer">
|
|
<Pill variant="primary" type="submit">Save AI settings</Pill>
|
|
{msg ? <span className={msg.ok ? "ai-hint ai-ok" : "ai-error"} role="status" style={{ margin: 0 }}>{msg.text}</span> : null}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|