// Nexa — Policies & Agents pages (live API)
const {
fmtDate: fmtDate_b, ruleLabel, ruleDetail, normPolicyRule, normAgent,
useFetch: useFetch_b, fmtMoney: fmtMoney_b, symbolFor: symbolFor_b,
} = window.TC_HELPERS;
const {
AgentBadge: AgentBadge_b, Toggle: Toggle_b,
PlusIcon: PlusIcon_b, ChevronIcon: ChevronIcon_b, XIcon: XIcon_b,
CheckIcon: CheckIcon_b, Spinner: Spinner_b, RefreshIcon: RefreshIcon_b,
LoadingBlock: LoadingBlock_b, ErrorBlock: ErrorBlock_b,
} = window.TC_UI;
// ── Constants ───────────────────────────────────────────────
const OPS = ["on_ramp", "off_ramp", "issue_card", "card_spend"];
const COMMON_CURRENCIES = ["GBP", "EUR", "USD", "CAD", "CHF", "AUD", "SGD"];
const RULE_TYPE_DEFS = [
{ value: "transaction_limit", label: "Per-Transaction Limit", shape: "amount" },
{ value: "daily_limit", label: "Daily Aggregate Limit", shape: "amount" },
{ value: "weekly_limit", label: "Weekly Aggregate Limit", shape: "amount" },
{ value: "monthly_limit", label: "Monthly Aggregate Limit", shape: "amount" },
{ value: "approval_threshold", label: "Approval Threshold", shape: "amount", alwaysSoft: true },
{ value: "min_balance", label: "Minimum Balance Floor", shape: "amount" },
{ value: "counterparty_whitelist", label: "Counterparty Whitelist", shape: "namelist" },
{ value: "counterparty_blacklist", label: "Counterparty Blacklist", shape: "namelist" },
{ value: "currency_whitelist", label: "Currency Whitelist", shape: "currency" },
{ value: "corridor_blacklist", label: "Corridor Blacklist", shape: "codelist" },
{ value: "time_window", label: "Time Window", shape: "time" },
{ value: "velocity_limit", label: "Velocity Limit", shape: "velocity" },
{ value: "registered_beneficiary", label: "Registered Beneficiary Required", shape: "toggle" },
];
// ── Rule form (create + edit) ────────────────────────────────
function RuleForm({ initial, onCancel, onSubmit, title = "New policy rule" }) {
// Initialise from existing rule or empty
const init = initial || {};
const [type, setType] = useState(init.type || RULE_TYPE_DEFS[0].value);
const [mode, setMode] = useState(init.mode || "block");
const [amount, setAmount] = useState(init.amount != null ? String(init.amount) : "");
const [currency, setCurrency] = useState(init.currency || "GBP");
const [nameList, setNameList] = useState((init.values || []).join("\n"));
const [codeList, setCodeList] = useState((init.values || []).join("\n"));
const [curList, setCurList] = useState(init.values || []);
const [customCur, setCustomCur] = useState("");
const [maxCount, setMaxCount] = useState(init.maxCount != null ? String(init.maxCount) : "50");
const [periodHours, setPH] = useState(init.periodHours != null ? String(init.periodHours) : "24");
const [startHour, setSH] = useState(init.startHour != null ? String(init.startHour) : "7");
const [endHour, setEH] = useState(init.endHour != null ? String(init.endHour) : "19");
const [togVal, setTogVal] = useState(init.enabled !== false);
const [submitting, setSub] = useState(false);
const meta = RULE_TYPE_DEFS.find(r => r.value === type);
const isAlwaysSoft = meta && meta.alwaysSoft;
function toggleCur(cur) {
setCurList(prev => prev.includes(cur) ? prev.filter(c => c !== cur) : [...prev, cur]);
}
function addCustomCur() {
const c = customCur.trim().toUpperCase();
if (c && !curList.includes(c)) { setCurList(prev => [...prev, c]); setCustomCur(""); }
}
const submit = async (e) => {
e.preventDefault();
const rule = {
policy_type: type,
is_hard_block: isAlwaysSoft ? false : (mode === "block"),
enabled: true,
};
if (meta.shape === "amount") {
if (!amount) return;
rule.amount = Number(amount);
if (currency) rule.currency = currency;
} else if (meta.shape === "namelist") {
const values = nameList.split("\n").map(s => s.trim()).filter(Boolean);
if (!values.length) return;
rule.values = values;
} else if (meta.shape === "codelist") {
const values = codeList.split("\n").map(s => s.trim().toUpperCase()).filter(Boolean);
if (!values.length) return;
rule.values = values;
} else if (meta.shape === "currency") {
if (!curList.length) return;
rule.values = curList;
} else if (meta.shape === "time") {
rule.start_hour = Number(startHour);
rule.end_hour = Number(endHour);
} else if (meta.shape === "velocity") {
rule.max_count = Number(maxCount);
rule.period_hours = Number(periodHours);
} else if (meta.shape === "toggle") {
rule.enabled = togVal;
}
setSub(true);
try { await onSubmit(rule); } finally { setSub(false); }
};
const hours = Array.from({length: 24}, (_, i) => i);
return (
);
}
// ── Policy dry-run ───────────────────────────────────────────
function DryRunPanel({ agentId }) {
const [amount, setAmount] = useState("");
const [currency, setCur] = useState("GBP");
const [cp, setCp] = useState("");
const [op, setOp] = useState("off_ramp");
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const run = async (e) => {
e.preventDefault();
if (!amount || !agentId) return;
setLoading(true); setResult(null); setError("");
try {
const r = await TC_API.policyDryRun(agentId, {
amount: Number(amount),
currency,
counterparty_name: cp,
operation: op,
});
setResult(r);
} catch (err) {
setError(err.message || String(err));
} finally {
setLoading(false);
}
};
const decisionColor = {
approved: "var(--good)",
rejected: "#963c2c",
requires_approval: "var(--warn)",
};
return (
Test a payment
dry-run · no side effects
Amount
setAmount(e.target.value)} required />
Currency
setCur(e.target.value)}>
{COMMON_CURRENCIES.map(c => {c} )}
Counterparty name
setCp(e.target.value)} />
Operation
setOp(e.target.value)}>
{OPS.map(o => {o} )}
{loading ? : "Run test"}
{error &&
}
{result && (
{result.decision.replace(/_/g," ")}
{result.triggered_rules.length > 0 && (
{result.triggered_rules.length} rule{result.triggered_rules.length > 1 ? "s" : ""} triggered
)}
{result.reasons.map((r, i) => (
{r}
))}
)}
);
}
// ────────────────────────────────────────────────────────────
// POLICIES PAGE
// ────────────────────────────────────────────────────────────
function PoliciesPage({ agents }) {
const [selectedAgentId, setSelectedAgentId] = useState(agents[0]?.id || "");
const [showForm, setShowForm] = useState(false);
const [editingRule, setEditingRule] = useState(null); // null | normPolicyRule object
const [resetting, setResetting] = useState(false);
useEffect(() => {
if (!selectedAgentId && agents[0]) setSelectedAgentId(agents[0].id);
}, [agents, selectedAgentId]);
const policyQ = useFetch_b(
() => selectedAgentId ? TC_API.getAgentPolicy(selectedAgentId) : Promise.resolve(null),
[selectedAgentId]
);
const agent = agents.find(a => a.id === selectedAgentId);
const rules = useMemo(
() => ((policyQ.data && policyQ.data.rules) || []).map(normPolicyRule),
[policyQ.data]
);
const closeForm = () => { setShowForm(false); setEditingRule(null); };
const onToggleRule = async (ruleId, enabled) => {
try { await TC_API.togglePolicyRule(selectedAgentId, ruleId, enabled); policyQ.refetch(); }
catch (e) { alert(`Update failed: ${e.message}`); }
};
const onDeleteRule = async (ruleId) => {
if (!confirm("Delete this rule?")) return;
try { await TC_API.deletePolicyRule(selectedAgentId, ruleId); policyQ.refetch(); }
catch (e) { alert(`Delete failed: ${e.message}`); }
};
const onAddRule = async (rule) => {
await TC_API.addPolicyRule(selectedAgentId, rule);
closeForm();
policyQ.refetch();
};
const onEditRule = async (updates) => {
const { policy_type, is_hard_block, enabled, amount, currency, values, period_hours, max_count, start_hour, end_hour } = updates;
const patch = { is_hard_block, enabled };
if (amount != null) patch.amount = amount;
if (currency != null) patch.currency = currency;
if (values != null) patch.values = values;
if (period_hours != null) patch.period_hours = period_hours;
if (max_count != null) patch.max_count = max_count;
if (start_hour != null) patch.start_hour = start_hour;
if (end_hour != null) patch.end_hour = end_hour;
await TC_API.updatePolicyRule(selectedAgentId, editingRule.id, patch);
closeForm();
policyQ.refetch();
};
const onResetToDefault = async () => {
if (!confirm("Replace all rules with the default policy template? This cannot be undone.")) return;
setResetting(true);
try { await TC_API.resetPolicyToDefault(selectedAgentId); policyQ.refetch(); }
catch (e) { alert(`Reset failed: ${e.message}`); }
finally { setResetting(false); }
};
if (agents.length === 0) {
return No agents registered yet. Register an agent to configure policy.
;
}
return (
Agent
{ setSelectedAgentId(e.target.value); closeForm(); }}>
{agents.map(a => {a.name} )}
{agent &&
}
{agent && agent.allowedOperations.length > 0 && (
allowed operations · {agent.allowedOperations.join(" · ")}
)}
{rules.filter(r => r.enabled).length}/{rules.length} active
{resetting ? : } Default template
Policy rules
evaluated on every instruction
{!showForm && !editingRule && (
setShowForm(true)}>
Add rule
)}
{showForm && !editingRule && (
)}
{editingRule && (
)}
{policyQ.loading && !rules.length
?
: policyQ.error
?
:
{rules.map(r => (
{ if (!showForm) { setEditingRule(editingRule?.id === r.id ? null : r); } }}
>
{ onToggleRule(r.id, v); }} />
{ruleLabel(r)}
{ruleDetail(r)}
{r.mode === "block" ? "Block" : "Approval"}
{ e.stopPropagation(); onDeleteRule(r.id); }}
>
Delete
))}
{rules.length === 0 && (
No rules configured. The agent operates without policy guardrails.
)}
}
{selectedAgentId &&
}
);
}
// ────────────────────────────────────────────────────────────
// AGENT CREATION FORM
// ────────────────────────────────────────────────────────────
function CreateAgentForm({ accounts, onCancel, onCreated }) {
const [name, setName] = useState("");
const [desc, setDesc] = useState("");
const [ops, setOps] = useState(["on_ramp", "off_ramp"]);
const [walletAll, setWallAll] = useState(true);
const [walletIds, setWIds] = useState([]);
const [submitting, setSub] = useState(false);
const [error, setError] = useState("");
function toggleOp(op) { setOps(prev => prev.includes(op) ? prev.filter(x => x !== op) : [...prev, op]); }
function toggleWallet(id) { setWIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); }
const submit = async (e) => {
e.preventDefault();
if (!name.trim()) { setError("Agent name is required."); return; }
if (ops.length === 0) { setError("Select at least one allowed operation."); return; }
setSub(true); setError("");
try {
const r = await TC_API.registerAgent({
name: name.trim(),
description: desc.trim(),
allowed_operations: ops,
allowed_wallet_ids: walletAll ? [] : walletIds,
});
onCreated(r);
} catch (err) {
setError(err.message || String(err));
setSub(false);
}
};
const OP_LABELS = { on_ramp: "On-ramp", off_ramp: "Off-ramp", issue_card: "Issue card", card_spend: "Card spend" };
return (
Create agent
Agent name
{ setName(e.target.value); setError(""); }}
required
autoFocus
/>
Description
setDesc(e.target.value)}
/>
{error && {error}
}
Cancel
{submitting ? : } Create agent
);
}
// ── API key reveal modal ─────────────────────────────────────
function ApiKeyModal({ agent, onClose, title = "Agent created" }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(agent.api_key).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
};
return (
{title}
{agent.name} · {agent.agent_id}
Save this key now — it won't be shown again
This is the only time the full API key is returned. Copy it to a secure secrets manager.
API key
{agent.api_key}
{copied ? <> Copied> : "Copy key"}
Connect snippet
{`fetch("${TC_API.BASE}/wallets/off-ramp", {
method: "POST",
headers: {
"X-Agent-Key": "${agent.api_key}",
"Content-Type": "application/json"
},
body: JSON.stringify({ ... })
})`}
Done
);
}
// ────────────────────────────────────────────────────────────
// AGENTS PAGE
// ────────────────────────────────────────────────────────────
function AgentsPage({ agents, agentsQ }) {
const [openId, setOpenId] = useState(agents[0]?.id || null);
const [busyId, setBusyId] = useState(null);
const [showCreate, setCreate] = useState(false);
const [newAgent, setNewAgent] = useState(null); // newly created/rotated agent with api_key
const [newAgentTitle, setNewAgentTitle] = useState("Agent created");
const [rotateConfirmId, setRotateConfirmId] = useState(null); // agent id pending rotation confirm
// Load accounts for wallet scope picker
const accountsQ = useFetch_b(() => TC_API.getAccounts(), []);
const accounts = useMemo(() => (accountsQ.data || []).map(a => ({
id: a.account_id || a.id,
currency: a.currency,
})), [accountsQ.data]);
useEffect(() => {
if (!openId && agents[0]) setOpenId(agents[0].id);
}, [agents, openId]);
const doAction = async (id, action) => {
setBusyId(id);
try {
if (action === "activate") await TC_API.activateAgent(id);
if (action === "suspend") await TC_API.suspendAgent(id);
if (action === "reactivate") await TC_API.activateAgent(id);
if (action === "revoke") await TC_API.revokeAgent(id);
agentsQ && agentsQ.refetch && agentsQ.refetch();
} catch (e) {
alert(`${action} failed: ${e.message}`);
} finally { setBusyId(null); }
};
const doRotate = async (id) => {
const agent = agents.find(a => a.id === id);
setBusyId(id);
setRotateConfirmId(null);
try {
const result = await TC_API.rotateAgentKey(id);
// Show the new key exactly like creation — result has {agent_id, api_key, rotated_at}
setNewAgent({ api_key: result.api_key, agent_id: result.agent_id, name: agent?.name || result.agent_id });
setNewAgentTitle("Key rotated");
agentsQ && agentsQ.refetch && agentsQ.refetch();
} catch (e) {
alert(`Key rotation failed: ${e.message}`);
} finally { setBusyId(null); }
};
const onCreated = (agent) => {
setCreate(false);
setNewAgentTitle("Agent created");
setNewAgent(agent);
agentsQ && agentsQ.refetch && agentsQ.refetch();
};
if (agentsQ && agentsQ.loading && !agents.length) {
return
;
}
if (agentsQ && agentsQ.error) {
return
;
}
return (
{newAgent &&
setNewAgent(null)} />}
{/* Rotate key confirmation modal */}
{rotateConfirmId && (() => {
const a = agents.find(x => x.id === rotateConfirmId);
return (
Rotate API key?
{a?.name || rotateConfirmId} will stop working immediately with the old key.
The new key will be shown once — save it to your secrets manager before closing.
setRotateConfirmId(null)}>Cancel
doRotate(rotateConfirmId)}>
{busyId === rotateConfirmId ? : "Rotate key"}
);
})()}
Registered agents
{agents.length} registered · {agents.filter(a => a.status === "active").length} active
{!showCreate && (
setCreate(true)}>
Create agent
)}
{showCreate && (
setCreate(false)}
onCreated={onCreated}
/>
)}
{agents.length === 0 && !showCreate ? (
No agents registered yet.
) : (
{agents.map(a => {
const isOpen = openId === a.id;
const walletLabel = !a.allowedWalletIds || a.allowedWalletIds.length === 0
? "all wallets"
: a.allowedWalletIds.join(" · ");
const maskedKey = a.apiKeyMasked || (a.apiKey
? `${a.apiKey.slice(0, 3)}${"•".repeat(20)}${a.apiKey.slice(-4)}`
: "—");
return (
setOpenId(isOpen ? null : a.id)}>
{a.name || a.id}
{a.id}
{a.lastUsedAt && (
last used {fmtDate_b(a.lastUsedAt)}
)}
{isOpen && (
{a.description && (
Description
{a.description}
)}
API key
{maskedKey}
Created
{a.createdAt ? fmtDate_b(a.createdAt) : "—"}
Last used
{a.lastUsedAt ? fmtDate_b(a.lastUsedAt) : "Never"}
Allowed operations
{(a.allowedOperations||[]).length ? a.allowedOperations.join(" · ") : "—"}
Wallet scope
{walletLabel}
{a.status === "suspended" && a.suspensionReason && (
Suspension reason
{a.suspensionReason}
)}
{(a.status === "created") && (
<>
doAction(a.id, "activate")}
title="Move to test mode first">
Test mode
doAction(a.id, "activate")}>
Activate
>
)}
{a.status === "test" && (
doAction(a.id, "activate")}>
Go live
)}
{a.status === "active" && (
doAction(a.id, "suspend")}>
Suspend
)}
{a.status === "suspended" && (
doAction(a.id, "reactivate")}>
Reactivate
)}
{a.status !== "revoked" && (
<>
setRotateConfirmId(a.id)}
title="Invalidate current key and issue a new one">
Rotate key
doAction(a.id, "revoke")}>
Revoke (permanent)
>
)}
)}
);
})}
)}
);
}
window.TC_PAGES_B = { PoliciesPage, AgentsPage };