mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix(ui): address UX review for Tools & Access (PAP-10389)
- Add EnforcementBanner composite (CVA default/denied-detected) persistent at the top of the surface, wired to last-hour denial count; add to /design-guide. - Add New Connection dialog: secret-reference picker (no free-text token) + live gateway probe before activate. - Mobile tab nav: horizontal scroll strip instead of staggered wrap. - Route decision/health/quarantine pills through canonical StatusBadge with new tool-access status keys; consolidate all tabs onto the canonical components/EmptyState (with description); /design-guide entries added. - Runtime tab gets a live cyan dot when a slot is running. - ErrorState maps 404/route-not-found to a deploy-skew friendly message. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1e86dd931f
commit
a312bbf3d2
14 files changed
+2578
-163
No files matched your search
@@ -5,17 +5,20 @@ import { Button } from "@/components/ui/button";
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
message: string;
|
||||
/** Optional secondary line rendered under the primary message. */
|
||||
description?: string;
|
||||
action?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, message, action, onAction }: EmptyStateProps) {
|
||||
export function EmptyState({ icon: Icon, message, description, action, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="bg-muted/50 p-4 mb-4">
|
||||
<Icon className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">{message}</p>
|
||||
<p className="text-sm font-medium text-foreground mb-1">{message}</p>
|
||||
{description && <p className="max-w-md text-sm text-muted-foreground mb-4">{description}</p>}
|
||||
{action && onAction && (
|
||||
<Button onClick={onAction}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
|
||||
/**
|
||||
* Persistent enforcement-state banner for the Tools & Access surface (PAP-10389).
|
||||
*
|
||||
* Always rendered so users never mistake the absence of a warning for the
|
||||
* absence of enforcement. The `denied-detected` variant tints when governed
|
||||
* tool calls were denied or failed in the last hour. This is an *observability*
|
||||
* banner — enforcement itself lives in the tool gateway, not here.
|
||||
*/
|
||||
const enforcementBanner = cva(
|
||||
"flex items-start gap-2.5 rounded-lg border px-4 py-3 text-sm",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-border bg-muted/40 text-muted-foreground",
|
||||
"denied-detected":
|
||||
"border-amber-500/40 bg-amber-50 text-amber-900 dark:bg-amber-950/40 dark:text-amber-200",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
const DENY_ACTIONS = new Set(["tool_gateway.call_denied", "tool_gateway.call_failed"]);
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
export interface EnforcementBannerProps extends VariantProps<typeof enforcementBanner> {
|
||||
companyId: string;
|
||||
className?: string;
|
||||
/** Override the computed variant (used by the design guide). */
|
||||
forceVariant?: "default" | "denied-detected";
|
||||
recentDenialCount?: number;
|
||||
}
|
||||
|
||||
export function EnforcementBanner({ companyId, className, forceVariant, recentDenialCount }: EnforcementBannerProps) {
|
||||
const audit = useQuery({
|
||||
queryKey: queryKeys.tools.audit(companyId, 100),
|
||||
queryFn: () => toolsApi.listAudit(companyId, 100),
|
||||
enabled: forceVariant === undefined && recentDenialCount === undefined && !!companyId,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const computedCount =
|
||||
recentDenialCount ??
|
||||
(audit.data ?? []).filter((row) => {
|
||||
if (!DENY_ACTIONS.has(row.action)) return false;
|
||||
const ts = new Date(row.createdAt).getTime();
|
||||
return Number.isFinite(ts) && Date.now() - ts <= ONE_HOUR_MS;
|
||||
}).length;
|
||||
|
||||
const variant: "default" | "denied-detected" =
|
||||
forceVariant ?? (computedCount > 0 ? "denied-detected" : "default");
|
||||
|
||||
return (
|
||||
<div className={cn(enforcementBanner({ variant }), className)} role="status">
|
||||
{variant === "denied-detected" ? (
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{variant === "denied-detected" ? (
|
||||
<p>
|
||||
<span className="font-medium">{computedCount}</span> governed tool call
|
||||
{computedCount === 1 ? " was" : "s were"} denied or failed in the last hour. Access is enforced
|
||||
server-side by the tool gateway — review what was blocked and why in the audit log.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Tool access is enforced server-side by the tool gateway. These screens configure and observe that
|
||||
enforcement — they do not replace it. Agents see and call only the tools their profiles and policies
|
||||
allow; everything else is denied by default.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/company/settings/tools/audit"
|
||||
className="shrink-0 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
View audit →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
agentStatusMotion,
|
||||
} from "../lib/status-colors";
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -17,7 +17,7 @@ export function StatusBadge({ status }: { status: string }) {
|
||||
statusBadge[status] ?? statusBadgeDefault
|
||||
)}
|
||||
>
|
||||
{status.replace(/_/g, " ")}
|
||||
{label ?? status.replace(/[_-]/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,21 @@ export const statusBadge: Record<string, string> = {
|
||||
blocked: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
done: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||
cancelled: "bg-muted text-muted-foreground",
|
||||
|
||||
// Tool access — policy decisions, catalog, and runtime health (Tools & Access, PAP-10389)
|
||||
allowed: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||
denied: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
block: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
"require-approval": "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||
redacted: "bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300",
|
||||
"rate-limit": "bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300",
|
||||
deferred: "bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300",
|
||||
hidden: "bg-muted text-muted-foreground",
|
||||
quarantined: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
"runtime-error": "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
healthy: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||
degraded: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||
unchecked: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
export const statusBadgeDefault = "bg-muted text-muted-foreground";
|
||||
|
||||
@@ -114,6 +114,7 @@ import {
|
||||
} from "@/components/ui/avatar";
|
||||
import { StatusBadge, AgentStatusBadge, AgentStatusCapsule } from "@/components/StatusBadge";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { EnforcementBanner } from "@/components/EnforcementBanner";
|
||||
import { PriorityIcon } from "@/components/PriorityIcon";
|
||||
import { EntityRow } from "@/components/EntityRow";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
@@ -1618,6 +1619,48 @@ export function DesignGuide() {
|
||||
</p>
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* TOOLS & ACCESS (PAP-10389) */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Tools & Access">
|
||||
<SubSection title="EnforcementBanner — default / denied-detected">
|
||||
<div className="space-y-3">
|
||||
<EnforcementBanner companyId="" forceVariant="default" recentDenialCount={0} />
|
||||
<EnforcementBanner companyId="" forceVariant="denied-detected" recentDenialCount={3} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Persistent at the top of the Tools & Access surface. Tints to <code>denied-detected</code> when
|
||||
governed tool calls were denied or failed in the last hour. Observability only — enforcement lives
|
||||
in the tool gateway.
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="Tool-access status keys (StatusBadge)">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
"allowed", "denied", "block", "require-approval", "redacted", "rate-limit",
|
||||
"deferred", "hidden", "quarantined", "healthy", "degraded", "runtime-error", "unchecked",
|
||||
].map((s) => (
|
||||
<StatusBadge key={s} status={s} />
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Policy decisions, connection/runtime health, and catalog quarantine all route through the canonical{" "}
|
||||
<code>StatusBadge</code> keys defined in <code>lib/status-colors</code>.
|
||||
</p>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="EmptyState (canonical, with description + action)">
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
message="No connections yet"
|
||||
description="Add a connection to an application to configure credentials and discover its tools."
|
||||
action="New connection"
|
||||
onAction={() => {}}
|
||||
/>
|
||||
</SubSection>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { ToolsPageHeader, LoadingState, ErrorState, EmptyState, RelativeTime } from "./shared";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { ToolsPageHeader, LoadingState, ErrorState, RelativeTime } from "./shared";
|
||||
|
||||
const APP_TYPES: { value: ToolApplicationType; label: string }[] = [
|
||||
{ value: "mcp_http", label: "MCP server (remote HTTP)" },
|
||||
@@ -92,15 +93,11 @@ export function ApplicationsTab({ companyId }: { companyId: string }) {
|
||||
|
||||
{list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<AppWindow className="h-6 w-6" />}
|
||||
title="No applications yet"
|
||||
icon={AppWindow}
|
||||
message="No applications yet"
|
||||
description="Register an MCP server or plugin tool bundle to start governing tool access."
|
||||
action={
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
New application
|
||||
</Button>
|
||||
}
|
||||
action="New application"
|
||||
onAction={() => setOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi, type ToolGatewayAuditRow } from "@/api/tools";
|
||||
import { ToolsPageHeader, LoadingState, ErrorState, EmptyState, RelativeTime } from "./shared";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { ToolsPageHeader, LoadingState, ErrorState, RelativeTime } from "./shared";
|
||||
|
||||
const OUTCOME_FILTERS = [
|
||||
{ value: "__all", label: "All outcomes" },
|
||||
@@ -136,8 +137,8 @@ export function AuditTab({ companyId }: { companyId: string }) {
|
||||
<ErrorState error={audit.error} onRetry={() => audit.refetch()} />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<ScrollText className="h-6 w-6" />}
|
||||
title="No matching audit events"
|
||||
icon={ScrollText}
|
||||
message="No matching audit events"
|
||||
description="Governed tool calls appear here as soon as agents start using the gateway."
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plug, RefreshCw, Stethoscope, ListTree } from "lucide-react";
|
||||
import type { ToolConnection } from "@paperclipai/shared";
|
||||
import { KeyRound, ListTree, Plug, Plus, RefreshCw, Stethoscope, Trash2 } from "lucide-react";
|
||||
import type { McpConnectionCredentialRef, ToolConnection } from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { toolsApi, type CreateToolConnectionInput } from "@/api/tools";
|
||||
import { secretsApi } from "@/api/secrets";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import {
|
||||
ToolsPageHeader,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
HealthBadge,
|
||||
RiskBadge,
|
||||
CapabilityBadges,
|
||||
QuarantineBadge,
|
||||
RelativeTime,
|
||||
} from "./shared";
|
||||
|
||||
function CatalogDialog({
|
||||
connection,
|
||||
onClose,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
function CatalogDialog({ connection, onClose }: { connection: ToolConnection; onClose: () => void }) {
|
||||
const catalog = useQuery({
|
||||
queryKey: queryKeys.tools.catalog(connection.id),
|
||||
queryFn: () => toolsApi.listCatalog(connection.id),
|
||||
@@ -62,9 +69,7 @@ function CatalogDialog({
|
||||
isWrite={entry.isWrite}
|
||||
isDestructive={entry.isDestructive}
|
||||
/>
|
||||
{entry.status === "quarantined" ? (
|
||||
<Badge variant="destructive">quarantined</Badge>
|
||||
) : null}
|
||||
{entry.status === "quarantined" ? <QuarantineBadge /> : null}
|
||||
{entry.description ? (
|
||||
<p className="w-full truncate text-xs text-muted-foreground">{entry.description}</p>
|
||||
) : null}
|
||||
@@ -77,10 +82,304 @@ function CatalogDialog({
|
||||
);
|
||||
}
|
||||
|
||||
type CredentialDraft = { secretId: string; headerName: string };
|
||||
|
||||
/**
|
||||
* New-connection dialog. Enforces secret *references* (no free-text token field)
|
||||
* and runs a live gateway probe (health-check) against the draft before the
|
||||
* operator activates it — per the Phase 0B spec surface map.
|
||||
*/
|
||||
function NewConnectionDialog({ companyId, onClose }: { companyId: string; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
|
||||
const apps = useQuery({
|
||||
queryKey: queryKeys.tools.applications(companyId),
|
||||
queryFn: () => toolsApi.listApplications(companyId),
|
||||
});
|
||||
const secrets = useQuery({
|
||||
queryKey: queryKeys.secrets.list(companyId),
|
||||
queryFn: () => secretsApi.list(companyId),
|
||||
});
|
||||
const templates = useQuery({
|
||||
queryKey: queryKeys.tools.stdioTemplates(companyId),
|
||||
queryFn: () => toolsApi.listStdioTemplates(companyId),
|
||||
});
|
||||
|
||||
const [applicationId, setApplicationId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [transport, setTransport] = useState<"remote_http" | "local_stdio">("remote_http");
|
||||
const [endpointUrl, setEndpointUrl] = useState("");
|
||||
const [templateId, setTemplateId] = useState("");
|
||||
const [creds, setCreds] = useState<CredentialDraft[]>([]);
|
||||
const [pendingSecretId, setPendingSecretId] = useState("");
|
||||
const [pendingHeader, setPendingHeader] = useState("Authorization");
|
||||
const [draft, setDraft] = useState<ToolConnection | null>(null);
|
||||
|
||||
const secretName = (id: string) => secrets.data?.find((s) => s.id === id)?.name ?? id.slice(0, 8);
|
||||
|
||||
const credentialRefs: McpConnectionCredentialRef[] = useMemo(
|
||||
() =>
|
||||
creds.map((c) => ({
|
||||
name: c.headerName,
|
||||
secretId: c.secretId,
|
||||
version: "latest",
|
||||
placement: "header",
|
||||
key: c.headerName,
|
||||
})),
|
||||
[creds],
|
||||
);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => {
|
||||
const config: Record<string, unknown> =
|
||||
transport === "remote_http" ? { url: endpointUrl.trim() } : { templateId };
|
||||
const input: CreateToolConnectionInput = {
|
||||
applicationId,
|
||||
name: name.trim(),
|
||||
transport,
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
config,
|
||||
credentialRefs,
|
||||
};
|
||||
return toolsApi.createConnection(companyId, input);
|
||||
},
|
||||
onSuccess: (conn) => setDraft(conn),
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Could not create connection",
|
||||
body: err instanceof ApiError ? err.message : String(err),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const probe = useMutation({
|
||||
mutationFn: (id: string) => toolsApi.checkConnectionHealth(id),
|
||||
onSuccess: (res) => setDraft(res.connection),
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Probe failed",
|
||||
body: err instanceof ApiError ? err.message : String(err),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (id: string) => toolsApi.updateConnection(id, { status: "active", enabled: true }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) });
|
||||
pushToast({ title: "Connection activated", tone: "success" });
|
||||
onClose();
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Activation failed",
|
||||
body: err instanceof ApiError ? err.message : String(err),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const addCred = () => {
|
||||
if (!pendingSecretId || !pendingHeader.trim()) return;
|
||||
setCreds((c) => [...c, { secretId: pendingSecretId, headerName: pendingHeader.trim() }]);
|
||||
setPendingSecretId("");
|
||||
};
|
||||
|
||||
const transportConfigValid =
|
||||
transport === "remote_http" ? endpointUrl.trim().length > 0 : templateId.length > 0;
|
||||
const canCreate = !!applicationId && name.trim().length > 0 && transportConfigValid && !create.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New connection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Credentials are stored as secret references — Paperclip resolves them at gateway use time and never
|
||||
exposes them to agents. The connection is created as a draft and probed before you activate it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Application</Label>
|
||||
<Select value={applicationId} onValueChange={setApplicationId} disabled={!!draft}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an application" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(apps.data?.applications ?? []).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="conn-name">Name</Label>
|
||||
<Input
|
||||
id="conn-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Production GitHub"
|
||||
disabled={!!draft}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Transport</Label>
|
||||
<Select
|
||||
value={transport}
|
||||
onValueChange={(v) => setTransport(v as "remote_http" | "local_stdio")}
|
||||
disabled={!!draft}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="remote_http">Remote HTTP (no local process)</SelectItem>
|
||||
<SelectItem value="local_stdio">Local stdio (approved template)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{transport === "remote_http" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="conn-url">Endpoint URL</Label>
|
||||
<Input
|
||||
id="conn-url"
|
||||
value={endpointUrl}
|
||||
onChange={(e) => setEndpointUrl(e.target.value)}
|
||||
placeholder="https://mcp.example.com"
|
||||
disabled={!!draft}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Command template</Label>
|
||||
<Select value={templateId} onValueChange={setTemplateId} disabled={!!draft}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an approved template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(templates.data?.templates ?? []).map((t) => (
|
||||
<SelectItem key={t.templateId} value={t.templateId}>
|
||||
{t.title ?? t.templateId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only board-approved command templates can run. Arbitrary commands are never accepted.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secret-reference picker — no free-text token field. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Credential references</Label>
|
||||
{creds.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{creds.map((c, i) => (
|
||||
<li key={`${c.secretId}-${i}`} className="flex items-center gap-2 text-sm">
|
||||
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-mono text-xs">{c.headerName}</span>
|
||||
<span className="text-muted-foreground">→ secret {secretName(c.secretId)}</span>
|
||||
{!draft ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setCreds((cs) => cs.filter((_, idx) => idx !== i))}
|
||||
aria-label="Remove credential reference"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{!draft ? (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Select value={pendingSecretId} onValueChange={setPendingSecretId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Secret" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(secrets.data ?? []).map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
value={pendingHeader}
|
||||
onChange={(e) => setPendingHeader(e.target.value)}
|
||||
placeholder="Header"
|
||||
className="w-32"
|
||||
aria-label="Header name"
|
||||
/>
|
||||
<Button type="button" size="sm" variant="outline" onClick={addCred} disabled={!pendingSecretId}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{draft ? (
|
||||
<div className="rounded-md border border-border bg-muted/40 p-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">Probe result</span>
|
||||
<HealthBadge status={draft.healthStatus} />
|
||||
</div>
|
||||
{draft.healthMessage ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{draft.healthMessage}</p>
|
||||
) : null}
|
||||
{draft.lastError ? <p className="mt-1 text-xs text-destructive">{draft.lastError}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!draft ? (
|
||||
<Button disabled={!canCreate} onClick={() => create.mutate()}>
|
||||
{create.isPending ? "Creating draft…" : "Create & probe"}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={probe.isPending}
|
||||
onClick={() => probe.mutate(draft.id)}
|
||||
>
|
||||
<Stethoscope className="mr-1 h-3.5 w-3.5" />
|
||||
{probe.isPending ? "Probing…" : "Re-probe"}
|
||||
</Button>
|
||||
<Button disabled={activate.isPending} onClick={() => activate.mutate(draft.id)}>
|
||||
{activate.isPending ? "Activating…" : "Activate"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionsTab({ companyId }: { companyId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [catalogFor, setCatalogFor] = useState<ToolConnection | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const connections = useQuery({
|
||||
queryKey: queryKeys.tools.connections(companyId),
|
||||
@@ -136,13 +435,21 @@ export function ConnectionsTab({ companyId }: { companyId: string }) {
|
||||
<ToolsPageHeader
|
||||
title="Connections"
|
||||
description="Managed credentials and transport for each application. Credentials are stored as secret references and only resolve at gateway/runtime use time — never sent to agents."
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
New connection
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Plug className="h-6 w-6" />}
|
||||
title="No connections yet"
|
||||
description="Add a connection to an application to configure credentials and discover its tools. (Create + mcp.json import wire to POST /tools/connections and /tools/mcp/import-json.)"
|
||||
icon={Plug}
|
||||
message="No connections yet"
|
||||
description="Add a connection to an application to configure credentials and discover its tools."
|
||||
action="New connection"
|
||||
onAction={() => setCreateOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
@@ -195,6 +502,7 @@ export function ConnectionsTab({ companyId }: { companyId: string }) {
|
||||
)}
|
||||
|
||||
{catalogFor ? <CatalogDialog connection={catalogFor} onClose={() => setCatalogFor(null)} /> : null}
|
||||
{createOpen ? <NewConnectionDialog companyId={companyId} onClose={() => setCreateOpen(false)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,218 @@
|
||||
import { ToolsPageHeader, PendingBackendNotice } from "./shared";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, CircleSlash, PackagePlus, PlayCircle, Sparkles, XCircle } from "lucide-react";
|
||||
import type { ToolExampleSmokeResult } from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import {
|
||||
CapabilityBadges,
|
||||
DecisionBadge,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
RiskBadge,
|
||||
ToolsPageHeader,
|
||||
} from "./shared";
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof ApiError ? error.message : error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function SmokeResult({ result }: { result: ToolExampleSmokeResult }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||
{result.ok ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
)}
|
||||
<span className="font-medium text-foreground">
|
||||
Smoke {result.ok ? "passed" : "failed"}
|
||||
</span>
|
||||
<Badge variant="outline">{result.actor.actorType}</Badge>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{result.checks.map((check) => (
|
||||
<div key={check.name} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{check.ok ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="font-medium text-foreground">{check.name.replaceAll("_", " ")}</span>
|
||||
{check.toolName ? <span className="font-mono text-xs text-muted-foreground">{check.toolName}</span> : null}
|
||||
{check.decision ? <DecisionBadge decision={check.decision} /> : null}
|
||||
{check.reasonCode ? <span className="font-mono text-xs text-muted-foreground">{check.reasonCode}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExamplesTab({ companyId }: { companyId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [smokeResults, setSmokeResults] = useState<Record<string, ToolExampleSmokeResult>>({});
|
||||
|
||||
const examples = useQuery({
|
||||
queryKey: queryKeys.tools.examples(companyId),
|
||||
queryFn: () => toolsApi.listExamples(companyId),
|
||||
});
|
||||
|
||||
const invalidateToolState = () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.examples(companyId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.applications(companyId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeSlots(companyId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeHealth(companyId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.audit(companyId, 100) });
|
||||
};
|
||||
|
||||
const install = useMutation({
|
||||
mutationFn: (exampleId: string) => toolsApi.installExample(companyId, exampleId),
|
||||
onSuccess: (result) => {
|
||||
invalidateToolState();
|
||||
pushToast({
|
||||
title: result.created ? "Example installed" : "Example already installed",
|
||||
body: `${result.profileEntries.length} read-only tool grant(s) active`,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Install failed",
|
||||
body: errorMessage(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const smoke = useMutation({
|
||||
mutationFn: (exampleId: string) => toolsApi.smokeExample(companyId, exampleId),
|
||||
onSuccess: (result) => {
|
||||
setSmokeResults((current) => ({ ...current, [result.exampleId]: result }));
|
||||
invalidateToolState();
|
||||
pushToast({
|
||||
title: result.ok ? "Smoke passed" : "Smoke failed",
|
||||
body: `${result.checks.filter((check) => check.ok).length}/${result.checks.length} checks passed`,
|
||||
tone: result.ok ? "success" : "error",
|
||||
});
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Smoke failed",
|
||||
body: errorMessage(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
if (examples.isLoading) return <LoadingState />;
|
||||
if (examples.error) return <ErrorState error={examples.error} onRetry={() => examples.refetch()} />;
|
||||
|
||||
const list = examples.data?.examples ?? [];
|
||||
|
||||
export function ExamplesTab() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ToolsPageHeader
|
||||
title="Examples & smoke tests"
|
||||
description="One-click safe fixtures (echo, calculator, read-only Paperclip self) to prove governance works, plus a smoke runner for allow/deny/audit paths."
|
||||
/>
|
||||
<PendingBackendNotice
|
||||
title="Example installer API not available yet"
|
||||
body="Phase 1 shipped a script-based fixture smoke harness (scripts/mcp-fixtures + scripts/smoke), but there is no board API to install an example fixture or trigger a smoke run from the UI. The 'fresh user enables a safe read-only example and sees an agent use it' acceptance criterion depends on that endpoint."
|
||||
issue={{ identifier: "PAP-10409", href: "/PAP/issues/PAP-10409" }}
|
||||
description="Install safe fixture connections and run server-side governance checks for allow, deny, and audit paths."
|
||||
/>
|
||||
|
||||
{list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Sparkles}
|
||||
message="No examples available"
|
||||
description="Example fixtures are bundled with Paperclip and should appear here when the server exposes them."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{list.map((example) => {
|
||||
const installed = example.install.installed;
|
||||
const result = smokeResults[example.id];
|
||||
return (
|
||||
<Card key={example.id}>
|
||||
<CardContent className="space-y-4 py-4">
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<Sparkles className="mt-0.5 h-5 w-5 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-foreground">{example.title}</span>
|
||||
<Badge variant={installed ? "default" : "outline"}>
|
||||
{installed ? "installed" : "available"}
|
||||
</Badge>
|
||||
<Badge variant="outline">{example.fixture.transport}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">{example.description}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={installed ? "outline" : "default"}
|
||||
disabled={!example.install.canInstall || install.isPending}
|
||||
onClick={() => install.mutate(example.id)}
|
||||
>
|
||||
<PackagePlus className="mr-1 h-3.5 w-3.5" />
|
||||
{installed ? "Repair" : "Install"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!installed || smoke.isPending}
|
||||
onClick={() => smoke.mutate(example.id)}
|
||||
>
|
||||
<PlayCircle className="mr-1 h-3.5 w-3.5" />
|
||||
Smoke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{example.install.reason ? (
|
||||
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
<CircleSlash className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{example.install.reason}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-normal text-muted-foreground">Fixture tools</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{example.fixture.tools.map((tool) => (
|
||||
<span key={tool.name} className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
|
||||
<span className="font-mono text-foreground">{tool.name}</span>
|
||||
<RiskBadge risk={tool.riskLevel} />
|
||||
<CapabilityBadges isReadOnly={tool.readOnly} isWrite={tool.riskLevel === "write"} isDestructive={tool.riskLevel === "destructive"} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-normal text-muted-foreground">Safe default profile</p>
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-foreground">{example.safeDefaultProfile.name}</span>
|
||||
<DecisionBadge decision={example.safeDefaultProfile.defaultAction} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Allows {example.safeDefaultProfile.allowedToolNames.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result ? <SmokeResult result={result} /> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,33 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FlaskConical, Shield, ShieldX } from "lucide-react";
|
||||
import type { ToolAccessDecision } from "@paperclipai/shared";
|
||||
import {
|
||||
FlaskConical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { ToolAccessDecision, ToolPolicy, ToolPolicyType, ToolRiskLevel } from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -18,17 +37,213 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import {
|
||||
ToolsPageHeader,
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
DecisionBadge,
|
||||
RelativeTime,
|
||||
PendingBackendNotice,
|
||||
} from "./shared";
|
||||
|
||||
const ANY_VALUE = "__any__";
|
||||
|
||||
type BuilderPolicyType = Exclude<ToolPolicyType, "trust_rule">;
|
||||
type ActorTypeValue = "agent" | "user" | "system" | "plugin";
|
||||
type RateLimitKeyBy = "company" | "agent" | "application" | "connection" | "tool";
|
||||
|
||||
const POLICY_TYPES: Array<{ value: BuilderPolicyType; label: string }> = [
|
||||
{ value: "allow", label: "Allow" },
|
||||
{ value: "block", label: "Block" },
|
||||
{ value: "require_approval", label: "Require approval" },
|
||||
{ value: "rate_limit", label: "Rate limit" },
|
||||
{ value: "redact", label: "Redact" },
|
||||
{ value: "validate", label: "Validate" },
|
||||
];
|
||||
|
||||
const RISK_LEVELS: ToolRiskLevel[] = ["read", "write", "destructive", "low", "medium", "high", "critical"];
|
||||
const RATE_LIMIT_KEY_FIELDS: RateLimitKeyBy[] = ["company", "agent", "application", "connection", "tool"];
|
||||
|
||||
type PolicyFormState = {
|
||||
id: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
policyType: BuilderPolicyType;
|
||||
priority: string;
|
||||
enabled: boolean;
|
||||
actorType: typeof ANY_VALUE | ActorTypeValue;
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
applicationId: string;
|
||||
connectionId: string;
|
||||
riskLevel: string;
|
||||
toolNames: string;
|
||||
rateLimitLimit: string;
|
||||
rateLimitWindowSeconds: string;
|
||||
rateLimitKeyBy: RateLimitKeyBy[];
|
||||
redactFields: string;
|
||||
conditionsJson: string;
|
||||
configJson: string;
|
||||
};
|
||||
|
||||
function emptyPolicyForm(): PolicyFormState {
|
||||
return {
|
||||
id: null,
|
||||
name: "",
|
||||
description: "",
|
||||
policyType: "allow",
|
||||
priority: "100",
|
||||
enabled: true,
|
||||
actorType: ANY_VALUE,
|
||||
agentId: ANY_VALUE,
|
||||
projectId: ANY_VALUE,
|
||||
applicationId: ANY_VALUE,
|
||||
connectionId: ANY_VALUE,
|
||||
riskLevel: ANY_VALUE,
|
||||
toolNames: "",
|
||||
rateLimitLimit: "60",
|
||||
rateLimitWindowSeconds: "3600",
|
||||
rateLimitKeyBy: ["agent", "tool"],
|
||||
redactFields: "",
|
||||
conditionsJson: "",
|
||||
configJson: "",
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
if (typeof value === "string" && value.trim()) return [value.trim()];
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
||||
}
|
||||
|
||||
function parseList(value: string): string[] {
|
||||
return value
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function selectValue(selectors: Record<string, unknown>, key: string) {
|
||||
const value = selectors[key];
|
||||
return typeof value === "string" && value.trim() ? value : ANY_VALUE;
|
||||
}
|
||||
|
||||
function formatJson(value: unknown) {
|
||||
return isRecord(value) && Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : "";
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string, label: string): Record<string, unknown> | null {
|
||||
if (!value.trim()) return null;
|
||||
const parsed = JSON.parse(value);
|
||||
if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function policyTypeLabel(type: ToolPolicyType | string) {
|
||||
return POLICY_TYPES.find((item) => item.value === type)?.label ?? type.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function policyIcon(type: ToolPolicyType) {
|
||||
if (type === "allow") return <ShieldCheck className="h-5 w-5 text-emerald-600" />;
|
||||
if (type === "block") return <ShieldX className="h-5 w-5 text-destructive" />;
|
||||
if (type === "require_approval") return <ShieldAlert className="h-5 w-5 text-amber-500" />;
|
||||
return <Shield className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function policyToForm(policy: ToolPolicy): PolicyFormState {
|
||||
const selectors = policy.selectors ?? {};
|
||||
const toolNames = [
|
||||
...stringList(selectors.toolName),
|
||||
...stringList(selectors.toolNames),
|
||||
].join(", ");
|
||||
const config = isRecord(policy.config) ? { ...policy.config } : {};
|
||||
const rawRateLimit = isRecord(config.rateLimit) ? config.rateLimit : config;
|
||||
const rawRedact = isRecord(config.redact) ? config.redact : {};
|
||||
const rateLimitKeyBy = stringList(rawRateLimit.keyBy).filter((item): item is RateLimitKeyBy =>
|
||||
RATE_LIMIT_KEY_FIELDS.includes(item as RateLimitKeyBy),
|
||||
);
|
||||
if (policy.policyType === "rate_limit") delete config.rateLimit;
|
||||
if (policy.policyType === "redact") delete config.redact;
|
||||
|
||||
return {
|
||||
...emptyPolicyForm(),
|
||||
id: policy.id,
|
||||
name: policy.name,
|
||||
description: policy.description ?? "",
|
||||
policyType: policy.policyType as BuilderPolicyType,
|
||||
priority: String(policy.priority),
|
||||
enabled: policy.enabled,
|
||||
actorType: selectValue(selectors, "actorType") as PolicyFormState["actorType"],
|
||||
agentId: selectValue(selectors, "agentId"),
|
||||
projectId: selectValue(selectors, "projectId"),
|
||||
applicationId: selectValue(selectors, "applicationId"),
|
||||
connectionId: selectValue(selectors, "connectionId"),
|
||||
riskLevel: selectValue(selectors, "riskLevel"),
|
||||
toolNames,
|
||||
rateLimitLimit: String(rawRateLimit.limit ?? "60"),
|
||||
rateLimitWindowSeconds: String(rawRateLimit.windowSeconds ?? "3600"),
|
||||
rateLimitKeyBy: rateLimitKeyBy.length > 0 ? rateLimitKeyBy : ["agent", "tool"],
|
||||
redactFields: stringList(rawRedact.fields).join(", "),
|
||||
conditionsJson: formatJson(policy.conditions),
|
||||
configJson: formatJson(config),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPolicyPayload(form: PolicyFormState) {
|
||||
const priority = Number(form.priority);
|
||||
if (!Number.isInteger(priority) || priority < 0 || priority > 10000) {
|
||||
throw new Error("Priority must be an integer from 0 to 10000");
|
||||
}
|
||||
const selectors: Record<string, unknown> = {};
|
||||
if (form.actorType !== ANY_VALUE) selectors.actorType = form.actorType;
|
||||
if (form.agentId !== ANY_VALUE) selectors.agentId = form.agentId;
|
||||
if (form.projectId !== ANY_VALUE) selectors.projectId = form.projectId;
|
||||
if (form.applicationId !== ANY_VALUE) selectors.applicationId = form.applicationId;
|
||||
if (form.connectionId !== ANY_VALUE) selectors.connectionId = form.connectionId;
|
||||
if (form.riskLevel !== ANY_VALUE) selectors.riskLevel = form.riskLevel;
|
||||
const toolNames = parseList(form.toolNames);
|
||||
if (toolNames.length === 1) selectors.toolName = toolNames[0];
|
||||
if (toolNames.length > 1) selectors.toolNames = toolNames;
|
||||
|
||||
const conditions = parseJsonObject(form.conditionsJson, "Conditions JSON");
|
||||
const config = parseJsonObject(form.configJson, "Config JSON") ?? {};
|
||||
if (form.policyType === "rate_limit") {
|
||||
const limit = Number(form.rateLimitLimit);
|
||||
const windowSeconds = Number(form.rateLimitWindowSeconds);
|
||||
if (!Number.isInteger(limit) || limit <= 0) throw new Error("Rate limit must be a positive integer");
|
||||
if (!Number.isInteger(windowSeconds) || windowSeconds <= 0) {
|
||||
throw new Error("Rate limit window must be a positive number of seconds");
|
||||
}
|
||||
config.rateLimit = {
|
||||
limit,
|
||||
windowSeconds,
|
||||
keyBy: form.rateLimitKeyBy,
|
||||
};
|
||||
}
|
||||
if (form.policyType === "redact") {
|
||||
const fields = parseList(form.redactFields);
|
||||
if (fields.length > 0) config.redact = { fields };
|
||||
}
|
||||
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
policyType: form.policyType,
|
||||
priority,
|
||||
enabled: form.enabled,
|
||||
selectors,
|
||||
conditions,
|
||||
config: Object.keys(config).length > 0 ? config : null,
|
||||
};
|
||||
}
|
||||
|
||||
function PolicySimulator({ companyId }: { companyId: string }) {
|
||||
const { pushToast } = useToast();
|
||||
const [agentId, setAgentId] = useState<string>("");
|
||||
@@ -64,10 +279,6 @@ function PolicySimulator({ companyId }: { companyId: string }) {
|
||||
<FlaskConical className="h-4 w-4" />
|
||||
Decision simulator
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Evaluate what the policy engine would decide for an agent + tool, using authoritative server
|
||||
state. This calls the same engine the gateway uses — it does not grant or change access.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Agent</Label>
|
||||
@@ -99,7 +310,7 @@ function PolicySimulator({ companyId }: { companyId: string }) {
|
||||
disabled={!agentId || !toolName.trim() || test.isPending}
|
||||
onClick={() => test.mutate()}
|
||||
>
|
||||
{test.isPending ? "Evaluating…" : "Evaluate decision"}
|
||||
{test.isPending ? "Evaluating..." : "Evaluate decision"}
|
||||
</Button>
|
||||
|
||||
{result ? (
|
||||
@@ -125,12 +336,87 @@ function PolicySimulator({ companyId }: { companyId: string }) {
|
||||
export function PoliciesTab({ companyId }: { companyId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [form, setForm] = useState<PolicyFormState | null>(null);
|
||||
|
||||
const policies = useQuery({
|
||||
queryKey: queryKeys.tools.policies(companyId),
|
||||
queryFn: () => toolsApi.listPolicies(companyId),
|
||||
});
|
||||
const agents = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
const projects = useQuery({
|
||||
queryKey: queryKeys.projects.list(companyId),
|
||||
queryFn: () => projectsApi.list(companyId),
|
||||
});
|
||||
const applications = useQuery({
|
||||
queryKey: queryKeys.tools.applications(companyId),
|
||||
queryFn: () => toolsApi.listApplications(companyId),
|
||||
});
|
||||
const connections = useQuery({
|
||||
queryKey: queryKeys.tools.connections(companyId),
|
||||
queryFn: () => toolsApi.listConnections(companyId),
|
||||
});
|
||||
const trustRules = useQuery({
|
||||
queryKey: queryKeys.tools.trustRules(companyId),
|
||||
queryFn: () => toolsApi.listTrustRules(companyId),
|
||||
});
|
||||
|
||||
const nameMaps = useMemo(() => ({
|
||||
agent: new Map((agents.data ?? []).map((item) => [item.id, item.name])),
|
||||
project: new Map((projects.data ?? []).map((item) => [item.id, item.name])),
|
||||
application: new Map((applications.data?.applications ?? []).map((item) => [item.id, item.name])),
|
||||
connection: new Map((connections.data?.connections ?? []).map((item) => [item.id, item.name])),
|
||||
}), [agents.data, applications.data, connections.data, projects.data]);
|
||||
|
||||
const invalidatePolicies = () => qc.invalidateQueries({ queryKey: queryKeys.tools.policies(companyId) });
|
||||
|
||||
const createPolicy = useMutation({
|
||||
mutationFn: (input: ReturnType<typeof buildPolicyPayload>) => toolsApi.createPolicy(companyId, input),
|
||||
onSuccess: () => {
|
||||
invalidatePolicies();
|
||||
setForm(null);
|
||||
pushToast({ title: "Policy created", tone: "success" });
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Could not save policy",
|
||||
body: err instanceof ApiError ? err.message : String(err),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const updatePolicy = useMutation({
|
||||
mutationFn: (input: { policyId: string; body: ReturnType<typeof buildPolicyPayload> }) =>
|
||||
toolsApi.updatePolicy(companyId, input.policyId, input.body),
|
||||
onSuccess: () => {
|
||||
invalidatePolicies();
|
||||
setForm(null);
|
||||
pushToast({ title: "Policy updated", tone: "success" });
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Could not save policy",
|
||||
body: err instanceof ApiError ? err.message : String(err),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const deletePolicy = useMutation({
|
||||
mutationFn: (policyId: string) => toolsApi.deletePolicy(companyId, policyId),
|
||||
onSuccess: () => {
|
||||
invalidatePolicies();
|
||||
pushToast({ title: "Policy deleted", tone: "success" });
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Delete failed",
|
||||
body: err instanceof ApiError ? err.message : String(err),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: (policyId: string) => toolsApi.revokeTrustRule(companyId, policyId),
|
||||
onSuccess: () => {
|
||||
@@ -145,21 +431,122 @@ export function PoliciesTab({ companyId }: { companyId: string }) {
|
||||
}),
|
||||
});
|
||||
|
||||
function selectorSummary(policy: ToolPolicy) {
|
||||
const selectors = policy.selectors ?? {};
|
||||
const parts: string[] = [];
|
||||
const add = (label: string, key: string, names?: Map<string, string>) => {
|
||||
const values = [...stringList(selectors[key]), ...stringList(selectors[`${key}s`])];
|
||||
if (values.length > 0) {
|
||||
parts.push(`${label}: ${values.map((value) => names?.get(value) ?? value).join(", ")}`);
|
||||
}
|
||||
};
|
||||
add("actor", "actorType");
|
||||
add("agent", "agentId", nameMaps.agent);
|
||||
add("project", "projectId", nameMaps.project);
|
||||
add("application", "applicationId", nameMaps.application);
|
||||
add("connection", "connectionId", nameMaps.connection);
|
||||
add("risk", "riskLevel");
|
||||
add("tool", "toolName");
|
||||
return parts.length > 0 ? parts.join(" · ") : "company-wide";
|
||||
}
|
||||
|
||||
function submitPolicy() {
|
||||
if (!form) return;
|
||||
try {
|
||||
const body = buildPolicyPayload(form);
|
||||
if (!body.name) throw new Error("Name is required");
|
||||
if (form.id) {
|
||||
updatePolicy.mutate({ policyId: form.id, body });
|
||||
} else {
|
||||
createPolicy.mutate(body);
|
||||
}
|
||||
} catch (err) {
|
||||
pushToast({
|
||||
title: "Invalid policy",
|
||||
body: err instanceof Error ? err.message : String(err),
|
||||
tone: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const policyList = policies.data?.policies ?? [];
|
||||
const saving = createPolicy.isPending || updatePolicy.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ToolsPageHeader
|
||||
title="Policies"
|
||||
description="Server-evaluated rules that allow, block, redact, rate-limit, or require approval for tool calls. Default posture is deny — agents see no external tools until a profile or policy grants access."
|
||||
description="Server-evaluated rules that allow, block, redact, rate-limit, or require approval for tool calls. Default posture is deny."
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setForm(emptyPolicyForm())}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
New policy
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
Policy rules
|
||||
</h3>
|
||||
{policies.isLoading ? (
|
||||
<LoadingState />
|
||||
) : policies.error ? (
|
||||
<ErrorState error={policies.error} onRetry={() => policies.refetch()} />
|
||||
) : policyList.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
message="No policies"
|
||||
description="Create an allow, block, approval, redaction, or rate-limit rule."
|
||||
action="New policy"
|
||||
onAction={() => setForm(emptyPolicyForm())}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{policyList.map((policy) => (
|
||||
<Card key={policy.id}>
|
||||
<CardContent className="flex flex-wrap items-center gap-3 py-3">
|
||||
{policyIcon(policy.policyType)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-foreground">{policy.name}</span>
|
||||
<Badge variant="outline">{policyTypeLabel(policy.policyType)}</Badge>
|
||||
<Badge variant="outline">priority {policy.priority}</Badge>
|
||||
{!policy.enabled ? <Badge variant="outline">disabled</Badge> : null}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{selectorSummary(policy)}</p>
|
||||
{policy.description ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{policy.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs">
|
||||
<RelativeTime value={policy.updatedAt} />
|
||||
</span>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={() => setForm(policyToForm(policy))}>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={deletePolicy.isPending}
|
||||
onClick={() => deletePolicy.mutate(policy.id)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PolicySimulator companyId={companyId} />
|
||||
|
||||
<PendingBackendNotice
|
||||
title="Full policy builder pending generic policy CRUD"
|
||||
body="The decision engine, policy test, and trust-rules are shipped. Selector-based allow/block/redact/rate-limit/approval rule editing needs the generic policy CRUD endpoints. The simulator above and trust rules below are the MVP."
|
||||
issue={{ identifier: "PAP-10410", href: "/PAP/issues/PAP-10410" }}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
@@ -171,9 +558,9 @@ export function PoliciesTab({ companyId }: { companyId: string }) {
|
||||
<ErrorState error={trustRules.error} onRetry={() => trustRules.refetch()} />
|
||||
) : (trustRules.data?.trustRules ?? []).length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Shield className="h-6 w-6" />}
|
||||
title="No trust rules"
|
||||
description="Trust rules are created by promoting a repeated approved action into a scoped auto-allow. They appear here once created."
|
||||
icon={Shield}
|
||||
message="No trust rules"
|
||||
description="Trust rules are created by promoting a repeated approved action into a scoped auto-allow."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
@@ -181,7 +568,7 @@ export function PoliciesTab({ companyId }: { companyId: string }) {
|
||||
<Card key={rule.id}>
|
||||
<CardContent className="flex flex-wrap items-center gap-3 py-3">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">{rule.name}</span>
|
||||
<Badge variant="outline">priority {rule.priority}</Badge>
|
||||
@@ -191,7 +578,7 @@ export function PoliciesTab({ companyId }: { companyId: string }) {
|
||||
<p className="truncate text-xs text-muted-foreground">{rule.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="ml-auto text-xs">
|
||||
<span className="text-xs">
|
||||
<RelativeTime value={rule.updatedAt} />
|
||||
</span>
|
||||
<Button
|
||||
@@ -209,6 +596,276 @@ export function PoliciesTab({ companyId }: { companyId: string }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={Boolean(form)} onOpenChange={(open) => !open && setForm(null)}>
|
||||
{form ? (
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{form.id ? "Edit policy" : "New policy"}</DialogTitle>
|
||||
<DialogDescription>Define the server-side rule and the selectors it applies to.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_180px_120px]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="policy-name">Name</Label>
|
||||
<Input
|
||||
id="policy-name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="e.g. Block destructive shell tools"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
value={form.policyType}
|
||||
onValueChange={(value) => setForm({ ...form, policyType: value as BuilderPolicyType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{POLICY_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="policy-priority">Priority</Label>
|
||||
<Input
|
||||
id="policy-priority"
|
||||
inputMode="numeric"
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm({ ...form, priority: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Enabled</p>
|
||||
<p className="text-xs text-muted-foreground">Disabled policies stay saved but do not match.</p>
|
||||
</div>
|
||||
<ToggleSwitch checked={form.enabled} onCheckedChange={(enabled) => setForm({ ...form, enabled })} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="policy-description">Description</Label>
|
||||
<Textarea
|
||||
id="policy-description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Optional reviewer-facing reason"
|
||||
className="min-h-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Actor type</Label>
|
||||
<Select
|
||||
value={form.actorType}
|
||||
onValueChange={(actorType) => setForm({ ...form, actorType: actorType as PolicyFormState["actorType"] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY_VALUE}>Any actor</SelectItem>
|
||||
<SelectItem value="agent">Agent</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="plugin">Plugin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Agent</Label>
|
||||
<Select value={form.agentId} onValueChange={(agentId) => setForm({ ...form, agentId })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY_VALUE}>Any agent</SelectItem>
|
||||
{(agents.data ?? []).map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Project</Label>
|
||||
<Select value={form.projectId} onValueChange={(projectId) => setForm({ ...form, projectId })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY_VALUE}>Any project</SelectItem>
|
||||
{(projects.data ?? []).map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Application</Label>
|
||||
<Select
|
||||
value={form.applicationId}
|
||||
onValueChange={(applicationId) => setForm({ ...form, applicationId })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY_VALUE}>Any application</SelectItem>
|
||||
{(applications.data?.applications ?? []).map((app) => (
|
||||
<SelectItem key={app.id} value={app.id}>
|
||||
{app.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Connection</Label>
|
||||
<Select
|
||||
value={form.connectionId}
|
||||
onValueChange={(connectionId) => setForm({ ...form, connectionId })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY_VALUE}>Any connection</SelectItem>
|
||||
{(connections.data?.connections ?? []).map((connection) => (
|
||||
<SelectItem key={connection.id} value={connection.id}>
|
||||
{connection.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Risk level</Label>
|
||||
<Select value={form.riskLevel} onValueChange={(riskLevel) => setForm({ ...form, riskLevel })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ANY_VALUE}>Any risk</SelectItem>
|
||||
{RISK_LEVELS.map((risk) => (
|
||||
<SelectItem key={risk} value={risk}>
|
||||
{risk}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="policy-tools">Tool names</Label>
|
||||
<Input
|
||||
id="policy-tools"
|
||||
value={form.toolNames}
|
||||
onChange={(e) => setForm({ ...form, toolNames: e.target.value })}
|
||||
placeholder="echo, add, mcp-server:tool_name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.policyType === "rate_limit" ? (
|
||||
<div className="grid gap-3 rounded-md border border-border p-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="policy-rate-limit">Limit</Label>
|
||||
<Input
|
||||
id="policy-rate-limit"
|
||||
inputMode="numeric"
|
||||
value={form.rateLimitLimit}
|
||||
onChange={(e) => setForm({ ...form, rateLimitLimit: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="policy-rate-window">Window seconds</Label>
|
||||
<Input
|
||||
id="policy-rate-window"
|
||||
inputMode="numeric"
|
||||
value={form.rateLimitWindowSeconds}
|
||||
onChange={(e) => setForm({ ...form, rateLimitWindowSeconds: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Bucket key</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{RATE_LIMIT_KEY_FIELDS.map((key) => (
|
||||
<label key={key} className="flex items-center gap-2 text-sm text-foreground">
|
||||
<Checkbox
|
||||
checked={form.rateLimitKeyBy.includes(key)}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = checked === true
|
||||
? [...new Set([...form.rateLimitKeyBy, key])]
|
||||
: form.rateLimitKeyBy.filter((item) => item !== key);
|
||||
setForm({ ...form, rateLimitKeyBy: next });
|
||||
}}
|
||||
/>
|
||||
{key}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{form.policyType === "redact" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="policy-redact-fields">Redacted fields</Label>
|
||||
<Input
|
||||
id="policy-redact-fields"
|
||||
value={form.redactFields}
|
||||
onChange={(e) => setForm({ ...form, redactFields: e.target.value })}
|
||||
placeholder="token, password, response.secret"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="policy-conditions">Conditions JSON</Label>
|
||||
<Textarea
|
||||
id="policy-conditions"
|
||||
value={form.conditionsJson}
|
||||
onChange={(e) => setForm({ ...form, conditionsJson: e.target.value })}
|
||||
placeholder='{"sideEffecting": true}'
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="policy-config">Config JSON</Label>
|
||||
<Textarea
|
||||
id="policy-config"
|
||||
value={form.configJson}
|
||||
onChange={(e) => setForm({ ...form, configJson: e.target.value })}
|
||||
placeholder='{"note": "optional"}'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setForm(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!form.name.trim() || saving} onClick={submitPolicy}>
|
||||
{saving ? "Saving..." : "Save policy"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
) : null}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,963 @@
|
||||
import { ToolsPageHeader, PendingBackendNotice } from "./shared";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Layers, Plus, Pencil, Trash2, Link2, ShieldCheck } from "lucide-react";
|
||||
import type {
|
||||
ToolProfileBindingTargetType,
|
||||
ToolProfileDefaultAction,
|
||||
ToolProfileEntry,
|
||||
ToolProfileEntryEffect,
|
||||
ToolProfileEntrySelectorType,
|
||||
ToolProfileStatus,
|
||||
ToolProfileWithDetails,
|
||||
ToolRiskLevel,
|
||||
} from "@paperclipai/shared";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { routinesApi } from "@/api/routines";
|
||||
import {
|
||||
toolsApi,
|
||||
type CreateToolProfileInput,
|
||||
type ToolProfileBindingInput,
|
||||
type ToolProfileEntryInput,
|
||||
type UpdateToolProfileInput,
|
||||
} from "@/api/tools";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { ErrorState, LoadingState, RelativeTime, RiskBadge, ToolsPageHeader } from "./shared";
|
||||
|
||||
const SELECTOR_TYPES: Array<{ value: ToolProfileEntrySelectorType; label: string }> = [
|
||||
{ value: "tool_name", label: "Tool name" },
|
||||
{ value: "risk_level", label: "Risk level" },
|
||||
{ value: "application", label: "Application" },
|
||||
{ value: "connection", label: "Connection" },
|
||||
{ value: "catalog_entry", label: "Catalog entry ID" },
|
||||
];
|
||||
|
||||
const TARGET_TYPES: Array<{ value: ToolProfileBindingTargetType; label: string }> = [
|
||||
{ value: "company", label: "Company" },
|
||||
{ value: "agent", label: "Agent" },
|
||||
{ value: "project", label: "Project" },
|
||||
{ value: "routine", label: "Routine" },
|
||||
{ value: "issue", label: "Issue ID" },
|
||||
];
|
||||
|
||||
const RISK_LEVELS: ToolRiskLevel[] = ["read", "write", "destructive", "low", "medium", "high", "critical"];
|
||||
|
||||
function slugifyProfileKey(name: string) {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._:-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 160);
|
||||
}
|
||||
|
||||
function statusVariant(status: ToolProfileStatus): "default" | "secondary" | "outline" | "destructive" {
|
||||
if (status === "active") return "default";
|
||||
if (status === "archived") return "outline";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function targetIdForType(input: {
|
||||
companyId: string;
|
||||
targetType: ToolProfileBindingTargetType;
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
routineId: string;
|
||||
issueId: string;
|
||||
}) {
|
||||
if (input.targetType === "company") return input.companyId;
|
||||
if (input.targetType === "agent") return input.agentId;
|
||||
if (input.targetType === "project") return input.projectId;
|
||||
if (input.targetType === "routine") return input.routineId;
|
||||
return input.issueId.trim();
|
||||
}
|
||||
|
||||
function buildEntryInput(input: {
|
||||
selectorType: ToolProfileEntrySelectorType;
|
||||
effect: ToolProfileEntryEffect;
|
||||
applicationId: string;
|
||||
connectionId: string;
|
||||
catalogEntryId: string;
|
||||
toolName: string;
|
||||
riskLevel: ToolRiskLevel;
|
||||
}): ToolProfileEntryInput | null {
|
||||
const base = { selectorType: input.selectorType, effect: input.effect };
|
||||
if (input.selectorType === "application") {
|
||||
return input.applicationId ? { ...base, applicationId: input.applicationId } : null;
|
||||
}
|
||||
if (input.selectorType === "connection") {
|
||||
return input.connectionId ? { ...base, connectionId: input.connectionId } : null;
|
||||
}
|
||||
if (input.selectorType === "catalog_entry") {
|
||||
const catalogEntryId = input.catalogEntryId.trim();
|
||||
return catalogEntryId ? { ...base, catalogEntryId } : null;
|
||||
}
|
||||
if (input.selectorType === "tool_name") {
|
||||
const toolName = input.toolName.trim();
|
||||
return toolName ? { ...base, toolName } : null;
|
||||
}
|
||||
return { ...base, riskLevel: input.riskLevel };
|
||||
}
|
||||
|
||||
function entryLabel(
|
||||
entry: ToolProfileEntry,
|
||||
applicationsById: Map<string, string>,
|
||||
connectionsById: Map<string, string>,
|
||||
) {
|
||||
if (entry.selectorType === "application") return applicationsById.get(entry.applicationId ?? "") ?? entry.applicationId ?? "application";
|
||||
if (entry.selectorType === "connection") return connectionsById.get(entry.connectionId ?? "") ?? entry.connectionId ?? "connection";
|
||||
if (entry.selectorType === "catalog_entry") return entry.catalogEntryId ?? "catalog entry";
|
||||
if (entry.selectorType === "risk_level") return entry.riskLevel ?? "risk level";
|
||||
return entry.toolName ?? "tool";
|
||||
}
|
||||
|
||||
function bindingLabel(
|
||||
targetType: ToolProfileBindingTargetType,
|
||||
targetId: string,
|
||||
labels: {
|
||||
companyId: string;
|
||||
agentsById: Map<string, string>;
|
||||
projectsById: Map<string, string>;
|
||||
routinesById: Map<string, string>;
|
||||
},
|
||||
) {
|
||||
if (targetType === "company") return targetId === labels.companyId ? "Company" : targetId;
|
||||
if (targetType === "agent") return labels.agentsById.get(targetId) ?? targetId;
|
||||
if (targetType === "project") return labels.projectsById.get(targetId) ?? targetId;
|
||||
if (targetType === "routine") return labels.routinesById.get(targetId) ?? targetId;
|
||||
return targetId;
|
||||
}
|
||||
|
||||
function useLookupData(companyId: string) {
|
||||
const agents = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
const projects = useQuery({
|
||||
queryKey: queryKeys.projects.list(companyId),
|
||||
queryFn: () => projectsApi.list(companyId),
|
||||
});
|
||||
const routines = useQuery({
|
||||
queryKey: queryKeys.routines.list(companyId),
|
||||
queryFn: () => routinesApi.list(companyId),
|
||||
});
|
||||
const applications = useQuery({
|
||||
queryKey: queryKeys.tools.applications(companyId),
|
||||
queryFn: () => toolsApi.listApplications(companyId),
|
||||
});
|
||||
const connections = useQuery({
|
||||
queryKey: queryKeys.tools.connections(companyId),
|
||||
queryFn: () => toolsApi.listConnections(companyId),
|
||||
});
|
||||
|
||||
const maps = useMemo(() => ({
|
||||
agentsById: new Map((agents.data ?? []).map((agent) => [agent.id, agent.name])),
|
||||
projectsById: new Map((projects.data ?? []).map((project) => [project.id, project.name])),
|
||||
routinesById: new Map((routines.data ?? []).map((routine) => [routine.id, routine.title])),
|
||||
applicationsById: new Map((applications.data?.applications ?? []).map((app) => [app.id, app.name])),
|
||||
connectionsById: new Map((connections.data?.connections ?? []).map((conn) => [conn.id, conn.name])),
|
||||
}), [agents.data, applications.data, connections.data, projects.data, routines.data]);
|
||||
|
||||
return { agents, projects, routines, applications, connections, maps };
|
||||
}
|
||||
|
||||
function EntryFields({
|
||||
selectorType,
|
||||
setSelectorType,
|
||||
effect,
|
||||
setEffect,
|
||||
applicationId,
|
||||
setApplicationId,
|
||||
connectionId,
|
||||
setConnectionId,
|
||||
catalogEntryId,
|
||||
setCatalogEntryId,
|
||||
toolName,
|
||||
setToolName,
|
||||
riskLevel,
|
||||
setRiskLevel,
|
||||
applications,
|
||||
connections,
|
||||
}: {
|
||||
selectorType: ToolProfileEntrySelectorType;
|
||||
setSelectorType: (value: ToolProfileEntrySelectorType) => void;
|
||||
effect: ToolProfileEntryEffect;
|
||||
setEffect: (value: ToolProfileEntryEffect) => void;
|
||||
applicationId: string;
|
||||
setApplicationId: (value: string) => void;
|
||||
connectionId: string;
|
||||
setConnectionId: (value: string) => void;
|
||||
catalogEntryId: string;
|
||||
setCatalogEntryId: (value: string) => void;
|
||||
toolName: string;
|
||||
setToolName: (value: string) => void;
|
||||
riskLevel: ToolRiskLevel;
|
||||
setRiskLevel: (value: ToolRiskLevel) => void;
|
||||
applications: Array<{ id: string; name: string }>;
|
||||
connections: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1fr]">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Selector</Label>
|
||||
<Select value={selectorType} onValueChange={(value) => setSelectorType(value as ToolProfileEntrySelectorType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SELECTOR_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Effect</Label>
|
||||
<Select value={effect} onValueChange={(value) => setEffect(value as ToolProfileEntryEffect)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="include">Include</SelectItem>
|
||||
<SelectItem value="exclude">Exclude</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectorType === "application" ? (
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label>Application</Label>
|
||||
<Select value={applicationId} onValueChange={setApplicationId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an application" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{applications.map((app) => (
|
||||
<SelectItem key={app.id} value={app.id}>
|
||||
{app.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
{selectorType === "connection" ? (
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label>Connection</Label>
|
||||
<Select value={connectionId} onValueChange={setConnectionId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a connection" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id}>
|
||||
{conn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
{selectorType === "catalog_entry" ? (
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="catalog-entry-id">Catalog entry ID</Label>
|
||||
<Input id="catalog-entry-id" value={catalogEntryId} onChange={(event) => setCatalogEntryId(event.target.value)} />
|
||||
</div>
|
||||
) : null}
|
||||
{selectorType === "tool_name" ? (
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="tool-name">Tool name</Label>
|
||||
<Input id="tool-name" value={toolName} onChange={(event) => setToolName(event.target.value)} placeholder="e.g. send_email" />
|
||||
</div>
|
||||
) : null}
|
||||
{selectorType === "risk_level" ? (
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label>Risk level</Label>
|
||||
<Select value={riskLevel} onValueChange={(value) => setRiskLevel(value as ToolRiskLevel)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RISK_LEVELS.map((risk) => (
|
||||
<SelectItem key={risk} value={risk}>
|
||||
{risk}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectiveAgentPanel({ companyId, agentOptions }: { companyId: string; agentOptions: Array<{ id: string; name: string }> }) {
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const effective = useQuery({
|
||||
queryKey: agentId
|
||||
? queryKeys.tools.effectiveProfilesForAgent(companyId, agentId)
|
||||
: ["tools", companyId, "profiles", "effective", "agent", "__none__"],
|
||||
queryFn: () => toolsApi.getEffectiveProfilesForAgent(companyId, agentId),
|
||||
enabled: Boolean(agentId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3 py-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">Effective agent access</p>
|
||||
<p className="text-xs text-muted-foreground">Resolve active company and agent bindings into allowed tool names.</p>
|
||||
</div>
|
||||
<div className="ml-auto min-w-64">
|
||||
<Select value={agentId} onValueChange={setAgentId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agentOptions.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{!agentId ? null : effective.isLoading ? (
|
||||
<LoadingState label="Resolving access..." />
|
||||
) : effective.error ? (
|
||||
<ErrorState error={effective.error} onRetry={() => effective.refetch()} />
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(effective.data?.profiles ?? []).map((profile) => (
|
||||
<Badge key={profile.id} variant="secondary">{profile.name}</Badge>
|
||||
))}
|
||||
{(effective.data?.allowedToolNames ?? []).slice(0, 24).map((tool) => (
|
||||
<Badge key={tool} variant="outline">{tool}</Badge>
|
||||
))}
|
||||
{(effective.data?.allowedToolNames ?? []).length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">No allowed tools resolved for this agent.</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilesTab({ companyId }: { companyId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editProfile, setEditProfile] = useState<ToolProfileWithDetails | null>(null);
|
||||
const [entryProfile, setEntryProfile] = useState<ToolProfileWithDetails | null>(null);
|
||||
const [bindProfileFor, setBindProfileFor] = useState<ToolProfileWithDetails | null>(null);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [profileKey, setProfileKey] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [defaultAction, setDefaultAction] = useState<ToolProfileDefaultAction>("deny");
|
||||
const [status, setStatus] = useState<ToolProfileStatus>("active");
|
||||
|
||||
const [selectorType, setSelectorType] = useState<ToolProfileEntrySelectorType>("tool_name");
|
||||
const [effect, setEffect] = useState<ToolProfileEntryEffect>("include");
|
||||
const [applicationId, setApplicationId] = useState("");
|
||||
const [connectionId, setConnectionId] = useState("");
|
||||
const [catalogEntryId, setCatalogEntryId] = useState("");
|
||||
const [toolName, setToolName] = useState("");
|
||||
const [riskLevel, setRiskLevel] = useState<ToolRiskLevel>("read");
|
||||
|
||||
const [targetType, setTargetType] = useState<ToolProfileBindingTargetType>("agent");
|
||||
const [targetAgentId, setTargetAgentId] = useState("");
|
||||
const [targetProjectId, setTargetProjectId] = useState("");
|
||||
const [targetRoutineId, setTargetRoutineId] = useState("");
|
||||
const [targetIssueId, setTargetIssueId] = useState("");
|
||||
const [priority, setPriority] = useState("100");
|
||||
|
||||
const lookups = useLookupData(companyId);
|
||||
const profiles = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(companyId),
|
||||
queryFn: () => toolsApi.listProfiles(companyId),
|
||||
});
|
||||
|
||||
const applicationOptions = lookups.applications.data?.applications ?? [];
|
||||
const connectionOptions = lookups.connections.data?.connections ?? [];
|
||||
const agentOptions = lookups.agents.data ?? [];
|
||||
const projectOptions = lookups.projects.data ?? [];
|
||||
const routineOptions = lookups.routines.data ?? [];
|
||||
|
||||
const invalidateProfiles = () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
|
||||
qc.invalidateQueries({ queryKey: ["tools", companyId, "profiles", "effective"] });
|
||||
};
|
||||
|
||||
const resetProfileForm = () => {
|
||||
setName("");
|
||||
setProfileKey("");
|
||||
setDescription("");
|
||||
setDefaultAction("deny");
|
||||
setStatus("active");
|
||||
};
|
||||
|
||||
const resetEntryForm = () => {
|
||||
setSelectorType("tool_name");
|
||||
setEffect("include");
|
||||
setApplicationId("");
|
||||
setConnectionId("");
|
||||
setCatalogEntryId("");
|
||||
setToolName("");
|
||||
setRiskLevel("read");
|
||||
};
|
||||
|
||||
const createProfile = useMutation({
|
||||
mutationFn: (input: CreateToolProfileInput) => toolsApi.createProfile(companyId, input),
|
||||
onSuccess: () => {
|
||||
invalidateProfiles();
|
||||
setCreateOpen(false);
|
||||
resetProfileForm();
|
||||
resetEntryForm();
|
||||
pushToast({ title: "Profile created", tone: "success" });
|
||||
},
|
||||
onError: (error) => pushToast({
|
||||
title: "Could not create profile",
|
||||
body: error instanceof ApiError ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const updateProfile = useMutation({
|
||||
mutationFn: ({ profileId, input }: { profileId: string; input: UpdateToolProfileInput }) =>
|
||||
toolsApi.updateProfile(profileId, input),
|
||||
onSuccess: () => {
|
||||
invalidateProfiles();
|
||||
setEditProfile(null);
|
||||
resetProfileForm();
|
||||
pushToast({ title: "Profile updated", tone: "success" });
|
||||
},
|
||||
onError: (error) => pushToast({
|
||||
title: "Could not update profile",
|
||||
body: error instanceof ApiError ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const addEntry = useMutation({
|
||||
mutationFn: ({ profileId, input }: { profileId: string; input: ToolProfileEntryInput }) =>
|
||||
toolsApi.addProfileEntry(profileId, input),
|
||||
onSuccess: () => {
|
||||
invalidateProfiles();
|
||||
setEntryProfile(null);
|
||||
resetEntryForm();
|
||||
pushToast({ title: "Entry added", tone: "success" });
|
||||
},
|
||||
onError: (error) => pushToast({
|
||||
title: "Could not add entry",
|
||||
body: error instanceof ApiError ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const deleteEntry = useMutation({
|
||||
mutationFn: (entryId: string) => toolsApi.deleteProfileEntry(entryId),
|
||||
onSuccess: () => {
|
||||
invalidateProfiles();
|
||||
pushToast({ title: "Entry removed", tone: "success" });
|
||||
},
|
||||
onError: (error) => pushToast({
|
||||
title: "Could not remove entry",
|
||||
body: error instanceof ApiError ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const bind = useMutation({
|
||||
mutationFn: ({ profileId, input }: { profileId: string; input: ToolProfileBindingInput }) =>
|
||||
toolsApi.bindProfile(companyId, profileId, input),
|
||||
onSuccess: () => {
|
||||
invalidateProfiles();
|
||||
setBindProfileFor(null);
|
||||
setTargetType("agent");
|
||||
setPriority("100");
|
||||
pushToast({ title: "Profile bound", tone: "success" });
|
||||
},
|
||||
onError: (error) => pushToast({
|
||||
title: "Could not bind profile",
|
||||
body: error instanceof ApiError ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const unbind = useMutation({
|
||||
mutationFn: ({ profileId, targetType, targetId }: {
|
||||
profileId: string;
|
||||
targetType: ToolProfileBindingTargetType;
|
||||
targetId: string;
|
||||
}) => toolsApi.unbindProfile(companyId, profileId, { targetType, targetId }),
|
||||
onSuccess: () => {
|
||||
invalidateProfiles();
|
||||
pushToast({ title: "Binding removed", tone: "success" });
|
||||
},
|
||||
onError: (error) => pushToast({
|
||||
title: "Could not remove binding",
|
||||
body: error instanceof ApiError ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
if (profiles.isLoading) return <LoadingState />;
|
||||
if (profiles.error) return <ErrorState error={profiles.error} onRetry={() => profiles.refetch()} />;
|
||||
|
||||
const list = profiles.data?.profiles ?? [];
|
||||
|
||||
const openEdit = (profile: ToolProfileWithDetails) => {
|
||||
setEditProfile(profile);
|
||||
setName(profile.name);
|
||||
setProfileKey(profile.profileKey);
|
||||
setDescription(profile.description ?? "");
|
||||
setDefaultAction(profile.defaultAction);
|
||||
setStatus(profile.status);
|
||||
};
|
||||
|
||||
const saveProfile = () => {
|
||||
const key = profileKey.trim() || slugifyProfileKey(name);
|
||||
if (!name.trim() || !key) return;
|
||||
if (editProfile) {
|
||||
updateProfile.mutate({
|
||||
profileId: editProfile.id,
|
||||
input: {
|
||||
name: name.trim(),
|
||||
profileKey: key,
|
||||
description: description.trim() || null,
|
||||
defaultAction,
|
||||
status,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = buildEntryInput({
|
||||
selectorType,
|
||||
effect,
|
||||
applicationId,
|
||||
connectionId,
|
||||
catalogEntryId,
|
||||
toolName,
|
||||
riskLevel,
|
||||
});
|
||||
createProfile.mutate({
|
||||
name: name.trim(),
|
||||
profileKey: key,
|
||||
description: description.trim() || null,
|
||||
defaultAction,
|
||||
status,
|
||||
entries: entry ? [entry] : [],
|
||||
});
|
||||
};
|
||||
|
||||
const saveEntry = () => {
|
||||
if (!entryProfile) return;
|
||||
const entry = buildEntryInput({
|
||||
selectorType,
|
||||
effect,
|
||||
applicationId,
|
||||
connectionId,
|
||||
catalogEntryId,
|
||||
toolName,
|
||||
riskLevel,
|
||||
});
|
||||
if (!entry) {
|
||||
pushToast({ title: "Entry target required", tone: "error" });
|
||||
return;
|
||||
}
|
||||
addEntry.mutate({ profileId: entryProfile.id, input: entry });
|
||||
};
|
||||
|
||||
const saveBinding = () => {
|
||||
if (!bindProfileFor) return;
|
||||
const targetId = targetIdForType({
|
||||
companyId,
|
||||
targetType,
|
||||
agentId: targetAgentId,
|
||||
projectId: targetProjectId,
|
||||
routineId: targetRoutineId,
|
||||
issueId: targetIssueId,
|
||||
});
|
||||
if (!targetId) {
|
||||
pushToast({ title: "Binding target required", tone: "error" });
|
||||
return;
|
||||
}
|
||||
bind.mutate({
|
||||
profileId: bindProfileFor.id,
|
||||
input: {
|
||||
targetType,
|
||||
targetId,
|
||||
priority: Number(priority) || 100,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export function ProfilesTab() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ToolsPageHeader
|
||||
title="Access profiles"
|
||||
description="Reusable bundles of allowed applications, connections, and tools, assignable to agents, projects, routines, or issues."
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
New profile
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PendingBackendNotice
|
||||
title="Profile management API not available yet"
|
||||
body="The data model for profiles, profile entries, and bindings shipped in Phase 2, but the board endpoints that read and edit them are not exposed yet. Building the profile builder against a guessed contract would risk UI-only assumptions, so it is intentionally deferred until the API lands."
|
||||
issue={{ identifier: "PAP-10408", href: "/PAP/issues/PAP-10408" }}
|
||||
/>
|
||||
|
||||
<EffectiveAgentPanel companyId={companyId} agentOptions={agentOptions} />
|
||||
|
||||
{list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Layers}
|
||||
message="No access profiles yet"
|
||||
description="Create a profile to group tool selectors, then bind it to the company or a specific agent."
|
||||
action="New profile"
|
||||
onAction={() => setCreateOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{list.map((profile) => (
|
||||
<Card key={profile.id}>
|
||||
<CardContent className="space-y-3 py-4">
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<Layers className="mt-0.5 h-5 w-5 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-foreground">{profile.name}</span>
|
||||
<Badge variant="outline">{profile.profileKey}</Badge>
|
||||
<Badge variant={statusVariant(profile.status)}>{profile.status}</Badge>
|
||||
<Badge variant={profile.defaultAction === "allow" ? "secondary" : "outline"}>
|
||||
default {profile.defaultAction}
|
||||
</Badge>
|
||||
</div>
|
||||
{profile.description ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{profile.description}</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{profile.entries.length} entries / {profile.bindings.length} bindings / updated{" "}
|
||||
<RelativeTime value={profile.updatedAt} />
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(profile)}>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
setEntryProfile(profile);
|
||||
resetEntryForm();
|
||||
}}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Entry
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setBindProfileFor(profile)}>
|
||||
<Link2 className="mr-1 h-3.5 w-3.5" />
|
||||
Bind
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profile.entries.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">No entries.</span>
|
||||
) : profile.entries.map((entry) => (
|
||||
<span key={entry.id} className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
|
||||
<Badge variant={entry.effect === "include" ? "secondary" : "destructive"}>{entry.effect}</Badge>
|
||||
<span className="font-mono">{entry.selectorType}</span>
|
||||
{entry.selectorType === "risk_level" ? <RiskBadge risk={entry.riskLevel} /> : (
|
||||
<span className="max-w-64 truncate">{entryLabel(entry, lookups.maps.applicationsById, lookups.maps.connectionsById)}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteEntry.mutate(entry.id)}
|
||||
aria-label={`Delete ${entry.selectorType} entry`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profile.bindings.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">No bindings.</span>
|
||||
) : profile.bindings.map((binding) => (
|
||||
<span key={binding.id} className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
|
||||
<Badge variant="outline">{binding.targetType}</Badge>
|
||||
<span>{bindingLabel(binding.targetType, binding.targetId, { companyId, ...lookups.maps })}</span>
|
||||
<span className="text-muted-foreground">p{binding.priority}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => unbind.mutate({
|
||||
profileId: profile.id,
|
||||
targetType: binding.targetType,
|
||||
targetId: binding.targetId,
|
||||
})}
|
||||
aria-label={`Remove ${binding.targetType} binding`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen || Boolean(editProfile)} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCreateOpen(false);
|
||||
setEditProfile(null);
|
||||
resetProfileForm();
|
||||
resetEntryForm();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editProfile ? "Edit profile" : "New profile"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Profile rules are enforced by the tool gateway policy service.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-name">Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
if (!editProfile && !profileKey.trim()) setProfileKey(slugifyProfileKey(event.target.value));
|
||||
}}
|
||||
placeholder="Engineering write tools"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-key">Key</Label>
|
||||
<Input id="profile-key" value={profileKey} onChange={(event) => setProfileKey(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-description">Description</Label>
|
||||
<Textarea
|
||||
id="profile-description"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="Optional context for reviewers."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Default action</Label>
|
||||
<Select value={defaultAction} onValueChange={(value) => setDefaultAction(value as ToolProfileDefaultAction)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">Deny unless included</SelectItem>
|
||||
<SelectItem value="allow">Allow unless excluded</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Status</Label>
|
||||
<Select value={status} onValueChange={(value) => setStatus(value as ToolProfileStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{!editProfile ? (
|
||||
<EntryFields
|
||||
selectorType={selectorType}
|
||||
setSelectorType={setSelectorType}
|
||||
effect={effect}
|
||||
setEffect={setEffect}
|
||||
applicationId={applicationId}
|
||||
setApplicationId={setApplicationId}
|
||||
connectionId={connectionId}
|
||||
setConnectionId={setConnectionId}
|
||||
catalogEntryId={catalogEntryId}
|
||||
setCatalogEntryId={setCatalogEntryId}
|
||||
toolName={toolName}
|
||||
setToolName={setToolName}
|
||||
riskLevel={riskLevel}
|
||||
setRiskLevel={setRiskLevel}
|
||||
applications={applicationOptions}
|
||||
connections={connectionOptions}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setCreateOpen(false);
|
||||
setEditProfile(null);
|
||||
resetProfileForm();
|
||||
resetEntryForm();
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!name.trim() || createProfile.isPending || updateProfile.isPending} onClick={saveProfile}>
|
||||
{editProfile ? "Save" : createProfile.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(entryProfile)} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEntryProfile(null);
|
||||
resetEntryForm();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add entry</DialogTitle>
|
||||
<DialogDescription>{entryProfile?.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EntryFields
|
||||
selectorType={selectorType}
|
||||
setSelectorType={setSelectorType}
|
||||
effect={effect}
|
||||
setEffect={setEffect}
|
||||
applicationId={applicationId}
|
||||
setApplicationId={setApplicationId}
|
||||
connectionId={connectionId}
|
||||
setConnectionId={setConnectionId}
|
||||
catalogEntryId={catalogEntryId}
|
||||
setCatalogEntryId={setCatalogEntryId}
|
||||
toolName={toolName}
|
||||
setToolName={setToolName}
|
||||
riskLevel={riskLevel}
|
||||
setRiskLevel={setRiskLevel}
|
||||
applications={applicationOptions}
|
||||
connections={connectionOptions}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEntryProfile(null)}>Cancel</Button>
|
||||
<Button disabled={addEntry.isPending} onClick={saveEntry}>
|
||||
Add entry
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(bindProfileFor)} onOpenChange={(open) => {
|
||||
if (!open) setBindProfileFor(null);
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bind profile</DialogTitle>
|
||||
<DialogDescription>{bindProfileFor?.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Target type</Label>
|
||||
<Select value={targetType} onValueChange={(value) => setTargetType(value as ToolProfileBindingTargetType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TARGET_TYPES.map((target) => (
|
||||
<SelectItem key={target.value} value={target.value}>
|
||||
{target.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{targetType === "agent" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Agent</Label>
|
||||
<Select value={targetAgentId} onValueChange={setTargetAgentId}>
|
||||
<SelectTrigger><SelectValue placeholder="Select an agent" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{agentOptions.map((agent) => <SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
{targetType === "project" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Project</Label>
|
||||
<Select value={targetProjectId} onValueChange={setTargetProjectId}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a project" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectOptions.map((project) => <SelectItem key={project.id} value={project.id}>{project.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
{targetType === "routine" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Routine</Label>
|
||||
<Select value={targetRoutineId} onValueChange={setTargetRoutineId}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a routine" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{routineOptions.map((routine) => <SelectItem key={routine.id} value={routine.id}>{routine.title}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
{targetType === "issue" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="target-issue-id">Issue ID</Label>
|
||||
<Input id="target-issue-id" value={targetIssueId} onChange={(event) => setTargetIssueId(event.target.value)} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-priority">Priority</Label>
|
||||
<Input id="profile-priority" type="number" min={0} max={10000} value={priority} onChange={(event) => setPriority(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBindProfileFor(null)}>Cancel</Button>
|
||||
<Button disabled={bind.isPending} onClick={saveBinding}>
|
||||
Bind
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,86 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Server } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2, RotateCw, Server, Square } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { ToolsPageHeader, LoadingState, ErrorState, EmptyState, HealthBadge, RelativeTime } from "./shared";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { ToolsPageHeader, LoadingState, ErrorState, HealthBadge, RelativeTime } from "./shared";
|
||||
|
||||
export function RuntimeTab({ companyId }: { companyId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const slots = useQuery({
|
||||
queryKey: queryKeys.tools.runtimeSlots(companyId),
|
||||
queryFn: () => toolsApi.listRuntimeSlots(companyId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const health = useQuery({
|
||||
queryKey: queryKeys.tools.runtimeHealth(companyId),
|
||||
queryFn: () => toolsApi.getRuntimeHealth(companyId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
if (slots.isLoading) return <LoadingState />;
|
||||
if (slots.error) return <ErrorState error={slots.error} onRetry={() => slots.refetch()} />;
|
||||
const invalidateRuntime = () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeSlots(companyId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeHealth(companyId) });
|
||||
};
|
||||
|
||||
const stopSlot = useMutation({
|
||||
mutationFn: (slotId: string) => toolsApi.stopRuntimeSlot(companyId, slotId),
|
||||
onSuccess: (slot) => {
|
||||
invalidateRuntime();
|
||||
pushToast({
|
||||
title: "Runtime slot stopped",
|
||||
body: slot.commandTemplateKey ?? slot.providerRef ?? slot.id,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Stop failed",
|
||||
body: err instanceof ApiError ? err.message : String(err),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const restartSlot = useMutation({
|
||||
mutationFn: (slotId: string) => toolsApi.restartRuntimeSlot(companyId, slotId),
|
||||
onSuccess: (slot) => {
|
||||
invalidateRuntime();
|
||||
pushToast({
|
||||
title: "Runtime slot restarted",
|
||||
body: slot.commandTemplateKey ?? slot.providerRef ?? slot.id,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Restart failed",
|
||||
body: err instanceof ApiError ? err.message : String(err),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
if (slots.isLoading || health.isLoading) return <LoadingState />;
|
||||
if (slots.error || health.error) {
|
||||
return (
|
||||
<ErrorState
|
||||
error={slots.error ?? health.error}
|
||||
onRetry={() => {
|
||||
slots.refetch();
|
||||
health.refetch();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const list = slots.data?.runtimeSlots ?? [];
|
||||
const firingAlerts = health.data?.alerts ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -27,16 +89,55 @@ export function RuntimeTab({ companyId }: { companyId: string }) {
|
||||
description="Managed lifecycle units for local stdio MCP servers and remote sessions. Slots are pooled and supervised — agents never spawn processes directly. Idle local slots shut down automatically."
|
||||
/>
|
||||
|
||||
<Card className={health.data?.status === "critical" ? "border-destructive/40" : undefined}>
|
||||
<CardContent className="space-y-3 py-4">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
Alert recommendations
|
||||
<HealthBadge
|
||||
status={health.data?.status === "critical" ? "error" : health.data?.status === "degraded" ? "degraded" : "ok"}
|
||||
label={health.data?.status ?? "unknown"}
|
||||
/>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{health.data?.runbookPath}</span>
|
||||
</div>
|
||||
{firingAlerts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No runtime alerts are firing. Recommended thresholds are still available in the runbook.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{firingAlerts.map((alert) => (
|
||||
<li key={alert.name} className="py-2 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs text-foreground">{alert.name}</span>
|
||||
<Badge variant={alert.severity === "critical" ? "destructive" : "secondary"}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{alert.observed}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{alert.firstResponderAction}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Server className="h-6 w-6" />}
|
||||
title="No runtime slots"
|
||||
icon={Server}
|
||||
message="No runtime slots"
|
||||
description="Local stdio connections lazy-start a runtime slot when a policy-allowed run first needs them. Remote HTTP connections do not use a local process."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{list.map((slot) => {
|
||||
const supportsControl = slot.runtimeKind === "local_stdio";
|
||||
const controlsPending = stopSlot.isPending || restartSlot.isPending;
|
||||
const stopPending = stopSlot.isPending && stopSlot.variables === slot.id;
|
||||
const restartPending = restartSlot.isPending && restartSlot.variables === slot.id;
|
||||
const stopDisabled = !supportsControl || controlsPending || slot.status === "stopped";
|
||||
const restartDisabled = !supportsControl || controlsPending;
|
||||
return (
|
||||
<Card key={slot.id}>
|
||||
<CardContent className="flex flex-wrap items-center gap-3 py-4">
|
||||
@@ -69,26 +170,54 @@ export function RuntimeTab({ companyId }: { companyId: string }) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={stopDisabled}
|
||||
aria-label="Stop runtime slot"
|
||||
onClick={() => stopSlot.mutate(slot.id)}
|
||||
>
|
||||
{stopPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Square className="mr-1 h-3.5 w-3.5" fill="currentColor" />
|
||||
)}
|
||||
Stop
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{supportsControl
|
||||
? "Stop/restart endpoints land in PAP-10411."
|
||||
: "Remote sessions have no local process to stop."}
|
||||
{!supportsControl
|
||||
? "Remote sessions have no local process to stop."
|
||||
: slot.status === "stopped"
|
||||
? "This runtime slot is already stopped."
|
||||
: "Stop this local stdio runtime slot."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={restartDisabled}
|
||||
aria-label="Restart runtime slot"
|
||||
onClick={() => restartSlot.mutate(slot.id)}
|
||||
>
|
||||
{restartPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Restart
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Control endpoints tracked in PAP-10411.</TooltipContent>
|
||||
<TooltipContent>
|
||||
{supportsControl
|
||||
? "Restart this local stdio runtime slot."
|
||||
: "Remote sessions have no local process to restart."}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -98,8 +227,8 @@ export function RuntimeTab({ companyId }: { companyId: string }) {
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stop/restart actions are disabled until the control endpoints ship (PAP-10411). Health and
|
||||
lifecycle shown here reflect server state.
|
||||
Health and lifecycle shown here reflect server state. Stop and restart controls apply only to local
|
||||
stdio runtime slots.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
AppWindow,
|
||||
@@ -14,6 +15,9 @@ import { Link, useParams } from "@/lib/router";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { EnforcementBanner } from "@/components/EnforcementBanner";
|
||||
import { OverviewTab } from "./OverviewTab";
|
||||
import { ApplicationsTab } from "./ApplicationsTab";
|
||||
import { ConnectionsTab } from "./ConnectionsTab";
|
||||
@@ -43,7 +47,7 @@ function renderTab(tab: TabKey, companyId: string) {
|
||||
case "connections":
|
||||
return <ConnectionsTab companyId={companyId} />;
|
||||
case "profiles":
|
||||
return <ProfilesTab />;
|
||||
return <ProfilesTab companyId={companyId} />;
|
||||
case "policies":
|
||||
return <PoliciesTab companyId={companyId} />;
|
||||
case "runtime":
|
||||
@@ -51,7 +55,7 @@ function renderTab(tab: TabKey, companyId: string) {
|
||||
case "audit":
|
||||
return <AuditTab companyId={companyId} />;
|
||||
case "examples":
|
||||
return <ExamplesTab />;
|
||||
return <ExamplesTab companyId={companyId} />;
|
||||
case "overview":
|
||||
default:
|
||||
return <OverviewTab companyId={companyId} />;
|
||||
@@ -64,6 +68,15 @@ export function ToolsAccess() {
|
||||
const params = useParams<{ tab?: string }>();
|
||||
const activeTab = (TABS.find((t) => t.key === params.tab)?.key ?? "overview") as TabKey;
|
||||
|
||||
// Drives the live cyan dot on the Runtime tab when any slot is running.
|
||||
const runtimeSlots = useQuery({
|
||||
queryKey: queryKeys.tools.runtimeSlots(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => toolsApi.listRuntimeSlots(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const runtimeActive = (runtimeSlots.data?.runtimeSlots ?? []).some((s) => s.status === "running");
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
@@ -83,21 +96,19 @@ export function ToolsAccess() {
|
||||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-xl font-bold text-foreground">Tools & Access</h1>
|
||||
</div>
|
||||
<p className="-mt-2 max-w-3xl text-sm text-muted-foreground">
|
||||
Govern which agents can use which external tools and MCP servers. Access is enforced server-side by the
|
||||
tool gateway — these screens configure and observe that enforcement, they do not replace it.
|
||||
</p>
|
||||
<EnforcementBanner companyId={selectedCompanyId} />
|
||||
|
||||
<nav className="flex flex-wrap gap-1 border-b border-border">
|
||||
<nav className="-mx-1 flex gap-1 overflow-x-auto whitespace-nowrap border-b border-border px-1">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = tab.key === activeTab;
|
||||
const showLiveDot = tab.key === "runtime" && runtimeActive;
|
||||
return (
|
||||
<Link
|
||||
key={tab.key}
|
||||
to={`/company/settings/tools/${tab.key}`}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
||||
"flex shrink-0 items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
@@ -105,6 +116,12 @@ export function ToolsAccess() {
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
{showLiveDot ? (
|
||||
<span
|
||||
className="ml-0.5 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-cyan-500"
|
||||
aria-label="runtime active"
|
||||
/>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,9 +4,10 @@ import type {
|
||||
ToolConnectionHealthStatus,
|
||||
ToolPolicyDecision,
|
||||
} from "@paperclipai/shared";
|
||||
import { AlertTriangle, Loader2, Wrench } from "lucide-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { ApiError } from "@/api/client";
|
||||
|
||||
/** Risk classification badge for a catalog tool. */
|
||||
@@ -40,17 +41,33 @@ export function CapabilityBadges({
|
||||
);
|
||||
}
|
||||
|
||||
const HEALTH_VARIANT: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
healthy: "default",
|
||||
ok: "default",
|
||||
degraded: "secondary",
|
||||
unchecked: "outline",
|
||||
unknown: "outline",
|
||||
error: "destructive",
|
||||
unhealthy: "destructive",
|
||||
};
|
||||
/** Catalog quarantine marker — canonical status key. */
|
||||
export function QuarantineBadge() {
|
||||
return <StatusBadge status="quarantined" />;
|
||||
}
|
||||
|
||||
/** Connection / runtime health badge with a colored status dot. */
|
||||
function healthToStatusKey(status: string): string {
|
||||
switch (status) {
|
||||
case "healthy":
|
||||
case "ok":
|
||||
case "":
|
||||
return "healthy";
|
||||
case "degraded":
|
||||
case "warning":
|
||||
return "degraded";
|
||||
case "error":
|
||||
case "unhealthy":
|
||||
case "critical":
|
||||
return "runtime-error";
|
||||
case "unchecked":
|
||||
case "unknown":
|
||||
return "unchecked";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Connection / runtime health badge, mapped onto canonical status colors. */
|
||||
export function HealthBadge({
|
||||
status,
|
||||
label,
|
||||
@@ -58,42 +75,43 @@ export function HealthBadge({
|
||||
status: ToolConnectionHealthStatus | string | null | undefined;
|
||||
label?: string;
|
||||
}) {
|
||||
const key = (status ?? "unknown").toString();
|
||||
const variant = HEALTH_VARIANT[key] ?? "outline";
|
||||
const dot =
|
||||
variant === "default"
|
||||
? "bg-emerald-500"
|
||||
: variant === "destructive"
|
||||
? "bg-destructive"
|
||||
: variant === "secondary"
|
||||
? "bg-amber-500"
|
||||
: "bg-muted-foreground";
|
||||
return (
|
||||
<Badge variant={variant} className="gap-1.5">
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${dot}`} />
|
||||
{label ?? key}
|
||||
</Badge>
|
||||
);
|
||||
const raw = (status ?? "unknown").toString();
|
||||
return <StatusBadge status={healthToStatusKey(raw)} label={label ?? raw} />;
|
||||
}
|
||||
|
||||
const DECISION_VARIANT: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
allow: "default",
|
||||
allowed: "default",
|
||||
require_approval: "secondary",
|
||||
requires_approval: "secondary",
|
||||
redact: "secondary",
|
||||
rate_limited: "secondary",
|
||||
defer: "secondary",
|
||||
block: "destructive",
|
||||
deny: "destructive",
|
||||
hidden: "outline",
|
||||
};
|
||||
function decisionToStatusKey(decision: string): { key: string; label: string } {
|
||||
switch (decision) {
|
||||
case "allow":
|
||||
case "allowed":
|
||||
return { key: "allowed", label: "allowed" };
|
||||
case "deny":
|
||||
case "denied":
|
||||
return { key: "denied", label: "denied" };
|
||||
case "block":
|
||||
return { key: "block", label: "block" };
|
||||
case "require_approval":
|
||||
case "requires_approval":
|
||||
return { key: "require-approval", label: "require approval" };
|
||||
case "redact":
|
||||
case "redacted":
|
||||
return { key: "redacted", label: "redacted" };
|
||||
case "rate_limited":
|
||||
return { key: "rate-limit", label: "rate limited" };
|
||||
case "defer":
|
||||
case "deferred":
|
||||
return { key: "deferred", label: "deferred" };
|
||||
case "hidden":
|
||||
return { key: "hidden", label: "hidden" };
|
||||
default:
|
||||
return { key: decision, label: decision };
|
||||
}
|
||||
}
|
||||
|
||||
/** Policy/gateway decision badge. */
|
||||
/** Policy/gateway decision badge — canonical status colors. */
|
||||
export function DecisionBadge({ decision }: { decision: ToolPolicyDecision | string | null | undefined }) {
|
||||
if (!decision) return <Badge variant="outline">—</Badge>;
|
||||
const variant = DECISION_VARIANT[decision.toString()] ?? "outline";
|
||||
return <Badge variant={variant}>{decision}</Badge>;
|
||||
const { key, label } = decisionToStatusKey(decision.toString());
|
||||
return <StatusBadge status={key} label={label} />;
|
||||
}
|
||||
|
||||
/** Compact relative time, falling back to absolute. */
|
||||
@@ -139,7 +157,7 @@ export function ToolsPageHeader({
|
||||
export function LoadingState({ label = "Loading…" }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
@@ -147,14 +165,21 @@ export function LoadingState({ label = "Loading…" }: { label?: string }) {
|
||||
|
||||
/** Actionable error surface — surfaces the server message and HTTP status. */
|
||||
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
? error.status === 403
|
||||
? "You do not have permission to view this. Tools & Access requires board/admin access."
|
||||
: error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Something went wrong.";
|
||||
let message: string;
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 403) {
|
||||
message = "You do not have permission to view this. Tools & Access requires board/admin access.";
|
||||
} else if (error.status === 404 || /route not found/i.test(error.message)) {
|
||||
// Snapshot-skew window: the route exists in this build but not on the live server snapshot yet.
|
||||
message = "Tools & Access isn't available on this server yet — try refreshing after the next deployment.";
|
||||
} else {
|
||||
message = error.message;
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message;
|
||||
} else {
|
||||
message = "Something went wrong.";
|
||||
}
|
||||
return (
|
||||
<Card className="border-destructive/40">
|
||||
<CardContent className="flex flex-col gap-3 py-6">
|
||||
@@ -179,24 +204,6 @@ export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () =>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action }: {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
||||
<div className="text-muted-foreground">{icon ?? <Wrench className="h-6 w-6" />}</div>
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
{description ? <p className="max-w-md text-sm text-muted-foreground">{description}</p> : null}
|
||||
{action ? <div className="pt-1">{action}</div> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Honest notice for surfaces whose backend contract has not shipped yet.
|
||||
* This must NOT pretend to enforce anything client-side — it links the
|
||||
|
||||
Reference in new issue
Block a user