feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-14 15:51:44 +03:30
co-authored by Claude Sonnet 5.5
parent c93b25741e
commit dee2f72c46
39 changed files with 9060 additions and 95 deletions
+224 -9
View File
@@ -1,16 +1,231 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
/**
* Platform owner console: the MCP gateway's two switches.
*
* An external agent can only act inside the platform through a scoped token, and
* this page is where the owner closes the door when something is wrong: the
* gateway as a whole, and each organization independently. A switch takes effect
* on the next call, because the gateway checks it per request rather than
* trusting a cached state.
*
* A revoked token keeps its place in the list — after an incident, "who could
* have done this" is the question that matters.
*/
import * as React from "react";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
LoadingState,
Stat,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { mcpApi, type McpOrgState } from "@/lib/api-org";
function errorMessage(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
export default function AdminMcpPage() {
const [state, setState] = React.useState<{
gateway_enabled: boolean;
organizations: McpOrgState[];
total_tokens: number;
} | null>(null);
const [loading, setLoading] = React.useState(true);
const [busy, setBusy] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [pendingSwitch, setPendingSwitch] = React.useState<McpOrgState | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
},
[],
);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setState(await mcpApi.adminState());
} catch (err) {
setError(errorMessage(err, "دریافت وضعیت MCP ناموفق بود."));
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const toggleGateway = async () => {
if (!state) return;
setBusy("gateway");
try {
const next = !state.gateway_enabled;
await mcpApi.setGateway(next);
setState((current) => (current ? { ...current, gateway_enabled: next } : current));
toast(next ? "دسترسی MCP سامانه روشن شد" : "دسترسی MCP سامانه خاموش شد");
} catch (err) {
toast("تغییر وضعیت ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const confirmOrgSwitch = async (org: McpOrgState) => {
setBusy(`org-${org.organization_id}`);
try {
await mcpApi.setOrgEnabled(org.organization_id, !org.mcp_enabled);
setState((current) =>
current
? {
...current,
organizations: current.organizations.map((row) =>
row.organization_id === org.organization_id
? { ...row, mcp_enabled: !row.mcp_enabled }
: row,
),
}
: current,
);
toast(org.mcp_enabled ? "دسترسی این سازمان خاموش شد" : "دسترسی این سازمان روشن شد");
setPendingSwitch(null);
} catch (err) {
toast("تغییر وضعیت ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
if (loading) return <LoadingState label="در حال دریافت وضعیت MCP…" />;
if (error || !state)
return <ErrorState message={error ?? "—"} onRetry={load} />;
const enabledOrgs = state.organizations.filter((org) => org.mcp_enabled).length;
return (
<PlaceholderPage
title="MCP"
description="کنترل دسترسی عامل‌های بیرونی به سازمان‌ها."
planned={[
"کلید سراسری",
"توکن‌های هر سازمان",
]}
/>
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">دسترسی MCP</h1>
<p className="text-sm text-slate-400">
عامل‌های بیرونی با توکن‌های محدود به یک سازمان کار می‌کنند. اینجا کل درگاه و
دسترسی هر سازمان را می‌توان خاموش کرد؛ توکن‌ها را مدیران خودشان در تنظیمات
سازمان می‌سازند.
</p>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<Stat
label="وضعیت درگاه"
value={state.gateway_enabled ? "روشن" : "خاموش"}
tone={state.gateway_enabled ? "success" : "danger"}
/>
<Stat label="سازمان‌های دارای دسترسی" value={enabledOrgs} />
<Stat label="توکن‌های فعال" value={state.total_tokens} />
</div>
<Card>
<CardTitle>کلید اصلی</CardTitle>
<CardHint>
خاموش‌کردن درگاه، همهٔ توکن‌ها را بی‌اثر می‌کند بدون آنکه حذف شوند: وقتی دوباره
روشن شود، همان توکن‌ها از جایشان کار می‌کنند.
</CardHint>
<div className="mt-4">
<Button
variant={state.gateway_enabled ? "danger" : "secondary"}
loading={busy === "gateway"}
onClick={() => void toggleGateway()}
>
{state.gateway_enabled ? "خاموش‌کردن دسترسی MCP سامانه" : "روشن‌کردن دسترسی MCP"}
</Button>
</div>
</Card>
<Card>
<CardTitle>سازمان‌ها</CardTitle>
<CardHint>هر سازمان جداگانه قابل خاموش‌کردن است.</CardHint>
<div className="mt-4 overflow-x-auto">
{state.organizations.length === 0 ? (
<EmptyState title="سازمانی وجود ندارد" />
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">سازمان</th>
<th className="px-2 py-2 text-right font-medium">اسلاگ</th>
<th className="px-2 py-2 text-right font-medium">توکن فعال</th>
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
<th className="px-2 py-2 text-right font-medium" />
</tr>
</thead>
<tbody className="anim-stagger">
{state.organizations.map((org) => (
<tr key={org.organization_id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{org.name}</td>
<td className="px-2 py-2 text-slate-400" dir="ltr">
{org.slug}
</td>
<td className="px-2 py-2 text-slate-400">{org.token_count}</td>
<td className="px-2 py-2">
<Badge tone={org.mcp_enabled ? "success" : "neutral"}>
{org.mcp_enabled ? "فعال" : "خاموش"}
</Badge>
</td>
<td className="px-2 py-2 text-end">
<Button
size="sm"
variant="ghost"
loading={busy === `org-${org.organization_id}`}
onClick={() =>
org.mcp_enabled
? setPendingSwitch(org)
: void confirmOrgSwitch(org)
}
>
{org.mcp_enabled ? "خاموش کن" : "روشن کن"}
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</Card>
<ConfirmDialog
open={pendingSwitch !== null}
title="خاموش‌کردن دسترسی یک سازمان"
description={
pendingSwitch
? `توکن‌های «${pendingSwitch.name}» از این لحظه کار نمی‌کنند. مدیران سازمان می‌توانند توکن تازه نسازند.`
: undefined
}
confirmLabel="خاموش کن"
onConfirm={() => pendingSwitch && void confirmOrgSwitch(pendingSwitch)}
onCancel={() => setPendingSwitch(null)}
/>
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
/>
</div>
);
}
@@ -1,17 +1,514 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
/**
* Platform owner console: which models each organization may use.
*
* Two independent switches decide whether a model is usable, and this page is
* the only place both can be set: the model is active in the catalogue, and the
* owner granted it to *this* organization. Revoking here takes effect on the
* engine's next query — there is no cached grant set to expire.
*
* The tabs mirror the three decisions: the per-organization matrix, the default
* set copied into every newly created organization, and the organization's
* policy (employee cap, monthly budget, automation and MCP switches).
*
* Model names are visible here and nowhere else: an organization's own users
* only ever see a neutral tier.
*/
import * as React from "react";
import type { AdminOrganization } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
Checkbox,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
Stat,
Tabs,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { orgApi, type ModelGrantRow, type OrgPolicyView } from "@/lib/api-org";
const TIER_TONES: Record<string, "success" | "info" | "warning"> = {
fast: "success",
balanced: "info",
strong: "warning",
};
function errorMessage(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
export default function AdminModelGrantsPage() {
const [organizations, setOrganizations] = React.useState<AdminOrganization[]>([]);
const [orgId, setOrgId] = React.useState("");
const [models, setModels] = React.useState<ModelGrantRow[]>([]);
const [defaults, setDefaults] = React.useState<ModelGrantRow[]>([]);
const [policy, setPolicy] = React.useState<OrgPolicyView | null>(null);
//: Two independent editors, so they need independent inputs: the org matrix
//: and the default set can hold different caps for the same model.
const [caps, setCaps] = React.useState<Record<string, string>>({});
const [defaultCaps, setDefaultCaps] = React.useState<Record<string, string>>({});
const [tab, setTab] = React.useState("org");
const [loading, setLoading] = React.useState(true);
const [busy, setBusy] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [orgError, setOrgError] = React.useState<string | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const [budget, setBudget] = React.useState("");
const [maxEmployees, setMaxEmployees] = React.useState("5");
const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
},
[],
);
const loadOrgs = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextOrganizations, nextDefaults] = await Promise.all([
api.adminOrganizations(),
orgApi.listDefaultGrants(),
]);
setOrganizations(nextOrganizations);
setDefaults(nextDefaults);
setDefaultCaps(
Object.fromEntries(
nextDefaults.map((m) => [
m.ai_model_id,
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
]),
),
);
setOrgId((current) => current || (nextOrganizations[0]?.id ?? ""));
} catch (err) {
setError(errorMessage(err, "دریافت داده‌های تخصیص مدل ناموفق بود."));
} finally {
setLoading(false);
}
}, []);
const loadOrgDetail = React.useCallback(async (id: string) => {
if (!id) {
setModels([]);
setPolicy(null);
return;
}
setOrgError(null);
try {
const [grants, nextPolicy] = await Promise.all([
orgApi.listOrgGrants(id),
orgApi.getOrgPolicy(id),
]);
setModels(grants.models);
setCaps(
Object.fromEntries(
grants.models.map((m) => [
m.ai_model_id,
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
]),
),
);
setPolicy(nextPolicy);
setMaxEmployees(String(nextPolicy.max_employees));
setBudget(
nextPolicy.monthly_credit_budget === null
? ""
: String(nextPolicy.monthly_credit_budget),
);
} catch (err) {
setOrgError(errorMessage(err, "دریافت تخصیص‌های این سازمان ناموفق بود."));
}
}, []);
React.useEffect(() => {
void loadOrgs();
}, [loadOrgs]);
React.useEffect(() => {
void loadOrgDetail(orgId);
}, [loadOrgDetail, orgId]);
const parseCap = (raw: string): number | null => {
const trimmed = raw.trim();
if (trimmed === "") return null;
const value = Number(trimmed);
return Number.isFinite(value) && value >= 0 ? value : null;
};
const toggleGrant = async (model: ModelGrantRow, next: boolean) => {
setBusy(model.ai_model_id);
try {
if (next) {
await orgApi.setOrgGrant(orgId, model.ai_model_id, parseCap(caps[model.ai_model_id] ?? ""));
} else {
await orgApi.revokeOrgGrant(orgId, model.ai_model_id);
}
await loadOrgDetail(orgId);
toast(next ? `مدل «${model.label}» تخصیص یافت` : `دسترسی مدل «${model.label}» لغو شد`);
} catch (err) {
toast("تغییر تخصیص ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const saveCap = async (model: ModelGrantRow) => {
setBusy(model.ai_model_id);
try {
await orgApi.setOrgGrant(orgId, model.ai_model_id, parseCap(caps[model.ai_model_id] ?? ""));
await loadOrgDetail(orgId);
toast("سقف اعتبار روزانه ذخیره شد");
} catch (err) {
toast("ذخیرهٔ سقف ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const toggleDefault = async (model: ModelGrantRow, next: boolean) => {
setBusy(model.ai_model_id);
try {
await orgApi.setDefaultGrant(model.ai_model_id, {
is_active: next,
daily_credit_cap: parseCap(defaultCaps[model.ai_model_id] ?? ""),
});
const nextDefaults = await orgApi.listDefaultGrants();
setDefaults(nextDefaults);
setDefaultCaps(
Object.fromEntries(
nextDefaults.map((m) => [
m.ai_model_id,
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
]),
),
);
toast(next ? "به مجموعهٔ پیش‌فرض افزوده شد" : "از مجموعهٔ پیش‌فرض حذف شد");
} catch (err) {
toast("تغییر مجموعهٔ پیش‌فرض ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const savePolicy = async (event: React.FormEvent) => {
event.preventDefault();
if (!policy) return;
setBusy("policy");
try {
const updated = await orgApi.updateOrgPolicy(orgId, {
max_employees: Number(maxEmployees),
monthly_credit_budget: budget.trim() === "" ? null : Number(budget),
automation_enabled: policy.automation_enabled,
mcp_enabled: policy.mcp_enabled,
});
setPolicy(updated);
toast("سیاست سازمان ذخیره شد");
} catch (err) {
toast("ذخیرهٔ سیاست ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const setPolicyFlag = (key: "automation_enabled" | "mcp_enabled", value: boolean) => {
setPolicy((current) => (current ? { ...current, [key]: value } : current));
};
if (loading) return <LoadingState label="در حال دریافت تخصیص مدل‌ها…" />;
if (error) return <ErrorState message={error} onRetry={loadOrgs} />;
const grantedCount = models.filter((m) => m.granted).length;
const defaultCount = defaults.filter((m) => m.granted).length;
const activeDefaults = defaults.filter((m) => m.granted);
return (
<PlaceholderPage
title="مدل‌های سازمان‌ها"
description="تخصیص مدل‌ها به هر سازمان و سیاست‌های سازمان."
planned={[
"ماتریس تخصیص مدل",
"سقف اعتبار روزانه",
"مجموعهٔ پیش‌فرض سازمان‌های جدید",
]}
/>
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">مدل‌های سازمان‌ها</h1>
<p className="text-sm text-slate-400">
هر سازمان فقط می‌تواند از مدل‌هایی استفاده کند که مالک سامانه تخصیص داده است. کاربران
سازمان نام مدل را نمی‌بینند؛ فقط سطح آن («سریع»، «متعادل»، «قوی») را می‌بینند.
</p>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="سازمان‌ها" value={organizations.length} />
<Stat label="مدل‌های تخصیص‌یافته (سازمان جاری)" value={grantedCount} />
<Stat label="مدل‌های پیش‌فرض" value={defaultCount} />
</div>
<Tabs
items={[
{ key: "org", label: "تخصیص سازمان‌ها" },
{ key: "defaults", label: "مجموعهٔ پیش‌فرض" },
{ key: "policy", label: "سیاست سازمان" },
]}
active={tab}
onChange={setTab}
/>
{tab !== "defaults" && (
<Card>
<CardTitle>انتخاب سازمان</CardTitle>
<div className="mt-3 max-w-sm">
<Select
label="سازمان"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
options={
organizations.length === 0
? [{ value: "", label: "سازمانی وجود ندارد" }]
: organizations.map((org) => ({
value: org.id,
label: `${org.name} (${org.slug})`,
}))
}
/>
</div>
</Card>
)}
{tab === "org" && (
<>
{orgError && <ErrorState message={orgError} onRetry={() => void loadOrgDetail(orgId)} />}
{!orgError && !orgId && (
<EmptyState
title="سازمانی وجود ندارد"
description="پس از ساخت نخستین سازمان توسط یک مدیر، تخصیص مدل‌ها از همین‌جا انجام می‌شود."
/>
)}
{!orgError && orgId && models.length === 0 && (
<EmptyState
title="مدلی در کاتالوگ نیست"
description="ابتدا از بخش «مدل‌ها» مدل‌های فعال را تعریف کنید."
/>
)}
{!orgError && orgId && models.length > 0 && (
<Card>
<CardTitle>ماتریس تخصیص</CardTitle>
<div className="mt-1">
<CardHint>
تیک هر مدل یعنی این سازمان می‌تواند از آن استفاده کند؛ لغو تیک بلافاصله اعمال
می‌شود. «سقف روزانه» اختیاری است و بر حسب اعتبار محاسبه می‌شود.
</CardHint>
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">مدل</th>
<th className="px-2 py-2 text-right font-medium">سطح</th>
<th className="px-2 py-2 text-right font-medium">قابلیت‌ها</th>
<th className="px-2 py-2 text-right font-medium">تخصیص</th>
<th className="px-2 py-2 text-right font-medium">سقف روزانه</th>
</tr>
</thead>
<tbody className="anim-stagger">
{models.map((model) => (
<tr key={model.ai_model_id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{model.label}</td>
<td className="px-2 py-2">
<Badge tone={TIER_TONES[model.tier] ?? "neutral"}>
{model.tier_label}
</Badge>
</td>
<td className="px-2 py-2 text-xs text-slate-400">
{model.capabilities.join("، ") || "—"}
</td>
<td className="px-2 py-2">
<Checkbox
label="فعال"
checked={model.granted}
disabled={busy === model.ai_model_id}
onChange={(e) => void toggleGrant(model, e.target.checked)}
/>
</td>
<td className="px-2 py-2">
<div className="flex items-center gap-2">
<input
type="number"
min={0}
step="0.5"
aria-label={`سقف روزانهٔ ${model.label}`}
value={caps[model.ai_model_id] ?? ""}
onChange={(e) =>
setCaps((current) => ({
...current,
[model.ai_model_id]: e.target.value,
}))
}
className="w-24 rounded-lg border border-surface-border bg-surface px-2 py-1 text-xs text-slate-100"
/>
<Button
size="sm"
variant="secondary"
disabled={!model.granted || busy === model.ai_model_id}
onClick={() => void saveCap(model)}
>
ذخیره
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</>
)}
{tab === "defaults" && (
<Card>
<CardTitle>مجموعهٔ پیش‌فرض سازمان‌های جدید</CardTitle>
<div className="mt-1">
<CardHint>
این مجموعه هنگام ساخت هر سازمان جدید به‌صورت خودکار کپی می‌شود. خاموش‌کردن یک مدل
آن را در فهرست نگه می‌دارد اما به سازمان‌های بعدی کپی نمی‌شود؛ سازمان‌های قبلی
دسترسی خود را از دست نمی‌دهند.
</CardHint>
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">مدل</th>
<th className="px-2 py-2 text-right font-medium">سطح</th>
<th className="px-2 py-2 text-right font-medium">در مجموعه</th>
<th className="px-2 py-2 text-right font-medium">سقف روزانه</th>
</tr>
</thead>
<tbody className="anim-stagger">
{defaults.map((model) => (
<tr key={model.ai_model_id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{model.label}</td>
<td className="px-2 py-2">
<Badge tone={TIER_TONES[model.tier] ?? "neutral"}>{model.tier_label}</Badge>
</td>
<td className="px-2 py-2">
<Checkbox
label="پیش‌فرض"
checked={model.granted}
disabled={busy === model.ai_model_id}
onChange={(e) => void toggleDefault(model, e.target.checked)}
/>
</td>
<td className="px-2 py-2">
<div className="flex items-center gap-2">
<input
type="number"
min={0}
step="0.5"
aria-label={`سقف روزانهٔ پیش‌فرض ${model.label}`}
value={defaultCaps[model.ai_model_id] ?? ""}
onChange={(e) =>
setDefaultCaps((current) => ({
...current,
[model.ai_model_id]: e.target.value,
}))
}
className="w-24 rounded-lg border border-surface-border bg-surface px-2 py-1 text-xs text-slate-100"
/>
<Button
size="sm"
variant="secondary"
disabled={!model.granted || busy === model.ai_model_id}
onClick={() => void toggleDefault(model, true)}
>
ذخیره
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{activeDefaults.length === 0 && (
<div className="mt-3">
<CardHint>
مجموعهٔ پیش‌فرض خالی است: سازمان‌های جدید تا زمانی که تخصیص دستی نگیرند هیچ
مدلی ندارند.
</CardHint>
</div>
)}
</Card>
)}
{tab === "policy" && (
<>
{orgError && <ErrorState message={orgError} onRetry={() => void loadOrgDetail(orgId)} />}
{!orgError && policy && (
<Card>
<CardTitle>سیاست سازمان</CardTitle>
<div className="mt-1">
<CardHint>
سقف پرسنل نمی‌تواند از سقف سامانه ({policy.max_employees_limit}) بیشتر باشد.
بودجهٔ ماهانه خالی یعنی بدون سقف صریح.
</CardHint>
</div>
<form onSubmit={savePolicy} className="mt-4 flex max-w-lg flex-col gap-4">
<Field
label="حداکثر پرسنل"
type="number"
min={1}
max={policy.max_employees_limit}
value={maxEmployees}
onChange={(e) => setMaxEmployees(e.target.value)}
/>
<Field
label="بودجهٔ ماهانه (اختیاری)"
type="number"
min={0}
step="0.5"
value={budget}
onChange={(e) => setBudget(e.target.value)}
hint="خالی بگذارید تا سقف صریحی اعمال نشود."
/>
<Checkbox
label="اتوماسیون فعال باشد"
checked={policy.automation_enabled}
onChange={(e) => setPolicyFlag("automation_enabled", e.target.checked)}
/>
<Checkbox
label="دسترسی MCP برای این سازمان فعال باشد"
checked={policy.mcp_enabled}
onChange={(e) => setPolicyFlag("mcp_enabled", e.target.checked)}
/>
<div>
<Button type="submit" loading={busy === "policy"}>
ذخیرهٔ سیاست
</Button>
</div>
</form>
</Card>
)}
</>
)}
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
/>
</div>
);
}
+19 -15
View File
@@ -1,19 +1,23 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
/**
* Knowledge is now a tab of ``/memory``: a document and a one-line rule are
* different things, and they belong on one page so a reader never has to guess
* which half of the organization's knowledge lives where.
*
* This route stays alive as a permanent redirect so an old bookmark (or a link
* in a chat message) lands on the page that replaced it.
*/
export default function KnowledgePage() {
return (
<PlaceholderPage
title="دانش سازمانی"
description="منابع دانش سازمان که کارمندهای هوش مصنوعی از آن‌ها استفاده می‌کنند."
planned={[
"افزودن فایل، متن، URL و مخزن به‌عنوان منبع دانش",
"استخراج متن، تکه‌بندی (chunk) و ایندکس برداری",
"جست‌وجوی سادهٔ کلیدواژه‌ای در MVP",
"اتصال هر منبع دانش به کارمندهای مشخص",
"جداسازی کامل دانش بین سازمان‌ها",
]}
/>
);
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function KnowledgeRedirectPage() {
const router = useRouter();
useEffect(() => {
router.replace("/memory");
}, [router]);
return null;
}
+531 -11
View File
@@ -1,18 +1,538 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
/**
* Org memory: what the organization has learned about itself.
*
* Four kinds of memory, four tabs, one page — the plan's "one place" rule. The
* Knowledge Base keeps its own endpoints and appears here as a second family of
* tabs, because a document and a one-line rule are different things and
* pretending otherwise is how knowledge bases become unreadable.
*
* Live by design: the API publishes ``memory.changed`` on ``org:<id>`` after
* every write, so a page left open on one tab sees a lesson that another member
* (or the engine's own reflection) just recorded — including the realtime
* refresh, not a stale list.
*/
import * as React from "react";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Modal,
Select,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import {
MEMORY_CHANGED_EVENT,
memoryApi,
memoryKinds,
type KnowledgeSourceRow,
type MemoryRow,
} from "@/lib/api-org";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
type Tab = "rules" | "lessons" | "code" | "knowledge";
const TABS: { key: Tab; label: string; hint: string }[] = [
{ key: "rules", label: "قوانین و ترجیحات", hint: "تصمیم‌های همیشگی سازمان" },
{ key: "lessons", label: "درس‌های آموخته", hint: "آنچه کارهای تمام‌شده یاد دادند" },
{ key: "code", label: "نقشهٔ کد", hint: "واقعیت‌ها و نقشهٔ اپلیکیشن" },
{ key: "knowledge", label: "منابع دانش", hint: "اسناد و متن‌های بلند" },
];
/** Which kinds each tab shows. */
const TAB_KINDS: Record<Tab, string[]> = {
rules: ["rule", "preference"],
lessons: ["lesson"],
code: ["code_map", "fact"],
knowledge: [],
};
const KIND_TONES: Record<string, "success" | "info" | "warning" | "neutral"> = {
rule: "success",
preference: "info",
lesson: "warning",
fact: "neutral",
code_map: "info",
};
function errorMessage(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
function formatDate(value: string | null): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleDateString("fa-IR");
}
export default function MemoryPage() {
const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const [tab, setTab] = React.useState<Tab>("rules");
const [items, setItems] = React.useState<MemoryRow[]>([]);
const [stats, setStats] = React.useState({ total: 0, pinned: 0, chars: 0 });
const [sources, setSources] = React.useState<KnowledgeSourceRow[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState<string | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const [teachOpen, setTeachOpen] = React.useState(false);
const [teachKind, setTeachKind] = React.useState<"rule" | "preference">("rule");
const [teachContent, setTeachContent] = React.useState("");
const [editing, setEditing] = React.useState<MemoryRow | null>(null);
const [editContent, setEditContent] = React.useState("");
const [pendingDelete, setPendingDelete] = React.useState<MemoryRow | null>(null);
const [sourceName, setSourceName] = React.useState("");
const [sourceText, setSourceText] = React.useState("");
const [pendingSource, setPendingSource] = React.useState<KnowledgeSourceRow | null>(null);
const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
},
[],
);
const load = React.useCallback(async () => {
if (!orgId) return;
setLoading(true);
setError(null);
try {
const [memory, knowledge] = await Promise.all([
memoryApi.list(orgId),
memoryApi.listKnowledge(orgId),
]);
setItems(memory.items);
setStats({ total: memory.total, pinned: memory.pinned, chars: memory.chars });
setSources(knowledge);
} catch (err) {
setError(errorMessage(err, "دریافت حافظهٔ سازمان ناموفق بود."));
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
// Live: another member (or a reflection) changed memory while this page was open.
useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => {
if ((event.type as string) === MEMORY_CHANGED_EVENT) void load();
});
const visible = React.useMemo(
() => items.filter((item) => TAB_KINDS[tab].includes(item.kind)),
[items, tab],
);
const run = async (key: string, action: () => Promise<void>) => {
setBusy(key);
try {
await action();
await load();
} catch (err) {
toast("عملیات ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const teach = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId || !teachContent.trim()) return;
await run("teach", async () => {
const created = await memoryApi.teach(orgId, {
kind: teachKind,
content: teachContent.trim(),
});
setTeachOpen(false);
setTeachContent("");
toast(created.kind_label === "قاعده" ? "قاعده یادآوری شد" : "ترجیح ذخیره شد");
});
};
const saveEdit = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId || !editing) return;
const content = editContent.trim();
if (!content) return;
await run(`edit-${editing.id}`, async () => {
await memoryApi.edit(orgId, editing.id, { content });
setEditing(null);
toast("یادداشت ویرایش شد");
});
};
const togglePin = (item: MemoryRow) =>
void run(`pin-${item.id}`, async () => {
await memoryApi.setPinned(orgId!, item.id, !item.pinned);
toast(item.pinned ? "سنجاق برداشته شد" : "سنجاق شد: همیشه در متن کارها");
});
const confirmLesson = (item: MemoryRow) =>
void run(`confirm-${item.id}`, async () => {
await memoryApi.confirm(orgId!, item.id);
toast("درس تأیید شد: دیگر منقضی نمی‌شود");
});
const remove = (item: MemoryRow) =>
void run(`delete-${item.id}`, async () => {
await memoryApi.remove(orgId!, item.id);
setPendingDelete(null);
toast("یادداشت حذف شد");
});
const addSource = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId || !sourceName.trim() || !sourceText.trim()) return;
await run("source", async () => {
const created = await memoryApi.createKnowledge(orgId, {
name: sourceName.trim(),
content: sourceText,
});
setSources((current) => [created, ...current]);
setSourceName("");
setSourceText("");
toast(`منبع «${created.name}» افزوده شد`);
});
};
const removeSource = (source: KnowledgeSourceRow) =>
void run(`source-${source.id}`, async () => {
await memoryApi.removeKnowledge(orgId!, source.id);
setSources((current) => current.filter((row) => row.id !== source.id));
setPendingSource(null);
toast("منبع دانش حذف شد");
});
if (!orgId) return <LoadingState label="در حال بارگذاری سازمان…" />;
if (loading && items.length === 0) return <LoadingState label="در حال دریافت حافظه…" />;
if (error && items.length === 0) return <ErrorState message={error} onRetry={load} />;
return (
<PlaceholderPage
title="حافظهٔ سازمان"
description="آنچه سازمان دربارهٔ شیوهٔ مدیریت و کد اپلیکیشن یاد گرفته است."
planned={[
"قوانین و ترجیحات",
"درس‌های آموخته",
"نقشهٔ کد",
"منابع دانش",
]}
/>
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">حافظهٔ سازمان</h1>
<p className="text-sm text-slate-400">
آنچه سازمان دربارهٔ شیوهٔ کار و اپلیکیشنش یاد گرفته است. کارمندهای هوش مصنوعی
پیش از هر کار، یادداشت‌های مرتبط را می‌خوانند؛ هر چه سازمان بیشتر یاد بدهد،
کارها دقیق‌تر انجام می‌شود.
</p>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-surface-border bg-surface p-3">
<p className="text-xs text-slate-400">یادداشت‌ها</p>
<p className="mt-1 text-lg text-slate-100">{stats.total}</p>
</div>
<div className="rounded-xl border border-surface-border bg-surface p-3">
<p className="text-xs text-slate-400">سنجاق‌شده</p>
<p className="mt-1 text-lg text-slate-100">{stats.pinned}</p>
</div>
<div className="rounded-xl border border-surface-border bg-surface p-3">
<p className="text-xs text-slate-400">حجم متن</p>
<p className="mt-1 text-lg text-slate-100">{stats.chars.toLocaleString("fa-IR")}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{TABS.map((entry) => (
<button
key={entry.key}
type="button"
onClick={() => setTab(entry.key)}
title={entry.hint}
className={
"rounded-lg border px-3 py-1.5 text-sm transition-colors " +
(tab === entry.key
? "border-accent bg-accent/10 text-slate-100"
: "border-surface-border text-slate-400 hover:text-slate-200")
}
>
{entry.label}
</button>
))}
<div className="ms-auto">
{tab !== "knowledge" && (
<Button size="sm" onClick={() => setTeachOpen(true)}>
به سازمان یاد بده
</Button>
)}
</div>
</div>
{error && <ErrorState message={error} onRetry={load} />}
{tab === "knowledge" ? (
<>
<Card>
<CardTitle>افزودن منبع متنی</CardTitle>
<CardHint>
منابع دانش اسناد بلند هستند: متن را یک‌جا می‌دهیم، سامانه آن را تکه‌تکه و
قابل جست‌وجو می‌کند. یادداشت‌های کوتاه‌تر را در تب‌های دیگر بیاموزید.
</CardHint>
<form onSubmit={addSource} className="mt-4 flex flex-col gap-3">
<Field
label="نام منبع"
value={sourceName}
onChange={(e) => setSourceName(e.target.value)}
placeholder="مثلاً قواعد کدنویسی"
required
/>
<label className="text-sm text-slate-300">
<span className="mb-1 block text-xs text-slate-400">متن</span>
<textarea
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
rows={5}
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
placeholder="متن منبع دانش…"
required
/>
</label>
<div>
<Button type="submit" loading={busy === "source"}>
افزودن منبع
</Button>
</div>
</form>
</Card>
{sources.length === 0 ? (
<EmptyState
title="منبعی ثبت نشده است"
description="متن‌های بلند سازمان را اینجا بگذارید تا کارمندها در کارهایشان از آن‌ها استفاده کنند."
/>
) : (
<Card>
<CardTitle>منابع دانش</CardTitle>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">نام</th>
<th className="px-2 py-2 text-right font-medium">نوع</th>
<th className="px-2 py-2 text-right font-medium">تکه‌ها</th>
<th className="px-2 py-2 text-right font-medium">ایجاد</th>
<th className="px-2 py-2 text-right font-medium" />
</tr>
</thead>
<tbody>
{sources.map((source) => (
<tr key={source.id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{source.name}</td>
<td className="px-2 py-2 text-slate-400">{source.kind}</td>
<td className="px-2 py-2 text-slate-400">
{source.chunk_count.toLocaleString("fa-IR")}
</td>
<td className="px-2 py-2 text-slate-400">{formatDate(source.created_at)}</td>
<td className="px-2 py-2 text-end">
<Button
size="sm"
variant="ghost"
onClick={() => setPendingSource(source)}
>
حذف
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</>
) : visible.length === 0 ? (
<EmptyState
title={tab === "lessons" ? "هنوز درسی ثبت نشده" : "هنوز چیزی یادشده نیست"}
description="با دکمهٔ «به سازمان یاد بده» یک قاعده یا ترجیح ثبت کنید؛ درس‌ها خودبه‌خود از کارهای تمام‌شده استخراج می‌شوند."
/>
) : (
<div className="flex flex-col gap-3">
{visible.map((item) => (
<Card key={item.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={KIND_TONES[item.kind] ?? "neutral"}>{item.kind_label}</Badge>
{item.pinned && <Badge tone="info">سنجاق‌شده</Badge>}
<span className="text-xs text-slate-500">{item.source_label}</span>
{item.key && (
<code className="rounded bg-surface-border/40 px-1.5 py-0.5 text-[11px] text-slate-400">
{item.key}
</code>
)}
{item.kind === "lesson" && (
<span className="text-[11px] text-slate-500">
اعتماد {Math.round(item.confidence * 100).toLocaleString("fa-IR")}٪
</span>
)}
</div>
<p className="whitespace-pre-wrap text-sm text-slate-200">{item.content}</p>
<p className="text-[11px] text-slate-500">
{item.hits > 0
? `${item.hits.toLocaleString("fa-IR")} بار در متن کارها آمده`
: "هنوز در کاری استفاده نشده"}
{" · "}
{formatDate(item.updated_at)}
{item.expires_at ? ` · انقضا ${formatDate(item.expires_at)}` : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="secondary"
loading={busy === `pin-${item.id}`}
onClick={() => togglePin(item)}
>
{item.pinned ? "برداشتن سنجاق" : "سنجاق کردن"}
</Button>
{item.kind === "lesson" && item.confidence < 1 && (
<Button
size="sm"
variant="secondary"
loading={busy === `confirm-${item.id}`}
onClick={() => confirmLesson(item)}
>
تأیید درس
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditing(item);
setEditContent(item.content);
}}
>
ویرایش
</Button>
<Button size="sm" variant="ghost" onClick={() => setPendingDelete(item)}>
حذف
</Button>
</div>
</div>
</Card>
))}
</div>
)}
<Modal
open={teachOpen}
title="به سازمان یاد بده"
description="یک قاعده یا یک ترجیح: یک جملهٔ کافی است."
onClose={() => setTeachOpen(false)}
>
<form onSubmit={teach} className="flex flex-col gap-4">
<Select
label="نوع"
value={teachKind}
onChange={(e) => setTeachKind(e.target.value as "rule" | "preference")}
options={memoryKinds
.filter((kind) => kind.key === "rule" || kind.key === "preference")
.map((kind) => ({ value: kind.key, label: kind.label }))}
/>
<label className="text-sm text-slate-300">
<span className="mb-1 block text-xs text-slate-400">متن یادداشت</span>
<textarea
value={teachContent}
onChange={(e) => setTeachContent(e.target.value)}
rows={4}
maxLength={1500}
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
placeholder="مثلاً: پیش از هر تغییر، تست مربوطه را اجرا کن."
required
/>
</label>
<p className="text-xs text-slate-500">
یک جملهٔ کافی است. هیچ گذرواژه یا کلیدی در یادداشت‌ها ذخیره نمی‌شود.
</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" type="button" onClick={() => setTeachOpen(false)}>
انصراف
</Button>
<Button type="submit" loading={busy === "teach"}>
یادآوری کن
</Button>
</div>
</form>
</Modal>
{editing && (
<Modal open title="ویرایش یادداشت" onClose={() => setEditing(null)}>
<form onSubmit={saveEdit} className="flex flex-col gap-4">
<label className="text-sm text-slate-300">
<span className="mb-1 block text-xs text-slate-400">متن</span>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={4}
maxLength={1500}
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
required
/>
</label>
<div className="flex justify-end gap-2">
<Button variant="ghost" type="button" onClick={() => setEditing(null)}>
انصراف
</Button>
<Button type="submit" loading={busy === `edit-${editing.id}`}>
ذخیره
</Button>
</div>
</form>
</Modal>
)}
<ConfirmDialog
open={pendingDelete !== null}
title="حذف یادداشت"
description={
pendingDelete
? `«${pendingDelete.content.slice(0, 60)}…» از حافظهٔ سازمان برداشته شود؟`
: ""
}
confirmLabel="حذف"
onConfirm={() => pendingDelete && remove(pendingDelete)}
onCancel={() => setPendingDelete(null)}
/>
<ConfirmDialog
open={pendingSource !== null}
title="حذف منبع دانش"
description={
pendingSource ? `منبع «${pendingSource.name}» و تکه‌های متن آن حذف شود؟` : ""
}
confirmLabel="حذف"
onConfirm={() => pendingSource && removeSource(pendingSource)}
onCancel={() => setPendingSource(null)}
/>
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
/>
</div>
);
}
+282 -3
View File
@@ -8,13 +8,17 @@ import {
Card,
CardHint,
CardTitle,
Checkbox,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Modal,
Select,
Timeline,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import {
@@ -23,7 +27,14 @@ import {
CAN_VIEW_AUDIT,
roleHasCapability,
} from "@/lib/permissions";
import { appProfileApi, setupApi } from "@/lib/api-org";
import {
mcpApi,
appProfileApi,
setupApi,
type McpSettings,
type McpTokenCreated,
type McpTokenRow,
} from "@/lib/api-org";
import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<Role, string> = {
@@ -64,7 +75,11 @@ const AUDIT_LABELS: Record<string, string> = {
"run.cancel": "لغو اجرا",
};
type Tab = "members" | "audit" | "app";
type Tab = "members" | "app" | "mcp" | "audit";
function errorText(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
/** Matches ``INVITE_TTL_DAYS`` in the API so the hint cannot drift. */
const INVITE_TTL_DAYS = 14;
@@ -101,6 +116,27 @@ export default function SettingsPage() {
const [busyMemberId, setBusyMemberId] = React.useState<string | null>(null);
const [pendingRemove, setPendingRemove] = React.useState<Member | null>(null);
// --- MCP integration ----------------------------------------------------
const [mcpTokens, setMcpTokens] = React.useState<McpTokenRow[]>([]);
const [mcpSettings, setMcpSettings] = React.useState<McpSettings | null>(null);
const [mcpLoading, setMcpLoading] = React.useState(false);
const [mcpName, setMcpName] = React.useState("");
const [mcpScopes, setMcpScopes] = React.useState<string[]>(["tasks:read"]);
const [mcpBusy, setMcpBusy] = React.useState(false);
const [mcpCreated, setMcpCreated] = React.useState<McpTokenCreated | null>(null);
const [pendingRevoke, setPendingRevoke] = React.useState<McpTokenRow | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
},
[],
);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
@@ -129,13 +165,36 @@ export default function SettingsPage() {
}
}, [orgId]);
const loadMcp = React.useCallback(async () => {
if (!orgId) return;
setMcpLoading(true);
try {
const [tokens, settings] = await Promise.all([
mcpApi.list(orgId),
mcpApi.settings(orgId),
]);
setMcpTokens(tokens);
setMcpSettings(settings);
setMcpScopes((current) =>
current.filter((scope) => (settings.scopes as string[]).includes(scope)).length > 0
? current
: (settings.scopes.slice(0, 1) as string[]),
);
} catch {
setMcpSettings(null);
} finally {
setMcpLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (tab === "audit") void loadAudit();
}, [tab, loadAudit]);
if (tab === "mcp") void loadMcp();
}, [tab, loadAudit, loadMcp]);
const onInvite = async (event: React.FormEvent) => {
event.preventDefault();
@@ -197,6 +256,45 @@ export default function SettingsPage() {
}
};
// --- MCP ----------------------------------------------------------------
const onCreateToken = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId || !mcpName.trim() || mcpScopes.length === 0) return;
setMcpBusy(true);
try {
const created = await mcpApi.create(orgId, {
name: mcpName.trim(),
scopes: mcpScopes,
});
setMcpCreated(created);
setMcpTokens((current) => [created, ...current]);
setMcpName("");
toast("توکن ساخته شد", "success", "توکن فقط یک بار نمایش داده می‌شود؛ آن را ذخیره کنید.");
} catch (err) {
toast("ساخت توکن ناموفق بود", "danger", errorText(err, "دوباره تلاش کنید."));
} finally {
setMcpBusy(false);
}
};
const onRevokeToken = async (token: McpTokenRow) => {
if (!orgId) return;
setMcpBusy(true);
try {
await mcpApi.revoke(orgId, token.id);
setMcpTokens((current) =>
current.map((row) => (row.id === token.id ? { ...row, status: "revoked" } : row)),
);
setPendingRevoke(null);
toast("دسترسی توکن لغو شد");
} catch (err) {
toast("لغو توکن ناموفق بود", "danger", errorText(err, "دوباره تلاش کنید."));
} finally {
setMcpBusy(false);
}
};
if (!activeOrganization) {
return (
<EmptyState
@@ -209,6 +307,8 @@ export default function SettingsPage() {
const canManageMembers = roleHasCapability(activeOrganization.role, CAN_MANAGE_MEMBERS);
const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
const canManageProjects = roleHasCapability(activeOrganization.role, CAN_MANAGE_PROJECTS);
const canManageMcp =
activeOrganization.role === "owner" || activeOrganization.role === "admin";
return (
<div className="anim-stagger flex flex-col gap-5">
@@ -222,6 +322,7 @@ export default function SettingsPage() {
[
["members", "اعضا و دعوت"],
["app", "اپلیکیشن"],
["mcp", "یکپارچه‌سازی (MCP)"],
["audit", "رویدادهای حسابرسی"],
] as const
).map(([value, label]) => (
@@ -378,6 +479,134 @@ export default function SettingsPage() {
</>
) : tab === "app" ? (
<AppTab orgId={activeOrganization.id} canEdit={canManageProjects} />
) : tab === "mcp" ? (
<div className="flex flex-col gap-4">
<Card>
<CardTitle>یکپارچه‌سازی (MCP)</CardTitle>
<CardHint>
با یک توکن، عامل‌های هوش مصنوعی بیرونی می‌توانند در همین سازمان کار کنند:
وظایف بسازند، حافظه را جست‌وجو کنند و اپلیکیشن را بخوانند. توکن نشان می‌دهد
که عامل از طرف کدام سازمان حرف می‌زند — هرگز برعکس.
</CardHint>
{mcpSettings && (!mcpSettings.gateway_enabled || !mcpSettings.mcp_enabled) && (
<p className="mt-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{!mcpSettings.gateway_enabled
? "دسترسی MCP سامانه با دستور مالک سامانه خاموش است؛ توکن‌ها تا روشن‌شدن آن کار نمی‌کنند."
: "دسترسی MCP این سازمان خاموش است؛ برای روشن‌کردن آن با مالک سامانه تماس بگیرید."}
</p>
)}
{mcpLoading ? (
<LoadingState label="در حال دریافت توکن‌ها…" />
) : mcpTokens.length === 0 ? (
<EmptyState
title="توکنی ساخته نشده است"
description="توکن‌ها دسترسی یک عامل بیرونی را به همین سازمان محدود می‌کنند."
/>
) : (
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">نام</th>
<th className="px-2 py-2 text-right font-medium">توکن</th>
<th className="px-2 py-2 text-right font-medium">محدوده‌ها</th>
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
<th className="px-2 py-2 text-right font-medium" />
</tr>
</thead>
<tbody className="anim-stagger">
{mcpTokens.map((token) => (
<tr key={token.id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{token.name}</td>
<td className="px-2 py-2 text-slate-400" dir="ltr">
{token.prefix}…
</td>
<td className="px-2 py-2 text-xs text-slate-400" dir="ltr">
{token.scopes.join(", ")}
</td>
<td className="px-2 py-2">
<Badge
tone={
token.status === "active"
? "success"
: token.status === "expired"
? "warning"
: "neutral"
}
>
{token.status === "active"
? "فعال"
: token.status === "expired"
? "منقضی"
: "لغو شده"}
</Badge>
</td>
<td className="px-2 py-2 text-end">
{canManageMcp && token.status === "active" && (
<Button
size="sm"
variant="ghost"
onClick={() => setPendingRevoke(token)}
>
لغو
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{canManageMcp && (
<Card>
<CardTitle>توکن تازه</CardTitle>
<CardHint>
توکن فقط یک بار، همین‌جا، نمایش داده می‌شود. محدوده‌ها را کمترین چیزی
بگیرید که عامل برای کارش لازم دارد.
</CardHint>
<form onSubmit={onCreateToken} className="mt-4 flex flex-col gap-4">
<Field
label="نام توکن"
value={mcpName}
onChange={(e) => setMcpName(e.target.value)}
placeholder="مثلاً عامل CI"
required
/>
<div>
<p className="mb-2 text-xs text-slate-400">محدوده‌های دسترسی</p>
<div className="grid gap-2 sm:grid-cols-2">
{(mcpSettings?.scopes ?? []).map((scope) => (
<Checkbox
key={scope}
label={scope}
checked={mcpScopes.includes(scope)}
onChange={(e) =>
setMcpScopes((current) =>
e.target.checked
? [...current, scope]
: current.filter((item) => item !== scope),
)
}
/>
))}
</div>
</div>
<div>
<Button
type="submit"
loading={mcpBusy}
disabled={mcpScopes.length === 0 || !mcpName.trim()}
>
ساخت توکن
</Button>
</div>
</form>
</Card>
)}
</div>
) : (
<Card>
<CardTitle>رویدادهای حسابرسی</CardTitle>
@@ -413,6 +642,56 @@ export default function SettingsPage() {
</Card>
)}
<Modal
open={mcpCreated !== null}
title="توکن ساخته شد"
description="این توکن فقط حالا نمایش داده می‌شود؛ بعداً هیچ راهی برای دیدن آن نیست."
onClose={() => setMcpCreated(null)}
>
<div className="flex flex-col gap-3">
<code
dir="ltr"
className="block break-all rounded-lg border border-surface-border bg-surface px-3 py-2 text-xs text-slate-100"
>
{mcpCreated?.token}
</code>
<div className="flex justify-end">
<Button
size="sm"
variant="secondary"
onClick={() => {
if (mcpCreated) void navigator.clipboard.writeText(mcpCreated.token);
toast("توکن کپی شد");
}}
>
کپی توکن
</Button>
</div>
<p className="text-xs text-slate-500">
این توکن را در تنظیمات عامل خود بگذارید (هدر Authorization با مقدار Bearer).
اگر گم شد، توکن را لغو و یکی دیگر بسازید.
</p>
</div>
</Modal>
<ConfirmDialog
open={pendingRevoke !== null}
title="لغو توکن"
description={
pendingRevoke
? `دسترسی «${pendingRevoke.name}» به این سازمان فوراً قطع می‌شود.`
: undefined
}
confirmLabel="لغو توکن"
onConfirm={() => pendingRevoke && void onRevokeToken(pendingRevoke)}
onCancel={() => setPendingRevoke(null)}
/>
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
/>
<ConfirmDialog
open={pendingRemove !== null}
title="حذف عضو"
+325
View File
@@ -22,6 +22,57 @@ import type {
import { apiRequest } from "./api";
/* -------------------------------------------------------------------------- */
/* Owner model grants + org policy */
/* -------------------------------------------------------------------------- */
/**
* One catalogue model as the owner sees it, plus whether an organization has it.
*
* ``label`` is the owner-facing model name; organization users never see this
* list — for them a model is only ever a tier («سریع» / «متعادل» / «قوی»).
*/
export interface ModelGrantRow {
ai_model_id: string;
label: string;
provider_id: string;
capabilities: string[];
tier: string;
tier_label: string;
output_price_per_1m: number;
model_active: boolean;
granted: boolean;
daily_credit_cap: number | null;
}
/** An active grant, as the engine would see it (no model name). */
export interface ActiveGrantRow {
ai_model_id: string;
label: string;
tier: string;
tier_label: string;
capabilities: string[];
daily_credit_cap: number | null;
is_active: boolean;
}
/** The limits the owner sets for one organization. */
export interface OrgPolicyView {
organization_id: string;
max_employees: number;
monthly_credit_budget: number | null;
automation_enabled: boolean;
mcp_enabled: boolean;
/** The platform ceiling for ``max_employees`` (``ENGINE_MAX_EMPLOYEES``). */
max_employees_limit: number;
}
export interface OrgGrantsView {
organization_id: string;
models: ModelGrantRow[];
}
/** The platform owner's account invitations (split into organizations of the future). */
export const orgApi = {
/** Create an invitation link for a new manager account. */
@@ -76,6 +127,280 @@ export const orgApi = {
`/api/organizations/${orgId}/invites/${encodeURIComponent(inviteId)}`,
{ method: "DELETE" },
),
// --- owner model grants --------------------------------------------------
listOrgGrants: (orgId: string) =>
apiRequest<OrgGrantsView>(`/api/admin/model-grants/${encodeURIComponent(orgId)}`),
listActiveGrants: (orgId: string) =>
apiRequest<ActiveGrantRow[]>(
`/api/admin/model-grants/${encodeURIComponent(orgId)}/active`,
),
setOrgGrant: (orgId: string, modelId: string, dailyCreditCap: number | null) =>
apiRequest<ActiveGrantRow>(
`/api/admin/model-grants/${encodeURIComponent(orgId)}/models/${encodeURIComponent(modelId)}`,
{ method: "PUT", body: { daily_credit_cap: dailyCreditCap } },
),
revokeOrgGrant: (orgId: string, modelId: string) =>
apiRequest<{ organization_id: string; ai_model_id: string; granted: boolean }>(
`/api/admin/model-grants/${encodeURIComponent(orgId)}/models/${encodeURIComponent(modelId)}`,
{ method: "DELETE" },
),
// --- the default set copied into every new organization ------------------
listDefaultGrants: () => apiRequest<ModelGrantRow[]>("/api/admin/default-model-grants"),
setDefaultGrant: (
modelId: string,
body: { is_active: boolean; daily_credit_cap: number | null },
) =>
apiRequest<ModelGrantRow>(
`/api/admin/default-model-grants/${encodeURIComponent(modelId)}`,
{ method: "PUT", body },
),
removeDefaultGrant: (modelId: string) =>
apiRequest<{ ai_model_id: string; in_default_set: boolean }>(
`/api/admin/default-model-grants/${encodeURIComponent(modelId)}`,
{ method: "DELETE" },
),
// --- org policy ----------------------------------------------------------
getOrgPolicy: (orgId: string) =>
apiRequest<OrgPolicyView>(`/api/admin/org-policies/${encodeURIComponent(orgId)}`),
updateOrgPolicy: (
orgId: string,
body: Partial<
Pick<
OrgPolicyView,
"max_employees" | "monthly_credit_budget" | "automation_enabled" | "mcp_enabled"
>
>,
) =>
apiRequest<OrgPolicyView>(`/api/admin/org-policies/${encodeURIComponent(orgId)}`, {
method: "PUT",
body,
}),
};
/* -------------------------------------------------------------------------- */
/* Org memory + knowledge sources */
/* -------------------------------------------------------------------------- */
/**
* One row of the organization's memory: a rule, a preference, a lesson, a fact
* or a code-map note.
*
* ``key`` is the stable handle a rule can be addressed by (the same key always
* corrects the same entry), ``confidence`` is a lesson's 0..1 score, and
* ``hits`` counts how many tasks have been built with this entry in context.
*/
export interface MemoryRow {
id: string;
kind: string;
kind_label: string;
key: string | null;
content: string;
source: string;
source_label: string;
evidence_task_id: string | null;
pinned: boolean;
confidence: number;
expires_at: string | null;
hits: number;
last_used_at: string | null;
created_by_user_id: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface MemoryList {
organization_id: string;
items: MemoryRow[];
total: number;
pinned: number;
chars: number;
}
/** A knowledge-base source, shown as the memory page's second family of tabs. */
export interface KnowledgeSourceRow {
id: string;
name: string;
kind: string;
project_id: string | null;
created_at: string;
chunk_count: number;
}
export const memoryKinds = [
{ key: "rule", label: "قاعده" },
{ key: "preference", label: "ترجیح" },
{ key: "lesson", label: "درس" },
{ key: "fact", label: "واقعیت" },
{ key: "code_map", label: "نقشهٔ کد" },
] as const;
export const memoryApi = {
// --- memory -------------------------------------------------------------
list: (orgId: string, params: { kind?: string; q?: string } = {}) =>
apiRequest<MemoryList>(
`/api/organizations/${encodeURIComponent(orgId)}/memory?${new URLSearchParams(
Object.fromEntries(
Object.entries(params).filter(([, value]) => Boolean(value)),
) as Record<string, string>,
).toString()}`,
),
teach: (
orgId: string,
body: { kind: string; content: string; key?: string | null },
) =>
apiRequest<MemoryRow>(`/api/organizations/${encodeURIComponent(orgId)}/memory`, {
method: "POST",
body,
}),
edit: (orgId: string, memoryId: string, body: { content: string; key?: string | null }) =>
apiRequest<MemoryRow>(
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}`,
{ method: "PATCH", body },
),
setPinned: (orgId: string, memoryId: string, pinned: boolean) =>
apiRequest<MemoryRow>(
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}/pin`,
{ method: "POST", body: { pinned } },
),
confirm: (orgId: string, memoryId: string) =>
apiRequest<MemoryRow>(
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}/confirm`,
{ method: "POST" },
),
remove: (orgId: string, memoryId: string) =>
apiRequest<void>(
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}`,
{ method: "DELETE" },
),
// --- knowledge sources (unchanged endpoints, shown as a second tab) ------
listKnowledge: (orgId: string) =>
apiRequest<KnowledgeSourceRow[]>(
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources`,
),
createKnowledge: (
orgId: string,
body: { name: string; content: string; title?: string },
) =>
apiRequest<KnowledgeSourceRow>(
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources`,
{ method: "POST", body },
),
removeKnowledge: (orgId: string, sourceId: string) =>
apiRequest<void>(
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources/${encodeURIComponent(sourceId)}`,
{ method: "DELETE" },
),
};
/* -------------------------------------------------------------------------- */
/* Org memory realtime */
/* -------------------------------------------------------------------------- */
/**
* The event the API publishes on ``org:<id>`` whenever memory changes.
*
* Declared here rather than in ``@agy/contracts``: the event vocabulary is a
* frozen, foundation-owned list, so a page compares the raw string instead of
* widening the union for one new type.
*/
export const MEMORY_CHANGED_EVENT = "memory.changed";
/* -------------------------------------------------------------------------- */
/* MCP tokens (org admin) + the owner's switches */
/* -------------------------------------------------------------------------- */
/** Every scope the gateway understands, as the mint dialog offers it. */
export const MCP_SCOPES = [
"app:read",
"tasks:read",
"tasks:write",
"memory:read",
"memory:write",
"delivery:read",
] as const;
export type McpScope = (typeof MCP_SCOPES)[number];
/**
* A token as the UI sees it: the prefix, the scopes, the status.
*
* The raw token exists only on the create response (``McpTokenCreated``), which
* is why that type is separate: nothing else in the app can produce one.
*/
export interface McpTokenRow {
id: string;
organization_id: string;
name: string;
prefix: string;
scopes: McpScope[];
expires_at: string | null;
last_used_at: string | null;
revoked_at: string | null;
created_at: string | null;
status: "active" | "expired" | "revoked";
}
export interface McpTokenCreated extends McpTokenRow {
/** Shown once, in the modal, with a copy button. Never stored by the client. */
token: string;
}
export interface McpSettings {
organization_id: string;
mcp_enabled: boolean;
gateway_enabled: boolean;
scopes: McpScope[];
token_count: number;
}
export interface McpOrgState {
organization_id: string;
name: string;
slug: string;
mcp_enabled: boolean;
token_count: number;
}
export interface McpAdminState {
gateway_enabled: boolean;
organizations: McpOrgState[];
total_tokens: number;
}
export const mcpApi = {
list: (orgId: string) =>
apiRequest<McpTokenRow[]>(`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens`),
create: (orgId: string, body: { name: string; scopes: string[]; ttl_days?: number }) =>
apiRequest<McpTokenCreated>(
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens`,
{ method: "POST", body },
),
revoke: (orgId: string, tokenId: string) =>
apiRequest<{ revoked: boolean }>(
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens/${encodeURIComponent(tokenId)}`,
{ method: "DELETE" },
),
settings: (orgId: string) =>
apiRequest<McpSettings>(
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens/settings`,
),
// --- owner ---------------------------------------------------------------
adminState: () => apiRequest<McpAdminState>("/api/admin/mcp"),
setGateway: (enabled: boolean) =>
apiRequest<{ gateway_enabled: boolean }>("/api/admin/mcp", {
method: "PUT",
body: { enabled },
}),
setOrgEnabled: (orgId: string, enabled: boolean) =>
apiRequest<{ mcp_enabled: boolean }>(
`/api/admin/mcp/organizations/${encodeURIComponent(orgId)}`,
{ method: "PUT", body: { enabled } },
),
};
+240
View File
@@ -0,0 +1,240 @@
/**
* MCP integration: the org settings tab and the owner's gateway switches.
*
* The security-relevant assertion is the show-once token: the raw value comes
* back from the create call and nowhere else, so the list table must never carry
* it, and the modal that shows it is the only place a user can copy it.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
api: {
listMembers: vi.fn(),
listAudit: vi.fn(),
updateMember: vi.fn(),
removeMember: vi.fn(),
},
orgApi: {
listOrgInvites: vi.fn(),
getAppProfile: vi.fn(),
patchAppProfile: vi.fn(),
transferOwnership: vi.fn(),
leaveOrganization: vi.fn(),
},
mcpApi: {
list: vi.fn(),
settings: vi.fn(),
create: vi.fn(),
revoke: vi.fn(),
adminState: vi.fn(),
setGateway: vi.fn(),
setOrgEnabled: vi.fn(),
},
session: {
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const },
refresh: vi.fn(async () => {}),
},
router: { replace: vi.fn() },
clipboard: { writeText: vi.fn(async () => {}) },
channels: [] as string[][],
};
});
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
vi.mock("@/lib/api-org", () => ({
orgApi: mocks.orgApi,
mcpApi: mocks.mcpApi,
MCP_SCOPES: [
"app:read",
"tasks:read",
"tasks:write",
"memory:read",
"memory:write",
"delivery:read",
],
memoryApi: { list: vi.fn(), listKnowledge: vi.fn() },
MEMORY_CHANGED_EVENT: "memory.changed",
memoryKinds: [],
useNeedsOnboarding: () => ({ needsOnboarding: false, ready: true }),
}));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => {
mocks.channels.push(channels);
},
}));
import AdminMcpPage from "@/app/(app)/admin/mcp/page";
import SettingsPage from "@/app/(app)/settings/page";
const TOKEN_ROW = {
id: "tok-1",
organization_id: "org-1",
name: "agent CI",
prefix: "smo_abc123",
scopes: ["tasks:read"],
expires_at: "2026-06-01T00:00:00Z",
last_used_at: null,
revoked_at: null,
created_at: "2026-01-01T00:00:00Z",
status: "active" as const,
};
beforeEach(() => {
vi.clearAllMocks();
mocks.channels = [];
Object.defineProperty(window.navigator, "clipboard", {
value: mocks.clipboard,
configurable: true,
});
mocks.session.activeOrganization = {
id: "org-1",
name: "Acme",
slug: "acme",
role: "admin" as const,
};
mocks.api.listMembers.mockResolvedValue([]);
mocks.api.listAudit.mockResolvedValue([]);
mocks.orgApi.listOrgInvites.mockResolvedValue([]);
mocks.orgApi.getAppProfile.mockRejectedValue(new mocks.FakeApiError(404, "nf", "no app"));
mocks.mcpApi.list.mockResolvedValue([TOKEN_ROW]);
mocks.mcpApi.settings.mockResolvedValue({
organization_id: "org-1",
mcp_enabled: true,
gateway_enabled: true,
scopes: [
"app:read",
"tasks:read",
"tasks:write",
"memory:read",
"memory:write",
"delivery:read",
],
token_count: 1,
});
mocks.mcpApi.adminState.mockResolvedValue({
gateway_enabled: true,
organizations: [
{
organization_id: "org-1",
name: "Acme",
slug: "acme",
mcp_enabled: true,
token_count: 1,
},
],
total_tokens: 1,
});
});
describe("settings — MCP integration", () => {
it("lists tokens by prefix and never shows the raw token", async () => {
render(<SettingsPage />);
fireEvent.click(await screen.findByRole("button", { name: "یکپارچه‌سازی (MCP)" }));
expect(await screen.findByText("agent CI")).toBeInTheDocument();
// Only the prefix is rendered — a nine-character handle, never a secret.
expect(screen.getByText("smo_abc123…")).toBeInTheDocument();
expect(screen.queryByText(/smo_[A-Za-z0-9_\-]{20,}/)).not.toBeInTheDocument();
});
it("shows a minted token exactly once, with a copy button", async () => {
mocks.mcpApi.create.mockResolvedValue({
...TOKEN_ROW,
id: "tok-2",
token: "smo_SEKRET-VALUE-1234567890abcdef",
});
render(<SettingsPage />);
fireEvent.click(await screen.findByRole("button", { name: "یکپارچه‌سازی (MCP)" }));
fireEvent.change(await screen.findByLabelText("نام توکن"), {
target: { value: "agent جدید" },
});
fireEvent.click(screen.getByRole("button", { name: "ساخت توکن" }));
const dialog = await screen.findByRole("dialog", { name: "توکن ساخته شد" });
expect(within(dialog).getByText("smo_SEKRET-VALUE-1234567890abcdef")).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "کپی توکن" }));
await waitFor(() =>
expect(mocks.clipboard.writeText).toHaveBeenCalledWith("smo_SEKRET-VALUE-1234567890abcdef"),
);
// After closing, the raw value is gone from the page for good.
fireEvent.click(within(dialog).getByRole("button", { name: "بستن" }));
await waitFor(() =>
expect(screen.queryByText("smo_SEKRET-VALUE-1234567890abcdef")).not.toBeInTheDocument(),
);
});
it("requires a scope before minting, and confirms before revoking", async () => {
render(<SettingsPage />);
fireEvent.click(await screen.findByRole("button", { name: "یکپارچه‌سازی (MCP)" }));
// Deselect the pre-checked scope: mint is disabled without one.
fireEvent.change(await screen.findByLabelText("نام توکن"), {
target: { value: "بدون محدوده" },
});
const checkbox = document.getElementById("check-tasks:read") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
expect(screen.getByRole("button", { name: "ساخت توکن" })).toBeDisabled();
// …and selectable again, which is what re-enables it.
fireEvent.click(checkbox);
expect(screen.getByRole("button", { name: "ساخت توکن" })).toBeEnabled();
mocks.mcpApi.revoke.mockResolvedValue({ revoked: true });
fireEvent.click(screen.getByRole("button", { name: "لغو" }));
const dialog = await screen.findByRole("dialog", { name: "لغو توکن" });
fireEvent.click(dialog.querySelectorAll("button")[1]);
await waitFor(() => expect(mocks.mcpApi.revoke).toHaveBeenCalledWith("org-1", "tok-1"));
});
});
describe("admin — MCP switches", () => {
it("shows the gateway state and switches off one organization", async () => {
mocks.mcpApi.setOrgEnabled.mockResolvedValue({ mcp_enabled: false });
render(<AdminMcpPage />);
expect(await screen.findByText("Acme")).toBeInTheDocument();
expect(screen.getByText("روشن")).toBeInTheDocument();
// Turning one organization off asks first — it is a destructive switch.
fireEvent.click(screen.getByRole("button", { name: "خاموش کن" }));
const dialog = await screen.findByRole("dialog", { name: "خاموش‌کردن دسترسی یک سازمان" });
fireEvent.click(dialog.querySelectorAll("button")[1]);
await waitFor(() =>
expect(mocks.mcpApi.setOrgEnabled).toHaveBeenCalledWith("org-1", false),
);
});
it("turns the whole gateway off with a confirmation", async () => {
mocks.mcpApi.setGateway.mockResolvedValue({ gateway_enabled: false });
render(<AdminMcpPage />);
const button = await screen.findByRole("button", {
name: "خاموش‌کردن دسترسی MCP سامانه",
});
fireEvent.click(button);
await waitFor(() => expect(mocks.mcpApi.setGateway).toHaveBeenCalledWith(false));
expect(
await screen.findByRole("button", { name: "روشن‌کردن دسترسی MCP" }),
).toBeInTheDocument();
});
});
+216
View File
@@ -0,0 +1,216 @@
/**
* Org memory page.
*
* Four tabs over one organization's memory, a "teach the organization" dialog
* that is a single sentence, and a live refresh on ``memory.changed``. The
* security-relevant assertion here is the payload one: a credential typed into
* the teach box must never reach the API — the server redacts, and the client
* must not smuggle it into a key field either.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
api: {},
memoryApi: {
list: vi.fn(),
teach: vi.fn(),
edit: vi.fn(),
setPinned: vi.fn(),
confirm: vi.fn(),
remove: vi.fn(),
listKnowledge: vi.fn(),
createKnowledge: vi.fn(),
removeKnowledge: vi.fn(),
},
session: {
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const },
refresh: vi.fn(async () => {}),
},
router: { replace: vi.fn() },
subscriptions: [] as string[][],
handlers: [] as ((event: unknown) => void)[],
};
});
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
vi.mock("@/lib/api-org", () => ({
MEMORY_CHANGED_EVENT: "memory.changed",
memoryApi: mocks.memoryApi,
memoryKinds: [
{ key: "rule", label: "قاعده" },
{ key: "preference", label: "ترجیح" },
{ key: "lesson", label: "درس" },
{ key: "fact", label: "واقعیت" },
{ key: "code_map", label: "نقشهٔ کد" },
],
}));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => {
mocks.subscriptions.push(channels);
mocks.handlers.push(onEvent);
},
}));
import MemoryPage from "@/app/(app)/memory/page";
const RULE = {
id: "m-rule",
kind: "rule",
kind_label: "قاعده",
key: "tests-first",
content: "پیش از هر تغییر، تست مربوطه را اجرا کن.",
source: "user",
source_label: "کاربر",
evidence_task_id: null,
pinned: true,
confidence: 1,
expires_at: null,
hits: 3,
last_used_at: "2026-02-01T00:00:00Z",
created_by_user_id: "u-1",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
};
const LESSON = {
...RULE,
id: "m-lesson",
kind: "lesson",
kind_label: "درس",
key: null,
content: "دروازهٔ lint دو بار شکست؛ پیش از ارسال، lint محلی را اجرا کن.",
source: "reflection",
source_label: "بازتاب",
confidence: 0.5,
pinned: false,
expires_at: "2026-02-15T00:00:00Z",
evidence_task_id: "task-9",
};
const KNOWLEDGE = {
id: "src-1",
name: "قواعد کدنویسی",
kind: "text",
project_id: null,
created_at: "2026-01-05T00:00:00Z",
chunk_count: 4,
};
beforeEach(() => {
vi.clearAllMocks();
mocks.subscriptions = [];
mocks.handlers = [];
mocks.memoryApi.list.mockResolvedValue({
organization_id: "org-1",
items: [RULE, LESSON],
total: 2,
pinned: 1,
chars: 180,
});
mocks.memoryApi.listKnowledge.mockResolvedValue([KNOWLEDGE]);
});
describe("memory page", () => {
it("shows rules and lessons on their own tabs", async () => {
render(<MemoryPage />);
expect(await screen.findByText(RULE.content)).toBeInTheDocument();
// The lesson lives on its own tab, not in the rules list.
expect(screen.queryByText(LESSON.content)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "درس‌های آموخته" }));
expect(await screen.findByText(LESSON.content)).toBeInTheDocument();
// A reflection-sourced lesson carries its half-confidence in the UI.
expect(screen.getByText(/اعتماد ۵۰/)).toBeInTheDocument();
expect(screen.queryByText(RULE.content)).not.toBeInTheDocument();
});
it("teaches a rule with one sentence and never puts a secret in the payload", async () => {
mocks.memoryApi.teach.mockResolvedValue({ ...RULE, id: "m-new", content: "یادداشت تازه" });
render(<MemoryPage />);
fireEvent.click(await screen.findByRole("button", { name: "به سازمان یاد بده" }));
const dialog = await screen.findByRole("dialog");
// A credential pasted into the teach box is a user error the server must
// catch; the client's job is to send exactly what was typed and nothing else.
fireEvent.change(dialog.querySelector("textarea")!, {
target: { value: "توکن CI من ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است" },
});
fireEvent.click(dialog.querySelector('button[type="submit"]')!);
await waitFor(() => expect(mocks.memoryApi.teach).toHaveBeenCalledTimes(1));
const [orgId, payload] = mocks.memoryApi.teach.mock.calls[0];
expect(orgId).toBe("org-1");
expect(payload.kind).toBe("rule");
// Only kind/content are ever sent (no key is invented), and there is no
// field a secret could hide in.
expect(Object.keys(payload).sort()).toEqual(["content", "kind"]);
});
it("pins a rule and asks for confirmation before deleting one", async () => {
mocks.memoryApi.setPinned.mockResolvedValue({ ...RULE, pinned: false });
mocks.memoryApi.remove.mockResolvedValue(undefined);
render(<MemoryPage />);
fireEvent.click(await screen.findByRole("button", { name: "برداشتن سنجاق" }));
await waitFor(() =>
expect(mocks.memoryApi.setPinned).toHaveBeenCalledWith("org-1", "m-rule", false),
);
fireEvent.click(screen.getByRole("button", { name: "حذف" }));
const dialog = await screen.findByRole("dialog", { name: "حذف یادداشت" });
const confirm = dialog.querySelector('button') as HTMLButtonElement;
// The confirm button is the dangerous one; the dialog explains the damage.
expect(confirm).toBeDefined();
expect(screen.getByText(/از حافظهٔ سازمان برداشته شود/)).toBeInTheDocument();
fireEvent.click(dialog.querySelectorAll("button")[1]);
await waitFor(() => expect(mocks.memoryApi.remove).toHaveBeenCalledWith("org-1", "m-rule"));
});
it("refreshes when the org channel says memory changed", async () => {
render(<MemoryPage />);
await screen.findByText(RULE.content);
// The page subscribes to its own tenant's channel only.
expect(mocks.subscriptions.at(-1)).toEqual(["org:org-1"]);
const callsBefore = mocks.memoryApi.list.mock.calls.length;
mocks.handlers.at(-1)!({ type: "memory.changed", data: { kind: "lesson" } });
await waitFor(() =>
expect(mocks.memoryApi.list.mock.calls.length).toBeGreaterThan(callsBefore),
);
// An unrelated event does not cause a reload.
const callsAfter = mocks.memoryApi.list.mock.calls.length;
mocks.handlers.at(-1)!({ type: "run.step", data: {} });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(mocks.memoryApi.list.mock.calls.length).toBe(callsAfter);
});
it("keeps the knowledge sources as a second tab", async () => {
render(<MemoryPage />);
fireEvent.click(await screen.findByRole("button", { name: "منابع دانش" }));
expect(await screen.findByText(KNOWLEDGE.name)).toBeInTheDocument();
// Chunk counts use Persian digits like every other number in the product.
expect(screen.getByText("۴")).toBeInTheDocument();
});
});