feat(mcp) [split 6/8]: add Tools and Profiles UI foundation (#9561)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Governed MCP access spans contracts, runtime enforcement, adapters, UI surfaces, and operator verification > - The parity reference PR #9534 is too large for effective automated or human review > - The feature therefore needs a linear stack whose individual diffs stay below the 100-file review limit > - This pull request is split 6/8 and focuses on UI API, shared components, and Tools/Profile surfaces > - The benefit is a standalone, testable review boundary while preserving byte-for-byte parity at the top of the stack ## Linked Issues or Issue Description - Related parity reference: #9534 - Problem: Operators need typed clients and administration surfaces that compile independently before navigation exposes them. - Proposed solution: Adds UI APIs, hooks, libraries, shared components, Tools/Profiles pages, and the plugin settings consumer required by the new company-scoped API. - Alternatives considered: keeping #9534 as one 403-file review, or rewriting the feature to manufacture seams; both were rejected in favor of path extraction plus compile-driven boundary moves. - Roadmap alignment: this advances the existing governed MCP/tool-access work already represented by #9534; it does not introduce a separate roadmap initiative. - Stack position: base branch is `pap10341-split/05-runtime-integration`. - Merge policy: merge bottom-up, in order, only after the complete eight-PR stack has been reviewed and the top-of-stack parity gate remains empty. - Requested review: UXDesigner sanity pass on Tools/Profile surfaces; Greptile on every PR. ## What Changed - Adds UI APIs, hooks, libraries, shared components, Tools/Profiles pages, and the plugin settings consumer required by the new company-scoped API. - Keeps this PR below 100 changed files and independently typecheckable. - Preserves the final tree from #9534 when combined with the other seven stack levels. ## Verification - `pnpm typecheck` - `pnpm check:token-gates` — all gates clean - Focused UI Vitest run with `NODE_ENV=test` — 28 files, 183 tests passed ## Risks - Large dead-code UI additions can drift from activation routes; PR 7 supplies the registration layer and top-level parity catches omissions. - Stack risk: merging out of order can expose incomplete layers; mitigate by following the documented bottom-up merge policy. - Parity risk: later edits to an intermediate branch can drift from #9534; mitigate by re-running the empty top-of-stack diff before merge. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, exact model ID `gpt-5.4`; runtime-managed context window; medium reasoning with repository, shell, Git, GitHub CLI, and code-execution tools enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] Internal references are omitted except the execution-plan link explicitly required for this coordinated split stack - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Stack Coordination - Internal execution plan: [PAP-13874](/PAP/issues/PAP-13874#document-plan) - Parity reference: #9534 - Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563 - Merge bottom-up only after full-stack review and an empty parity diff at #9563. ## UI Evidence QA captured these from the live Garden MCP split stack at 1440px and verified clean rendering:    --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
9c8adee48b
commit
a116713b9a
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
|
|
@ -0,0 +1,59 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDisabledAdaptersSync } from "./use-disabled-adapters";
|
||||
|
||||
const mockAdaptersApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/adapters", () => ({
|
||||
adaptersApi: mockAdaptersApi,
|
||||
}));
|
||||
|
||||
function Probe({ enabled }: { enabled: boolean }) {
|
||||
useDisabledAdaptersSync({ enabled });
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useDisabledAdaptersSync", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not fetch adapters when disabled", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Probe enabled={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockAdaptersApi.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -16,10 +16,12 @@ import { queryKeys } from "@/lib/queryKeys";
|
|||
* Returns a reactive Set of disabled types for use as useMemo dependencies.
|
||||
* Call this at the top of any component that renders adapter menus.
|
||||
*/
|
||||
export function useDisabledAdaptersSync(): Set<string> {
|
||||
export function useDisabledAdaptersSync(options: { enabled?: boolean } = {}): Set<string> {
|
||||
const enabled = options.enabled ?? true;
|
||||
const { data: adapters } = useQuery({
|
||||
queryKey: queryKeys.adapters.all,
|
||||
queryFn: () => adaptersApi.list(),
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
type CurrentUserProfile,
|
||||
type UpdateCurrentUserProfile,
|
||||
} from "@paperclipai/shared";
|
||||
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
|
||||
|
||||
type AuthErrorBody =
|
||||
| {
|
||||
|
|
@ -60,15 +61,66 @@ function extractAuthError(payload: AuthErrorBody, status: number) {
|
|||
return new AuthApiError(message, status, payload, code);
|
||||
}
|
||||
|
||||
async function authPost(path: string, body: Record<string, unknown>) {
|
||||
const res = await fetch(`/api/auth${path}`, {
|
||||
method: "POST",
|
||||
// Rich diagnostics for auth requests. Network-layer failures (Safari
|
||||
// "Load failed" / Chrome "Failed to fetch") throw a TypeError *before* any
|
||||
// HTTP response, so they are indistinguishable from a bad password in the UI
|
||||
// unless we log the resolved request URL + origin here. See PAP-13466.
|
||||
function resolveAuthUrl(path: string) {
|
||||
const relative = `/api/auth${path}`;
|
||||
try {
|
||||
return new URL(relative, window.location.origin).href;
|
||||
} catch {
|
||||
return relative;
|
||||
}
|
||||
}
|
||||
|
||||
function logAuthNetworkFailure(method: string, path: string, error: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[auth] request failed at the network layer (no HTTP response)", {
|
||||
method,
|
||||
requestUrl: resolveAuthUrl(path),
|
||||
pageOrigin: typeof window !== "undefined" ? window.location.origin : "(no window)",
|
||||
pageHref: typeof window !== "undefined" ? redactUrlSecrets(window.location.href) : "(no window)",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
online: typeof navigator !== "undefined" ? navigator.onLine : "(no navigator)",
|
||||
errorName: error instanceof Error ? error.name : typeof error,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
error,
|
||||
hint:
|
||||
"This means the browser never got a response from the server. Common causes: " +
|
||||
"the page origin differs from the API host (mixed http/https, wrong hostname/port, " +
|
||||
"or a proxy/tunnel that only forwards the page but not /api), an SSL error, or the " +
|
||||
"connection was reset. A wrong password would instead return HTTP 401, not this.",
|
||||
});
|
||||
}
|
||||
|
||||
function logAuthHttpError(method: string, path: string, status: number, statusText: string, body: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[auth] request returned an error status", {
|
||||
method,
|
||||
requestUrl: resolveAuthUrl(path),
|
||||
status,
|
||||
statusText,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async function authPost(path: string, body: Record<string, unknown>) {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api/auth${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (networkError) {
|
||||
logAuthNetworkFailure("POST", path, networkError);
|
||||
throw networkError;
|
||||
}
|
||||
const payload = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
logAuthHttpError("POST", path, res.status, res.statusText, payload);
|
||||
throw extractAuthError(payload as AuthErrorBody, res.status);
|
||||
}
|
||||
return payload;
|
||||
|
|
|
|||
|
|
@ -139,6 +139,10 @@ export function __inflightGetCount(): number {
|
|||
return inflightGets.size;
|
||||
}
|
||||
|
||||
function isRequestOptions(value: unknown): value is RequestOptions {
|
||||
return typeof value === "object" && value !== null && "signal" in value;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, options?: RequestOptions) => coalescedGet<T>(path, options),
|
||||
post: <T>(path: string, body: unknown, options?: RequestOptions) =>
|
||||
|
|
@ -149,8 +153,11 @@ export const api = {
|
|||
request<T>(path, { method: "PUT", body: JSON.stringify(body), signal: options?.signal }),
|
||||
patch: <T>(path: string, body: unknown, options?: RequestOptions) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body), signal: options?.signal }),
|
||||
delete: <T>(path: string, options?: RequestOptions) =>
|
||||
request<T>(path, { method: "DELETE", signal: options?.signal }),
|
||||
delete: <T>(path: string, bodyOrOptions?: unknown, options?: RequestOptions) => {
|
||||
const requestOptions = isRequestOptions(bodyOrOptions) ? bodyOrOptions : options;
|
||||
const body = bodyOrOptions === undefined || isRequestOptions(bodyOrOptions) ? undefined : JSON.stringify(bodyOrOptions);
|
||||
return request<T>(path, { method: "DELETE", ...(body === undefined ? {} : { body }), signal: requestOptions?.signal });
|
||||
},
|
||||
deleteWithBody: <T>(path: string, body: unknown, options?: RequestOptions) =>
|
||||
request<T>(path, { method: "DELETE", body: JSON.stringify(body), signal: options?.signal }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { companiesListQueryOptions } from "./companies-query";
|
||||
import { ApiError } from "./client";
|
||||
|
||||
const mockCompaniesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./companies", () => ({
|
||||
companiesApi: mockCompaniesApi,
|
||||
}));
|
||||
|
||||
describe("companiesListQueryOptions", () => {
|
||||
it.each([401, 403])("treats %s company-list failures as unauthorized bootstrap state", async (status) => {
|
||||
mockCompaniesApi.list.mockRejectedValueOnce(new ApiError("Board access required", status, { error: "Board access required" }));
|
||||
|
||||
await expect(companiesListQueryOptions.queryFn()).resolves.toEqual({
|
||||
companies: [],
|
||||
unauthorized: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -15,7 +15,7 @@ export const companiesListQueryOptions = {
|
|||
try {
|
||||
return { companies: await companiesApi.list(), unauthorized: false };
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||
return { companies: [], unauthorized: true };
|
||||
}
|
||||
throw err;
|
||||
|
|
|
|||
|
|
@ -358,8 +358,8 @@ export const pluginsApi = {
|
|||
*
|
||||
* @param pluginId - UUID of the plugin.
|
||||
*/
|
||||
getConfig: (pluginId: string) =>
|
||||
api.get<PluginConfig | null>(`/plugins/${pluginId}/config`),
|
||||
getConfig: (pluginId: string, companyId: string) =>
|
||||
api.get<PluginConfig | null>(`/plugins/${pluginId}/config?companyId=${encodeURIComponent(companyId)}`),
|
||||
|
||||
/**
|
||||
* Save (create or update) the configuration for a plugin.
|
||||
|
|
@ -370,8 +370,8 @@ export const pluginsApi = {
|
|||
* @param pluginId - UUID of the plugin.
|
||||
* @param configJson - Configuration values matching the plugin's `instanceConfigSchema`.
|
||||
*/
|
||||
saveConfig: (pluginId: string, configJson: Record<string, unknown>) =>
|
||||
api.post<PluginConfig>(`/plugins/${pluginId}/config`, { configJson }),
|
||||
saveConfig: (pluginId: string, companyId: string, configJson: Record<string, unknown>) =>
|
||||
api.post<PluginConfig>(`/plugins/${pluginId}/config`, { companyId, configJson }),
|
||||
|
||||
/**
|
||||
* Call the plugin's `validateConfig` RPC method to test the configuration
|
||||
|
|
@ -385,8 +385,8 @@ export const pluginsApi = {
|
|||
* @param pluginId - UUID of the plugin.
|
||||
* @param configJson - Configuration values to validate.
|
||||
*/
|
||||
testConfig: (pluginId: string, configJson: Record<string, unknown>) =>
|
||||
api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }),
|
||||
testConfig: (pluginId: string, companyId: string, configJson: Record<string, unknown>) =>
|
||||
api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { companyId, configJson }),
|
||||
|
||||
/**
|
||||
* List manifest-declared and stored company-scoped local folders for a plugin.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import type {
|
||||
CreateSmokeRun,
|
||||
RecordSmokeRunStep,
|
||||
SmokeLabServiceStatus,
|
||||
SmokeRun,
|
||||
SmokeRunStep,
|
||||
UpdateSmokeRun,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
/**
|
||||
* Smoke Lab API client (PAP-13347 / S2, plan §D3). Mirrors the S1 results API
|
||||
* shipped in `server/src/routes/smoke-lab.ts` (PAP-13346). Every endpoint is
|
||||
* company-scoped and gated server-side on `experimental.enableSmokeLab`,
|
||||
* `deploymentMode === local_trusted`, and a non-production environment — the UI
|
||||
* only ever surfaces these screens when the board-readable experimental flag is
|
||||
* on, but the server stays authoritative.
|
||||
*/
|
||||
|
||||
export interface SmokeLabServicesResponse {
|
||||
services: SmokeLabServiceStatus[];
|
||||
}
|
||||
|
||||
export interface SmokeLabInstallFixturesResponse {
|
||||
created: boolean;
|
||||
applications: Array<{ id: string; name: string }>;
|
||||
connections: Array<{ id: string; name: string }>;
|
||||
catalog: unknown[];
|
||||
profile: { id: string };
|
||||
}
|
||||
|
||||
export interface SmokeLabRunsResponse {
|
||||
runs: SmokeRun[];
|
||||
}
|
||||
|
||||
export interface SmokeLabRunDetailResponse {
|
||||
run: SmokeRun;
|
||||
steps: SmokeRunStep[];
|
||||
}
|
||||
|
||||
const base = (companyId: string) => `/companies/${companyId}/smoke-lab`;
|
||||
|
||||
export const smokeLabApi = {
|
||||
listServices: (companyId: string) =>
|
||||
api.get<SmokeLabServicesResponse>(`${base(companyId)}/services`),
|
||||
startServices: (companyId: string) =>
|
||||
api.post<SmokeLabServicesResponse>(`${base(companyId)}/services/start`, {}),
|
||||
stopServices: (companyId: string) =>
|
||||
api.post<SmokeLabServicesResponse>(`${base(companyId)}/services/stop`, {}),
|
||||
installFixtures: (companyId: string) =>
|
||||
api.post<SmokeLabInstallFixturesResponse>(`${base(companyId)}/install-fixtures`, {}),
|
||||
reset: (companyId: string) =>
|
||||
api.post<{ reset: boolean }>(`${base(companyId)}/reset`, {}),
|
||||
|
||||
listRuns: (companyId: string) =>
|
||||
api.get<SmokeLabRunsResponse>(`${base(companyId)}/runs`),
|
||||
getRun: (companyId: string, runId: string) =>
|
||||
api.get<SmokeLabRunDetailResponse>(`${base(companyId)}/runs/${runId}`),
|
||||
createRun: (companyId: string, input: CreateSmokeRun) =>
|
||||
api.post<{ run: SmokeRun }>(`${base(companyId)}/runs`, input),
|
||||
updateRun: (companyId: string, runId: string, input: UpdateSmokeRun) =>
|
||||
api.patch<{ run: SmokeRun }>(`${base(companyId)}/runs/${runId}`, input),
|
||||
recordStep: (companyId: string, runId: string, input: RecordSmokeRunStep) =>
|
||||
api.post<{ step: SmokeRunStep; summary: Record<string, unknown> }>(
|
||||
`${base(companyId)}/runs/${runId}/steps`,
|
||||
input,
|
||||
),
|
||||
};
|
||||
|
|
@ -0,0 +1,460 @@
|
|||
import type {
|
||||
ToolApplication,
|
||||
ToolConnection,
|
||||
ToolConnectionInstall,
|
||||
ToolConnectionInstallSnapshot,
|
||||
ConnectToolAppResult,
|
||||
FinishToolAppResult,
|
||||
ToolCatalogEntry,
|
||||
ToolRuntimeSlot,
|
||||
ToolPolicy,
|
||||
ToolConnectionHealthCheckResult,
|
||||
ToolCatalogRefreshResult,
|
||||
ToolAccessDecision,
|
||||
ToolAccessDecisionInput,
|
||||
CreateToolPolicy,
|
||||
DuplicateToolPolicy,
|
||||
McpJsonImportPreview,
|
||||
ToolRuntimeHealthSummary,
|
||||
ToolRunDecisionLookup,
|
||||
ToolOAuthStartResult,
|
||||
ToolStdioCommandTemplate,
|
||||
ToolProfileBinding,
|
||||
ToolProfileBindingTargetType,
|
||||
ToolProfileDefaultAction,
|
||||
ToolProfileEffectiveSummary,
|
||||
ToolProfileEntry,
|
||||
ToolProfileEntryEffect,
|
||||
ToolProfileEntrySelectorType,
|
||||
ToolProfileStatus,
|
||||
ToolProfileWithDetails,
|
||||
ToolProfileNewToolReviewDecision,
|
||||
ToolProfileNewToolsReview,
|
||||
ToolProfileNewToolsReviewResult,
|
||||
ToolRiskLevel,
|
||||
UpdateToolPolicy,
|
||||
ReorderToolPolicies,
|
||||
AppGalleryEntry,
|
||||
ToolAppsAttentionResponse,
|
||||
ToolConnectionActivityResponse,
|
||||
ToolConnectionTestAgentsResponse,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestCallStatus,
|
||||
ToolActionRequest,
|
||||
ToolActionRequestStatus,
|
||||
ToolActionRequestsResponse,
|
||||
ToolMcpGatewayWithTokens,
|
||||
ToolMcpGatewayTokenCreated,
|
||||
ToolMcpGatewayToken,
|
||||
CreateToolMcpGateway,
|
||||
CreateToolMcpGatewayToken,
|
||||
UpdateToolMcpGateway,
|
||||
CreateToolTrustRuleFromActionRequest,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
/**
|
||||
* Tools & Access API client (Phase 6, PAP-10389).
|
||||
*
|
||||
* Mirrors the governed MCP/tool-access contracts shipped by Phases 2-5
|
||||
* (`server/src/routes/tool-access.ts` and `tool-gateway.ts`). The UI consumes
|
||||
* server-side enforcement contracts directly instead of faking tool access in
|
||||
* the browser.
|
||||
*/
|
||||
|
||||
export type ToolApplicationsResponse = { applications: ToolApplication[] };
|
||||
export type ToolConnectionsResponse = { connections: ToolConnection[] };
|
||||
export type ToolCatalogResponse = { catalog: ToolCatalogEntry[] };
|
||||
export type ToolRuntimeSlotsResponse = { runtimeSlots: ToolRuntimeSlot[] };
|
||||
export type ToolRuntimeHealthResponse = ToolRuntimeHealthSummary;
|
||||
export type ToolTrustRulesResponse = { trustRules: ToolPolicy[] };
|
||||
export type ToolPoliciesResponse = { policies: ToolPolicy[] };
|
||||
export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] };
|
||||
export type ToolGalleryResponse = { apps: AppGalleryEntry[] };
|
||||
export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] };
|
||||
export type CreateGatewayTokenInput = Omit<CreateToolMcpGatewayToken, "expiresAt"> & {
|
||||
expiresAt?: string | Date | null;
|
||||
};
|
||||
/** Gateway update payload — `companyId` is injected by the client method. */
|
||||
export type UpdateGatewayInput = Omit<UpdateToolMcpGateway, "companyId">;
|
||||
export type ReviewNewToolsInput = {
|
||||
decisions: Array<{ catalogEntryId: string; decision: ToolProfileNewToolReviewDecision }>;
|
||||
};
|
||||
|
||||
export type StdioTemplateSummary = ToolStdioCommandTemplate;
|
||||
export type StdioTemplatesResponse = { templates: StdioTemplateSummary[] };
|
||||
|
||||
/** Admin "run your own" command-template create input (M8b, PAP-10862). */
|
||||
export interface CreateStdioTemplateInput {
|
||||
templateId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
command: string;
|
||||
args?: string[];
|
||||
envKeys?: string[];
|
||||
}
|
||||
|
||||
export interface CreateToolApplicationInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
type: ToolApplication["type"];
|
||||
pluginId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface UpdateToolApplicationInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
status?: ToolApplication["status"];
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface CreateToolConnectionInput {
|
||||
applicationId?: string;
|
||||
applicationName?: string;
|
||||
name: string;
|
||||
transport: NonNullable<ToolConnection["transport"]>;
|
||||
status?: ToolConnection["status"];
|
||||
config?: Record<string, unknown>;
|
||||
credentialRefs?: ToolConnection["credentialRefs"];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateToolConnectionInput {
|
||||
name?: string;
|
||||
status?: ToolConnection["status"];
|
||||
config?: Record<string, unknown>;
|
||||
transportConfig?: Record<string, unknown>;
|
||||
credentialRefs?: ToolConnection["credentialRefs"];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolProfileEntryInput {
|
||||
selectorType: ToolProfileEntrySelectorType;
|
||||
effect?: ToolProfileEntryEffect;
|
||||
applicationId?: string | null;
|
||||
connectionId?: string | null;
|
||||
catalogEntryId?: string | null;
|
||||
toolName?: string | null;
|
||||
riskLevel?: ToolRiskLevel | null;
|
||||
conditions?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface CreateToolProfileInput {
|
||||
profileKey: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status?: ToolProfileStatus;
|
||||
defaultAction?: ToolProfileDefaultAction;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
entries?: ToolProfileEntryInput[];
|
||||
}
|
||||
|
||||
export interface UpdateToolProfileInput {
|
||||
profileKey?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
status?: ToolProfileStatus;
|
||||
defaultAction?: ToolProfileDefaultAction;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
entries?: ToolProfileEntryInput[];
|
||||
}
|
||||
|
||||
export interface ToolProfileBindingInput {
|
||||
targetType: ToolProfileBindingTargetType;
|
||||
targetId: string;
|
||||
priority?: number;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type UnbindToolProfileInput = Pick<ToolProfileBindingInput, "targetType" | "targetId">;
|
||||
|
||||
/** Redacted tool-gateway audit row (subset of `activity_log`). */
|
||||
export interface ToolGatewayAuditRow {
|
||||
id: string;
|
||||
companyId: string;
|
||||
action: string;
|
||||
actorType: string | null;
|
||||
actorId: string | null;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Normalized outcome for the humanized Activity feed. */
|
||||
export type ToolAuditOutcome =
|
||||
| "allowed"
|
||||
| "blocked"
|
||||
| "asked_first"
|
||||
| "waiting"
|
||||
| "failed"
|
||||
| "unknown";
|
||||
|
||||
/**
|
||||
* Audit row enriched server-side with humanized display names and a normalized
|
||||
* outcome — the shape returned by `GET /tool-gateway/audit`.
|
||||
*/
|
||||
export interface ToolGatewayActivityEvent extends ToolGatewayAuditRow {
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
applicationId: string | null;
|
||||
connectionId: string | null;
|
||||
agentDisplayName: string | null;
|
||||
appDisplayName: string | null;
|
||||
applicationDisplayName: string | null;
|
||||
connectionDisplayName: string | null;
|
||||
toolDisplayName: string | null;
|
||||
normalizedOutcome: ToolAuditOutcome;
|
||||
}
|
||||
|
||||
export type ToolGatewayActivityResponse = {
|
||||
events: ToolGatewayActivityEvent[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
export type ToolAuditWindow = "1h" | "24h" | "7d" | "30d";
|
||||
|
||||
export interface ListActivityParams {
|
||||
app?: string | null;
|
||||
agent?: string | null;
|
||||
outcome?: string | null;
|
||||
window?: ToolAuditWindow;
|
||||
search?: string | null;
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
}
|
||||
|
||||
export type ToolPolicyTestResponse = {
|
||||
decision: ToolAccessDecision;
|
||||
auditEvent: unknown | null;
|
||||
};
|
||||
|
||||
export const toolsApi = {
|
||||
// --- Applications ---
|
||||
listGallery: (companyId: string) =>
|
||||
api.get<ToolGalleryResponse>(`/companies/${companyId}/tools/gallery`),
|
||||
connectApp: (companyId: string, input: {
|
||||
galleryKey?: string;
|
||||
link?: string;
|
||||
name?: string;
|
||||
credentialValues?: Record<string, string>;
|
||||
configValues?: Record<string, unknown>;
|
||||
applicationId?: string;
|
||||
}) =>
|
||||
api.post<ConnectToolAppResult>(`/companies/${companyId}/tools/apps/connect`, input),
|
||||
startOAuth: (connectionId: string) =>
|
||||
api.post<ToolOAuthStartResult>(`/tools/oauth/${connectionId}/start`, {}),
|
||||
finishApp: (companyId: string, connectionId: string, input: {
|
||||
enabledCatalogEntryIds: string[];
|
||||
askFirstCatalogEntryIds: string[];
|
||||
access: "all_agents" | { agentIds: string[] };
|
||||
}) =>
|
||||
api.post<FinishToolAppResult>(
|
||||
`/companies/${companyId}/tools/apps/${connectionId}/finish`,
|
||||
input,
|
||||
),
|
||||
listAppsAttention: (companyId: string) =>
|
||||
api.get<ToolAppsAttentionResponse>(`/companies/${companyId}/tools/apps/attention`),
|
||||
listApplications: (companyId: string) =>
|
||||
api.get<ToolApplicationsResponse>(`/companies/${companyId}/tools/applications`),
|
||||
createApplication: (companyId: string, input: CreateToolApplicationInput) =>
|
||||
api.post<ToolApplication>(`/companies/${companyId}/tools/applications`, input),
|
||||
updateApplication: (applicationId: string, input: UpdateToolApplicationInput) =>
|
||||
api.patch<ToolApplication>(`/tool-applications/${applicationId}`, input),
|
||||
deleteApplication: (applicationId: string) =>
|
||||
api.delete<ToolApplication>(`/tool-applications/${applicationId}`),
|
||||
|
||||
// --- Connections ---
|
||||
listConnections: (companyId: string) =>
|
||||
api.get<ToolConnectionsResponse>(`/companies/${companyId}/tools/connections`),
|
||||
getConnection: (connectionId: string) =>
|
||||
api.get<ToolConnection>(`/tool-connections/${connectionId}`),
|
||||
// --- Installs (Phase 3b, PAP-13618): which agents carry this connection's
|
||||
// tools in their runtime context. `installed ⊆ permitted`; the server
|
||||
// auto-extends access (adds profile bindings) for any newly-installed target.
|
||||
getConnectionInstalls: (connectionId: string) =>
|
||||
api.get<{ connectionId: string; installs: ToolConnectionInstall[] }>(
|
||||
`/tool-connections/${connectionId}/installs`,
|
||||
),
|
||||
putConnectionInstalls: (
|
||||
connectionId: string,
|
||||
installs: Array<{ targetType: "company" | "agent"; targetId: string }>,
|
||||
) =>
|
||||
api.put<ToolConnectionInstallSnapshot>(
|
||||
`/tool-connections/${connectionId}/installs`,
|
||||
{ installs },
|
||||
),
|
||||
createConnection: (companyId: string, input: CreateToolConnectionInput) =>
|
||||
api.post<ToolConnection>(`/companies/${companyId}/tools/connections`, input),
|
||||
updateConnection: (connectionId: string, input: UpdateToolConnectionInput) =>
|
||||
api.patch<ToolConnection>(`/tool-connections/${connectionId}`, input),
|
||||
archiveConnection: (connectionId: string) =>
|
||||
api.delete<ToolConnection>(`/tool-connections/${connectionId}`),
|
||||
checkConnectionHealth: (connectionId: string) =>
|
||||
api.post<ToolConnectionHealthCheckResult>(`/tool-connections/${connectionId}/health-check`, {}),
|
||||
reconnectConnection: (connectionId: string, credentialValues: Record<string, string>) =>
|
||||
api.post<ToolConnectionHealthCheckResult>(`/tool-connections/${connectionId}/reconnect`, {
|
||||
credentialValues,
|
||||
}),
|
||||
refreshCatalog: (connectionId: string) =>
|
||||
api.post<ToolCatalogRefreshResult>(`/tool-connections/${connectionId}/catalog/refresh`, {}),
|
||||
listCatalog: (connectionId: string) =>
|
||||
api.get<ToolCatalogResponse>(`/tool-connections/${connectionId}/catalog`),
|
||||
listConnectionActivity: (connectionId: string, limit = 20) =>
|
||||
api.get<ToolConnectionActivityResponse>(
|
||||
`/tool-connections/${connectionId}/activity?limit=${limit}`,
|
||||
),
|
||||
listTestAgents: (connectionId: string) =>
|
||||
api.get<ToolConnectionTestAgentsResponse>(
|
||||
`/tool-connections/${connectionId}/test-agents`,
|
||||
),
|
||||
runTestCall: (
|
||||
connectionId: string,
|
||||
input: { agentId: string; toolName: string; parameters?: Record<string, unknown> },
|
||||
) =>
|
||||
api.post<ToolConnectionTestCallResult>(
|
||||
`/tool-connections/${connectionId}/test-calls`,
|
||||
input,
|
||||
),
|
||||
getTestCallStatus: (connectionId: string, actionRequestId: string) =>
|
||||
api.get<ToolConnectionTestCallStatus>(
|
||||
`/tool-connections/${connectionId}/test-calls/${actionRequestId}`,
|
||||
),
|
||||
importMcpJson: (companyId: string, body: { mcpJson: unknown }) =>
|
||||
api.post<McpJsonImportPreview>(`/companies/${companyId}/tools/mcp/import-json`, body),
|
||||
listStdioTemplates: (companyId: string) =>
|
||||
api.get<StdioTemplatesResponse>(`/companies/${companyId}/tools/stdio-templates`),
|
||||
createStdioTemplate: (companyId: string, input: CreateStdioTemplateInput) =>
|
||||
api.post<StdioTemplateSummary>(`/companies/${companyId}/tools/stdio-templates`, input),
|
||||
disableStdioTemplate: (companyId: string, templateId: string, reason?: string | null) =>
|
||||
api.post<StdioTemplateSummary>(
|
||||
`/companies/${companyId}/tools/stdio-templates/${encodeURIComponent(templateId)}/disable`,
|
||||
{ reason: reason ?? null },
|
||||
),
|
||||
|
||||
// --- Profiles ---
|
||||
listProfiles: (companyId: string) =>
|
||||
api.get<ToolProfilesResponse>(`/companies/${companyId}/tools/profiles`),
|
||||
getProfileNewTools: (profileId: string) =>
|
||||
api.get<ToolProfileNewToolsReview>(`/tool-profiles/${profileId}/new-tools`),
|
||||
reviewProfileNewTools: (profileId: string, input: ReviewNewToolsInput) =>
|
||||
api.post<ToolProfileNewToolsReviewResult>(`/tool-profiles/${profileId}/new-tools/review`, input),
|
||||
createProfile: (companyId: string, input: CreateToolProfileInput) =>
|
||||
api.post<ToolProfileWithDetails>(`/companies/${companyId}/tools/profiles`, input),
|
||||
updateProfile: (profileId: string, input: UpdateToolProfileInput) =>
|
||||
api.patch<ToolProfileWithDetails>(`/tool-profiles/${profileId}`, input),
|
||||
duplicateProfile: (
|
||||
profileId: string,
|
||||
input: { name: string; includeAssignments?: boolean },
|
||||
) => api.post<ToolProfileWithDetails>(`/tool-profiles/${profileId}/duplicate`, input),
|
||||
deleteProfile: (
|
||||
profileId: string,
|
||||
input: { force?: boolean; reassignToProfileId?: string } = {},
|
||||
) => api.delete<{ deleted: true }>(`/tool-profiles/${profileId}`, input),
|
||||
addProfileEntry: (profileId: string, input: ToolProfileEntryInput) =>
|
||||
api.post<ToolProfileEntry>(`/tool-profiles/${profileId}/entries`, input),
|
||||
updateProfileEntry: (entryId: string, input: Partial<ToolProfileEntryInput>) =>
|
||||
api.patch<ToolProfileEntry>(`/tool-profile-entries/${entryId}`, input),
|
||||
deleteProfileEntry: (entryId: string) =>
|
||||
api.delete<ToolProfileEntry>(`/tool-profile-entries/${entryId}`),
|
||||
bindProfile: (companyId: string, profileId: string, input: ToolProfileBindingInput) =>
|
||||
api.post<ToolProfileBinding>(`/companies/${companyId}/tools/profiles/${profileId}/bind`, input),
|
||||
unbindProfile: (companyId: string, profileId: string, input: UnbindToolProfileInput) =>
|
||||
api.post<{ unbound: number }>(`/companies/${companyId}/tools/profiles/${profileId}/unbind`, input),
|
||||
getEffectiveProfilesForAgent: (companyId: string, agentId: string) =>
|
||||
api.get<ToolProfileEffectiveSummary>(
|
||||
`/companies/${companyId}/tools/profiles/effective/agents/${encodeURIComponent(agentId)}`,
|
||||
),
|
||||
|
||||
// --- Runtime ---
|
||||
listRuntimeSlots: (companyId: string) =>
|
||||
api.get<ToolRuntimeSlotsResponse>(`/companies/${companyId}/tools/runtime-slots`),
|
||||
stopRuntimeSlot: (companyId: string, slotId: string) =>
|
||||
api.post<ToolRuntimeSlot>(`/companies/${companyId}/tools/runtime-slots/${slotId}/stop`, {}),
|
||||
restartRuntimeSlot: (companyId: string, slotId: string) =>
|
||||
api.post<ToolRuntimeSlot>(`/companies/${companyId}/tools/runtime-slots/${slotId}/restart`, {}),
|
||||
getRuntimeHealth: (companyId: string) =>
|
||||
api.get<ToolRuntimeHealthResponse>(`/companies/${companyId}/tools/runtime-health`),
|
||||
getRunDecisionLookup: (companyId: string, runId: string) =>
|
||||
api.get<ToolRunDecisionLookup>(`/companies/${companyId}/tools/runs/${runId}/decisions`),
|
||||
listLiveRuntimeSlots: (companyId: string) =>
|
||||
api.get<ToolRuntimeSlot[]>(`/tool-gateway/runtime-slots?companyId=${encodeURIComponent(companyId)}`),
|
||||
|
||||
// --- Policies (trust rules + decision simulator) ---
|
||||
listPolicies: (companyId: string) =>
|
||||
api.get<ToolPoliciesResponse>(`/companies/${companyId}/tools/policies`),
|
||||
createPolicy: (companyId: string, input: CreateToolPolicy) =>
|
||||
api.post<ToolPolicy>(`/companies/${companyId}/tools/policies`, input),
|
||||
reorderPolicies: (companyId: string, input: ReorderToolPolicies) =>
|
||||
api.post<ToolPoliciesResponse>(`/companies/${companyId}/tools/policies/reorder`, input),
|
||||
duplicatePolicy: (companyId: string, policyId: string, input: DuplicateToolPolicy = {}) =>
|
||||
api.post<ToolPolicy>(`/companies/${companyId}/tools/policies/${policyId}/duplicate`, input),
|
||||
updatePolicy: (companyId: string, policyId: string, input: UpdateToolPolicy) =>
|
||||
api.patch<ToolPolicy>(`/companies/${companyId}/tools/policies/${policyId}`, input),
|
||||
deletePolicy: (companyId: string, policyId: string) =>
|
||||
api.delete<ToolPolicy>(`/companies/${companyId}/tools/policies/${policyId}`),
|
||||
listTrustRules: (companyId: string) =>
|
||||
api.get<ToolTrustRulesResponse>(`/companies/${companyId}/tools/trust-rules`),
|
||||
revokeTrustRule: (companyId: string, policyId: string, reason?: string | null) =>
|
||||
api.post<ToolPolicy>(`/companies/${companyId}/tools/trust-rules/${policyId}/revoke`, {
|
||||
reason: reason ?? null,
|
||||
}),
|
||||
testPolicy: (companyId: string, input: Omit<ToolAccessDecisionInput, "companyId">) =>
|
||||
api.post<ToolPolicyTestResponse>(`/companies/${companyId}/tools/policy/test`, input),
|
||||
|
||||
// --- Review queue (Ask first) ---
|
||||
listActionRequests: (companyId: string, status: ToolActionRequestStatus = "pending") =>
|
||||
api.get<ToolActionRequestsResponse>(
|
||||
`/companies/${companyId}/tools/action-requests?status=${encodeURIComponent(status)}`,
|
||||
),
|
||||
approveActionRequest: (companyId: string, actionRequestId: string) =>
|
||||
api.post<ToolActionRequest>(`/tool-gateway/action-requests/${actionRequestId}/approve`, { companyId }),
|
||||
declineActionRequest: (companyId: string, actionRequestId: string) =>
|
||||
api.post<ToolActionRequest>(`/tool-gateway/action-requests/${actionRequestId}/decline`, { companyId }),
|
||||
createTrustRuleFromActionRequest: (
|
||||
companyId: string,
|
||||
actionRequestId: string,
|
||||
input: CreateToolTrustRuleFromActionRequest = {},
|
||||
) =>
|
||||
api.post<ToolPolicy>(
|
||||
`/companies/${companyId}/tools/action-requests/${actionRequestId}/trust-rule`,
|
||||
input,
|
||||
),
|
||||
|
||||
// --- Named MCP gateways ---
|
||||
listGateways: (companyId: string) =>
|
||||
api.get<ToolMcpGatewaysResponse>(`/companies/${companyId}/tools/gateways`),
|
||||
createGateway: (companyId: string, input: CreateToolMcpGateway) =>
|
||||
api.post<ToolMcpGatewayWithTokens>(`/companies/${companyId}/tools/gateways`, input),
|
||||
updateGateway: (companyId: string, gatewayId: string, input: UpdateGatewayInput) =>
|
||||
api.patch<ToolMcpGatewayWithTokens>(`/tool-gateway/gateways/${gatewayId}`, { ...input, companyId }),
|
||||
createGatewayToken: (companyId: string, gatewayId: string, input: CreateGatewayTokenInput) =>
|
||||
api.post<ToolMcpGatewayTokenCreated>(`/tool-gateway/gateways/${gatewayId}/tokens`, { ...input, companyId }),
|
||||
revokeGatewayToken: (companyId: string, tokenId: string) =>
|
||||
api.post<ToolMcpGatewayToken>(`/tool-gateway/gateway-tokens/${tokenId}/revoke`, { companyId }),
|
||||
|
||||
// --- Audit / Activity ---
|
||||
/**
|
||||
* Humanized Activity feed with server-side filters and cursor pagination.
|
||||
* Returns `{ events, nextCursor }`.
|
||||
*/
|
||||
listActivity: (companyId: string, params: ListActivityParams = {}) => {
|
||||
const search = new URLSearchParams({ companyId });
|
||||
if (params.app) search.set("app", params.app);
|
||||
if (params.agent) search.set("agent", params.agent);
|
||||
if (params.outcome) search.set("outcome", params.outcome);
|
||||
if (params.window) search.set("window", params.window);
|
||||
if (params.search) search.set("search", params.search);
|
||||
if (params.cursor) search.set("cursor", params.cursor);
|
||||
search.set("limit", String(params.limit ?? 50));
|
||||
return api.get<ToolGatewayActivityResponse>(`/tool-gateway/audit?${search.toString()}`);
|
||||
},
|
||||
/** Flat audit sample (no pagination) — used by derived counters/banners. */
|
||||
listAudit: (companyId: string, limit = 100) =>
|
||||
api
|
||||
.get<ToolGatewayActivityResponse>(
|
||||
`/tool-gateway/audit?companyId=${encodeURIComponent(companyId)}&window=30d&limit=${Math.min(limit, 100)}`,
|
||||
)
|
||||
.then((res) => res.events),
|
||||
};
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentMultiSelect } from "./AgentMultiSelect";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> | undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
act(() => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
valueSetter?.call(input, value);
|
||||
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("AgentMultiSelect", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("keeps agent lists compact and searchable", async () => {
|
||||
const onChange = vi.fn();
|
||||
const agents = Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `agent-${index}`,
|
||||
name: index === 17 ? "Search Target" : `Agent ${index}`,
|
||||
title: `Role ${index}`,
|
||||
}));
|
||||
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<AgentMultiSelect agents={agents} selectedAgentIds={new Set()} onChange={onChange} />,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("Select agents");
|
||||
expect(document.body.textContent).not.toContain("Agent 0");
|
||||
|
||||
act(() => {
|
||||
container.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const filter = document.body.querySelector<HTMLInputElement>('input[placeholder="Filter agents"]');
|
||||
expect(filter).not.toBeNull();
|
||||
setInputValue(filter!, "search target");
|
||||
await flush();
|
||||
|
||||
expect(document.body.textContent).toContain("Search Target");
|
||||
expect(document.body.textContent).not.toContain("Agent 0");
|
||||
|
||||
act(() => {
|
||||
document.body
|
||||
.querySelector('[aria-label="Allow Search Target"]')
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.calls[0]?.[0]).toEqual(new Set(["agent-17"]));
|
||||
});
|
||||
|
||||
it("previews selected agents and stages changes until save", async () => {
|
||||
const onSave = vi.fn();
|
||||
const agents = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `agent-${index}`,
|
||||
name: `Agent ${index}`,
|
||||
}));
|
||||
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<AgentMultiSelect
|
||||
agents={agents}
|
||||
selectedAgentIds={new Set(["agent-0", "agent-1", "agent-2", "agent-3", "agent-4"])}
|
||||
onSave={onSave}
|
||||
triggerLabel="Add to agent"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Agent 0");
|
||||
expect(container.textContent).toContain("Agent 2");
|
||||
expect(container.textContent).toContain("and 2 more");
|
||||
expect(container.textContent).not.toContain("Agent 4");
|
||||
|
||||
act(() => {
|
||||
container.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
act(() => {
|
||||
document.body
|
||||
.querySelector('[aria-label="Allow Agent 5"]')
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
const save = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent === "Save");
|
||||
act(() => {
|
||||
save?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(new Set(agents.map((agent) => agent.id)));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
import { useEffect, useMemo, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AgentMultiSelectOption {
|
||||
id: string;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export function AgentMultiSelect({
|
||||
agents,
|
||||
selectedAgentIds,
|
||||
onChange,
|
||||
onSave,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
pending = false,
|
||||
getDescription,
|
||||
isAgentDisabled,
|
||||
renderNameSuffix,
|
||||
triggerLabel,
|
||||
triggerIcon,
|
||||
triggerVariant = "outline",
|
||||
triggerSize = "default",
|
||||
triggerFullWidth = true,
|
||||
triggerClassName,
|
||||
contentAlign = "start",
|
||||
headerContent,
|
||||
emptyMessage = "No agents yet.",
|
||||
showSelectionPreview = true,
|
||||
onOpenChange,
|
||||
}: {
|
||||
agents: AgentMultiSelectOption[];
|
||||
selectedAgentIds: Set<string>;
|
||||
onChange?: (next: Set<string>) => void;
|
||||
onSave?: (next: Set<string>) => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
pending?: boolean;
|
||||
getDescription?: (agent: AgentMultiSelectOption) => string | null | undefined;
|
||||
isAgentDisabled?: (agent: AgentMultiSelectOption) => boolean;
|
||||
renderNameSuffix?: (agent: AgentMultiSelectOption) => ReactNode;
|
||||
triggerLabel?: string;
|
||||
triggerIcon?: ReactNode;
|
||||
triggerVariant?: ComponentProps<typeof Button>["variant"];
|
||||
triggerSize?: ComponentProps<typeof Button>["size"];
|
||||
triggerFullWidth?: boolean;
|
||||
triggerClassName?: string;
|
||||
contentAlign?: ComponentProps<typeof PopoverContent>["align"];
|
||||
headerContent?: ReactNode;
|
||||
emptyMessage?: string;
|
||||
showSelectionPreview?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [draftAgentIds, setDraftAgentIds] = useState<Set<string>>(new Set(selectedAgentIds));
|
||||
const staged = Boolean(onSave);
|
||||
const workingAgentIds = staged ? draftAgentIds : selectedAgentIds;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && staged) setDraftAgentIds(new Set(selectedAgentIds));
|
||||
}, [open, selectedAgentIds, staged]);
|
||||
|
||||
const normalizedFilter = filter.trim().toLowerCase();
|
||||
const filteredAgents = useMemo(
|
||||
() =>
|
||||
agents
|
||||
.filter((agent) => {
|
||||
const description = getDescription?.(agent) ?? agent.title ?? "";
|
||||
return `${agent.name} ${description}`.toLowerCase().includes(normalizedFilter);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aSelected = workingAgentIds.has(a.id);
|
||||
const bSelected = workingAgentIds.has(b.id);
|
||||
if (aSelected !== bSelected) return aSelected ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}),
|
||||
[agents, getDescription, normalizedFilter, workingAgentIds],
|
||||
);
|
||||
const selectedCount = selectedAgentIds.size;
|
||||
const selectedAgents = agents.filter((agent) => selectedAgentIds.has(agent.id));
|
||||
|
||||
function setSelection(next: Set<string>) {
|
||||
if (staged) setDraftAgentIds(next);
|
||||
else onChange?.(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen);
|
||||
if (!nextOpen) setFilter("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={triggerVariant}
|
||||
size={triggerSize}
|
||||
className={cn("justify-between", triggerFullWidth && "w-full", triggerClassName)}
|
||||
disabled={disabled || pending}
|
||||
>
|
||||
<span className="flex min-w-0 items-center">
|
||||
{triggerIcon}
|
||||
<span className="truncate">
|
||||
{triggerLabel ?? (selectedCount === 0
|
||||
? "Select agents"
|
||||
: `${selectedCount} ${selectedCount === 1 ? "agent" : "agents"} selected`)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align={contentAlign}>
|
||||
<div className="border-b border-border p-3">
|
||||
<Input
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="Filter agents"
|
||||
className="h-8"
|
||||
autoFocus
|
||||
/>
|
||||
{headerContent}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-2 p-3">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
) : (
|
||||
<div className="max-h-60 overflow-y-auto py-1">
|
||||
{filteredAgents.map((agent) => {
|
||||
const description = getDescription?.(agent) ?? agent.title;
|
||||
const optionDisabled = isAgentDisabled?.(agent) ?? false;
|
||||
return (
|
||||
<label
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"flex items-start gap-2 px-3 py-2 hover:bg-accent/30",
|
||||
optionDisabled ? "opacity-60" : "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={workingAgentIds.has(agent.id)}
|
||||
disabled={optionDisabled}
|
||||
aria-label={`Allow ${agent.name}`}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = new Set(workingAgentIds);
|
||||
if (checked) next.add(agent.id);
|
||||
else next.delete(agent.id);
|
||||
setSelection(next);
|
||||
}}
|
||||
/>
|
||||
<AgentIcon icon={agent.icon ?? null} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="truncate">{agent.name}</span>
|
||||
{renderNameSuffix?.(agent)}
|
||||
</span>
|
||||
{description ? <span className="truncate text-xs text-muted-foreground">{description}</span> : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{filteredAgents.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">No matches.</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-t border-border px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{workingAgentIds.size === 0 ? "No agents selected" : `${workingAgentIds.size} selected`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{staged ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (staged) onSave?.(draftAgentIds);
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
{staged ? (pending ? "Saving…" : "Save") : "Done"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{showSelectionPreview && selectedAgents.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{selectedAgents.slice(0, 3).map((agent) => (
|
||||
<div key={agent.id} className="flex items-center gap-2 px-1.5 py-1 text-sm">
|
||||
<AgentIcon icon={agent.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{selectedAgents.length > 3 ? (
|
||||
<p className="px-1.5 pt-0.5 text-xs text-muted-foreground">and {selectedAgents.length - 3} more</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -160,6 +160,7 @@ describe("CompanySettingsSidebar", () => {
|
|||
expect(container.textContent).not.toContain("Cloud upstream");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(container.textContent).toContain("Secrets");
|
||||
expect(container.textContent).not.toContain("Tools & Access");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings",
|
||||
|
|
@ -222,6 +223,11 @@ describe("CompanySettingsSidebar", () => {
|
|||
label: "Adapters",
|
||||
}),
|
||||
);
|
||||
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/tools",
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ interface EmptyStateProps {
|
|||
/** Optional bold heading rendered above the message. */
|
||||
title?: string;
|
||||
message: string;
|
||||
/** Optional secondary line rendered under the primary message. */
|
||||
description?: string;
|
||||
action?: string;
|
||||
onAction?: () => void;
|
||||
/** Hide the leading "+" glyph on the action button (e.g. for a "Set up" CTA). */
|
||||
|
|
@ -17,6 +19,7 @@ export function EmptyState({
|
|||
icon: Icon,
|
||||
title,
|
||||
message,
|
||||
description,
|
||||
action,
|
||||
onAction,
|
||||
hideActionIcon = false,
|
||||
|
|
@ -26,8 +29,17 @@ export function EmptyState({
|
|||
<div className="bg-muted/50 p-4 mb-4">
|
||||
<Icon className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
{title && <p className="text-base font-semibold text-foreground mb-1.5">{title}</p>}
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">{message}</p>
|
||||
{title ? (
|
||||
<>
|
||||
<p className="text-base font-semibold text-foreground mb-1.5">{title}</p>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">{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}>
|
||||
{!hideActionIcon && <Plus className="h-4 w-4 mr-1.5" />}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { ShieldAlert, ShieldCheck, type LucideIcon } 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).
|
||||
*
|
||||
* Two modes:
|
||||
*
|
||||
* 1. **Data-driven** (default) — pass only `companyId`. Renders the standing
|
||||
* "enforcement is server-side" message and tints to `denied-detected` when
|
||||
* governed tool calls were denied or failed in the last hour. This is an
|
||||
* *observability* banner — enforcement itself lives in the tool gateway.
|
||||
*
|
||||
* 2. **Presentational** (`tone` + `title`/`body`) — a static governance banner
|
||||
* used to surface a fixed message such as the PAP-10400 trust-tier copy on
|
||||
* the Runtime tab. Tones map to the same OKLCH token palette used elsewhere:
|
||||
* `info` (neutral/shield), `warning` (amber), `error` (destructive).
|
||||
*/
|
||||
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",
|
||||
info: "border-border bg-muted/40 text-muted-foreground",
|
||||
warning:
|
||||
"border-amber-500/40 bg-amber-50 text-amber-900 dark:bg-amber-950/40 dark:text-amber-200",
|
||||
error:
|
||||
"border-destructive/40 bg-destructive/5 text-destructive dark:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
const DENY_ACTIONS = new Set(["tool_gateway.call_denied", "tool_gateway.call_failed"]);
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
export type EnforcementTone = "info" | "warning" | "error";
|
||||
|
||||
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;
|
||||
/**
|
||||
* Presentational mode: when provided, the banner renders a static governance
|
||||
* message with this tone instead of the data-driven denial summary.
|
||||
*/
|
||||
tone?: EnforcementTone;
|
||||
/** Presentational title (bold first line). */
|
||||
title?: ReactNode;
|
||||
/** Presentational body copy. */
|
||||
body?: ReactNode;
|
||||
/** Override the leading icon (presentational mode). */
|
||||
icon?: LucideIcon;
|
||||
/** Optional trailing action node (presentational mode). */
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
function PresentationalBanner({
|
||||
tone,
|
||||
title,
|
||||
body,
|
||||
icon,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
tone: EnforcementTone;
|
||||
title?: ReactNode;
|
||||
body?: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = icon ?? (tone === "info" ? ShieldCheck : ShieldAlert);
|
||||
const iconTone =
|
||||
tone === "info"
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: tone === "warning"
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-destructive";
|
||||
return (
|
||||
<div className={cn(enforcementBanner({ variant: tone }), className)} role="status">
|
||||
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", iconTone)} />
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
{title ? <p className="font-medium">{title}</p> : null}
|
||||
{body ? <p className="opacity-90">{body}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnforcementBanner(props: EnforcementBannerProps) {
|
||||
const { companyId, className, forceVariant, recentDenialCount, tone, title, body, icon, action } = props;
|
||||
|
||||
// Presentational mode short-circuits the data hook below.
|
||||
const isPresentational = tone !== undefined;
|
||||
|
||||
const audit = useQuery({
|
||||
queryKey: queryKeys.tools.audit(companyId ?? "", 100),
|
||||
queryFn: () => toolsApi.listAudit(companyId ?? "", 100),
|
||||
enabled:
|
||||
!isPresentational &&
|
||||
forceVariant === undefined &&
|
||||
recentDenialCount === undefined &&
|
||||
!!companyId,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isPresentational) {
|
||||
return (
|
||||
<PresentationalBanner
|
||||
tone={tone}
|
||||
title={title}
|
||||
body={body}
|
||||
icon={icon}
|
||||
action={action}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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="/apps/advanced/audit"
|
||||
className="shrink-0 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
View audit →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,12 +11,19 @@ import {
|
|||
pendingAskUserQuestionsInteraction,
|
||||
commentExpiredAskUserQuestionsInteraction,
|
||||
commentExpiredRequestConfirmationInteraction,
|
||||
declinedToolActionInteraction,
|
||||
disabledDeclineReasonRequestConfirmationInteraction,
|
||||
executedToolActionInteraction,
|
||||
expiredToolActionInteraction,
|
||||
failedRequestConfirmationInteraction,
|
||||
failedToolActionInteraction,
|
||||
pendingRequestConfirmationInteraction,
|
||||
pendingToolActionDestructiveInteraction,
|
||||
pendingToolActionWriteInteraction,
|
||||
planApprovalResumeFailedRequestConfirmationInteraction,
|
||||
pendingRequestItemVerdictsInteraction,
|
||||
pendingSuggestedTasksInteraction,
|
||||
runningToolActionInteraction,
|
||||
completeRequestItemVerdictsInteraction,
|
||||
supersededRequestItemVerdictsInteraction,
|
||||
staleTargetRequestConfirmationInteraction,
|
||||
|
|
@ -586,3 +593,126 @@ describe("IssueThreadInteractionCard", () => {
|
|||
expect(host.textContent?.toLowerCase()).toContain("revert");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IssueThreadInteractionCard tool-action card", () => {
|
||||
it("selects the pending state with the Approve & run affordance and identity header", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingToolActionWriteInteraction,
|
||||
onAcceptInteraction: vi.fn(),
|
||||
onRejectInteraction: vi.fn(),
|
||||
});
|
||||
|
||||
// Pending eyebrow, never a bare "Accepted".
|
||||
expect(host.textContent).toContain("Awaiting approval");
|
||||
// Identity header: tool display name + WRITE risk badge + app/tool sub-line.
|
||||
expect(host.textContent).toContain("Append row to spreadsheet");
|
||||
expect(host.textContent).toContain("WRITE");
|
||||
expect(host.textContent).toContain("Google Sheets");
|
||||
// Primary CTA is "Approve & run" (approve = run), plus the hint + countdown.
|
||||
const approve = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Approve & run"),
|
||||
);
|
||||
expect(approve).toBeTruthy();
|
||||
expect(host.textContent).toContain("Approving runs this action now.");
|
||||
expect(host.textContent).toContain("Approval expires in");
|
||||
// Technical details drawer is present but collapsed by default (hash hidden).
|
||||
expect(host.textContent).toContain("Technical details");
|
||||
expect(host.textContent).not.toContain("args hash");
|
||||
});
|
||||
|
||||
it("uses the destructive risk badge and a destructive primary button", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingToolActionDestructiveInteraction,
|
||||
onAcceptInteraction: vi.fn(),
|
||||
onRejectInteraction: vi.fn(),
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("DESTRUCTIVE");
|
||||
const approve = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Approve & run"),
|
||||
);
|
||||
expect(approve?.getAttribute("data-variant")).toBe("destructive");
|
||||
});
|
||||
|
||||
it("reveals redacted args and the hash when the technical drawer is opened", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingToolActionWriteInteraction,
|
||||
onAcceptInteraction: vi.fn(),
|
||||
onRejectInteraction: vi.fn(),
|
||||
});
|
||||
|
||||
const trigger = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Technical details"),
|
||||
);
|
||||
act(() => {
|
||||
(trigger as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("args hash");
|
||||
expect(host.textContent).toContain("sha256:9f2c1a7be4d0c8a3");
|
||||
// Redacted arguments render verbatim, never raw secrets.
|
||||
expect(host.textContent).toContain("[redacted]");
|
||||
});
|
||||
|
||||
it("renders the approved-running state with a spinner and no action buttons", () => {
|
||||
const host = renderCard({ interaction: runningToolActionInteraction });
|
||||
|
||||
expect(host.textContent).toContain("Running…");
|
||||
expect(host.textContent).toContain("running the action now");
|
||||
expect(host.textContent).not.toContain("Approve & run");
|
||||
expect(host.querySelector(".animate-spin")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the executed state with a result summary and never reads Accepted", () => {
|
||||
const host = renderCard({ interaction: executedToolActionInteraction });
|
||||
|
||||
expect(host.textContent).toContain("Executed");
|
||||
expect(host.textContent).toContain("Row 42 added");
|
||||
expect(host.textContent).not.toContain("Accepted");
|
||||
const link = Array.from(host.querySelectorAll("a")).find((a) =>
|
||||
a.textContent?.includes("View result"),
|
||||
);
|
||||
expect(link?.getAttribute("href")).toContain("docs.google.com");
|
||||
});
|
||||
|
||||
it("distinguishes failed (ran + connector error) from declined (did not run)", () => {
|
||||
const failed = renderCard({ interaction: failedToolActionInteraction });
|
||||
expect(failed.textContent).toContain("Failed");
|
||||
expect(failed.textContent).toContain("insufficient_permission");
|
||||
expect(failed.textContent).toContain("but the connector returned an error");
|
||||
|
||||
act(() => root?.unmount());
|
||||
failed.remove();
|
||||
root = null;
|
||||
|
||||
const declined = renderCard({ interaction: declinedToolActionInteraction });
|
||||
expect(declined.textContent).toContain("Declined");
|
||||
expect(declined.textContent).toContain("did");
|
||||
expect(declined.textContent).toContain("not");
|
||||
expect(declined.textContent).toContain("run");
|
||||
expect(declined.textContent).toContain("use the CRM sync instead");
|
||||
expect(declined.textContent).not.toContain("Approve & run");
|
||||
});
|
||||
|
||||
it("renders the expired state with the 60-minute rule and a recovery path", () => {
|
||||
const host = renderCard({ interaction: expiredToolActionInteraction });
|
||||
|
||||
expect(host.textContent).toContain("Expired");
|
||||
expect(host.textContent).toContain("no one responded within 60 minutes");
|
||||
expect(host.textContent).toContain("the agent can request approval again");
|
||||
expect(host.textContent).not.toContain("Approve & run");
|
||||
});
|
||||
|
||||
it("keeps the generic confirmation rendering for cards without a toolAction", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingRequestConfirmationInteraction,
|
||||
onAcceptInteraction: vi.fn(),
|
||||
onRejectInteraction: vi.fn(),
|
||||
});
|
||||
|
||||
// Legacy confirmation keeps its own prompt + labels, no tool-action surface.
|
||||
expect(host.textContent).toContain("Approve the plan and let the responsible start implementation?");
|
||||
expect(host.textContent).not.toContain("Approve & run");
|
||||
expect(host.textContent).not.toContain("Technical details");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { AlertTriangle, ArrowUpRight, Check, CheckCircle2, ChevronRight, CircleDashed, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ThumbsUp, X, XCircle } from "lucide-react";
|
||||
import { AlertTriangle, ArrowUpRight, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Wrench, X, XCircle } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import {
|
||||
|
|
@ -30,6 +30,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
|||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
||||
import { Button } from "./ui/button";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
|
@ -235,6 +236,154 @@ function planStatusClasses(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `request_confirmation` that carries a `payload.toolAction` block gates a
|
||||
* write/destructive MCP tool call (PAP-13726 §D1). It renders as a dedicated
|
||||
* tool-approval card (PAP-13745) instead of the generic confirmation rendering.
|
||||
* The governing rule: approve = run, so the card never terminally reads
|
||||
* "Accepted" — terminal states are Executed / Failed / Declined / Expired.
|
||||
*/
|
||||
function toolActionPayload(
|
||||
interaction: IssueThreadInteraction,
|
||||
): NonNullable<RequestConfirmationInteraction["payload"]["toolAction"]> | null {
|
||||
if (interaction.kind !== "request_confirmation") return null;
|
||||
return interaction.payload.toolAction ?? null;
|
||||
}
|
||||
|
||||
function isToolActionConfirmation(interaction: IssueThreadInteraction): boolean {
|
||||
return toolActionPayload(interaction) != null;
|
||||
}
|
||||
|
||||
type ToolActionCardState =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "executed"
|
||||
| "failed"
|
||||
| "declined"
|
||||
| "expired";
|
||||
|
||||
/**
|
||||
* Derives the visible lifecycle state from the interaction status plus the
|
||||
* `result.toolAction.status` written back by the gateway. The card must render
|
||||
* the resolved state without polling — the lifecycle metadata is authoritative,
|
||||
* so an optimistic "running…" reconciles to the server's terminal state.
|
||||
*/
|
||||
function toolActionCardState(
|
||||
interaction: RequestConfirmationInteraction,
|
||||
): ToolActionCardState {
|
||||
const execStatus = interaction.result?.toolAction?.status ?? null;
|
||||
if (interaction.status === "pending") return "pending";
|
||||
if (interaction.status === "rejected") return "declined";
|
||||
if (interaction.status === "expired") return "expired";
|
||||
// Terminal execution outcomes take precedence over the coarse interaction
|
||||
// status so a self-resolving "running…" advances to its real result.
|
||||
if (execStatus === "executed") return "executed";
|
||||
if (execStatus === "failed") return "failed";
|
||||
if (execStatus === "expired") return "expired";
|
||||
if (interaction.status === "failed") return "failed";
|
||||
// accepted + approved/executing/unknown → the transient running state.
|
||||
return "running";
|
||||
}
|
||||
|
||||
function toolActionStatusClasses(state: ToolActionCardState): {
|
||||
shell: string;
|
||||
badge: string;
|
||||
label: string;
|
||||
Icon: typeof CheckCircle2;
|
||||
spin?: boolean;
|
||||
dimmed?: boolean;
|
||||
} {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return {
|
||||
shell: "border-2 border-amber-500/70 bg-transparent",
|
||||
badge: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100",
|
||||
label: "Running…",
|
||||
Icon: Loader2,
|
||||
spin: true,
|
||||
};
|
||||
case "executed":
|
||||
return {
|
||||
shell: "border-2 border-green-500/80 bg-transparent",
|
||||
badge: "border-green-500/60 bg-green-500/10 text-green-900 dark:bg-green-500/15 dark:text-green-100",
|
||||
label: "Executed",
|
||||
Icon: CheckCircle2,
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
shell: "border-2 border-amber-500/70 bg-transparent",
|
||||
badge: "border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100",
|
||||
label: "Failed",
|
||||
Icon: XCircle,
|
||||
};
|
||||
case "declined":
|
||||
return {
|
||||
shell: "border-2 border-red-500/80 bg-transparent",
|
||||
badge: "border-red-500/60 bg-red-500/10 text-red-900 dark:bg-red-500/15 dark:text-red-100",
|
||||
label: "Declined",
|
||||
Icon: XCircle,
|
||||
dimmed: true,
|
||||
};
|
||||
case "expired":
|
||||
return {
|
||||
shell: "border-2 border-border bg-transparent",
|
||||
badge: "border-border bg-muted/60 text-muted-foreground",
|
||||
label: "Expired",
|
||||
Icon: Clock,
|
||||
dimmed: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
shell: "border-2 border-violet-500/80 bg-transparent",
|
||||
badge: "border-violet-500/60 bg-violet-500/10 text-violet-900 dark:bg-violet-500/15 dark:text-violet-100",
|
||||
label: "Awaiting approval",
|
||||
Icon: ShieldAlert,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toolActionRiskBadge(risk: "write" | "destructive") {
|
||||
if (risk === "destructive") {
|
||||
return {
|
||||
label: "DESTRUCTIVE",
|
||||
Icon: TriangleAlert,
|
||||
className:
|
||||
"border-red-500/60 bg-red-500/10 text-red-900 dark:bg-red-500/15 dark:text-red-100",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "WRITE",
|
||||
Icon: AlertTriangle,
|
||||
className:
|
||||
"border-amber-500/60 bg-amber-500/10 text-amber-900 dark:bg-amber-500/15 dark:text-amber-100",
|
||||
};
|
||||
}
|
||||
|
||||
function toolActionInitial(payload: {
|
||||
appDisplayName: string | null;
|
||||
toolDisplayName: string;
|
||||
}): string {
|
||||
const source = payload.appDisplayName?.trim() || payload.toolDisplayName.trim();
|
||||
return source ? source.charAt(0).toUpperCase() : "?";
|
||||
}
|
||||
|
||||
function formatToolActionCountdown(expiresAt: string, nowMs: number): {
|
||||
text: string;
|
||||
urgent: boolean;
|
||||
} | null {
|
||||
const expiresMs = new Date(expiresAt).getTime();
|
||||
if (Number.isNaN(expiresMs)) return null;
|
||||
const remainingMs = expiresMs - nowMs;
|
||||
if (remainingMs <= 0) {
|
||||
return { text: "Approval window closed · auto-declines any moment", urgent: true };
|
||||
}
|
||||
const minutes = Math.ceil(remainingMs / 60000);
|
||||
return {
|
||||
text: `Approval expires in ${minutes} min · auto-declines if not answered`,
|
||||
urgent: minutes <= 5,
|
||||
};
|
||||
}
|
||||
|
||||
function TaskField({
|
||||
label,
|
||||
value,
|
||||
|
|
@ -1243,6 +1392,423 @@ function RequestConfirmationResolution({
|
|||
return null;
|
||||
}
|
||||
|
||||
function ToolActionIdentityHeader({
|
||||
payload,
|
||||
state,
|
||||
}: {
|
||||
payload: NonNullable<RequestConfirmationInteraction["payload"]["toolAction"]>;
|
||||
state: ToolActionCardState;
|
||||
}) {
|
||||
const risk = toolActionRiskBadge(payload.risk);
|
||||
const RiskIcon = risk.Icon;
|
||||
const dimmed = state === "declined" || state === "expired";
|
||||
const subParts = [payload.appDisplayName, payload.toolName].filter(
|
||||
(part): part is string => Boolean(part && part.trim()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-start gap-3", dimmed && "opacity-60 grayscale")}>
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/60 text-base font-semibold text-foreground"
|
||||
>
|
||||
{toolActionInitial(payload)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-base font-bold leading-tight text-foreground">
|
||||
{payload.toolDisplayName}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)",
|
||||
risk.className,
|
||||
)}
|
||||
>
|
||||
<RiskIcon className="h-3 w-3" />
|
||||
{risk.label}
|
||||
</span>
|
||||
</div>
|
||||
{subParts.length > 0 ? (
|
||||
<div className="mt-1 truncate font-mono text-(length:--text-compact) text-muted-foreground">
|
||||
{subParts.join(" · ")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolActionTechnicalDetails({
|
||||
payload,
|
||||
}: {
|
||||
payload: NonNullable<RequestConfirmationInteraction["payload"]["toolAction"]>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasArgs = payload.argumentsSummaryJson.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded-sm py-1 text-left text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40">
|
||||
{open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Technical details
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">
|
||||
{hasArgs ? (
|
||||
<pre className="max-h-64 overflow-auto rounded-sm border border-border/70 bg-muted/40 p-3 font-mono text-xs leading-5 text-foreground">
|
||||
{payload.argumentsSummaryJson}
|
||||
</pre>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-semibold uppercase tracking-(--tracking-eyebrow) text-(length:--text-nano)">
|
||||
args hash
|
||||
</span>
|
||||
<code className="truncate font-mono">{payload.argumentsHash}</code>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolActionResolution({
|
||||
state,
|
||||
interaction,
|
||||
resolvedByLabel,
|
||||
requestedByLabel,
|
||||
}: {
|
||||
state: ToolActionCardState;
|
||||
interaction: RequestConfirmationInteraction;
|
||||
resolvedByLabel: string | null;
|
||||
requestedByLabel: string;
|
||||
}) {
|
||||
const result = interaction.result?.toolAction ?? null;
|
||||
const who = resolvedByLabel ?? "the board";
|
||||
const when = interaction.resolvedAt
|
||||
? formatDateTime(interaction.resolvedAt)
|
||||
: result?.updatedAt
|
||||
? formatDateTime(result.updatedAt)
|
||||
: null;
|
||||
const whenSuffix = when ? ` at ${when}` : "";
|
||||
|
||||
if (state === "running") {
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex items-start gap-2 rounded-sm border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100"
|
||||
>
|
||||
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin" />
|
||||
<div className="space-y-1 leading-6">
|
||||
<div className="font-medium">Approved by {who} — running the action now</div>
|
||||
<p className="text-amber-900/80 dark:text-amber-100/80">
|
||||
The action is executing server-side with the exact arguments you approved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "executed") {
|
||||
const summary = result?.resultSummary?.trim();
|
||||
const href = result?.resultHref?.trim();
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="space-y-2 rounded-sm border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-900 dark:text-green-100"
|
||||
>
|
||||
<div className="flex items-start gap-2 leading-6">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">Executed · approved by {who}{whenSuffix}</div>
|
||||
<p className="text-green-900/80 dark:text-green-100/80">
|
||||
{requestedByLabel} was resumed with this result.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="rounded-sm border border-green-500/40 bg-background/60 px-3 py-2 font-medium text-foreground">
|
||||
{summary}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-sm border border-green-500/40 bg-background/60 px-3 py-2 text-foreground">
|
||||
Executed successfully.
|
||||
</div>
|
||||
)}
|
||||
{href ? (
|
||||
<Button asChild size="sm" variant="outline" className="h-7 px-2">
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
View result
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "failed") {
|
||||
const errorText = result?.errorMessage?.trim();
|
||||
const errorCode = result?.errorCode?.trim();
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="space-y-2 rounded-sm border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100"
|
||||
>
|
||||
<div className="flex items-start gap-2 leading-6">
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400" />
|
||||
<div>
|
||||
<div className="font-medium">Failed · approved by {who}{whenSuffix}</div>
|
||||
<p className="text-amber-900/80 dark:text-amber-100/80">
|
||||
You approved it and it ran, but the connector returned an error.{" "}
|
||||
{requestedByLabel} was resumed with this error.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{errorText || errorCode ? (
|
||||
<div className="rounded-sm border border-red-500/50 bg-red-500/10 px-3 py-2 text-red-900 dark:text-red-100">
|
||||
{errorCode ? (
|
||||
<div className="text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow) text-red-700 dark:text-red-300">
|
||||
{errorCode}
|
||||
</div>
|
||||
) : null}
|
||||
{errorText ? (
|
||||
<p className={cn("leading-6", errorCode && "mt-1")}>{errorText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "declined") {
|
||||
const reason = interaction.result?.reason?.trim();
|
||||
return (
|
||||
<div className="space-y-2 rounded-sm border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-900 dark:text-red-100">
|
||||
<div className="flex items-start gap-2 leading-6">
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">Declined by {who}{whenSuffix}</div>
|
||||
<p className="text-red-900/80 dark:text-red-100/80">
|
||||
The action did <strong>not</strong> run. {requestedByLabel} was resumed with
|
||||
your reason and told not to retry the same call.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{reason ? (
|
||||
<div className="rounded-sm border border-red-500/40 bg-background/60 px-3 py-2 text-foreground">
|
||||
<MarkdownBody>{reason}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// expired
|
||||
return (
|
||||
<div className="space-y-1 rounded-sm border border-border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-start gap-2 leading-6">
|
||||
<Clock className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-foreground">
|
||||
Expired{when ? ` at ${when}` : ""} — no one responded within 60 minutes
|
||||
</div>
|
||||
<p>
|
||||
The action did <strong>not</strong> run. If it's still needed, the agent can
|
||||
request approval again — a fresh card will appear.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestToolActionCard({
|
||||
interaction,
|
||||
state,
|
||||
resolvedByLabel,
|
||||
requestedByLabel,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
externalReferences,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
state: ToolActionCardState;
|
||||
resolvedByLabel: string | null;
|
||||
requestedByLabel: string;
|
||||
onAcceptInteraction?: (
|
||||
interaction: RequestConfirmationInteraction,
|
||||
) => Promise<void> | void;
|
||||
onRejectInteraction?: (
|
||||
interaction: RequestConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
const payload = interaction.payload.toolAction!;
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const isPending = state === "pending";
|
||||
const isDestructive = payload.risk === "destructive";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending) return;
|
||||
const timer = setInterval(() => setNowMs(Date.now()), 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [isPending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "pending") {
|
||||
setRejecting(false);
|
||||
setWorking(null);
|
||||
}
|
||||
}, [interaction.id, state]);
|
||||
|
||||
async function handleAccept() {
|
||||
if (!onAcceptInteraction) return;
|
||||
setWorking("accept");
|
||||
setActionError(null);
|
||||
try {
|
||||
await onAcceptInteraction(interaction);
|
||||
} catch {
|
||||
setActionError("Couldn't submit. Try again.");
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!onRejectInteraction) return;
|
||||
setWorking("reject");
|
||||
setActionError(null);
|
||||
try {
|
||||
await onRejectInteraction(interaction, rejectReason.trim() || undefined);
|
||||
setRejecting(false);
|
||||
} catch {
|
||||
setActionError("Couldn't submit. Try again.");
|
||||
} finally {
|
||||
setWorking(null);
|
||||
}
|
||||
}
|
||||
|
||||
const countdown = isPending ? formatToolActionCountdown(payload.expiresAt, nowMs) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ToolActionIdentityHeader payload={payload} state={state} />
|
||||
|
||||
<div className="text-sm leading-6 text-foreground">
|
||||
<MarkdownBody externalReferences={externalReferences}>
|
||||
{payload.previewMarkdown}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
|
||||
<ToolActionTechnicalDetails payload={payload} />
|
||||
|
||||
{isPending ? (
|
||||
<>
|
||||
{countdown ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-(length:--text-micro) font-medium",
|
||||
countdown.urgent ? "text-amber-700 dark:text-amber-300" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{countdown.text}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isDestructive ? "destructive" : "cta"}
|
||||
disabled={!onAcceptInteraction || working !== null}
|
||||
onClick={() => void handleAccept()}
|
||||
>
|
||||
{working === "accept" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Approving…
|
||||
</>
|
||||
) : (
|
||||
"Approve & run"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!onRejectInteraction || working !== null}
|
||||
onClick={() => setRejecting((current) => !current)}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">
|
||||
Approving runs this action now.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{rejecting ? (
|
||||
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-3">
|
||||
<Textarea
|
||||
value={rejectReason}
|
||||
onChange={(event) => setRejectReason(event.target.value)}
|
||||
placeholder="Optional: tell the agent why, so it doesn't retry the same call."
|
||||
className="min-h-20 bg-background text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={working !== null}
|
||||
onClick={() => setRejecting(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!onRejectInteraction || working !== null}
|
||||
onClick={() => void handleReject()}
|
||||
>
|
||||
{working === "reject" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Declining…
|
||||
</>
|
||||
) : (
|
||||
"Decline"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ToolActionResolution
|
||||
state={state}
|
||||
interaction={interaction}
|
||||
resolvedByLabel={resolvedByLabel}
|
||||
requestedByLabel={requestedByLabel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestConfirmationCard({
|
||||
interaction,
|
||||
isPlan = false,
|
||||
|
|
@ -2454,10 +3020,19 @@ export function IssueThreadInteractionCard({
|
|||
externalReferences,
|
||||
}: IssueThreadInteractionCardProps) {
|
||||
const isPlan = isPlanConfirmation(interaction);
|
||||
const isToolAction =
|
||||
interaction.kind === "request_confirmation" && isToolActionConfirmation(interaction);
|
||||
const toolActionState =
|
||||
isToolAction && interaction.kind === "request_confirmation"
|
||||
? toolActionCardState(interaction)
|
||||
: null;
|
||||
const toolActionStyles = toolActionState ? toolActionStatusClasses(toolActionState) : null;
|
||||
const resumeFailure = requestConfirmationResumeFailure(interaction);
|
||||
const planStyles = isPlan ? planStatusClasses(interaction.status, resumeFailure) : null;
|
||||
const StatusIcon = planStyles ? planStyles.Icon : statusIcon(interaction.status);
|
||||
const styles = planStyles ?? statusClasses(interaction.status);
|
||||
const activeStyles = toolActionStyles ?? planStyles;
|
||||
const StatusIcon = activeStyles ? activeStyles.Icon : statusIcon(interaction.status);
|
||||
const iconSpin = toolActionStyles?.spin ?? false;
|
||||
const styles = activeStyles ?? statusClasses(interaction.status);
|
||||
const createdByLabel = resolveActorLabel({
|
||||
agentId: interaction.createdByAgentId,
|
||||
userId: interaction.createdByUserId,
|
||||
|
|
@ -2482,10 +3057,10 @@ export function IssueThreadInteractionCard({
|
|||
<div className="min-w-0 flex-1 basis-64">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn("inline-flex items-center gap-1 rounded-sm border px-2.5 py-1 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow)", styles.badge)}>
|
||||
<StatusIcon className="h-3.5 w-3.5" />
|
||||
<StatusIcon className={cn("h-3.5 w-3.5", iconSpin && "animate-spin")} />
|
||||
{isPlan ? "Plan" : interactionKindLabel(interaction.kind)}
|
||||
<span className="text-current/60">/</span>
|
||||
{planStyles ? planStyles.label : statusLabel(interaction.status)}
|
||||
{activeStyles ? activeStyles.label : statusLabel(interaction.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -2497,11 +3072,13 @@ export function IssueThreadInteractionCard({
|
|||
? interaction.payload.title ?? "Questions for the operator"
|
||||
: interaction.kind === "request_checkbox_confirmation"
|
||||
? "Checkbox confirmation requested"
|
||||
: interaction.kind === "request_item_verdicts"
|
||||
? "Review these items"
|
||||
: isPlan
|
||||
? "Plan review"
|
||||
: "Confirmation requested")}
|
||||
: isToolAction
|
||||
? "Tool approval requested"
|
||||
: interaction.kind === "request_item_verdicts"
|
||||
? "Review these items"
|
||||
: isPlan
|
||||
? "Plan review"
|
||||
: "Confirmation requested")}
|
||||
</div>
|
||||
{interaction.summary ? (
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
|
|
@ -2547,6 +3124,16 @@ export function IssueThreadInteractionCard({
|
|||
onRejectInteraction={onRejectInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : isToolAction && interaction.kind === "request_confirmation" && toolActionState ? (
|
||||
<RequestToolActionCard
|
||||
interaction={interaction}
|
||||
state={toolActionState}
|
||||
resolvedByLabel={resolvedByLabel}
|
||||
requestedByLabel={createdByLabel}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : interaction.kind === "request_item_verdicts" ? (
|
||||
<RequestItemVerdictsCard
|
||||
interaction={interaction}
|
||||
|
|
@ -2565,7 +3152,7 @@ export function IssueThreadInteractionCard({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{resolvedByLabel ? (
|
||||
{resolvedByLabel && !isToolAction ? (
|
||||
<div className="mt-4 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
Resolved by <span className="font-medium text-foreground">{resolvedByLabel}</span>
|
||||
{interaction.resolvedAt ? ` on ${formatShortDate(interaction.resolvedAt)}` : ""}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ describe("JsonSchemaForm secret-ref rendering", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("writes the secret id to form values when the picker selects an existing secret", async () => {
|
||||
it("writes a secret_ref binding to form values when the picker selects an existing secret", async () => {
|
||||
const root = createRoot(container);
|
||||
const onChange = vi.fn();
|
||||
|
||||
|
|
@ -173,7 +173,11 @@ describe("JsonSchemaForm secret-ref rendering", () => {
|
|||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
apiKey: "11111111-1111-4111-8111-111111111111",
|
||||
apiKey: {
|
||||
type: "secret_ref",
|
||||
secretId: "11111111-1111-4111-8111-111111111111",
|
||||
version: "latest",
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { isUuidLike } from "@paperclipai/shared";
|
||||
import { isUuidLike, type EnvSecretRefBinding } from "@paperclipai/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -107,6 +107,8 @@ export interface JsonSchemaFormProps {
|
|||
disabled?: boolean;
|
||||
/** Additional CSS class for the root container. */
|
||||
className?: string;
|
||||
/** Label for the disclosure that hides advanced fields. Defaults to "Advanced options". */
|
||||
advancedLabel?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -189,6 +191,13 @@ export function validateField(
|
|||
// Skip further validation if empty and not required
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
|
||||
if (type === "secret-ref" && isSecretRefBinding(value)) {
|
||||
return null;
|
||||
}
|
||||
if (type === "secret-ref" && typeof value === "object") {
|
||||
return "Invalid secret reference";
|
||||
}
|
||||
|
||||
if (type === "string" || type === "secret-ref") {
|
||||
const str = String(value);
|
||||
if (schema.minLength != null && str.length < schema.minLength) {
|
||||
|
|
@ -446,6 +455,16 @@ BooleanField.displayName = "BooleanField";
|
|||
*/
|
||||
const ENUM_UNSET_VALUE = "__paperclip_unset__";
|
||||
|
||||
function isSecretRefBinding(value: unknown): value is EnvSecretRefBinding {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
(value as { type?: unknown }).type === "secret_ref" &&
|
||||
typeof (value as { secretId?: unknown }).secretId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized field for enum (select) values.
|
||||
*/
|
||||
|
|
@ -557,9 +576,11 @@ const SecretField = React.memo(({
|
|||
const [isVisible, setIsVisible] = useState(false);
|
||||
const isTextArea = maxLength != null && maxLength > TEXTAREA_THRESHOLD;
|
||||
|
||||
const secretRefValue = isSecretRefBinding(value) ? value : null;
|
||||
const stringValue = typeof value === "string" ? value : "";
|
||||
const trimmed = stringValue.trim();
|
||||
const isBoundToSecret = trimmed.length > 0 && isUuidLike(trimmed);
|
||||
const legacySecretId = trimmed.length > 0 && isUuidLike(trimmed) ? trimmed : null;
|
||||
const isBoundToSecret = secretRefValue !== null || legacySecretId !== null;
|
||||
const hasRawValue = stringValue.length > 0 && !isBoundToSecret;
|
||||
|
||||
const [showRawInput, setShowRawInput] = useState(hasRawValue);
|
||||
|
|
@ -572,14 +593,20 @@ const SecretField = React.memo(({
|
|||
if (hasRawValue) setShowRawInput(true);
|
||||
}, [hasRawValue]);
|
||||
|
||||
const bindingValue: SecretBindingValue | null = isBoundToSecret
|
||||
? { secretId: trimmed }
|
||||
: null;
|
||||
const bindingValue: SecretBindingValue | null = secretRefValue
|
||||
? { secretId: secretRefValue.secretId, version: secretRefValue.version }
|
||||
: legacySecretId
|
||||
? { secretId: legacySecretId }
|
||||
: null;
|
||||
|
||||
const handlePickerChange = useCallback(
|
||||
(next: SecretBindingValue | null) => {
|
||||
if (next) {
|
||||
onChange(next.secretId);
|
||||
onChange({
|
||||
type: "secret_ref",
|
||||
secretId: next.secretId,
|
||||
version: next.version ?? "latest",
|
||||
});
|
||||
setShowRawInput(false);
|
||||
setIsVisible(false);
|
||||
} else {
|
||||
|
|
@ -1186,6 +1213,7 @@ export function JsonSchemaForm({
|
|||
errors = {},
|
||||
disabled,
|
||||
className,
|
||||
advancedLabel = "Advanced options",
|
||||
}: JsonSchemaFormProps) {
|
||||
const type = resolveType(schema);
|
||||
|
||||
|
|
@ -1331,7 +1359,7 @@ export function JsonSchemaForm({
|
|||
onClick={() => setIsAdvancedOpen((open) => !open)}
|
||||
aria-expanded={isAdvancedOpen}
|
||||
>
|
||||
<span className="text-sm font-medium">Advanced options</span>
|
||||
<span className="text-sm font-medium">{advancedLabel}</span>
|
||||
{isAdvancedOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -114,9 +114,6 @@ export function OnboardingWizard() {
|
|||
const location = useLocation();
|
||||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||
|
||||
// Sync disabled adapter types from server so the adapter grid filters them out.
|
||||
const disabledTypes = useDisabledAdaptersSync();
|
||||
|
||||
// Support opening the wizard from a route (e.g. /onboarding or an existing
|
||||
// company's "add agent" entry point) in addition to the dialog context.
|
||||
const routeOnboardingOptions =
|
||||
|
|
@ -133,6 +130,11 @@ export function OnboardingWizard() {
|
|||
? onboardingOptions
|
||||
: routeOnboardingOptions ?? {};
|
||||
|
||||
// Sync disabled adapter types only when the wizard is visible. The wizard is
|
||||
// mounted globally, including on /auth, where protected adapter routes are
|
||||
// expected to reject signed-out browsers.
|
||||
const disabledTypes = useDisabledAdaptersSync({ enabled: effectiveOnboardingOpen });
|
||||
|
||||
const initialStep = effectiveOnboardingOptions.initialStep ?? 0;
|
||||
const existingCompanyId = effectiveOnboardingOptions.companyId;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SmokeLabDashboardCard } from "./SmokeLabDashboardCard";
|
||||
|
||||
const getExperimentalMock = vi.hoisted(() => vi.fn());
|
||||
const listRunsMock = vi.hoisted(() => vi.fn());
|
||||
const getRunMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: { getExperimental: () => getExperimentalMock() },
|
||||
}));
|
||||
|
||||
vi.mock("@/api/smokeLab", () => ({
|
||||
smokeLabApi: {
|
||||
listRuns: (c: string) => listRunsMock(c),
|
||||
getRun: (c: string, r: string) => getRunMock(c, r),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const RUN = {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
trigger: "manual",
|
||||
status: "failed",
|
||||
startedAt: "2026-07-10T00:00:00Z",
|
||||
finishedAt: "2026-07-10T00:05:00Z",
|
||||
summary: {},
|
||||
createdAt: "2026-07-10T00:00:00Z",
|
||||
updatedAt: "2026-07-10T00:05:00Z",
|
||||
};
|
||||
|
||||
describe("SmokeLabDashboardCard", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
getExperimentalMock.mockResolvedValue({ enableSmokeLab: true });
|
||||
listRunsMock.mockResolvedValue({ runs: [RUN] });
|
||||
getRunMock.mockResolvedValue({
|
||||
run: RUN,
|
||||
steps: [
|
||||
{
|
||||
id: "s1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
path: "P3",
|
||||
scenarioStep: "allowed-read",
|
||||
status: "fail",
|
||||
detail: null,
|
||||
screenshotArtifactRef: null,
|
||||
durationMs: null,
|
||||
createdAt: "2026-07-10T00:00:01Z",
|
||||
updatedAt: "2026-07-10T00:00:01Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SmokeLabDashboardCard companyId="company-1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders nothing when the flag is off", async () => {
|
||||
getExperimentalMock.mockResolvedValue({ enableSmokeLab: false });
|
||||
await render();
|
||||
|
||||
expect(container.querySelector('[data-testid="smoke-lab-dashboard-card"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Integration smoke");
|
||||
expect(listRunsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the card with failing paths and a link to the Smoke Lab tab when enabled", async () => {
|
||||
await render();
|
||||
|
||||
const card = container.querySelector<HTMLAnchorElement>('[data-testid="smoke-lab-dashboard-card"]');
|
||||
expect(card).not.toBeNull();
|
||||
expect(card?.getAttribute("href")).toBe("/apps/advanced/smoke-lab");
|
||||
expect(container.textContent).toContain("Integration smoke");
|
||||
expect(container.textContent).toContain("Failing paths: P3");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FlaskConical, ChevronRight } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { smokeLabApi } from "@/api/smokeLab";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled";
|
||||
import { advancedTabHref } from "@/pages/tools/tool-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { failingPaths, runHealth, type SmokeHealth } from "@/pages/tools/smoke-lab-matrix";
|
||||
|
||||
const HEALTH_DOT: Record<SmokeHealth, string> = {
|
||||
green: "bg-emerald-500",
|
||||
amber: "bg-amber-500",
|
||||
red: "bg-destructive",
|
||||
unknown: "bg-muted-foreground/40",
|
||||
};
|
||||
|
||||
const HEALTH_LABEL: Record<SmokeHealth, string> = {
|
||||
green: "All paths passing",
|
||||
amber: "Needs a run",
|
||||
red: "Failing paths",
|
||||
unknown: "No runs yet",
|
||||
};
|
||||
|
||||
function formatTime(value: string | Date | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value as string | Date);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "Integration smoke" dashboard card (PAP-13347 / S2, plan §D3).
|
||||
* Renders only when `experimental.enableSmokeLab` is on (board-readable flag),
|
||||
* so the dashboard stays clean for everyone who isn't running the Smoke Lab.
|
||||
* Operator-facing copy stays plain; protocol depth lives behind the link into
|
||||
* the Developer › Smoke Lab tab.
|
||||
*/
|
||||
export function SmokeLabDashboardCard({ companyId }: { companyId: string }) {
|
||||
const { enabled, loaded } = useSmokeLabEnabled();
|
||||
|
||||
const runsQuery = useQuery({
|
||||
queryKey: queryKeys.smokeLab.runs(companyId),
|
||||
queryFn: () => smokeLabApi.listRuns(companyId),
|
||||
enabled: enabled && loaded,
|
||||
});
|
||||
|
||||
const latestRun = runsQuery.data?.runs?.[0];
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: queryKeys.smokeLab.run(companyId, latestRun?.id ?? "__none__"),
|
||||
queryFn: () => smokeLabApi.getRun(companyId, latestRun!.id),
|
||||
enabled: enabled && loaded && !!latestRun,
|
||||
});
|
||||
|
||||
if (!loaded || !enabled) return null;
|
||||
|
||||
const steps = detailQuery.data?.steps ?? [];
|
||||
const health = runHealth(latestRun, steps);
|
||||
const failing = failingPaths(steps);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={advancedTabHref("smoke-lab")}
|
||||
className="group flex items-center justify-between gap-3 rounded-lg border border-border bg-card p-4 shadow-sm transition-colors hover:bg-accent/40"
|
||||
data-testid="smoke-lab-dashboard-card"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<FlaskConical className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("h-2.5 w-2.5 shrink-0 rounded-full", HEALTH_DOT[health])} />
|
||||
<p className="truncate text-sm font-semibold text-foreground">Integration smoke</p>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{HEALTH_LABEL[health]}
|
||||
{failing.length > 0 && `: ${failing.join(", ")}`}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-(length:--text-micro) text-muted-foreground/80">
|
||||
{latestRun ? `Last run ${formatTime(latestRun.startedAt)}` : "Run one from the Smoke Lab tab"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,9 +25,9 @@ function sentenceCaseStatus(status: string): string {
|
|||
/**
|
||||
* Generic status badge for runs / goals / approvals (not task status).
|
||||
*/
|
||||
// design-allow(pill-pattern): DECISION-SHEET.md C8 — status badges keep the bespoke WCAG-tuned
|
||||
// design-allow(pill-pattern): DECISION-SHEET.md C8 - status badges keep the bespoke WCAG-tuned
|
||||
// .status-chip color-mix mechanic and do not wrap the Badge primitive.
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -35,7 +35,7 @@ export function StatusBadge({ status }: { status: string }) {
|
|||
statusBadge[status] ?? statusBadgeDefault
|
||||
)}
|
||||
>
|
||||
{status.replace(/_/g, " ")}
|
||||
{label ?? status.replace(/[_-]/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ActionCard, ActionCardMobile, BindingsTable, shortSha } from "./ActionCard";
|
||||
|
||||
// EnforcementBanner pulls in a react-query hook; the stale variant only needs
|
||||
// its presentational copy, so stub it to keep the test free of a QueryClient.
|
||||
vi.mock("@/components/EnforcementBanner", () => ({
|
||||
EnforcementBanner: ({ title, body }: { title?: string; body?: string }) => (
|
||||
<div data-testid="stale-banner">
|
||||
<span>{title}</span>
|
||||
<span>{body}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
function render(element: ReactElement) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
flushSync(() => root?.render(element));
|
||||
return container;
|
||||
}
|
||||
|
||||
const baseBinding = {
|
||||
application: "Slack",
|
||||
manifestVersion: "2.4.1",
|
||||
connection: "https://slack.com/api",
|
||||
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
toolName: "slack.post_message",
|
||||
risk: "medium" as const,
|
||||
isWrite: true,
|
||||
binding: baseBinding,
|
||||
input: { channel: "#launch", text: "hi" },
|
||||
reason: "Write-capable tool.",
|
||||
policyNumber: 7,
|
||||
expiresInLabel: "expires in 23h 51m",
|
||||
};
|
||||
|
||||
function approveButton(c: HTMLElement): HTMLButtonElement {
|
||||
const btn = Array.from(c.querySelectorAll("button")).find((b) => b.textContent?.trim() === "Approve");
|
||||
if (!btn) throw new Error("Approve button not found");
|
||||
return btn as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe("ActionCard", () => {
|
||||
it("surfaces the signed payload sha256 and expiry (PAP-10400)", () => {
|
||||
const c = render(<ActionCard {...baseProps} />);
|
||||
expect(c.textContent).toContain(shortSha(baseBinding.payloadSha256));
|
||||
expect(c.textContent).toContain("signed");
|
||||
expect(c.textContent).toContain("expires in 23h 51m");
|
||||
});
|
||||
|
||||
it("references the policy number in the explanation", () => {
|
||||
const c = render(<ActionCard {...baseProps} />);
|
||||
expect(c.textContent).toContain("Policy #7");
|
||||
});
|
||||
|
||||
it("enables Approve on the pending variant and fires the handler", () => {
|
||||
const onApprove = vi.fn();
|
||||
const c = render(<ActionCard {...baseProps} onApprove={onApprove} />);
|
||||
const btn = approveButton(c);
|
||||
expect(btn.disabled).toBe(false);
|
||||
flushSync(() => btn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onApprove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disables Approve and shows the catalog mismatch on the stale variant", () => {
|
||||
const c = render(
|
||||
<ActionCard
|
||||
{...baseProps}
|
||||
variant="stale"
|
||||
binding={{
|
||||
...baseBinding,
|
||||
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7deadbeefcafef00dba5eba5eba5eba5e",
|
||||
previousCatalogSha256: baseBinding.catalogSha256,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(approveButton(c).disabled).toBe(true);
|
||||
expect(c.querySelector('[data-testid="stale-banner"]')).not.toBeNull();
|
||||
// Previous (now-invalid) hash is struck through next to the current one.
|
||||
const struck = c.querySelector(".line-through");
|
||||
expect(struck?.textContent).toContain(shortSha(baseBinding.catalogSha256));
|
||||
});
|
||||
|
||||
it("stacks buttons Approve / Deny / Edit & re-sign on mobile with a 70px label column", () => {
|
||||
const c = render(<ActionCardMobile {...baseProps} />);
|
||||
const labels = c.querySelectorAll("dt");
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
expect((labels[0] as HTMLElement).style.width).toBe("70px");
|
||||
|
||||
const buttonText = Array.from(c.querySelectorAll("button")).map((b) => b.textContent?.trim());
|
||||
const order = buttonText.filter((t) => t === "Approve" || t === "Deny" || t?.startsWith("Edit"));
|
||||
expect(order[0]).toBe("Approve");
|
||||
expect(order[1]).toBe("Deny");
|
||||
expect(order[2]).toContain("Edit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BindingsTable", () => {
|
||||
it("renders mono rows with the default 132px label column", () => {
|
||||
const c = render(
|
||||
<BindingsTable rows={[{ label: "Catalog", value: "sha256:abc", mono: true }]} />,
|
||||
);
|
||||
const dt = c.querySelector("dt") as HTMLElement;
|
||||
expect(dt.style.width).toBe("132px");
|
||||
expect(c.querySelector("dd")?.className).toContain("font-mono");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortSha", () => {
|
||||
it("truncates a long sha to the review form", () => {
|
||||
expect(shortSha("sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")).toBe(
|
||||
"sha256:9f86d08188…f00a08",
|
||||
);
|
||||
});
|
||||
it("leaves a short sha intact", () => {
|
||||
expect(shortSha("sha256:abcd")).toBe("sha256:abcd");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Clock, Pencil, ShieldCheck } from "lucide-react";
|
||||
import type { ToolRiskLevel } from "@paperclipai/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { EnforcementBanner } from "@/components/EnforcementBanner";
|
||||
import { CapabilityBadges, DecisionBadge, RiskBadge } from "@/pages/tools/shared";
|
||||
|
||||
/**
|
||||
* Action approval card (PAP-10787 / PAP-10778, surfaces 11/12/99).
|
||||
*
|
||||
* The card an agent's run posts into the issue thread when a governed tool
|
||||
* call needs human approval. Two hard requirements from the PAP-10400 security
|
||||
* hardening must never regress:
|
||||
*
|
||||
* 1. The **signed payload sha256 + expiry** are always surfaced, so a reviewer
|
||||
* approves exactly the bytes that were signed and can see when the request
|
||||
* lapses.
|
||||
* 2. The server-driven **stale** variant disables Approve and shows the
|
||||
* catalog-hash mismatch (previous hash struck through next to current), so
|
||||
* re-issuance is visibly required — an approval can never be granted
|
||||
* against a catalog the orchestrator no longer trusts.
|
||||
*/
|
||||
|
||||
export type ActionCardVariant = "pending" | "stale";
|
||||
|
||||
/** One key/value row in the {@link BindingsTable}. */
|
||||
export interface BindingRow {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
/** Render the value in the mono catalog/hash treatment. */
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column key/value block with mono values. Lives inside {@link ActionCard}
|
||||
* and is reused in the audit row drilldown, so it takes raw rows rather than a
|
||||
* baked-in binding shape. `labelWidth` narrows to 70px on mobile (surface 99).
|
||||
*/
|
||||
export function BindingsTable({
|
||||
rows,
|
||||
labelWidth = 132,
|
||||
className,
|
||||
}: {
|
||||
rows: BindingRow[];
|
||||
labelWidth?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<dl className={cn("divide-y divide-border rounded-md border border-border text-sm", className)}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex gap-3 px-3 py-2">
|
||||
<dt
|
||||
className="shrink-0 pt-0.5 text-xs font-medium uppercase tracking-normal text-muted-foreground"
|
||||
style={{ width: labelWidth }}
|
||||
>
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd className={cn("min-w-0 flex-1 break-all text-foreground", row.mono && "font-mono text-xs")}>
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/** Truncate a sha to the standard `sha256:abcd…1234` review form. */
|
||||
export function shortSha(sha: string): string {
|
||||
const hex = sha.replace(/^sha256:/, "");
|
||||
if (hex.length <= 16) return `sha256:${hex}`;
|
||||
return `sha256:${hex.slice(0, 10)}…${hex.slice(-6)}`;
|
||||
}
|
||||
|
||||
export interface ActionCardBinding {
|
||||
/** Application the tool belongs to. */
|
||||
application: string;
|
||||
/** Manifest version the catalog was discovered at. */
|
||||
manifestVersion: string;
|
||||
/** Connection label (mono URL / command). */
|
||||
connection: string;
|
||||
/** Current catalog sha256 the gateway will enforce against. */
|
||||
catalogSha256: string;
|
||||
/** sha256 of the signed argument payload — never elided. */
|
||||
payloadSha256: string;
|
||||
/**
|
||||
* Previous catalog sha256, only present on the stale variant. Rendered struck
|
||||
* through next to {@link catalogSha256} so the mismatch is obvious.
|
||||
*/
|
||||
previousCatalogSha256?: string;
|
||||
}
|
||||
|
||||
export interface ActionCardProps {
|
||||
/** Requesting agent — defaults to "Coder" to match the spec copy. */
|
||||
agentName?: string;
|
||||
agentAvatarUrl?: string | null;
|
||||
/** Tool the agent is asking to call, e.g. `slack.post_message`. */
|
||||
toolName: string;
|
||||
risk: ToolRiskLevel;
|
||||
isReadOnly?: boolean;
|
||||
isWrite?: boolean;
|
||||
isDestructive?: boolean;
|
||||
binding: ActionCardBinding;
|
||||
/** Raw tool input, rendered as pretty JSON in a mono block. */
|
||||
input: unknown;
|
||||
/** Free-form "why I'm asking" explanation. */
|
||||
reason: ReactNode;
|
||||
/** Policy number the explanation references, e.g. `7` → "Policy #7". */
|
||||
policyNumber?: number | string;
|
||||
/** Footrow expiry copy, e.g. "expires in 23h 51m". */
|
||||
expiresInLabel?: string;
|
||||
variant?: ActionCardVariant;
|
||||
/** Mobile (390×844) layout: stacked full-width buttons + 70px label column. */
|
||||
mobile?: boolean;
|
||||
onApprove?: () => void;
|
||||
onDeny?: () => void;
|
||||
onEditResign?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.map((part) => part[0])
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function bindingRows(binding: ActionCardBinding, isStale: boolean): BindingRow[] {
|
||||
const catalogValue = isStale && binding.previousCatalogSha256 ? (
|
||||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted-foreground line-through decoration-amber-500" title="Previous catalog hash">
|
||||
{shortSha(binding.previousCatalogSha256)}
|
||||
</span>
|
||||
<span className="text-amber-600 dark:text-amber-400" title="Current catalog hash">
|
||||
{shortSha(binding.catalogSha256)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
shortSha(binding.catalogSha256)
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Application",
|
||||
value: (
|
||||
<span>
|
||||
{binding.application}
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">manifest v{binding.manifestVersion}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ label: "Connection", value: binding.connection, mono: true },
|
||||
{ label: "Catalog", value: catalogValue, mono: !isStale },
|
||||
{
|
||||
label: "Payload",
|
||||
value: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
<span>{shortSha(binding.payloadSha256)}</span>
|
||||
<span className="font-sans text-(length:--text-micro) uppercase tracking-normal text-muted-foreground">signed</span>
|
||||
</span>
|
||||
),
|
||||
mono: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function ActionCard({
|
||||
agentName = "Coder",
|
||||
agentAvatarUrl,
|
||||
toolName,
|
||||
risk,
|
||||
isReadOnly,
|
||||
isWrite,
|
||||
isDestructive,
|
||||
binding,
|
||||
input,
|
||||
reason,
|
||||
policyNumber,
|
||||
expiresInLabel,
|
||||
variant = "pending",
|
||||
mobile = false,
|
||||
onApprove,
|
||||
onDeny,
|
||||
onEditResign,
|
||||
className,
|
||||
}: ActionCardProps) {
|
||||
const isStale = variant === "stale";
|
||||
const json = typeof input === "string" ? input : JSON.stringify(input, null, 2);
|
||||
|
||||
// Surface 99: buttons stack full-width in the order Approve / Deny /
|
||||
// Edit & re-sign; desktop keeps them inline as Edit & re-sign / Deny / Approve.
|
||||
const approveButton = (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onApprove}
|
||||
disabled={isStale}
|
||||
className={mobile ? "w-full" : undefined}
|
||||
title={isStale ? "Re-issue the request before approving — the catalog hash changed." : undefined}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
);
|
||||
const denyButton = (
|
||||
<Button size="sm" variant="outline" onClick={onDeny} className={mobile ? "w-full" : undefined}>
|
||||
Deny
|
||||
</Button>
|
||||
);
|
||||
const editButton = (
|
||||
<Button size="sm" variant="outline" onClick={onEditResign} className={mobile ? "w-full" : undefined}>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
Edit & re-sign
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"gap-0 py-0",
|
||||
isStale && "border-amber-500/50 dark:border-amber-500/40",
|
||||
className,
|
||||
)}
|
||||
data-variant={variant}
|
||||
>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{/* Header: avatar + request line + outcome pill */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar size="sm" className="shrink-0">
|
||||
{agentAvatarUrl ? <AvatarImage src={agentAvatarUrl} alt={agentName} /> : null}
|
||||
<AvatarFallback>{initials(agentName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">{agentName}</span> requested approval to call
|
||||
</p>
|
||||
<p className="mt-0.5 font-mono text-xs text-muted-foreground break-all">{toolName}</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<DecisionBadge decision="require_approval" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: tool name + risk / capability pills */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm text-foreground">{toolName}</span>
|
||||
<RiskBadge risk={risk} />
|
||||
<CapabilityBadges isReadOnly={isReadOnly} isWrite={isWrite} isDestructive={isDestructive} />
|
||||
</div>
|
||||
|
||||
{/* Stale banner (PAP-10400 hardening) */}
|
||||
{isStale ? (
|
||||
<EnforcementBanner
|
||||
tone="warning"
|
||||
title="Catalog changed since this request was signed."
|
||||
body="The application's tool catalog hash no longer matches the one this approval was issued against. Approval is disabled — the agent must edit & re-sign to request again."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Bindings table */}
|
||||
<BindingsTable rows={bindingRows(binding, isStale)} labelWidth={mobile ? 70 : 132} />
|
||||
|
||||
{/* JSON input */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-normal text-muted-foreground">Input</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed text-foreground">
|
||||
{json}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Why I'm asking */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-normal text-muted-foreground">Why I'm asking</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{reason}
|
||||
{policyNumber != null ? (
|
||||
<>
|
||||
{" "}
|
||||
<span className="font-medium text-foreground">Policy #{policyNumber}</span> requires approval here.
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Footrow: expiry + actions */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 border-t border-border p-4",
|
||||
mobile ? "flex-col" : "flex-wrap items-center justify-between",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
{expiresInLabel ?? "no expiry set"}
|
||||
</span>
|
||||
{mobile ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{approveButton}
|
||||
{denyButton}
|
||||
{editButton}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{editButton}
|
||||
{denyButton}
|
||||
{approveButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mobile (390×844) presentation of {@link ActionCard}. */
|
||||
export function ActionCardMobile(props: Omit<ActionCardProps, "mobile">) {
|
||||
return <ActionCard {...props} mobile />;
|
||||
}
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui";
|
||||
import type { TranscriptEntry } from "../../adapters";
|
||||
import { buildTranscript, type RunLogChunk } from "../../adapters";
|
||||
import { buildTranscript, type RunLogChunk, type TranscriptEntry } from "../../adapters";
|
||||
import type { ToolRunDecision } from "@paperclipai/shared";
|
||||
import { ThemeProvider } from "../../context/ThemeContext";
|
||||
import { RunTranscriptView, normalizeTranscript } from "./RunTranscriptView";
|
||||
|
||||
|
|
@ -207,6 +207,103 @@ describe("RunTranscriptView", () => {
|
|||
expect(html).not.toContain("result");
|
||||
});
|
||||
|
||||
it("links tool rows to pending governed action decisions", () => {
|
||||
const invocationId = "11111111-1111-4111-8111-111111111111";
|
||||
const actionRequestId = "22222222-2222-4222-8222-222222222222";
|
||||
const decision: ToolRunDecision = {
|
||||
invocation: {
|
||||
id: invocationId,
|
||||
companyId: "company-1",
|
||||
idempotencyKey: null,
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
runId: "run-1",
|
||||
applicationId: null,
|
||||
connectionId: null,
|
||||
catalogEntryId: null,
|
||||
toolName: "send_email",
|
||||
argumentsHash: "hash-1",
|
||||
argumentsSummary: { summary: "{\"to\":\"redacted\"}" },
|
||||
policyDecision: "require_approval",
|
||||
matchedPolicyIds: [],
|
||||
approvalState: "pending",
|
||||
status: "awaiting_approval",
|
||||
upstreamRequestId: null,
|
||||
resultHash: null,
|
||||
resultSummary: null,
|
||||
resultSizeBytes: null,
|
||||
resultArtifactId: null,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-03-12T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-12T00:00:00.000Z"),
|
||||
},
|
||||
actionRequest: {
|
||||
id: actionRequestId,
|
||||
companyId: "company-1",
|
||||
invocationId,
|
||||
issueId: "issue-1",
|
||||
interactionId: "33333333-3333-4333-8333-333333333333",
|
||||
approvalId: null,
|
||||
status: "pending",
|
||||
canonicalArgumentsHash: "hash-1",
|
||||
canonicalArgumentsSummary: { summary: "{\"to\":\"redacted\"}" },
|
||||
signedArguments: null,
|
||||
previewMarkdown: "Tool: `send_email`",
|
||||
requestedByAgentId: "agent-1",
|
||||
requestedByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
decidedByAgentId: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
expiresAt: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-03-12T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-12T00:00:00.000Z"),
|
||||
},
|
||||
auditEvents: [],
|
||||
latestAuditEvent: null,
|
||||
decision: "require_approval",
|
||||
outcome: "pending",
|
||||
reasonCode: "requires_approval_policy",
|
||||
denialReason: null,
|
||||
pendingAction: {
|
||||
actionRequestId,
|
||||
issueId: "issue-1",
|
||||
interactionId: "33333333-3333-4333-8333-333333333333",
|
||||
approvalId: null,
|
||||
status: "pending",
|
||||
previewMarkdown: "Tool: `send_email`",
|
||||
},
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<ThemeProvider>
|
||||
<RunTranscriptView
|
||||
density="compact"
|
||||
entries={[
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: "2026-03-12T00:00:00.000Z",
|
||||
name: "send_email",
|
||||
invocationId,
|
||||
input: { to: "redacted@example.com" },
|
||||
},
|
||||
]}
|
||||
toolDecisions={[decision]}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Needs approval");
|
||||
expect(html).toContain(`Action request ${actionRequestId.slice(0, 8)}`);
|
||||
});
|
||||
|
||||
it("windows large raw transcripts instead of rendering every entry at once", () => {
|
||||
const entries: TranscriptEntry[] = Array.from({ length: 500 }, (_, index) => ({
|
||||
kind: "stdout",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TranscriptEntry } from "../../adapters";
|
||||
import type { ToolRunDecision } from "@paperclipai/shared";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "../MarkdownBody";
|
||||
import { cn, formatTokens } from "../../lib/utils";
|
||||
import { runningLabelText } from "../../lib/status-colors";
|
||||
|
|
@ -24,6 +25,7 @@ const RAW_INITIAL_ROWS = 180;
|
|||
|
||||
interface RunTranscriptViewProps {
|
||||
entries: TranscriptEntry[];
|
||||
toolDecisions?: readonly ToolRunDecision[];
|
||||
mode?: TranscriptMode;
|
||||
density?: TranscriptDensity;
|
||||
limit?: number;
|
||||
|
|
@ -55,6 +57,8 @@ type TranscriptBlock =
|
|||
endTs?: string;
|
||||
name: string;
|
||||
toolUseId?: string;
|
||||
invocationId?: string;
|
||||
actionRequestId?: string;
|
||||
input: unknown;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
|
|
@ -74,6 +78,9 @@ type TranscriptBlock =
|
|||
items: Array<{
|
||||
ts: string;
|
||||
endTs?: string;
|
||||
toolUseId?: string;
|
||||
invocationId?: string;
|
||||
actionRequestId?: string;
|
||||
input: unknown;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
|
|
@ -88,6 +95,9 @@ type TranscriptBlock =
|
|||
ts: string;
|
||||
endTs?: string;
|
||||
name: string;
|
||||
toolUseId?: string;
|
||||
invocationId?: string;
|
||||
actionRequestId?: string;
|
||||
input: unknown;
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
|
|
@ -325,6 +335,89 @@ function summarizeToolResult(result: string | undefined, isError: boolean | unde
|
|||
return truncate(firstLine, density === "compact" ? 84 : 140);
|
||||
}
|
||||
|
||||
type ToolDecisionRefs = {
|
||||
toolUseId?: string;
|
||||
invocationId?: string;
|
||||
actionRequestId?: string;
|
||||
};
|
||||
|
||||
type ToolDecisionMaps = {
|
||||
byInvocationId: Map<string, ToolRunDecision>;
|
||||
byActionRequestId: Map<string, ToolRunDecision>;
|
||||
};
|
||||
|
||||
function buildToolDecisionMaps(decisions: readonly ToolRunDecision[] | undefined): ToolDecisionMaps {
|
||||
const byInvocationId = new Map<string, ToolRunDecision>();
|
||||
const byActionRequestId = new Map<string, ToolRunDecision>();
|
||||
for (const decision of decisions ?? []) {
|
||||
byInvocationId.set(decision.invocation.id, decision);
|
||||
if (decision.actionRequest?.id) {
|
||||
byActionRequestId.set(decision.actionRequest.id, decision);
|
||||
}
|
||||
if (decision.latestAuditEvent?.actionRequestId) {
|
||||
byActionRequestId.set(decision.latestAuditEvent.actionRequestId, decision);
|
||||
}
|
||||
}
|
||||
return { byInvocationId, byActionRequestId };
|
||||
}
|
||||
|
||||
function findToolDecision(maps: ToolDecisionMaps, refs: ToolDecisionRefs): ToolRunDecision | null {
|
||||
if (refs.invocationId) {
|
||||
const decision = maps.byInvocationId.get(refs.invocationId);
|
||||
if (decision) return decision;
|
||||
}
|
||||
if (refs.actionRequestId) {
|
||||
const decision = maps.byActionRequestId.get(refs.actionRequestId);
|
||||
if (decision) return decision;
|
||||
}
|
||||
if (refs.toolUseId) {
|
||||
return maps.byInvocationId.get(refs.toolUseId) ?? maps.byActionRequestId.get(refs.toolUseId) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function summarizeToolDecision(decision: ToolRunDecision | null): { label: string; className: string; detail?: string } | null {
|
||||
if (!decision) return null;
|
||||
if (decision.pendingAction) {
|
||||
return {
|
||||
label: "Needs approval",
|
||||
className: "text-amber-700 dark:text-amber-300",
|
||||
detail: `Action request ${decision.pendingAction.actionRequestId.slice(0, 8)}`,
|
||||
};
|
||||
}
|
||||
if (decision.denialReason || decision.invocation.status === "denied" || decision.outcome === "denied") {
|
||||
return {
|
||||
label: "Denied",
|
||||
className: "text-red-700 dark:text-red-300",
|
||||
detail: decision.denialReason ?? decision.reasonCode ?? undefined,
|
||||
};
|
||||
}
|
||||
if (decision.invocation.status === "failed" || decision.invocation.status === "timed_out" || decision.outcome === "failure" || decision.outcome === "timeout") {
|
||||
return {
|
||||
label: decision.invocation.status === "timed_out" || decision.outcome === "timeout" ? "Timed out" : "Failed",
|
||||
className: "text-red-700 dark:text-red-300",
|
||||
detail: decision.denialReason ?? decision.reasonCode ?? undefined,
|
||||
};
|
||||
}
|
||||
if (decision.actionRequest?.status === "approved") {
|
||||
return { label: "Approved", className: "text-emerald-700 dark:text-emerald-300" };
|
||||
}
|
||||
if (decision.actionRequest?.status === "executed") {
|
||||
return { label: "Executed", className: "text-emerald-700 dark:text-emerald-300" };
|
||||
}
|
||||
if (decision.decision === "allow" || decision.invocation.status === "authorized" || decision.invocation.status === "executing" || decision.invocation.status === "succeeded") {
|
||||
return { label: "Allowed", className: "text-emerald-700 dark:text-emerald-300" };
|
||||
}
|
||||
if (decision.decision === "require_approval" || decision.invocation.approvalState === "pending") {
|
||||
return { label: "Needs approval", className: "text-amber-700 dark:text-amber-300" };
|
||||
}
|
||||
return {
|
||||
label: humanizeLabel(decision.invocation.status),
|
||||
className: "text-foreground/70",
|
||||
detail: decision.reasonCode ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSystemActivity(text: string): { activityId?: string; name: string; status: "running" | "completed" } | null {
|
||||
const match = text.match(/^item (started|completed):\s*([a-z0-9_-]+)(?:\s+\(id=([^)]+)\))?$/i);
|
||||
if (!match) return null;
|
||||
|
|
@ -368,6 +461,9 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
|||
pending.push({
|
||||
ts: block.ts,
|
||||
endTs: block.endTs,
|
||||
toolUseId: block.toolUseId,
|
||||
invocationId: block.invocationId,
|
||||
actionRequestId: block.actionRequestId,
|
||||
input: block.input,
|
||||
result: block.result,
|
||||
isError: block.isError,
|
||||
|
|
@ -412,6 +508,9 @@ function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
|||
ts: block.ts,
|
||||
endTs: block.endTs,
|
||||
name: block.name,
|
||||
toolUseId: block.toolUseId,
|
||||
invocationId: block.invocationId,
|
||||
actionRequestId: block.actionRequestId,
|
||||
input: block.input,
|
||||
result: block.result,
|
||||
isError: block.isError,
|
||||
|
|
@ -484,6 +583,8 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||
ts: entry.ts,
|
||||
name: displayToolName(entry.name, entry.input),
|
||||
toolUseId,
|
||||
invocationId: entry.invocationId,
|
||||
actionRequestId: entry.actionRequestId,
|
||||
input: entry.input,
|
||||
status: "running",
|
||||
};
|
||||
|
|
@ -726,14 +827,69 @@ function TranscriptThinkingBlock({
|
|||
);
|
||||
}
|
||||
|
||||
function ToolDecisionBadge({ decision }: { decision: ToolRunDecision | null }) {
|
||||
const summary = summarizeToolDecision(decision);
|
||||
if (!summary) return null;
|
||||
return (
|
||||
<span className={cn("text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)", summary.className)}>
|
||||
{summary.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolDecisionInlineDetail({ decision }: { decision: ToolRunDecision | null }) {
|
||||
const summary = summarizeToolDecision(decision);
|
||||
if (!summary?.detail) return null;
|
||||
return (
|
||||
<div className="mt-1 break-words text-(length:--text-micro) text-muted-foreground">
|
||||
{summary.detail}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolDecisionDetails({ decision, compact }: { decision: ToolRunDecision | null; compact: boolean }) {
|
||||
if (!decision) return null;
|
||||
const actionRequest = decision.actionRequest;
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg border border-border/60 bg-background/60 p-2",
|
||||
compact ? "text-(length:--text-micro)" : "text-xs",
|
||||
)}>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">Decision</span>
|
||||
<ToolDecisionBadge decision={decision} />
|
||||
{decision.reasonCode && <span className="font-mono text-muted-foreground">{decision.reasonCode}</span>}
|
||||
</div>
|
||||
{decision.denialReason && (
|
||||
<div className="mt-1 break-words text-red-700 dark:text-red-300">
|
||||
{decision.denialReason}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 grid gap-1 font-mono text-muted-foreground sm:grid-cols-2">
|
||||
<span>invocation {decision.invocation.id.slice(0, 8)}</span>
|
||||
<span>audit {decision.auditEvents.length}</span>
|
||||
{actionRequest && <span>action {actionRequest.status} {actionRequest.id.slice(0, 8)}</span>}
|
||||
{actionRequest?.interactionId && <span>card {actionRequest.interactionId.slice(0, 8)}</span>}
|
||||
</div>
|
||||
{decision.pendingAction?.previewMarkdown && (
|
||||
<MarkdownBody className="mt-2 text-(length:--text-micro) leading-5 text-foreground/75 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
{decision.pendingAction.previewMarkdown}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptToolCard({
|
||||
block,
|
||||
density,
|
||||
decision,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "tool" }>;
|
||||
density: TranscriptDensity;
|
||||
decision: ToolRunDecision | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(block.status === "error");
|
||||
const [open, setOpen] = useState(block.status === "error" || Boolean(decision?.pendingAction || decision?.denialReason));
|
||||
const compact = density === "compact";
|
||||
const parsedResult = parseStructuredToolResult(block.result);
|
||||
const statusLabel =
|
||||
|
|
@ -784,10 +940,12 @@ function TranscriptToolCard({
|
|||
<span className={cn("text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)", statusTone)}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<ToolDecisionBadge decision={decision} />
|
||||
</div>
|
||||
<div className={cn("mt-1 break-words text-foreground/80", compact ? "text-xs" : "text-sm")}>
|
||||
{summary}
|
||||
</div>
|
||||
<ToolDecisionInlineDetail decision={decision} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -822,6 +980,7 @@ function TranscriptToolCard({
|
|||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<ToolDecisionDetails decision={decision} compact={compact} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -837,14 +996,22 @@ function hasSelectedText() {
|
|||
function TranscriptCommandGroup({
|
||||
block,
|
||||
density,
|
||||
toolDecisionMaps,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "command_group" }>;
|
||||
density: TranscriptDensity;
|
||||
toolDecisionMaps: ToolDecisionMaps;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
|
||||
const latestItem = block.items[block.items.length - 1] ?? null;
|
||||
const highlightedDecision =
|
||||
block.items
|
||||
.map((item) => findToolDecision(toolDecisionMaps, item))
|
||||
.find((decision) => decision?.pendingAction || decision?.denialReason)
|
||||
?? block.items.map((item) => findToolDecision(toolDecisionMaps, item)).find(Boolean)
|
||||
?? null;
|
||||
const hasError = block.items.some((item) => item.status === "error");
|
||||
const isRunning = Boolean(runningItem);
|
||||
const showExpandedErrorState = open && hasError;
|
||||
|
|
@ -898,6 +1065,12 @@ function TranscriptCommandGroup({
|
|||
<div className="text-(length:--text-micro) font-semibold uppercase leading-none tracking-(--tracking-label) text-muted-foreground/70">
|
||||
{title}
|
||||
</div>
|
||||
{highlightedDecision && (
|
||||
<div className="mt-1">
|
||||
<ToolDecisionBadge decision={highlightedDecision} />
|
||||
<ToolDecisionInlineDetail decision={highlightedDecision} />
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
|
||||
{subtitle}
|
||||
|
|
@ -951,6 +1124,7 @@ function TranscriptCommandGroup({
|
|||
{formatToolPayload(item.result)}
|
||||
</pre>
|
||||
)}
|
||||
<ToolDecisionDetails decision={findToolDecision(toolDecisionMaps, item)} compact={compact} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -962,15 +1136,23 @@ function TranscriptCommandGroup({
|
|||
function TranscriptToolGroup({
|
||||
block,
|
||||
density,
|
||||
toolDecisionMaps,
|
||||
}: {
|
||||
block: Extract<TranscriptBlock, { type: "tool_group" }>;
|
||||
density: TranscriptDensity;
|
||||
toolDecisionMaps: ToolDecisionMaps;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const compact = density === "compact";
|
||||
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
|
||||
const hasError = block.items.some((item) => item.status === "error");
|
||||
const isRunning = Boolean(runningItem);
|
||||
const highlightedDecision =
|
||||
block.items
|
||||
.map((item) => findToolDecision(toolDecisionMaps, item))
|
||||
.find((decision) => decision?.pendingAction || decision?.denialReason)
|
||||
?? block.items.map((item) => findToolDecision(toolDecisionMaps, item)).find(Boolean)
|
||||
?? null;
|
||||
const uniqueNames = [...new Set(block.items.map((item) => item.name))];
|
||||
const toolLabel =
|
||||
uniqueNames.length === 1
|
||||
|
|
@ -1024,6 +1206,12 @@ function TranscriptToolGroup({
|
|||
<div className={cn("font-semibold uppercase leading-none tracking-(--tracking-label)", compact ? "text-(length:--text-nano)" : "text-(length:--text-micro)", "text-muted-foreground/70")}>
|
||||
{title}
|
||||
</div>
|
||||
{highlightedDecision && (
|
||||
<div className="mt-1">
|
||||
<ToolDecisionBadge decision={highlightedDecision} />
|
||||
<ToolDecisionInlineDetail decision={highlightedDecision} />
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
|
||||
{subtitle}
|
||||
|
|
@ -1065,6 +1253,7 @@ function TranscriptToolGroup({
|
|||
)}>
|
||||
{item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"}
|
||||
</span>
|
||||
<ToolDecisionBadge decision={findToolDecision(toolDecisionMaps, item)} />
|
||||
</div>
|
||||
<div className={cn("grid gap-2 pl-7", compact ? "grid-cols-1" : "lg:grid-cols-2")}>
|
||||
<div>
|
||||
|
|
@ -1085,6 +1274,9 @@ function TranscriptToolGroup({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-7">
|
||||
<ToolDecisionDetails decision={findToolDecision(toolDecisionMaps, item)} compact={compact} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1501,6 +1693,7 @@ function RawTranscriptView({
|
|||
|
||||
export function RunTranscriptView({
|
||||
entries,
|
||||
toolDecisions,
|
||||
mode = "nice",
|
||||
density = "comfortable",
|
||||
limit,
|
||||
|
|
@ -1511,6 +1704,7 @@ export function RunTranscriptView({
|
|||
thinkingClassName,
|
||||
externalReferences,
|
||||
}: RunTranscriptViewProps) {
|
||||
const toolDecisionMaps = useMemo(() => buildToolDecisionMaps(toolDecisions), [toolDecisions]);
|
||||
const blocks = useMemo(
|
||||
() => (mode === "raw" ? [] : normalizeTranscript(entries, streaming)),
|
||||
[entries, mode, streaming],
|
||||
|
|
@ -1556,9 +1750,19 @@ export function RunTranscriptView({
|
|||
externalReferences={externalReferences}
|
||||
/>
|
||||
)}
|
||||
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
|
||||
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
|
||||
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
|
||||
{block.type === "tool" && (
|
||||
<TranscriptToolCard
|
||||
block={block}
|
||||
density={density}
|
||||
decision={findToolDecision(toolDecisionMaps, block)}
|
||||
/>
|
||||
)}
|
||||
{block.type === "command_group" && (
|
||||
<TranscriptCommandGroup block={block} density={density} toolDecisionMaps={toolDecisionMaps} />
|
||||
)}
|
||||
{block.type === "tool_group" && (
|
||||
<TranscriptToolGroup block={block} density={density} toolDecisionMaps={toolDecisionMaps} />
|
||||
)}
|
||||
{block.type === "diff_group" && <TranscriptDiffGroup block={block} density={density} />}
|
||||
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
|
||||
{block.type === "system_group" && <TranscriptSystemGroup block={block} density={density} />}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { ToggleSwitch } from "./toggle-switch";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function click(el: Element) {
|
||||
flushSync(() => {
|
||||
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
it("fires onCheckedChange when clicked", () => {
|
||||
const onCheckedChange = vi.fn();
|
||||
flushSync(() => {
|
||||
root.render(<ToggleSwitch checked={false} onCheckedChange={onCheckedChange} />);
|
||||
});
|
||||
click(container.querySelector('[role="switch"]')!);
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
// Regression for PAP-12392: a caller-supplied onClick (e.g. stopPropagation in
|
||||
// a clickable table row) must NOT clobber the internal toggle — both run.
|
||||
it("runs the caller's onClick and still toggles", () => {
|
||||
const onClick = vi.fn();
|
||||
const onCheckedChange = vi.fn();
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<ToggleSwitch
|
||||
checked
|
||||
onClick={onClick}
|
||||
onCheckedChange={onCheckedChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
click(container.querySelector('[role="switch"]')!);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
|
@ -21,7 +21,7 @@ export const ToggleSwitch = React.forwardRef<
|
|||
ToggleSwitchProps
|
||||
>(
|
||||
(
|
||||
{ checked, onCheckedChange, size = "default", className, disabled, ...props },
|
||||
{ checked, onCheckedChange, size = "default", className, disabled, onClick, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const isLg = size === "lg";
|
||||
|
|
@ -44,8 +44,14 @@ export const ToggleSwitch = React.forwardRef<
|
|||
: "border-transparent bg-input/90",
|
||||
className,
|
||||
)}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
{...props}
|
||||
// Run the caller's onClick first (e.g. stopPropagation in a clickable
|
||||
// row) but always fire the toggle — spreading `props` must not clobber
|
||||
// the state change, so this handler stays after the spread.
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
onCheckedChange(!checked);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
AskUserQuestionsInteraction,
|
||||
RequestCheckboxConfirmationInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
RequestConfirmationToolActionPayload,
|
||||
RequestItemVerdictsInteraction,
|
||||
SuggestTasksInteraction,
|
||||
} from "../lib/issue-thread-interactions";
|
||||
|
|
@ -507,6 +508,181 @@ export const rejectedNoReasonRequestConfirmationInteraction = createRequestConfi
|
|||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP tool-approval fixtures (PAP-13745). A `request_confirmation` carrying a
|
||||
// `payload.toolAction` block renders as the dedicated tool-approval card. The
|
||||
// pending fixtures use a live `expiresAt` so the countdown renders meaningfully
|
||||
// in Storybook; the destructive one sits inside the ~5-min urgent window.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function expiresInMinutes(minutes: number): string {
|
||||
return new Date(Date.now() + minutes * 60000).toISOString();
|
||||
}
|
||||
|
||||
const sheetsToolActionBase: RequestConfirmationToolActionPayload = {
|
||||
version: 1,
|
||||
actionRequestId: "aaaaaaa1-1111-4111-8111-1111111111a1",
|
||||
invocationId: "bbbbbbb2-2222-4222-8222-2222222222b2",
|
||||
toolName: "google_sheets.append_row",
|
||||
toolDisplayName: "Append row to spreadsheet",
|
||||
connectionId: "ccccccc3-3333-4333-8333-3333333333c3",
|
||||
applicationId: "ddddddd4-4444-4444-8444-4444444444d4",
|
||||
appDisplayName: "Google Sheets",
|
||||
risk: "write" as const,
|
||||
previewMarkdown:
|
||||
"Add **1 row** to the **Q3 Growth Leads** sheet:\n\n"
|
||||
+ "| Column | Value |\n| --- | --- |\n| Name | Priya Anand |\n| Company | Northwind |\n| Stage | Qualified |\n| Owner | growth-bot |",
|
||||
argumentsSummaryJson: JSON.stringify(
|
||||
{
|
||||
spreadsheetId: "1AbC…xyz",
|
||||
range: "Leads!A2:D2",
|
||||
values: [["Priya Anand", "Northwind", "Qualified", "growth-bot"]],
|
||||
apiKey: "[redacted]",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
argumentsHash: "sha256:9f2c1a7be4d0c8a3",
|
||||
expiresAt: expiresInMinutes(42),
|
||||
};
|
||||
|
||||
function createToolActionConfirmationInteraction(
|
||||
overrides: Partial<RequestConfirmationInteraction> & {
|
||||
toolAction?: Partial<RequestConfirmationToolActionPayload>;
|
||||
},
|
||||
): RequestConfirmationInteraction {
|
||||
const { toolAction: toolActionOverrides, payload, ...rest } = overrides;
|
||||
return createRequestConfirmationInteraction({
|
||||
id: "interaction-tool-action-default",
|
||||
title: undefined,
|
||||
summary: undefined,
|
||||
createdByAgentId: "agent-codex",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve running this tool call?",
|
||||
acceptLabel: "Approve & run",
|
||||
rejectLabel: "Decline",
|
||||
...payload,
|
||||
toolAction: { ...sheetsToolActionBase, ...toolActionOverrides },
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
}
|
||||
|
||||
export const pendingToolActionWriteInteraction = createToolActionConfirmationInteraction({
|
||||
id: "interaction-tool-action-pending-write",
|
||||
});
|
||||
|
||||
export const pendingToolActionDestructiveInteraction = createToolActionConfirmationInteraction({
|
||||
id: "interaction-tool-action-pending-destructive",
|
||||
toolAction: {
|
||||
actionRequestId: "aaaaaaa5-5555-4555-8555-5555555555a5",
|
||||
invocationId: "bbbbbbb6-6666-4666-8666-6666666666b6",
|
||||
toolName: "google_sheets.delete_rows",
|
||||
toolDisplayName: "Delete rows from spreadsheet",
|
||||
risk: "destructive",
|
||||
previewMarkdown:
|
||||
"**Permanently delete 12 rows** (rows 30–41) from the **Q3 Growth Leads** sheet. "
|
||||
+ "This cannot be undone.",
|
||||
argumentsSummaryJson: JSON.stringify(
|
||||
{ spreadsheetId: "1AbC…xyz", range: "Leads!A30:D41", rowCount: 12 },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
argumentsHash: "sha256:1c4e77aa90b3f2d1",
|
||||
expiresAt: expiresInMinutes(4),
|
||||
},
|
||||
});
|
||||
|
||||
export const runningToolActionInteraction = createToolActionConfirmationInteraction({
|
||||
id: "interaction-tool-action-running",
|
||||
status: "accepted",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:02:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:02:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
toolAction: {
|
||||
version: 1,
|
||||
status: "approved",
|
||||
updatedAt: "2026-04-20T15:02:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const executedToolActionInteraction = createToolActionConfirmationInteraction({
|
||||
id: "interaction-tool-action-executed",
|
||||
status: "accepted",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:02:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:02:12.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
toolAction: {
|
||||
version: 1,
|
||||
status: "executed",
|
||||
resultSummary: "Row 42 added to “Q3 Growth Leads”.",
|
||||
resultHref: "https://docs.google.com/spreadsheets/d/1AbCxyz/edit#gid=0&range=A42",
|
||||
updatedAt: "2026-04-20T15:02:12.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const failedToolActionInteraction = createToolActionConfirmationInteraction({
|
||||
id: "interaction-tool-action-failed",
|
||||
status: "accepted",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:02:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:02:09.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "accepted",
|
||||
toolAction: {
|
||||
version: 1,
|
||||
status: "failed",
|
||||
errorCode: "insufficient_permission",
|
||||
errorMessage:
|
||||
"The caller does not have permission to edit this spreadsheet (Google API 403). "
|
||||
+ "Ask the sheet owner to grant edit access to the connected account.",
|
||||
updatedAt: "2026-04-20T15:02:09.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const declinedToolActionInteraction = createToolActionConfirmationInteraction({
|
||||
id: "interaction-tool-action-declined",
|
||||
status: "rejected",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:01:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:01:00.000Z"),
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: "We don't add leads to this sheet manually — use the CRM sync instead.",
|
||||
},
|
||||
});
|
||||
|
||||
export const expiredToolActionInteraction = createToolActionConfirmationInteraction({
|
||||
id: "interaction-tool-action-expired",
|
||||
status: "expired",
|
||||
updatedAt: new Date("2026-04-20T16:00:00.000Z"),
|
||||
resolvedAt: new Date("2026-04-20T16:00:00.000Z"),
|
||||
toolAction: {
|
||||
expiresAt: "2026-04-20T16:00:00.000Z",
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
toolAction: {
|
||||
version: 1,
|
||||
status: "expired",
|
||||
updatedAt: "2026-04-20T16:00:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const commentExpiredRequestConfirmationInteraction = createRequestConfirmationInteraction({
|
||||
id: "interaction-confirmation-expired-comment",
|
||||
status: "expired",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
export function useAppsEnabled() {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
|
||||
return {
|
||||
enabled: query.data?.enableApps === true,
|
||||
loaded: query.isFetched,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { useContext } from "react";
|
||||
import { QueryClient, QueryClientContext, useQuery } from "@tanstack/react-query";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
/**
|
||||
* Fallback client for hosts that render gated components without a
|
||||
* QueryClientProvider (isolated unit-test mounts). The query is disabled in
|
||||
* that case, so this client never fetches — it only keeps `useQuery` from
|
||||
* throwing. Created lazily so app code never pays for it.
|
||||
*/
|
||||
let detachedClient: QueryClient | null = null;
|
||||
function getDetachedClient(): QueryClient {
|
||||
detachedClient ??= new QueryClient();
|
||||
return detachedClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoke Lab experimental flag (PAP-13343 / S2, plan §D3).
|
||||
*
|
||||
* Wraps the board-readable experimental-settings GET (same query the sidebar
|
||||
* and `InstanceExperimentalSettings` use) so the Smoke Lab tab, its sidebar
|
||||
* nav item, and the dashboard card share one gate. `enabled` stays false while
|
||||
* the query is in flight (no flash of gated UI, matching the sidebar's
|
||||
* `showWorkspacesLink` pattern); `loaded` lets route gates avoid redirecting
|
||||
* before the flag value is known.
|
||||
*
|
||||
* Renders without a QueryClientProvider resolve to the flag-off default
|
||||
* (`{ enabled: false, loaded: true }`) instead of throwing.
|
||||
*/
|
||||
export function useSmokeLabEnabled(): { enabled: boolean; loaded: boolean } {
|
||||
const contextClient = useContext(QueryClientContext);
|
||||
const { data, isFetched } = useQuery(
|
||||
{
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
enabled: contextClient != null,
|
||||
},
|
||||
contextClient ?? getDetachedClient(),
|
||||
);
|
||||
if (!contextClient) {
|
||||
return { enabled: false, loaded: true };
|
||||
}
|
||||
return { enabled: data?.enableSmokeLab === true, loaded: isFetched };
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* Prosumer copy for the Apps surface (PAP-10856).
|
||||
*
|
||||
* The P1a gallery manifest carries developer-flavoured taglines and credential
|
||||
* labels (e.g. "Connect Zapier-hosted MCP actions", "Zapier MCP token"). Those
|
||||
* strings would fail the vocabulary gate from PAP-10827 — no "MCP", "server",
|
||||
* "profile", "policy", "gateway", or "transport" anywhere on this surface. So
|
||||
* the UI never renders the raw manifest copy directly: it looks up plain copy
|
||||
* here, and `sanitizeProsumerCopy` is a final backstop for any free-text we do
|
||||
* surface (app names, fallback taglines).
|
||||
*/
|
||||
|
||||
/** Words that must never appear in prosumer-facing copy on the Apps surface. */
|
||||
const BANNED_WORDS = [
|
||||
"mcp",
|
||||
"server",
|
||||
"profile",
|
||||
"policy",
|
||||
"gateway",
|
||||
"transport",
|
||||
"stdio",
|
||||
"endpoint",
|
||||
];
|
||||
|
||||
const BANNED_RE = new RegExp(`\\b(${BANNED_WORDS.join("|")})s?\\b`, "gi");
|
||||
|
||||
/**
|
||||
* Strip banned vocabulary from a free-text string as a last-resort backstop.
|
||||
* Prefer curated copy below; this only protects against manifest text we can't
|
||||
* fully control (e.g. a newly added gallery app with no curated entry yet).
|
||||
*/
|
||||
export function sanitizeProsumerCopy(text: string): string {
|
||||
return text
|
||||
.replace(BANNED_RE, "")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.replace(/\s+([.,])/g, "$1")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export interface AppCopy {
|
||||
/** Two short lines for the gallery card (M2). */
|
||||
tagline: string;
|
||||
/** Single line for the connect step header (M3b). */
|
||||
short: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated prosumer copy keyed by gallery key. Taken from the M-series wires
|
||||
* (https://happy-grove-jzyc.here.now/). Apps without an entry fall back to a
|
||||
* generic, gate-safe line.
|
||||
*/
|
||||
const APP_COPY: Record<string, AppCopy> = {
|
||||
zapier: {
|
||||
tagline: "Reach 9,000+ apps your team already uses.",
|
||||
short: "Reach 9,000+ apps from your agents.",
|
||||
},
|
||||
github: {
|
||||
tagline: "Read code and pull requests, comment on issues.",
|
||||
short: "Read code and pull requests, comment on issues.",
|
||||
},
|
||||
slack: {
|
||||
tagline: "Send and read messages in your team's channels.",
|
||||
short: "Send and read messages in your channels.",
|
||||
},
|
||||
notion: {
|
||||
tagline: "Read and update pages in your workspace.",
|
||||
short: "Read and update pages in your workspace.",
|
||||
},
|
||||
linear: {
|
||||
tagline: "Create, update and read tickets.",
|
||||
short: "Create, update and read tickets.",
|
||||
},
|
||||
"google-sheets": {
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
short: "Share each sheet with the robot email, then paste the links.",
|
||||
},
|
||||
gmail: {
|
||||
tagline: "Read mail and send drafts for your review.",
|
||||
short: "Read mail and send drafts for your review.",
|
||||
},
|
||||
hubspot: {
|
||||
tagline: "Look up contacts and update deal stages.",
|
||||
short: "Look up contacts and update deal stages.",
|
||||
},
|
||||
intercom: {
|
||||
tagline: "Read and reply to customer conversations.",
|
||||
short: "Read and reply to customer conversations.",
|
||||
},
|
||||
figma: {
|
||||
tagline: "Read files and post comments on frames.",
|
||||
short: "Read files and post comments on frames.",
|
||||
},
|
||||
stripe: {
|
||||
tagline: "Read customers, invoices, and payouts.",
|
||||
short: "Read customers, invoices, and payouts.",
|
||||
},
|
||||
context7: {
|
||||
tagline: "Look up up-to-date docs for your libraries.",
|
||||
short: "Look up up-to-date docs for your libraries.",
|
||||
},
|
||||
};
|
||||
|
||||
const GENERIC: AppCopy = {
|
||||
tagline: "Give your agents access to this app.",
|
||||
short: "Give your agents access to this app.",
|
||||
};
|
||||
|
||||
/** Curated, gate-safe copy for a gallery app. */
|
||||
export function appCopyFor(key: string, fallbackTagline?: string | null): AppCopy {
|
||||
const curated = APP_COPY[key];
|
||||
if (curated) return curated;
|
||||
if (fallbackTagline) {
|
||||
const cleaned = sanitizeProsumerCopy(fallbackTagline);
|
||||
if (cleaned) return { tagline: cleaned, short: cleaned };
|
||||
}
|
||||
return GENERIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for a single credential field on the key-paste step (M3b). The raw
|
||||
* manifest label can contain banned vocab ("Zapier MCP token"), so for the
|
||||
* common single-field case we present "Your {App} key" per the wires; multi-
|
||||
* field apps fall back to a sanitized version of the manifest label.
|
||||
*/
|
||||
export function credentialFieldLabel(
|
||||
appName: string,
|
||||
rawLabel: string,
|
||||
fieldCount: number,
|
||||
): string {
|
||||
if (fieldCount <= 1) return `Your ${appName} key`;
|
||||
const cleaned = sanitizeProsumerCopy(rawLabel);
|
||||
return cleaned || `Your ${appName} key`;
|
||||
}
|
||||
|
|
@ -72,6 +72,16 @@ describe("company routes", () => {
|
|||
expect(toCompanyRelativePath("/PAP/artifacts")).toBe("/artifacts");
|
||||
});
|
||||
|
||||
it("treats /tools routes as board routes that need a company prefix", () => {
|
||||
expect(isBoardPathWithoutPrefix("/tools")).toBe(true);
|
||||
expect(isBoardPathWithoutPrefix("/tools/runtime")).toBe(true);
|
||||
expect(extractCompanyPrefixFromPath("/tools")).toBeNull();
|
||||
expect(applyCompanyPrefix("/tools", "PAP")).toBe("/PAP/tools");
|
||||
expect(applyCompanyPrefix("/tools/runtime", "PAP")).toBe("/PAP/tools/runtime");
|
||||
expect(applyCompanyPrefix("/PAP/tools/runtime", "PAP")).toBe("/PAP/tools/runtime");
|
||||
expect(toCompanyRelativePath("/PAP/tools/runtime")).toBe("/tools/runtime");
|
||||
});
|
||||
|
||||
it("recognizes Decisions without retaining the legacy attention route", () => {
|
||||
expect(isBoardPathWithoutPrefix("/decisions")).toBe(true);
|
||||
expect(extractCompanyPrefixFromPath("/decisions")).toBeNull();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||
"teams-catalog",
|
||||
"org",
|
||||
"agents",
|
||||
"apps",
|
||||
"projects",
|
||||
"workspaces",
|
||||
"execution-workspaces",
|
||||
|
|
@ -13,6 +14,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||
"routines",
|
||||
"goals",
|
||||
"artifacts",
|
||||
"tools",
|
||||
"approvals",
|
||||
"costs",
|
||||
"usage",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export type {
|
|||
RequestConfirmationPayload,
|
||||
RequestConfirmationResult,
|
||||
RequestConfirmationTarget,
|
||||
RequestConfirmationToolActionPayload,
|
||||
RequestConfirmationToolActionResult,
|
||||
RequestItemVerdictsInteraction,
|
||||
RequestItemVerdictsItem,
|
||||
RequestItemVerdictsPayload,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,58 @@ export const queryKeys = {
|
|||
detail: (id: string) => ["companies", id] as const,
|
||||
stats: ["companies", "stats"] as const,
|
||||
},
|
||||
apps: {
|
||||
gallery: (companyId: string) => ["apps", companyId, "gallery"] as const,
|
||||
attention: (companyId: string) => ["apps", companyId, "attention"] as const,
|
||||
},
|
||||
tools: {
|
||||
applications: (companyId: string) => ["tools", companyId, "applications"] as const,
|
||||
connections: (companyId: string) => ["tools", companyId, "connections"] as const,
|
||||
connection: (connectionId: string) => ["tools", "connection", connectionId] as const,
|
||||
connectionInstalls: (connectionId: string) =>
|
||||
["tools", "connection", connectionId, "installs"] as const,
|
||||
catalog: (connectionId: string) => ["tools", "connection", connectionId, "catalog"] as const,
|
||||
connectionActivity: (connectionId: string) =>
|
||||
["tools", "connection", connectionId, "activity"] as const,
|
||||
testAgents: (connectionId: string) =>
|
||||
["tools", "connection", connectionId, "test-agents"] as const,
|
||||
testCallStatus: (connectionId: string, actionRequestId: string) =>
|
||||
["tools", "connection", connectionId, "test-calls", actionRequestId] as const,
|
||||
actionRequests: (companyId: string, status: string) =>
|
||||
["tools", companyId, "action-requests", status] as const,
|
||||
gateways: (companyId: string) => ["tools", "gateways", companyId] as const,
|
||||
profiles: (companyId: string) => ["tools", companyId, "profiles"] as const,
|
||||
profileNewTools: (profileId: string) => ["tools", "profiles", profileId, "new-tools"] as const,
|
||||
effectiveProfilesForAgent: (companyId: string, agentId: string) =>
|
||||
["tools", companyId, "profiles", "effective", "agent", agentId] as const,
|
||||
stdioTemplates: (companyId: string) => ["tools", companyId, "stdio-templates"] as const,
|
||||
runtimeSlots: (companyId: string) => ["tools", companyId, "runtime-slots"] as const,
|
||||
runtimeHealth: (companyId: string) => ["tools", companyId, "runtime-health"] as const,
|
||||
runDecisions: (companyId: string, runId: string) => ["tools", companyId, "runs", runId, "decisions"] as const,
|
||||
liveRuntimeSlots: (companyId: string) => ["tools", companyId, "runtime-slots", "live"] as const,
|
||||
policies: (companyId: string) => ["tools", companyId, "policies"] as const,
|
||||
trustRules: (companyId: string) => ["tools", companyId, "trust-rules"] as const,
|
||||
audit: (companyId: string, limit: number) => ["tools", companyId, "audit", limit] as const,
|
||||
activity: (
|
||||
companyId: string,
|
||||
filters: { app?: string; agent?: string; outcome?: string; window?: string; search?: string },
|
||||
) =>
|
||||
[
|
||||
"tools",
|
||||
companyId,
|
||||
"activity",
|
||||
filters.app ?? "__all",
|
||||
filters.agent ?? "__all",
|
||||
filters.outcome ?? "__all",
|
||||
filters.window ?? "24h",
|
||||
filters.search ?? "",
|
||||
] as const,
|
||||
},
|
||||
smokeLab: {
|
||||
services: (companyId: string) => ["smoke-lab", companyId, "services"] as const,
|
||||
runs: (companyId: string) => ["smoke-lab", companyId, "runs"] as const,
|
||||
run: (companyId: string, runId: string) => ["smoke-lab", companyId, "runs", runId] as const,
|
||||
},
|
||||
companySkills: {
|
||||
list: (companyId: string) => ["company-skills", companyId] as const,
|
||||
listRecent: (companyId: string) =>
|
||||
|
|
@ -322,7 +374,7 @@ export const queryKeys = {
|
|||
detail: (pluginId: string) => ["plugins", pluginId] as const,
|
||||
health: (pluginId: string) => ["plugins", pluginId, "health"] as const,
|
||||
uiContributions: ["plugins", "ui-contributions"] as const,
|
||||
config: (pluginId: string) => ["plugins", pluginId, "config"] as const,
|
||||
config: (pluginId: string, companyId: string) => ["plugins", pluginId, "companies", companyId, "config"] as const,
|
||||
localFolders: (pluginId: string, companyId: string) =>
|
||||
["plugins", pluginId, "companies", companyId, "local-folders"] as const,
|
||||
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { redactUrlSecrets } from "./redact-url-secrets";
|
||||
|
||||
describe("redactUrlSecrets", () => {
|
||||
it("redacts credential-like query parameters while preserving useful URL context", () => {
|
||||
expect(
|
||||
redactUrlSecrets("https://mcp.zapier.com/api/v1/connect?token=zapier-secret®ion=us"),
|
||||
).toBe("https://mcp.zapier.com/api/v1/connect?token=REDACTED®ion=us");
|
||||
});
|
||||
|
||||
it("redacts common credential names case-insensitively", () => {
|
||||
expect(
|
||||
redactUrlSecrets("https://example.test/mcp?API_KEY=secret&access-token=other&mode=read"),
|
||||
).toBe("https://example.test/mcp?API_KEY=REDACTED&access-token=REDACTED&mode=read");
|
||||
});
|
||||
|
||||
it("redacts URL user info and credential-like hash parameters", () => {
|
||||
expect(
|
||||
redactUrlSecrets("mcp+https://user:password@example.test/connect#access_token=secret&state=safe"),
|
||||
).toBe("mcp+https://REDACTED@example.test/connect#access_token=REDACTED&state=REDACTED");
|
||||
});
|
||||
|
||||
it("redacts OAuth callback parameters", () => {
|
||||
expect(
|
||||
redactUrlSecrets("https://example.test/oauth/callback?code=secret&state=opaque&nonce=once"),
|
||||
).toBe("https://example.test/oauth/callback?code=REDACTED&state=REDACTED&nonce=REDACTED");
|
||||
});
|
||||
|
||||
it("falls back to masking secret assignments in non-standard URL text", () => {
|
||||
expect(redactUrlSecrets("connect to host?token=secret value")).toBe(
|
||||
"connect to host?token=REDACTED value",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves ordinary URLs unchanged", () => {
|
||||
expect(redactUrlSecrets("https://example.test/mcp?workspace=paperclip&page=2")).toBe(
|
||||
"https://example.test/mcp?workspace=paperclip&page=2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
const REDACTED_URL_VALUE = "REDACTED";
|
||||
|
||||
const SENSITIVE_URL_FIELD_PATTERN =
|
||||
String.raw`(?:code|state|nonce|key|[A-Za-z0-9_-]*(?:api[-_]?key|access[-_]?token|auth(?:[-_]?token)?|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*)`;
|
||||
const SENSITIVE_URL_FIELD_RE = new RegExp(`^${SENSITIVE_URL_FIELD_PATTERN}$`, "i");
|
||||
|
||||
function redactSearchParams(params: URLSearchParams): boolean {
|
||||
let changed = false;
|
||||
for (const key of [...params.keys()]) {
|
||||
if (!SENSITIVE_URL_FIELD_RE.test(key)) continue;
|
||||
params.set(key, REDACTED_URL_VALUE);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function redactUrlHash(url: URL) {
|
||||
const hash = url.hash.slice(1);
|
||||
if (!hash.includes("=")) return;
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
if (redactSearchParams(params)) url.hash = params.toString();
|
||||
}
|
||||
|
||||
function redactUrlUserInfo(url: URL) {
|
||||
if (!url.username && !url.password) return;
|
||||
url.username = REDACTED_URL_VALUE;
|
||||
url.password = "";
|
||||
}
|
||||
|
||||
function redactUrlWithParser(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
redactUrlUserInfo(url);
|
||||
redactSearchParams(url.searchParams);
|
||||
redactUrlHash(url);
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function redactUrlWithFallback(value: string): string {
|
||||
const secretAssignment = new RegExp(
|
||||
`([?&#;]\\s*${SENSITIVE_URL_FIELD_PATTERN}\\s*=)[^&#;\\s]*`,
|
||||
"gi",
|
||||
);
|
||||
return value.replace(secretAssignment, `$1${REDACTED_URL_VALUE}`);
|
||||
}
|
||||
|
||||
export function redactUrlSecrets(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return value;
|
||||
return redactUrlWithParser(trimmed) ?? redactUrlWithFallback(value);
|
||||
}
|
||||
|
|
@ -118,6 +118,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";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import type { ToolConnectionInstall } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Shared "Permitted vs Installed" helpers (Phase 3b, PAP-13618).
|
||||
*
|
||||
* The one mental model: `installed ⊆ permitted`. **Access** = who may use an
|
||||
* app (zero context cost). **Installed** = whose harness actually carries the
|
||||
* app's tools on every run (a real per-run context cost). These helpers derive
|
||||
* the install state from a connection's `installs` rows and centralize the
|
||||
* copy so every surface (app detail, agent Tools tab, connect flow) speaks the
|
||||
* same language.
|
||||
*/
|
||||
|
||||
export interface InstallState {
|
||||
/** A `company` install row: the app is installed on every agent. */
|
||||
onAll: boolean;
|
||||
/** Explicit per-agent install rows. */
|
||||
agentIds: Set<string>;
|
||||
}
|
||||
|
||||
export function installStateFrom(installs: ToolConnectionInstall[] | undefined): InstallState {
|
||||
const agentIds = new Set<string>();
|
||||
let onAll = false;
|
||||
for (const install of installs ?? []) {
|
||||
if (install.targetType === "company") onAll = true;
|
||||
else if (install.targetType === "agent") agentIds.add(install.targetId);
|
||||
}
|
||||
return { onAll, agentIds };
|
||||
}
|
||||
|
||||
/** True when this connection's tools load into the given agent's context. */
|
||||
export function isAgentInstalled(state: InstallState, agentId: string): boolean {
|
||||
return state.onAll || state.agentIds.has(agentId);
|
||||
}
|
||||
|
||||
/** Serialize an install state back into the PUT payload the API expects. */
|
||||
export function installPayload(
|
||||
companyId: string,
|
||||
state: InstallState,
|
||||
): Array<{ targetType: "company" | "agent"; targetId: string }> {
|
||||
if (state.onAll) return [{ targetType: "company", targetId: companyId }];
|
||||
return [...state.agentIds].map((targetId) => ({ targetType: "agent" as const, targetId }));
|
||||
}
|
||||
|
||||
// --- Copy (verbatim from the PAP-13615 wireframe spec) ---
|
||||
|
||||
export function installInfoNotice(appName: string): string {
|
||||
return `Installing adds ${appName}'s tools to the agent's context on every run — install only where it will actually be used.`;
|
||||
}
|
||||
|
||||
export const INSTALL_ALL_WARNING =
|
||||
"Adds context cost to every run of every agent — a deliberate choice. New agents you add later are installed automatically.";
|
||||
|
||||
export function autoExtendNotice(agentName: string): string {
|
||||
return `Installing on ${agentName} will also grant access. A tool can't be installed on an agent that isn't allowed to use it, so we'll add ${agentName} to who can use it. This is logged.`;
|
||||
}
|
||||
|
||||
export const INSTALLED_HINT =
|
||||
"Has access — tick to load its tools into this agent's context.";
|
||||
|
|
@ -96,10 +96,14 @@ export function PluginSettings() {
|
|||
const configSchema = plugin?.manifestJson?.instanceConfigSchema as JsonSchemaNode | undefined;
|
||||
const hasConfigSchema = configSchema && configSchema.properties && Object.keys(configSchema.properties).length > 0;
|
||||
|
||||
const configQueryKey = pluginId && selectedCompanyId
|
||||
? queryKeys.plugins.config(pluginId, selectedCompanyId)
|
||||
: ["plugins", pluginId ?? "__missing_plugin__", "companies", "__missing_company__", "config"] as const;
|
||||
|
||||
const { data: configData, isLoading: configLoading } = useQuery({
|
||||
queryKey: queryKeys.plugins.config(pluginId!),
|
||||
queryFn: () => pluginsApi.getConfig(pluginId!),
|
||||
enabled: !!pluginId && !!hasConfigSchema,
|
||||
queryKey: configQueryKey,
|
||||
queryFn: () => pluginsApi.getConfig(pluginId!, selectedCompanyId!),
|
||||
enabled: !!pluginId && !!hasConfigSchema && !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { slots } = usePluginSlots({
|
||||
|
|
@ -246,6 +250,7 @@ export function PluginSettings() {
|
|||
) : hasConfigSchema ? (
|
||||
<PluginConfigForm
|
||||
pluginId={pluginId!}
|
||||
companyId={selectedCompanyId}
|
||||
schema={configSchema!}
|
||||
initialValues={configData?.configJson}
|
||||
isLoading={configLoading}
|
||||
|
|
@ -751,7 +756,7 @@ function PluginLocalFolderRow({ pluginId, companyId, declaration, status }: Plug
|
|||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending || !isDirty}
|
||||
disabled={saveMutation.isPending || !isDirty || !companyId}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
|
@ -920,6 +925,7 @@ function isLikelyAbsolutePath(pathValue: string) {
|
|||
|
||||
interface PluginConfigFormProps {
|
||||
pluginId: string;
|
||||
companyId: string | null;
|
||||
schema: JsonSchemaNode;
|
||||
initialValues?: Record<string, unknown>;
|
||||
isLoading?: boolean;
|
||||
|
|
@ -936,7 +942,7 @@ interface PluginConfigFormProps {
|
|||
* Separated from PluginSettings to isolate re-render scope — only the form
|
||||
* re-renders on field changes, not the entire page.
|
||||
*/
|
||||
function PluginConfigForm({ pluginId, schema, initialValues, isLoading, pluginStatus, supportsConfigTest }: PluginConfigFormProps) {
|
||||
function PluginConfigForm({ pluginId, companyId, schema, initialValues, isLoading, pluginStatus, supportsConfigTest }: PluginConfigFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Form values: start with saved values, fall back to schema defaults
|
||||
|
|
@ -949,6 +955,11 @@ function PluginConfigForm({ pluginId, schema, initialValues, isLoading, pluginSt
|
|||
// don't overwrite in-progress user edits if the query refetches (e.g. on
|
||||
// window focus).
|
||||
const hasHydratedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
hasHydratedRef.current = false;
|
||||
setValues(getDefaultValues(schema));
|
||||
}, [companyId, pluginId, schema]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues && !hasHydratedRef.current) {
|
||||
hasHydratedRef.current = true;
|
||||
|
|
@ -971,12 +982,16 @@ function PluginConfigForm({ pluginId, schema, initialValues, isLoading, pluginSt
|
|||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (configJson: Record<string, unknown>) =>
|
||||
pluginsApi.saveConfig(pluginId, configJson),
|
||||
mutationFn: (configJson: Record<string, unknown>) => {
|
||||
if (!companyId) throw new Error("Select a company before saving plugin configuration.");
|
||||
return pluginsApi.saveConfig(pluginId, companyId, configJson);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSaveMessage({ type: "success", text: "Configuration saved." });
|
||||
setTestResult(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.config(pluginId) });
|
||||
if (companyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.config(pluginId, companyId) });
|
||||
}
|
||||
// Clear success message after 3s
|
||||
setTimeout(() => setSaveMessage(null), 3000);
|
||||
},
|
||||
|
|
@ -987,8 +1002,10 @@ function PluginConfigForm({ pluginId, schema, initialValues, isLoading, pluginSt
|
|||
|
||||
// Test configuration mutation
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (configJson: Record<string, unknown>) =>
|
||||
pluginsApi.testConfig(pluginId, configJson),
|
||||
mutationFn: (configJson: Record<string, unknown>) => {
|
||||
if (!companyId) throw new Error("Select a company before testing plugin configuration.");
|
||||
return pluginsApi.testConfig(pluginId, companyId, configJson);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.valid) {
|
||||
setTestResult({ type: "success", text: "Configuration test passed." });
|
||||
|
|
@ -1095,7 +1112,7 @@ function PluginConfigForm({ pluginId, schema, initialValues, isLoading, pluginSt
|
|||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testMutation.isPending}
|
||||
disabled={testMutation.isPending || !companyId}
|
||||
size="sm"
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { ToolsAccess } from "./ToolsAccess";
|
||||
|
||||
/**
|
||||
* Admin gate for the Advanced door (PAP-10862, plan D8). The developer surface
|
||||
* lives under `/apps/advanced` and is reserved for administrators (`tools:admin`
|
||||
* on the server). This is a best-effort UX gate — the server is authoritative —
|
||||
* derived from the caller's board access: instance admins and company
|
||||
* owners/admins pass. Non-admins get a friendly explanation rather than a 403.
|
||||
*/
|
||||
export function AdvancedToolsRoute() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const boardAccess = useQuery({
|
||||
queryKey: queryKeys.access.currentBoardAccess,
|
||||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (boardAccess.isLoading) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
const data = boardAccess.data;
|
||||
const membership = data?.memberships?.find((m) => m.companyId === selectedCompanyId);
|
||||
const isAdmin =
|
||||
Boolean(data?.isInstanceAdmin) ||
|
||||
membership?.membershipRole === "owner" ||
|
||||
membership?.membershipRole === "admin";
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border bg-card p-6">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<ShieldAlert className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Advanced setup is for administrators</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This area lets administrators wire up tools that aren't in the gallery. Ask an administrator if you
|
||||
need a new app connected, or head back to{" "}
|
||||
<Link to="/apps" className="font-medium text-primary hover:underline">
|
||||
your apps
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ToolsAccess />;
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AuditTab } from "./AuditTab";
|
||||
|
||||
const listActivityMock = vi.hoisted(() => vi.fn());
|
||||
const listApplicationsMock = vi.hoisted(() => vi.fn());
|
||||
const listPoliciesMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listActivity: (companyId: string, params: unknown) => listActivityMock(companyId, params),
|
||||
listApplications: (companyId: string) => listApplicationsMock(companyId),
|
||||
listPolicies: (companyId: string) => listPoliciesMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: {
|
||||
list: (companyId: string) => listAgentsMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function event(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "evt-1",
|
||||
companyId: "company-1",
|
||||
action: "tool_gateway.call_denied",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
entityType: "issue",
|
||||
entityId: "run-1",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
applicationId: "app-1",
|
||||
connectionId: "conn-1",
|
||||
agentDisplayName: "Fable",
|
||||
appDisplayName: "Gmail",
|
||||
applicationDisplayName: "Gmail",
|
||||
connectionDisplayName: "Gmail",
|
||||
toolDisplayName: "Send Email",
|
||||
normalizedOutcome: "blocked",
|
||||
details: {
|
||||
reasonCode: "deny_policy_block",
|
||||
tool: "mail:send_email",
|
||||
issueId: "issue-1",
|
||||
runId: "run-1",
|
||||
matchedPolicyIds: ["pol-1"],
|
||||
},
|
||||
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AuditTab", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listActivityMock.mockResolvedValue({ events: [event()], nextCursor: null });
|
||||
listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-1", name: "Gmail" }] });
|
||||
listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "Fable" }]);
|
||||
listPoliciesMock.mockResolvedValue({ policies: [{ id: "pol-1", name: "destructive actions → Block" }] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AuditTab companyId="company-1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
function clickButton(text: string) {
|
||||
const btn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
||||
expect(btn, `button "${text}"`).toBeTruthy();
|
||||
return act(async () => {
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
it("renders humanized sentences, the outcome chip, and the footer note", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Fable");
|
||||
expect(container.textContent).toContain("Send Email");
|
||||
expect(container.textContent).toContain("Gmail");
|
||||
expect(container.textContent).toContain("Blocked");
|
||||
expect(container.textContent).toContain("Recorded by Paperclip — entries can't be edited.");
|
||||
// Vocabulary gate: no raw tool ID or ops terms in the sentence list.
|
||||
expect(container.textContent).not.toContain("mail:send_email");
|
||||
expect(container.textContent).not.toContain("server-authoritative");
|
||||
});
|
||||
|
||||
it("expands a row to show the plain reason, the linked rule, and the Details collapse", async () => {
|
||||
await render();
|
||||
|
||||
await clickButton("used Send Email");
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Blocked by a rule.");
|
||||
const ruleLink = Array.from(container.querySelectorAll("a")).find((a) =>
|
||||
a.textContent?.includes("destructive actions"),
|
||||
);
|
||||
expect(ruleLink?.getAttribute("href")).toBe("/apps/advanced/policies");
|
||||
|
||||
// Raw tool name + reason code only appear once Details is opened.
|
||||
expect(container.textContent).not.toContain("mail:send_email");
|
||||
await clickButton("Details");
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("mail:send_email");
|
||||
expect(container.textContent).toContain("deny_policy_block");
|
||||
});
|
||||
|
||||
it("shows redacted parameters and MCP transport diagnostics", async () => {
|
||||
listActivityMock.mockResolvedValue({
|
||||
events: [event({
|
||||
action: "tool_gateway.call_completed",
|
||||
normalizedOutcome: "allowed",
|
||||
details: {
|
||||
reasonCode: "tool_completed",
|
||||
tool: "zapier:send_lead",
|
||||
argumentsSummary: {
|
||||
summary: JSON.stringify({ email: "person@example.com", apiToken: "***REDACTED***" }),
|
||||
},
|
||||
execution: {
|
||||
transport: "remote_http",
|
||||
request: {
|
||||
httpMethod: "POST",
|
||||
endpoint: "https://mcp.zapier.com/api/mcp",
|
||||
mcpMethod: "tools/call",
|
||||
requestId: "paperclip-tool-request-1",
|
||||
dispatched: true,
|
||||
},
|
||||
response: {
|
||||
httpStatus: 200,
|
||||
contentType: "application/json",
|
||||
bodySizeBytes: 321,
|
||||
upstreamRequestId: "zapier-request-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
})],
|
||||
nextCursor: null,
|
||||
});
|
||||
await render();
|
||||
|
||||
await clickButton("used Send Email");
|
||||
await clickButton("Details");
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Parameters (redacted)");
|
||||
expect(container.textContent).toContain("person@example.com");
|
||||
expect(container.textContent).toContain("***REDACTED***");
|
||||
expect(container.textContent).toContain("POST https://mcp.zapier.com/api/mcp");
|
||||
expect(container.textContent).toContain("tools/call");
|
||||
expect(container.textContent).toContain("HTTP status200");
|
||||
expect(container.textContent).toContain("zapier-request-1");
|
||||
});
|
||||
|
||||
it("explains when permitted MCP connections were not installed for a run", async () => {
|
||||
listActivityMock.mockResolvedValue({
|
||||
events: [event({
|
||||
action: "tool_gateway.runtime_mcp_delivery",
|
||||
normalizedOutcome: "unknown",
|
||||
toolDisplayName: null,
|
||||
appDisplayName: null,
|
||||
connectionDisplayName: null,
|
||||
applicationDisplayName: null,
|
||||
connectionId: null,
|
||||
applicationId: null,
|
||||
details: {
|
||||
reasonCode: "permitted_connections_not_installed",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
deliveredServerCount: 0,
|
||||
permittedNotInstalledCount: 1,
|
||||
permittedNotInstalledConnections: [{ id: "conn-zapier", name: "Zapier" }],
|
||||
},
|
||||
})],
|
||||
nextCursor: null,
|
||||
});
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Fable's run received 0 MCP servers — 1 permitted connection not installed");
|
||||
await clickButton("received 0 MCP servers");
|
||||
expect(container.textContent).toContain("Permitted connections were not installed");
|
||||
await clickButton("Details");
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Delivered MCP servers0");
|
||||
expect(container.querySelector('a[href="/apps/conn-zapier/permissions"]')?.textContent).toBe("Zapier");
|
||||
});
|
||||
|
||||
it("shows the true-empty state when there is no activity", async () => {
|
||||
listActivityMock.mockResolvedValue({ events: [], nextCursor: null });
|
||||
await render();
|
||||
expect(container.textContent).toContain("Nothing here yet");
|
||||
// No active filters yet → no Clear filters button.
|
||||
expect(Array.from(container.querySelectorAll("button")).some((b) => b.textContent?.trim() === "Clear filters")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads more when a cursor is returned", async () => {
|
||||
listActivityMock.mockImplementation((_companyId: string, params: { cursor?: string }) => {
|
||||
if (params.cursor === "cursor-2") {
|
||||
return Promise.resolve({ events: [event({ id: "evt-2", toolDisplayName: "Read Email" })], nextCursor: null });
|
||||
}
|
||||
return Promise.resolve({ events: [event()], nextCursor: "cursor-2" });
|
||||
});
|
||||
await render();
|
||||
|
||||
expect(container.textContent).not.toContain("Read Email");
|
||||
await clickButton("Load more");
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Read Email");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,498 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, ChevronRight, ScrollText } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
toolsApi,
|
||||
type ToolAuditOutcome,
|
||||
type ToolAuditWindow,
|
||||
type ToolGatewayActivityEvent,
|
||||
} from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { ToolsPageHeader, LoadingState, ErrorState, RelativeTime } from "./shared";
|
||||
import { advancedTabHref } from "./tool-tabs";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const ALL = "__all";
|
||||
|
||||
/** Outcome chip vocabulary (spec §4C / §5): Allowed · Blocked · Asked first · Failed · Waiting. */
|
||||
const OUTCOME_META: Record<ToolAuditOutcome, { label: string; status: string }> = {
|
||||
allowed: { label: "Allowed", status: "allowed" },
|
||||
blocked: { label: "Blocked", status: "denied" },
|
||||
asked_first: { label: "Asked first", status: "require-approval" },
|
||||
waiting: { label: "Waiting", status: "deferred" },
|
||||
failed: { label: "Failed", status: "failed" },
|
||||
unknown: { label: "Recorded", status: "unchecked" },
|
||||
};
|
||||
|
||||
const OUTCOME_FILTERS: { value: string; label: string }[] = [
|
||||
{ value: ALL, label: "All outcomes" },
|
||||
{ value: "allowed", label: "Allowed" },
|
||||
{ value: "blocked", label: "Blocked" },
|
||||
{ value: "asked_first", label: "Asked first" },
|
||||
{ value: "waiting", label: "Waiting" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
];
|
||||
|
||||
const WINDOW_FILTERS: { value: ToolAuditWindow; label: string }[] = [
|
||||
{ value: "1h", label: "Last 1 hour" },
|
||||
{ value: "24h", label: "Last 24 hours" },
|
||||
{ value: "7d", label: "Last 7 days" },
|
||||
{ value: "30d", label: "Last 30 days" },
|
||||
];
|
||||
|
||||
function detailString(details: Record<string, unknown> | null, key: string): string | undefined {
|
||||
const v = details?.[key];
|
||||
return typeof v === "string" && v.trim().length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
function detailStringArray(details: Record<string, unknown> | null, key: string): string[] {
|
||||
const v = details?.[key];
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
||||
}
|
||||
|
||||
function detailRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function detailNumber(details: Record<string, unknown> | null, key: string): number | undefined {
|
||||
const value = details?.[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function formattedArguments(details: Record<string, unknown> | null): string | undefined {
|
||||
const summary = detailRecord(details?.argumentsSummary);
|
||||
const serialized = typeof summary?.summary === "string" ? summary.summary : undefined;
|
||||
if (!serialized) return undefined;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(serialized), null, 2);
|
||||
} catch {
|
||||
return serialized;
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain-words "why" for the row expander, keyed off the reason code. */
|
||||
function plainReason(event: ToolGatewayActivityEvent): string {
|
||||
const code = detailString(event.details, "reasonCode");
|
||||
if (code === "permitted_connections_not_installed") {
|
||||
return "Permitted connections were not installed, so their tools were not added to this run.";
|
||||
}
|
||||
switch (event.normalizedOutcome) {
|
||||
case "allowed":
|
||||
return "Allowed by your rules.";
|
||||
case "blocked":
|
||||
if (code === "rate_limited") return "Blocked because it ran too many times in a short window.";
|
||||
if (code?.includes("secret")) return "Blocked to keep a sensitive value from leaving.";
|
||||
return "Blocked by a rule.";
|
||||
case "asked_first":
|
||||
return "Held for someone to approve before it could run.";
|
||||
case "waiting":
|
||||
return "Waiting — the app it needs wasn't ready yet.";
|
||||
case "failed":
|
||||
return "The app was allowed to run it, but returned an error.";
|
||||
default:
|
||||
return "Recorded by Paperclip.";
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact monospace fact row inside the Details collapse. */
|
||||
function DetailFact({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className={cn("min-w-0 break-all text-foreground", mono && "font-mono text-(length:--text-micro)")}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutcomeChip({ outcome }: { outcome: ToolAuditOutcome }) {
|
||||
const meta = OUTCOME_META[outcome] ?? OUTCOME_META.unknown;
|
||||
return <StatusBadge status={meta.status} label={meta.label} />;
|
||||
}
|
||||
|
||||
function ActivityRow({
|
||||
event,
|
||||
ruleNamesById,
|
||||
}: {
|
||||
event: ToolGatewayActivityEvent;
|
||||
ruleNamesById: Map<string, string>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const who = event.agentDisplayName ?? "An agent";
|
||||
const action = event.toolDisplayName ?? "an action";
|
||||
const app = event.appDisplayName ?? event.connectionDisplayName ?? event.applicationDisplayName ?? null;
|
||||
const rawTool = detailString(event.details, "tool") ?? detailString(event.details, "toolName");
|
||||
|
||||
const issueId = detailString(event.details, "issueId");
|
||||
const runId = event.runId ?? detailString(event.details, "runId");
|
||||
const agentId = event.agentId ?? detailString(event.details, "agentId");
|
||||
const reasonCode = detailString(event.details, "reasonCode") ?? event.action.replace("tool_gateway.", "");
|
||||
const matchedRuleId = detailStringArray(event.details, "matchedPolicyIds").find((id) => ruleNamesById.has(id));
|
||||
const matchedRuleName = matchedRuleId ? ruleNamesById.get(matchedRuleId) : undefined;
|
||||
const argumentsText = formattedArguments(event.details);
|
||||
const execution = detailRecord(event.details?.execution);
|
||||
const request = detailRecord(execution?.request);
|
||||
const response = detailRecord(execution?.response);
|
||||
const transport = detailString(execution, "transport");
|
||||
const requestMethod = detailString(request, "httpMethod");
|
||||
const endpoint = detailString(request, "endpoint");
|
||||
const mcpMethod = detailString(request, "mcpMethod");
|
||||
const requestId = detailString(request, "requestId");
|
||||
const httpStatus = detailNumber(response, "httpStatus");
|
||||
const contentType = detailString(response, "contentType");
|
||||
const responseBytes = detailNumber(response, "bodySizeBytes");
|
||||
const upstreamRequestId = detailString(response, "upstreamRequestId");
|
||||
const permittedNotInstalledCount = detailNumber(event.details, "permittedNotInstalledCount");
|
||||
const permittedNotInstalledConnections = Array.isArray(event.details?.permittedNotInstalledConnections)
|
||||
? event.details.permittedNotInstalledConnections
|
||||
.map(detailRecord)
|
||||
.filter((connection): connection is Record<string, unknown> => connection !== null)
|
||||
: [];
|
||||
const isRuntimeMcpDeliveryDiagnostic = reasonCode === "permitted_connections_not_installed";
|
||||
|
||||
return (
|
||||
<li className="text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-start gap-2.5 px-4 py-3 text-left hover:bg-accent/50"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
{isRuntimeMcpDeliveryDiagnostic ? (
|
||||
<span className="block text-foreground">
|
||||
<span className="font-medium">{who}</span>'s run received 0 MCP servers —{" "}
|
||||
<span className="font-medium">{permittedNotInstalledCount ?? permittedNotInstalledConnections.length}</span>{" "}
|
||||
permitted {(permittedNotInstalledCount ?? permittedNotInstalledConnections.length) === 1 ? "connection" : "connections"} not installed
|
||||
</span>
|
||||
) : (
|
||||
<span className="block text-foreground">
|
||||
<span className="font-medium">{who}</span> used <span className="font-medium">{action}</span>
|
||||
{app ? (
|
||||
<>
|
||||
{" "}
|
||||
in <span className="font-medium">{app}</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 whitespace-nowrap">
|
||||
<OutcomeChip outcome={event.normalizedOutcome} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· <RelativeTime value={event.createdAt} />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="space-y-3 border-t border-border bg-muted/30 px-4 py-3 pl-10 text-sm">
|
||||
<p className="text-foreground">
|
||||
{plainReason(event)}
|
||||
{matchedRuleName ? (
|
||||
<>
|
||||
{" "}
|
||||
<Link to={advancedTabHref("policies")} className="text-primary hover:underline">
|
||||
{matchedRuleName}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{issueId ? (
|
||||
<Link to={`/issues/${issueId}`} className="text-primary hover:underline">
|
||||
View task
|
||||
</Link>
|
||||
) : null}
|
||||
{runId && agentId ? (
|
||||
<Link to={`/agents/${agentId}/runs/${runId}`} className="text-primary hover:underline">
|
||||
View run
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsOpen((v) => !v)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{detailsOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
Details
|
||||
</button>
|
||||
{detailsOpen ? (
|
||||
<div className="mt-2 space-y-1.5 text-xs">
|
||||
{rawTool ? <DetailFact label="Action name" value={rawTool} mono /> : null}
|
||||
<DetailFact label="Reason code" value={reasonCode} mono />
|
||||
<DetailFact label="Actor type" value={event.actorType ?? "—"} />
|
||||
{runId ? <DetailFact label="Run ID" value={runId} mono /> : null}
|
||||
{transport ? <DetailFact label="Transport" value={transport} mono /> : null}
|
||||
{requestMethod && endpoint ? <DetailFact label="HTTP request" value={`${requestMethod} ${endpoint}`} mono /> : null}
|
||||
{mcpMethod ? <DetailFact label="MCP method" value={mcpMethod} mono /> : null}
|
||||
{requestId ? <DetailFact label="Request ID" value={requestId} mono /> : null}
|
||||
{request ? <DetailFact label="Dispatched" value={request.dispatched === true ? "Yes" : "No"} /> : null}
|
||||
{httpStatus !== undefined ? <DetailFact label="HTTP status" value={String(httpStatus)} mono /> : null}
|
||||
{contentType ? <DetailFact label="Content type" value={contentType} mono /> : null}
|
||||
{responseBytes !== undefined ? <DetailFact label="Response size" value={`${responseBytes} bytes`} /> : null}
|
||||
{upstreamRequestId ? <DetailFact label="Upstream ID" value={upstreamRequestId} mono /> : null}
|
||||
{isRuntimeMcpDeliveryDiagnostic ? (
|
||||
<>
|
||||
<DetailFact label="Delivered MCP servers" value="0" mono />
|
||||
{permittedNotInstalledConnections.map((connection) => {
|
||||
const connectionId = detailString(connection, "id");
|
||||
const connectionName = detailString(connection, "name") ?? "Unnamed connection";
|
||||
return connectionId ? (
|
||||
<div key={connectionId} className="flex gap-2">
|
||||
<span className="shrink-0 text-muted-foreground">Not installed</span>
|
||||
<Link to={`/apps/${connectionId}/permissions`} className="font-medium text-primary hover:underline">
|
||||
{connectionName}
|
||||
</Link>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
{argumentsText ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground">Parameters (redacted)</span>
|
||||
<pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-background p-3 font-mono text-xs text-foreground">
|
||||
{argumentsText}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditTab({ companyId }: { companyId: string }) {
|
||||
const [app, setApp] = useState<string>(ALL);
|
||||
const [agent, setAgent] = useState<string>(ALL);
|
||||
const [outcome, setOutcome] = useState<string>(ALL);
|
||||
const [windowKey, setWindowKey] = useState<ToolAuditWindow>("24h");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Debounce the search box so each keystroke doesn't fire a server request.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setSearch(searchInput.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [searchInput]);
|
||||
|
||||
const apps = useQuery({
|
||||
queryKey: queryKeys.tools.applications(companyId),
|
||||
queryFn: () => toolsApi.listApplications(companyId),
|
||||
});
|
||||
const agents = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
// Map matched rule IDs to their humanized names for the row "why" link.
|
||||
const policies = useQuery({
|
||||
queryKey: queryKeys.tools.policies(companyId),
|
||||
queryFn: () => toolsApi.listPolicies(companyId),
|
||||
});
|
||||
const ruleNamesById = useMemo(
|
||||
() => new Map((policies.data?.policies ?? []).map((p) => [p.id, p.name])),
|
||||
[policies.data],
|
||||
);
|
||||
|
||||
const filters = {
|
||||
app: app === ALL ? undefined : app,
|
||||
agent: agent === ALL ? undefined : agent,
|
||||
outcome: outcome === ALL ? undefined : outcome,
|
||||
window: windowKey,
|
||||
search: search || undefined,
|
||||
};
|
||||
const hasActiveFilters =
|
||||
app !== ALL || agent !== ALL || outcome !== ALL || windowKey !== "24h" || search.length > 0;
|
||||
|
||||
const activity = useInfiniteQuery({
|
||||
queryKey: queryKeys.tools.activity(companyId, {
|
||||
app: filters.app,
|
||||
agent: filters.agent,
|
||||
outcome: filters.outcome,
|
||||
window: filters.window,
|
||||
search: filters.search,
|
||||
}),
|
||||
queryFn: ({ pageParam }) =>
|
||||
toolsApi.listActivity(companyId, { ...filters, limit: PAGE_SIZE, cursor: pageParam ?? undefined }),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
});
|
||||
|
||||
const events = useMemo(
|
||||
() => activity.data?.pages.flatMap((page) => page.events) ?? [],
|
||||
[activity.data],
|
||||
);
|
||||
|
||||
const clearFilters = () => {
|
||||
setApp(ALL);
|
||||
setAgent(ALL);
|
||||
setOutcome(ALL);
|
||||
setWindowKey("24h");
|
||||
setSearchInput("");
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ToolsPageHeader
|
||||
title="Activity"
|
||||
description="What your agents actually did with your apps, newest first. Each line is one decision — allowed, blocked, asked first, waiting, or failed."
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={app} onValueChange={setApp}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="App" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All apps</SelectItem>
|
||||
{(apps.data?.applications ?? []).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={agent} onValueChange={setAgent}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All agents</SelectItem>
|
||||
{(agents.data ?? []).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={outcome} onValueChange={setOutcome}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OUTCOME_FILTERS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={windowKey} onValueChange={(v) => setWindowKey(v as ToolAuditWindow)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WINDOW_FILTERS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="Search activity…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{hasActiveFilters ? (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activity.isLoading ? (
|
||||
<LoadingState />
|
||||
) : activity.error ? (
|
||||
<ErrorState error={activity.error} onRetry={() => activity.refetch()} />
|
||||
) : events.length === 0 ? (
|
||||
hasActiveFilters ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-14 text-center">
|
||||
<ScrollText className="h-10 w-10 text-muted-foreground/40" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">No activity matches these filters</p>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||
Try a wider time window or different filters.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-14 text-center">
|
||||
<ScrollText className="h-10 w-10 text-muted-foreground/40" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Nothing here yet</p>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||
As soon as your agents start using connected apps, what they do shows up here.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="px-0 py-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{events.map((event) => (
|
||||
<ActivityRow key={event.id} event={event} ruleNamesById={ruleNamesById} />
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activity.hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => activity.fetchNextPage()}
|
||||
disabled={activity.isFetchingNextPage}
|
||||
>
|
||||
{activity.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recorded by Paperclip — entries can't be edited. Sensitive values are never stored.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ToolMcpGatewayToken, ToolMcpGatewayWithTokens, ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GatewaysTab } from "./GatewaysTab";
|
||||
import { RelativeTime } from "./shared";
|
||||
|
||||
const listGatewaysMock = vi.hoisted(() => vi.fn());
|
||||
const listProfilesMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const listProjectsMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listGateways: (companyId: string) => listGatewaysMock(companyId),
|
||||
listProfiles: (companyId: string) => listProfilesMock(companyId),
|
||||
createGateway: vi.fn(),
|
||||
createGatewayToken: vi.fn(),
|
||||
revokeGatewayToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: {
|
||||
list: (companyId: string) => listAgentsMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/projects", () => ({
|
||||
projectsApi: {
|
||||
list: (companyId: string) => listProjectsMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: pushToastMock }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function profile(overrides: Partial<ToolProfileWithDetails> = {}): ToolProfileWithDetails {
|
||||
return {
|
||||
id: "profile-1",
|
||||
companyId: "company-1",
|
||||
profileKey: "engineering",
|
||||
name: "Engineering",
|
||||
description: null,
|
||||
status: "active",
|
||||
defaultAction: "deny",
|
||||
newToolsReviewedAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
entries: [],
|
||||
bindings: [],
|
||||
summary: {
|
||||
accessMode: "selected",
|
||||
allowedToolCount: 2,
|
||||
allowedApplicationCount: 1,
|
||||
excludedToolCount: 0,
|
||||
totalToolCount: 5,
|
||||
assignmentCount: 1,
|
||||
appliesToAgentCount: 0,
|
||||
isCompanyDefault: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function token(overrides: Partial<ToolMcpGatewayToken> = {}): ToolMcpGatewayToken {
|
||||
return {
|
||||
id: "token-1",
|
||||
companyId: "company-1",
|
||||
gatewayId: "gateway-1",
|
||||
name: "Token",
|
||||
tokenPrefix: "pcgw_token",
|
||||
subjectType: "gateway_client",
|
||||
subjectId: null,
|
||||
clientLabel: "Cursor",
|
||||
ownerNote: "Local IDE",
|
||||
allowedActions: ["tools/list", "tools/call"],
|
||||
expiresAt: "2026-06-18T12:00:00.000Z",
|
||||
expiryOverrideReason: null,
|
||||
expiryOverrideByUserId: null,
|
||||
expiryOverrideByAgentId: null,
|
||||
expiryOverrideAt: null,
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt: "2026-06-01T00:00:00.000Z",
|
||||
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function gateway(overrides: Partial<ToolMcpGatewayWithTokens> = {}): ToolMcpGatewayWithTokens {
|
||||
return {
|
||||
id: "gateway-1",
|
||||
companyId: "company-1",
|
||||
gatewayPublicId: "gw_public",
|
||||
name: "Dotta's MacBook",
|
||||
displaySlug: "dottas-macbook",
|
||||
slug: "dottas-macbook",
|
||||
description: null,
|
||||
status: "active",
|
||||
profileId: "profile-1",
|
||||
defaultProfileMode: "gateway_only",
|
||||
contextScopeType: "company",
|
||||
contextScopeId: null,
|
||||
agentId: null,
|
||||
projectId: null,
|
||||
issueId: null,
|
||||
approvalIssueId: null,
|
||||
endpointPath: "/api/tool-gateway/gateways/gateway-1/mcp",
|
||||
authConfig: {
|
||||
version: 1,
|
||||
bearer: {
|
||||
enabled: true,
|
||||
tokenPrefix: "pcgw",
|
||||
defaultTtlSeconds: 7776000,
|
||||
requireFiniteExpiry: true,
|
||||
longLivedTokenRequiresOverride: true,
|
||||
},
|
||||
oauth: {
|
||||
enabled: false,
|
||||
reservedFor: "v1_5",
|
||||
dynamicClientRegistration: false,
|
||||
authorizationCodePkce: false,
|
||||
},
|
||||
},
|
||||
headerPolicy: {
|
||||
version: 1,
|
||||
callerPassthrough: { enabled: false, allowedHeaders: [] },
|
||||
staticHeaders: [],
|
||||
generatedMetadata: { enabled: false, allowedHeaders: [] },
|
||||
responseHeaders: { forwardMcpRequiredHeaders: true, forwardSafeCacheHeaders: true },
|
||||
},
|
||||
metadataPolicy: {
|
||||
version: 1,
|
||||
forwardCompanyId: false,
|
||||
forwardGatewayId: false,
|
||||
forwardProjectId: false,
|
||||
forwardIssueId: false,
|
||||
forwardAgentId: false,
|
||||
forwardRunId: false,
|
||||
forwardCorrelationId: true,
|
||||
},
|
||||
onDemandToolsConfig: {
|
||||
enabled: false,
|
||||
searchToolName: "search_tools",
|
||||
runToolName: "run_tool",
|
||||
},
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
archivedAt: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
tokens: [],
|
||||
clientSnippets: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GatewaysTab", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-16T12:00:00.000Z").getTime());
|
||||
listProfilesMock.mockResolvedValue({ profiles: [profile()] });
|
||||
listAgentsMock.mockResolvedValue([]);
|
||||
listProjectsMock.mockResolvedValue([]);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function render(node: ReactNode) {
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(node);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders future relative times as an in-prefix and preserves past ago labels", async () => {
|
||||
await render(
|
||||
<>
|
||||
<RelativeTime value="2026-06-18T12:00:00.000Z" />
|
||||
<RelativeTime value="2026-06-14T12:00:00.000Z" />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("in 2d");
|
||||
expect(container.textContent).toContain("2d ago");
|
||||
});
|
||||
|
||||
it("renders token expiry, revocation date, and empty snippets copy", async () => {
|
||||
listGatewaysMock.mockResolvedValue({
|
||||
gateways: [
|
||||
gateway({
|
||||
tokens: [
|
||||
token({
|
||||
id: "token-future",
|
||||
name: "Future token",
|
||||
expiresAt: "2026-06-18T12:00:00.000Z",
|
||||
}),
|
||||
token({
|
||||
id: "token-revoked",
|
||||
name: "Revoked token",
|
||||
expiresAt: "2026-06-18T12:00:00.000Z",
|
||||
revokedAt: "2026-06-16T09:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
clientSnippets: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await render(
|
||||
<QueryClientProvider client={client}>
|
||||
<GatewaysTab companyId="company-1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("expires in 2d");
|
||||
expect(container.textContent).not.toContain("expires 2d ago");
|
||||
expect(container.textContent).toContain("revoked 3h ago");
|
||||
expect(container.textContent).toContain("No snippets available.");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,659 @@
|
|||
import { type FormEvent, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
ToolMcpGatewayContextScopeType,
|
||||
ToolMcpGatewayTokenAction,
|
||||
ToolMcpGatewayTokenCreated,
|
||||
ToolMcpGatewayWithTokens,
|
||||
ToolProfileWithDetails,
|
||||
} from "@paperclipai/shared";
|
||||
import { Check, ChevronDown, Copy, KeyRound, Link as LinkIcon, Plus, RotateCcw, X } from "lucide-react";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { ErrorState, LoadingState, RelativeTime, ToolsPageHeader } from "./shared";
|
||||
|
||||
type CreateGatewayDraft = {
|
||||
name: string;
|
||||
description: string;
|
||||
profileId: string;
|
||||
};
|
||||
|
||||
type TokenDraft = {
|
||||
name: string;
|
||||
clientLabel: string;
|
||||
ownerNote: string;
|
||||
expiresAt: string;
|
||||
allowedActions: ToolMcpGatewayTokenAction[];
|
||||
};
|
||||
|
||||
const defaultTokenDraft = (): TokenDraft => ({
|
||||
name: "",
|
||||
clientLabel: "",
|
||||
ownerNote: "",
|
||||
expiresAt: toDateInputValue(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)),
|
||||
allowedActions: ["tools/list", "tools/call"],
|
||||
});
|
||||
|
||||
function toDateInputValue(value: Date) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function shortId(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value.length > 12 ? `${value.slice(0, 8)}...` : value;
|
||||
}
|
||||
|
||||
function pluralize(count: number, singular: string, plural = `${singular}s`) {
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
function dateValue(value: Date | string | null | undefined) {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function latestTokenActivity(gateway: ToolMcpGatewayWithTokens) {
|
||||
return gateway.tokens.reduce<Date | null>((latest, token) => {
|
||||
const candidate = dateValue(token.lastUsedAt);
|
||||
if (!candidate) return latest;
|
||||
return !latest || candidate.getTime() > latest.getTime() ? candidate : latest;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function formatOwner(gateway: ToolMcpGatewayWithTokens, agentNames: Map<string, string>) {
|
||||
if (gateway.agentId) return agentNames.get(gateway.agentId) ?? `Agent ${shortId(gateway.agentId)}`;
|
||||
if (gateway.createdByAgentId) {
|
||||
return agentNames.get(gateway.createdByAgentId) ?? `Agent ${shortId(gateway.createdByAgentId)}`;
|
||||
}
|
||||
if (gateway.createdByUserId) return `Board user ${shortId(gateway.createdByUserId)}`;
|
||||
return "Board";
|
||||
}
|
||||
|
||||
function formatScope(
|
||||
gateway: ToolMcpGatewayWithTokens,
|
||||
projectNames: Map<string, string>,
|
||||
agentNames: Map<string, string>,
|
||||
) {
|
||||
if (gateway.contextScopeType !== "none" && gateway.contextScopeId) {
|
||||
if (gateway.contextScopeType === "project") {
|
||||
return `Project ${projectNames.get(gateway.contextScopeId) ?? shortId(gateway.contextScopeId)}`;
|
||||
}
|
||||
if (gateway.contextScopeType === "agent") {
|
||||
return `Agent ${agentNames.get(gateway.contextScopeId) ?? shortId(gateway.contextScopeId)}`;
|
||||
}
|
||||
return `${gateway.contextScopeType} ${shortId(gateway.contextScopeId)}`;
|
||||
}
|
||||
if (gateway.projectId) return `Project ${projectNames.get(gateway.projectId) ?? shortId(gateway.projectId)}`;
|
||||
if (gateway.issueId) return `Issue ${shortId(gateway.issueId)}`;
|
||||
if (gateway.agentId) return `Agent ${agentNames.get(gateway.agentId) ?? shortId(gateway.agentId)}`;
|
||||
return "Company";
|
||||
}
|
||||
|
||||
function formatAllowedTools(profile: ToolProfileWithDetails | undefined) {
|
||||
if (!profile) return "Profile unavailable";
|
||||
const allowed = profile.summary.allowedToolCount;
|
||||
if (profile.summary.accessMode === "all_except") {
|
||||
return `${pluralize(Math.max(profile.summary.totalToolCount - profile.summary.excludedToolCount, 0), "tool")} allowed`;
|
||||
}
|
||||
return allowed === 0 ? "No tools allowed" : `${pluralize(allowed, "tool")} allowed`;
|
||||
}
|
||||
|
||||
function formatSnippetConfig(config: Record<string, unknown>) {
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
function buildTokenExpiresAt(value: string) {
|
||||
return value ? `${value}T23:59:59.000Z` : null;
|
||||
}
|
||||
|
||||
export function GatewaysTab({ companyId }: { companyId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createDraft, setCreateDraft] = useState<CreateGatewayDraft>({
|
||||
name: "",
|
||||
description: "",
|
||||
profileId: "",
|
||||
});
|
||||
const [tokenDrafts, setTokenDrafts] = useState<Record<string, TokenDraft>>({});
|
||||
const [issuingGatewayId, setIssuingGatewayId] = useState<string | null>(null);
|
||||
const [createdTokens, setCreatedTokens] = useState<Record<string, ToolMcpGatewayTokenCreated>>({});
|
||||
const [confirmingRevokeTokenId, setConfirmingRevokeTokenId] = useState<string | null>(null);
|
||||
|
||||
const gatewaysQuery = useQuery({
|
||||
queryKey: queryKeys.tools.gateways(companyId),
|
||||
queryFn: () => toolsApi.listGateways(companyId),
|
||||
});
|
||||
const profilesQuery = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(companyId),
|
||||
queryFn: () => toolsApi.listProfiles(companyId),
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: queryKeys.projects.list(companyId),
|
||||
queryFn: () => projectsApi.list(companyId),
|
||||
});
|
||||
|
||||
const origin = useMemo(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
return window.location.origin;
|
||||
}, []);
|
||||
|
||||
const profiles = profilesQuery.data?.profiles ?? [];
|
||||
const activeProfiles = profiles.filter((profile) => profile.status !== "archived");
|
||||
const profileById = useMemo(() => new Map(profiles.map((profile) => [profile.id, profile])), [profiles]);
|
||||
const agentNames = useMemo(() => new Map((agentsQuery.data ?? []).map((agent) => [agent.id, agent.name])), [agentsQuery.data]);
|
||||
const projectNames = useMemo(
|
||||
() => new Map((projectsQuery.data ?? []).map((project) => [project.id, project.name])),
|
||||
[projectsQuery.data],
|
||||
);
|
||||
|
||||
const invalidateGateways = () => queryClient.invalidateQueries({ queryKey: queryKeys.tools.gateways(companyId) });
|
||||
|
||||
const createGatewayMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
toolsApi.createGateway(companyId, {
|
||||
name: createDraft.name.trim(),
|
||||
description: createDraft.description.trim() || null,
|
||||
profileId: createDraft.profileId,
|
||||
defaultProfileMode: "gateway_only",
|
||||
contextScopeType: "company" satisfies ToolMcpGatewayContextScopeType,
|
||||
}),
|
||||
onSuccess: async (gateway) => {
|
||||
setCreateDraft({ name: "", description: "", profileId: activeProfiles[0]?.id ?? "" });
|
||||
setCreating(false);
|
||||
pushToast({ title: "Gateway created", body: gateway.name, tone: "success" });
|
||||
await invalidateGateways();
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({ title: "Gateway was not created", body: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const createTokenMutation = useMutation({
|
||||
mutationFn: async (gatewayId: string) => {
|
||||
const draft = tokenDrafts[gatewayId] ?? defaultTokenDraft();
|
||||
return toolsApi.createGatewayToken(companyId, gatewayId, {
|
||||
name: draft.name.trim(),
|
||||
clientLabel: draft.clientLabel.trim(),
|
||||
ownerNote: draft.ownerNote.trim(),
|
||||
allowedActions: draft.allowedActions,
|
||||
expiresAt: buildTokenExpiresAt(draft.expiresAt),
|
||||
});
|
||||
},
|
||||
onSuccess: async (token) => {
|
||||
setCreatedTokens((current) => ({ ...current, [token.gatewayId]: token }));
|
||||
setIssuingGatewayId(null);
|
||||
setTokenDrafts((current) => ({ ...current, [token.gatewayId]: defaultTokenDraft() }));
|
||||
pushToast({ title: "Token issued", body: `${token.name} was created. Copy it now; it will not be shown again.`, tone: "success" });
|
||||
await invalidateGateways();
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({ title: "Token was not issued", body: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const revokeTokenMutation = useMutation({
|
||||
mutationFn: (tokenId: string) => toolsApi.revokeGatewayToken(companyId, tokenId),
|
||||
onSuccess: async (token) => {
|
||||
setConfirmingRevokeTokenId(null);
|
||||
pushToast({ title: "Token revoked", body: token.name, tone: "success" });
|
||||
await invalidateGateways();
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({ title: "Token was not revoked", body: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
async function copyText(value: string, label: string) {
|
||||
try {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
|
||||
throw new Error("Clipboard access is unavailable.");
|
||||
}
|
||||
await navigator.clipboard.writeText(value);
|
||||
pushToast({ title: "Copied to clipboard", body: label, tone: "success" });
|
||||
} catch (error) {
|
||||
pushToast({ title: "Copy failed", body: error instanceof Error ? error.message : "Clipboard access is unavailable.", tone: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
function startIssuing(gatewayId: string) {
|
||||
setCreatedTokens((current) => {
|
||||
const next = { ...current };
|
||||
delete next[gatewayId];
|
||||
return next;
|
||||
});
|
||||
setTokenDrafts((current) => ({ ...current, [gatewayId]: current[gatewayId] ?? defaultTokenDraft() }));
|
||||
setIssuingGatewayId(gatewayId);
|
||||
}
|
||||
|
||||
function submitCreateGateway(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!createDraft.profileId) {
|
||||
pushToast({ title: "Pick a profile", body: "A gateway needs an access profile before it can be created.", tone: "warn" });
|
||||
return;
|
||||
}
|
||||
createGatewayMutation.mutate();
|
||||
}
|
||||
|
||||
function submitCreateToken(event: FormEvent<HTMLFormElement>, gatewayId: string) {
|
||||
event.preventDefault();
|
||||
const draft = tokenDrafts[gatewayId] ?? defaultTokenDraft();
|
||||
if (draft.allowedActions.length === 0) {
|
||||
pushToast({ title: "Pick token actions", body: "Gateway tokens need at least one allowed MCP action.", tone: "warn" });
|
||||
return;
|
||||
}
|
||||
createTokenMutation.mutate(gatewayId);
|
||||
}
|
||||
|
||||
if (gatewaysQuery.isLoading) return <LoadingState label="Loading gateways..." />;
|
||||
if (gatewaysQuery.isError) return <ErrorState error={gatewaysQuery.error} />;
|
||||
|
||||
const gateways = gatewaysQuery.data?.gateways ?? [];
|
||||
const profileLoading = profilesQuery.isLoading;
|
||||
const createDisabled = profileLoading || activeProfiles.length === 0 || createGatewayMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<ToolsPageHeader
|
||||
title="Named MCP gateways"
|
||||
description="Stable endpoints for external clients that use the same profiles, rules, and audit trail as agent tool access."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreateDraft((current) => ({ ...current, profileId: current.profileId || activeProfiles[0]?.id || "" }));
|
||||
setCreating((value) => !value);
|
||||
}}
|
||||
disabled={profileLoading}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Create gateway
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{creating ? (
|
||||
<form className="space-y-3 rounded-md border border-border p-4" onSubmit={submitCreateGateway}>
|
||||
<div className="grid gap-3 md:grid-cols-(--gtc-60)">
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Gateway name</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={createDraft.name}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Engineering laptops"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Access profile</span>
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={createDraft.profileId}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, profileId: event.target.value }))}
|
||||
required
|
||||
disabled={activeProfiles.length === 0}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{profileLoading ? "Loading profiles..." : "Choose a profile"}
|
||||
</option>
|
||||
{activeProfiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name} - {formatAllowedTools(profile)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Description</span>
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={createDraft.description}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, description: event.target.value }))}
|
||||
placeholder="Who this endpoint is for and when it should be rotated."
|
||||
/>
|
||||
</label>
|
||||
{activeProfiles.length === 0 && !profileLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Create an access profile before adding a gateway.</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setCreating(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={createDisabled || !createDraft.name.trim() || !createDraft.profileId}>
|
||||
{createGatewayMutation.isPending ? "Creating..." : "Create gateway"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{gateways.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border p-5 text-sm text-muted-foreground">
|
||||
No named gateways yet. Create one here, then issue a token for the client that will connect to it.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{gateways.map((gateway) => {
|
||||
const endpoint = `${origin}${gateway.endpointPath}`;
|
||||
const snippets = gateway.clientSnippets ?? [];
|
||||
const profile = profileById.get(gateway.profileId);
|
||||
const lastActivity = latestTokenActivity(gateway);
|
||||
const tokenDraft = tokenDrafts[gateway.id] ?? defaultTokenDraft();
|
||||
const createdToken = createdTokens[gateway.id];
|
||||
return (
|
||||
<section key={gateway.id} className="space-y-4 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="truncate text-sm font-semibold text-foreground">{gateway.name}</h3>
|
||||
<span className="text-xs text-muted-foreground">{gateway.status}</span>
|
||||
</div>
|
||||
{gateway.description ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{gateway.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void copyText(endpoint, "Gateway endpoint")}>
|
||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||
Copy endpoint
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => startIssuing(gateway.id)}>
|
||||
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
|
||||
Issue token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="break-all rounded bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{endpoint}
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-x-4 gap-y-2 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-muted-foreground">Owner</dt>
|
||||
<dd className="mt-0.5 text-foreground">{formatOwner(gateway, agentNames)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-muted-foreground">Scope</dt>
|
||||
<dd className="mt-0.5 text-foreground">{formatScope(gateway, projectNames, agentNames)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-muted-foreground">Allowed tools</dt>
|
||||
<dd className="mt-0.5 text-foreground">
|
||||
{profile ? `${formatAllowedTools(profile)} via ${profile.name}` : `Profile ${shortId(gateway.profileId)}`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-muted-foreground">Last activity</dt>
|
||||
<dd className="mt-0.5 text-foreground">
|
||||
{lastActivity ? <RelativeTime value={lastActivity} /> : "Never used"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{issuingGatewayId === gateway.id ? (
|
||||
<form className="space-y-3 rounded-md border border-border p-3" onSubmit={(event) => submitCreateToken(event, gateway.id)}>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Token name</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={tokenDraft.name}
|
||||
onChange={(event) =>
|
||||
setTokenDrafts((current) => ({
|
||||
...current,
|
||||
[gateway.id]: { ...tokenDraft, name: event.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="Dotta's MacBook"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Client label</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={tokenDraft.clientLabel}
|
||||
onChange={(event) =>
|
||||
setTokenDrafts((current) => ({
|
||||
...current,
|
||||
[gateway.id]: { ...tokenDraft, clientLabel: event.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="Cursor on work laptop"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Owner note</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={tokenDraft.ownerNote}
|
||||
onChange={(event) =>
|
||||
setTokenDrafts((current) => ({
|
||||
...current,
|
||||
[gateway.id]: { ...tokenDraft, ownerNote: event.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="Who owns this token and why it exists"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Expires</span>
|
||||
<input
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
type="date"
|
||||
value={tokenDraft.expiresAt}
|
||||
onChange={(event) =>
|
||||
setTokenDrafts((current) => ({
|
||||
...current,
|
||||
[gateway.id]: { ...tokenDraft, expiresAt: event.target.value },
|
||||
}))
|
||||
}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
{(["tools/list", "tools/call"] as ToolMcpGatewayTokenAction[]).map((action) => (
|
||||
<label key={action} className="flex items-center gap-2 text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tokenDraft.allowedActions.includes(action)}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked
|
||||
? Array.from(new Set([...tokenDraft.allowedActions, action]))
|
||||
: tokenDraft.allowedActions.filter((item) => item !== action);
|
||||
setTokenDrafts((current) => ({ ...current, [gateway.id]: { ...tokenDraft, allowedActions: next } }));
|
||||
}}
|
||||
/>
|
||||
{action}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setIssuingGatewayId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={
|
||||
createTokenMutation.isPending ||
|
||||
!tokenDraft.name.trim() ||
|
||||
!tokenDraft.clientLabel.trim() ||
|
||||
!tokenDraft.ownerNote.trim() ||
|
||||
!tokenDraft.expiresAt
|
||||
}
|
||||
>
|
||||
{createTokenMutation.isPending ? "Issuing..." : "Issue token"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{createdToken ? (
|
||||
<div className="space-y-2 rounded-md border border-border bg-muted/40 p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="font-medium text-foreground">New token for {createdToken.name}</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void copyText(createdToken.token, "Gateway bearer token")}>
|
||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||
Copy token
|
||||
</Button>
|
||||
</div>
|
||||
<div className="break-all rounded bg-background px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{createdToken.token}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
Tokens
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
{gateway.tokens.length === 0 ? (
|
||||
<p className="text-muted-foreground">No tokens issued.</p>
|
||||
) : (
|
||||
gateway.tokens.map((token) => {
|
||||
const revoked = Boolean(token.revokedAt);
|
||||
const confirming = confirmingRevokeTokenId === token.id;
|
||||
return (
|
||||
<div key={token.id} className="space-y-1 py-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-foreground">{token.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{token.clientLabel || token.tokenPrefix} · {token.allowedActions.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{token.revokedAt ? (
|
||||
<>
|
||||
revoked <RelativeTime value={token.revokedAt} />
|
||||
</>
|
||||
) : token.expiresAt ? (
|
||||
<>
|
||||
expires <RelativeTime value={token.expiresAt} />
|
||||
</>
|
||||
) : (
|
||||
"no expiry"
|
||||
)}
|
||||
</span>
|
||||
{!revoked ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => setConfirmingRevokeTokenId(token.id)}
|
||||
aria-label={`Revoke ${token.name}`}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{confirming ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 text-xs text-muted-foreground">
|
||||
<span>Revoke this token now?</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setConfirmingRevokeTokenId(null)}
|
||||
>
|
||||
<X className="mr-1 h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => revokeTokenMutation.mutate(token.id)}
|
||||
disabled={revokeTokenMutation.isPending}
|
||||
>
|
||||
<Check className="mr-1 h-3.5 w-3.5" />
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs font-medium text-muted-foreground">Client snippets</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
{snippets.length === 0 ? (
|
||||
<p className="text-muted-foreground">No snippets available.</p>
|
||||
) : (
|
||||
snippets.map((snippet) => (
|
||||
<details key={snippet.client} className="rounded px-2 py-1 open:bg-muted/40">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-left">
|
||||
<span className="flex min-w-0 items-center gap-2 text-foreground">
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{snippet.label}</span>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void copyText(formatSnippetConfig(snippet.config), `${snippet.label} snippet`);
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-words rounded bg-background p-3 text-xs text-muted-foreground">
|
||||
{formatSnippetConfig(snippet.config)}
|
||||
</pre>
|
||||
{snippet.notes.length > 0 ? (
|
||||
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||
{snippet.notes.map((note) => (
|
||||
<div key={note}>{note}</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectToolAppResult, McpJsonImportPreview } from "@paperclipai/shared";
|
||||
import { PasteConfigTab } from "./PasteConfigTab";
|
||||
|
||||
const toolsApiMock = vi.hoisted(() => ({
|
||||
importMcpJson: vi.fn(),
|
||||
connectApp: vi.fn(),
|
||||
finishApp: vi.fn(),
|
||||
}));
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/api/tools", () => ({ toolsApi: toolsApiMock }));
|
||||
// The tab uses `useNavigate` from the app router (PAP-11088 draft hand-off),
|
||||
// which needs CompanyProvider; stub it so the copy hint renders in isolation.
|
||||
vi.mock("@/lib/router", () => ({ useNavigate: () => mockNavigate }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(textarea, value);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function buttonStartingWith(text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.body.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim().startsWith(text),
|
||||
) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
function connectResult(overrides: Partial<ConnectToolAppResult> = {}): ConnectToolAppResult {
|
||||
return {
|
||||
connectionId: "conn-1",
|
||||
application: {
|
||||
id: "app-1",
|
||||
companyId: "company-1",
|
||||
applicationKey: "app-gallery:link:test",
|
||||
name: "kv-demo",
|
||||
description: null,
|
||||
type: "mcp_http",
|
||||
status: "draft",
|
||||
pluginId: null,
|
||||
ownerAgentId: null,
|
||||
ownerUserId: null,
|
||||
metadata: null,
|
||||
archivedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
connection: {
|
||||
id: "conn-1",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-1",
|
||||
name: "kv-demo",
|
||||
connectionKind: "managed",
|
||||
transport: "remote_http",
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
config: { url: "http://127.0.0.1:8848/mcp" },
|
||||
transportConfig: { url: "http://127.0.0.1:8848/mcp" },
|
||||
credentialRefs: [],
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "ok",
|
||||
healthMessage: "ok",
|
||||
healthCheckedAt: new Date(),
|
||||
lastHealthAt: new Date(),
|
||||
lastCatalogRefreshAt: new Date(),
|
||||
lastError: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "board",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
catalog: [],
|
||||
actions: {
|
||||
readOnly: [{
|
||||
catalogEntryId: "cat-read",
|
||||
toolName: "kv_get",
|
||||
title: "Get value",
|
||||
description: "Read a value.",
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
}],
|
||||
canMakeChanges: [],
|
||||
},
|
||||
suggestedDefaults: { access: "all_agents", askFirstRiskLevels: ["write", "destructive"] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PasteConfigTab — discoverability copy (PAP-11091)", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<PasteConfigTab companyId="company-1" />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
it("shows a hint linking to the Browse app surface", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Just a URL?");
|
||||
const link = Array.from(container.querySelectorAll("a")).find((a) =>
|
||||
a.textContent?.includes("Browse planned app connections"),
|
||||
);
|
||||
expect(link).toBeTruthy();
|
||||
expect(link?.getAttribute("href")).toBe("/apps/browse");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<PasteConfigTab companyId="company-1" />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
return root;
|
||||
}
|
||||
|
||||
async function pasteAndCheck(preview: McpJsonImportPreview, snippet: string) {
|
||||
toolsApiMock.importMcpJson.mockResolvedValue(preview);
|
||||
await render();
|
||||
const textarea = container.querySelector("textarea")!;
|
||||
await act(async () => setTextareaValue(textarea, snippet));
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonStartingWith("Check config")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders a Continue button for a remote draft that navigates to the prefilled connect wizard", async () => {
|
||||
await pasteAndCheck(
|
||||
{
|
||||
drafts: [
|
||||
{
|
||||
name: "kv-demo",
|
||||
transport: "remote_http",
|
||||
status: "draft",
|
||||
config: { url: "http://127.0.0.1:8848/mcp" },
|
||||
credentialRefs: [],
|
||||
credentialFields: [],
|
||||
warnings: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
'{ "mcpServers": { "kv-demo": { "url": "http://127.0.0.1:8848/mcp" } } }',
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("We found 1 app in that config");
|
||||
const checkButton = buttonStartingWith("Check actions");
|
||||
expect(checkButton).toBeTruthy();
|
||||
|
||||
toolsApiMock.connectApp.mockResolvedValue(connectResult());
|
||||
await act(async () => {
|
||||
checkButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
expect(toolsApiMock.connectApp).toHaveBeenCalledWith("company-1", {
|
||||
link: "http://127.0.0.1:8848/mcp",
|
||||
name: "kv-demo",
|
||||
credentialValues: {},
|
||||
});
|
||||
expect(container.textContent).toContain("Review actions for kv-demo");
|
||||
// The dead-end "Next, you'll add the keys" copy is gone.
|
||||
expect(container.textContent).not.toContain("Next, you'll add the keys");
|
||||
});
|
||||
|
||||
it("collects imported headers as secret replacement fields before checking actions", async () => {
|
||||
await pasteAndCheck(
|
||||
{
|
||||
drafts: [
|
||||
{
|
||||
name: "secure-demo",
|
||||
transport: "remote_http",
|
||||
status: "draft",
|
||||
config: { url: "https://secure.example/mcp" },
|
||||
credentialRefs: [],
|
||||
credentialFields: [{
|
||||
configPath: "headers.Authorization",
|
||||
label: "Authorization",
|
||||
placement: "header",
|
||||
key: "Authorization",
|
||||
prefix: null,
|
||||
required: true,
|
||||
}],
|
||||
warnings: ["Header Authorization will be stored as a Paperclip secret before activation."],
|
||||
},
|
||||
],
|
||||
},
|
||||
'{ "mcpServers": { "secure-demo": { "url": "https://secure.example/mcp", "headers": { "Authorization": "Bearer old" } } } }',
|
||||
);
|
||||
|
||||
const checkButton = buttonStartingWith("Check actions")!;
|
||||
expect(checkButton.disabled).toBe(true);
|
||||
const input = container.querySelector('input[type="password"]') as HTMLInputElement;
|
||||
await act(async () => setInputValue(input, "Bearer new"));
|
||||
await flushReact();
|
||||
|
||||
expect(buttonStartingWith("Check actions")!.disabled).toBe(false);
|
||||
toolsApiMock.connectApp.mockResolvedValue(connectResult({
|
||||
application: { ...connectResult().application, name: "secure-demo" },
|
||||
}));
|
||||
await act(async () => {
|
||||
buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(toolsApiMock.connectApp).toHaveBeenCalledWith("company-1", {
|
||||
link: "https://secure.example/mcp",
|
||||
name: "secure-demo",
|
||||
credentialValues: { "headers.Authorization": "Bearer new" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer Continue for a stdio draft (draft-only, no link to hand off)", async () => {
|
||||
await pasteAndCheck(
|
||||
{
|
||||
drafts: [
|
||||
{
|
||||
name: "github",
|
||||
transport: "local_stdio",
|
||||
status: "draft",
|
||||
config: { importedCommand: "npx -y @modelcontextprotocol/server-github", importedArgs: [] },
|
||||
credentialRefs: [{ name: "GITHUB_TOKEN", secretId: "draft-token", placement: "env", key: "GITHUB_TOKEN" }],
|
||||
credentialFields: [],
|
||||
warnings: ["Imported stdio commands stay draft-only unless mapped to an approved Paperclip template."],
|
||||
},
|
||||
],
|
||||
},
|
||||
'{ "mcpServers": { "github": { "command": "npx -y @modelcontextprotocol/server-github" } } }',
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("We found 1 app in that config");
|
||||
expect(buttonStartingWith("Check actions")).toBeFalsy();
|
||||
expect(container.textContent).toContain("stay as drafts until an admin");
|
||||
expect(container.textContent).toContain("Keys from this config stay draft-only");
|
||||
expect(container.textContent).not.toContain("No keys needed for this one.");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,457 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckCircle2, KeyRound, Loader2, ShieldCheck } from "lucide-react";
|
||||
import type {
|
||||
ConnectToolAppResult,
|
||||
McpJsonImportDraft,
|
||||
McpJsonImportPreview,
|
||||
ToolAppConnectionActionSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { ErrorState } from "./shared";
|
||||
|
||||
const SAMPLE_CONFIG = `{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"command": "npx -y @modelcontextprotocol/server-github",
|
||||
"env": { "GITHUB_TOKEN": "ghp_..." }
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
/** Turn an env/header key (e.g. `GITHUB_TOKEN`) into a friendly field label. */
|
||||
function humanizeKey(raw: string): string {
|
||||
const cleaned = raw.replace(/[_-]+/g, " ").trim().toLowerCase();
|
||||
if (!cleaned) return "Key";
|
||||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
|
||||
function draftSummary(draft: McpJsonImportDraft): string {
|
||||
const keyCount = draft.credentialFields.length || draft.credentialRefs.length;
|
||||
const where = draft.transport === "local_stdio" ? "Runs in your workspace" : "Connects over the web";
|
||||
if (keyCount === 0) return `${where} · no keys needed`;
|
||||
return `${where} · needs ${keyCount} ${keyCount === 1 ? "key" : "keys"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft is connectable when it's a remote server with a real http(s) URL — that
|
||||
* is exactly what the "Connect with a link" wizard step needs to land an active
|
||||
* `tool_connection`. Imported stdio commands stay draft-only (they require an
|
||||
* approved Paperclip template), so they get no hand-off here.
|
||||
*/
|
||||
function draftConnectUrl(draft: McpJsonImportDraft): string | null {
|
||||
if (draft.transport !== "remote_http") return null;
|
||||
const raw = draft.config?.url;
|
||||
if (typeof raw !== "string") return null;
|
||||
try {
|
||||
const parsed = new URL(raw.trim());
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function credentialValueKey(draft: McpJsonImportDraft, configPath: string): string {
|
||||
return `${draft.name}::${configPath}`;
|
||||
}
|
||||
|
||||
function credentialValuesForDraft(draft: McpJsonImportDraft, values: Record<string, string>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const field of draft.credentialFields) {
|
||||
const value = values[credentialValueKey(draft, field.configPath)]?.trim();
|
||||
if (value) out[field.configPath] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function missingCredentialFields(draft: McpJsonImportDraft, values: Record<string, string>): string[] {
|
||||
return draft.credentialFields
|
||||
.filter((field) => field.required)
|
||||
.filter((field) => !values[credentialValueKey(draft, field.configPath)]?.trim())
|
||||
.map((field) => field.configPath);
|
||||
}
|
||||
|
||||
function askFirstLevelsFrom(result: ConnectToolAppResult): string[] {
|
||||
const raw = (result.suggestedDefaults as { askFirstRiskLevels?: unknown })?.askFirstRiskLevels;
|
||||
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : ["write", "destructive"];
|
||||
}
|
||||
|
||||
/**
|
||||
* M8a — "Paste a config" tab on the Advanced door (PAP-10862, plan D8).
|
||||
*
|
||||
* A thin, honest surface over `POST /companies/:id/tools/mcp/import-json`: paste
|
||||
* the snippet a README tells you to copy, and we parse it into a friendly
|
||||
* preview (humanized field labels, never the raw transport jargon). This is one
|
||||
* of the two M8 screens where "MCP" vocabulary is allowed (PAP-10827 vocab map).
|
||||
*/
|
||||
export function PasteConfigTab({ companyId }: { companyId: string }) {
|
||||
const [draftText, setDraftText] = useState("");
|
||||
const [preview, setPreview] = useState<McpJsonImportPreview | null>(null);
|
||||
const [credentialValues, setCredentialValues] = useState<Record<string, string>>({});
|
||||
const [connectResult, setConnectResult] = useState<ConnectToolAppResult | null>(null);
|
||||
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
|
||||
const [activatedName, setActivatedName] = useState<string | null>(null);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (mcpJson: string) => toolsApi.importMcpJson(companyId, { mcpJson }),
|
||||
onSuccess: (result) => {
|
||||
setPreview(result);
|
||||
setConnectResult(null);
|
||||
setActivatedName(null);
|
||||
},
|
||||
});
|
||||
|
||||
const connectMutation = useMutation({
|
||||
mutationFn: (draft: McpJsonImportDraft) => {
|
||||
const url = draftConnectUrl(draft);
|
||||
if (!url) throw new Error("Only remote HTTP drafts can be checked and activated from pasted config.");
|
||||
return toolsApi.connectApp(companyId, {
|
||||
link: url,
|
||||
name: draft.name,
|
||||
credentialValues: credentialValuesForDraft(draft, credentialValues),
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setConnectResult(result);
|
||||
const defaults: Record<string, boolean> = {};
|
||||
for (const action of result.actions.readOnly) defaults[action.catalogEntryId] = true;
|
||||
for (const action of result.actions.canMakeChanges) defaults[action.catalogEntryId] = false;
|
||||
setEnabled(defaults);
|
||||
setActivatedName(null);
|
||||
},
|
||||
});
|
||||
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const askFirstLevels = connectResult ? askFirstLevelsFrom(connectResult) : [];
|
||||
const enabledIds = Object.entries(enabled).filter(([, on]) => on).map(([id]) => id);
|
||||
const askFirstIds = (connectResult?.actions.canMakeChanges ?? [])
|
||||
.filter((action) => enabled[action.catalogEntryId] && askFirstLevels.includes(action.riskLevel))
|
||||
.map((action) => action.catalogEntryId);
|
||||
return toolsApi.finishApp(companyId, connectResult!.connectionId, {
|
||||
enabledCatalogEntryIds: enabledIds,
|
||||
askFirstCatalogEntryIds: askFirstIds,
|
||||
access: "all_agents",
|
||||
});
|
||||
},
|
||||
onSuccess: () => setActivatedName(connectResult?.application.name ?? "Imported app"),
|
||||
});
|
||||
|
||||
const drafts = preview?.drafts ?? [];
|
||||
const canSubmit = draftText.trim().length > 0 && !importMutation.isPending;
|
||||
|
||||
const localParseError = useMemo(() => {
|
||||
const trimmed = draftText.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return null;
|
||||
} catch {
|
||||
return "That doesn't look like valid JSON yet — paste the whole snippet, including the outer braces.";
|
||||
}
|
||||
}, [draftText]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Paste the MCP config snippet from the tool's README and we'll turn it into a friendly setup.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Just a URL?{" "}
|
||||
<Link to="/apps/browse" className="text-primary hover:underline">
|
||||
Browse planned app connections
|
||||
</Link>{" "}
|
||||
instead.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={draftText}
|
||||
onChange={(event) => {
|
||||
setDraftText(event.target.value);
|
||||
if (preview) setPreview(null);
|
||||
setConnectResult(null);
|
||||
setActivatedName(null);
|
||||
}}
|
||||
spellCheck={false}
|
||||
rows={10}
|
||||
placeholder={SAMPLE_CONFIG}
|
||||
className="min-h-(--sz-220px) bg-slate-900 font-mono text-(length:--text-compact) leading-relaxed text-slate-100 placeholder:text-slate-500 focus-visible:ring-slate-400"
|
||||
/>
|
||||
{localParseError ? (
|
||||
<p className="text-xs text-amber-600">{localParseError}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste an MCP config — the snippet a README tells you to copy.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
onClick={() => importMutation.mutate(draftText)}
|
||||
disabled={!canSubmit || Boolean(localParseError)}
|
||||
>
|
||||
{importMutation.isPending ? "Checking…" : "Check config"}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
We'll read it and show what we found before anything is saved.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{importMutation.isError ? <ErrorState error={importMutation.error} /> : null}
|
||||
|
||||
{preview ? (
|
||||
drafts.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
We couldn't find an app in that config. Double-check you pasted the whole snippet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
We found {drafts.length} {drafts.length === 1 ? "app" : "apps"} in that config
|
||||
</h3>
|
||||
{drafts.map((draft, index) => {
|
||||
const url = draftConnectUrl(draft);
|
||||
const missingFields = missingCredentialFields(draft, credentialValues);
|
||||
return (
|
||||
<DraftCard
|
||||
key={`${draft.name}-${index}`}
|
||||
draft={draft}
|
||||
credentialValues={credentialValues}
|
||||
onCredentialChange={(configPath, value) =>
|
||||
setCredentialValues((prev) => ({ ...prev, [credentialValueKey(draft, configPath)]: value }))
|
||||
}
|
||||
checking={connectMutation.isPending && connectMutation.variables?.name === draft.name}
|
||||
canCheck={Boolean(url) && missingFields.length === 0}
|
||||
onCheck={url ? () => connectMutation.mutate(draft) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{drafts.some((d) => draftConnectUrl(d)) ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Checking a remote app creates a draft connection, stores any header replacements as Paperclip secrets,
|
||||
and runs health/catalog discovery before activation.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
We humanized the field names from the config. These run-in-your-workspace tools stay as drafts until an
|
||||
admin maps them to an approved template.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{connectMutation.isError ? <ErrorState error={connectMutation.error} /> : null}
|
||||
{connectResult ? (
|
||||
<CatalogReview
|
||||
result={connectResult}
|
||||
enabled={enabled}
|
||||
onToggle={(id, on) => setEnabled((prev) => ({ ...prev, [id]: on }))}
|
||||
onBulk={(ids, on) =>
|
||||
setEnabled((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of ids) next[id] = on;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
finishing={finishMutation.isPending}
|
||||
activatedName={activatedName}
|
||||
onFinish={() => finishMutation.mutate()}
|
||||
/>
|
||||
) : null}
|
||||
{finishMutation.isError ? <ErrorState error={finishMutation.error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftCard({
|
||||
draft,
|
||||
credentialValues,
|
||||
onCredentialChange,
|
||||
checking,
|
||||
canCheck,
|
||||
onCheck,
|
||||
}: {
|
||||
draft: McpJsonImportDraft;
|
||||
credentialValues: Record<string, string>;
|
||||
onCredentialChange: (configPath: string, value: string) => void;
|
||||
checking: boolean;
|
||||
canCheck: boolean;
|
||||
onCheck?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-foreground">{draft.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{draftSummary(draft)}</div>
|
||||
</div>
|
||||
{onCheck ? (
|
||||
<Button size="sm" className="shrink-0" onClick={onCheck} disabled={checking || !canCheck}>
|
||||
{checking ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Check actions
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{draft.credentialFields.length > 0 ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{draft.credentialFields.map((field) => (
|
||||
<div key={`${field.configPath}-${field.key}`} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{humanizeKey(field.label || field.key)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded border border-border bg-muted/40 px-2 py-1 font-mono text-(length:--text-micro) text-muted-foreground">
|
||||
{field.key}
|
||||
</code>
|
||||
<Input
|
||||
type="password"
|
||||
value={credentialValues[credentialValueKey(draft, field.configPath)] ?? ""}
|
||||
onChange={(event) => onCredentialChange(field.configPath, event.target.value)}
|
||||
placeholder="Paste replacement value"
|
||||
className="h-8 max-w-sm text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : draft.credentialRefs.length > 0 ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Keys from this config stay draft-only until an admin maps them to an approved template.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-muted-foreground">No keys needed for this one.</p>
|
||||
)}
|
||||
|
||||
{draft.warnings.length > 0 ? (
|
||||
<ul className="mt-4 space-y-1 border-t border-border pt-3">
|
||||
{draft.warnings.map((warning, i) => (
|
||||
<li key={i} className="text-xs text-amber-600">
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogReview({
|
||||
result,
|
||||
enabled,
|
||||
onToggle,
|
||||
onBulk,
|
||||
finishing,
|
||||
activatedName,
|
||||
onFinish,
|
||||
}: {
|
||||
result: ConnectToolAppResult;
|
||||
enabled: Record<string, boolean>;
|
||||
onToggle: (id: string, on: boolean) => void;
|
||||
onBulk: (ids: string[], on: boolean) => void;
|
||||
finishing: boolean;
|
||||
activatedName: string | null;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
const askFirstLevels = askFirstLevelsFrom(result);
|
||||
const enabledCount = Object.values(enabled).filter(Boolean).length;
|
||||
const total = result.actions.readOnly.length + result.actions.canMakeChanges.length;
|
||||
return (
|
||||
<div className="space-y-4 border-t border-border pt-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-600" />
|
||||
Review actions for {result.application.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Health and catalog checks passed. Read-only actions start on; actions that can change data start off.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={onFinish} disabled={finishing || enabledCount === 0 || Boolean(activatedName)}>
|
||||
{finishing ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Activate {enabledCount} of {total}
|
||||
</Button>
|
||||
</div>
|
||||
<ActionGroup
|
||||
title="Read-only"
|
||||
actions={result.actions.readOnly}
|
||||
enabled={enabled}
|
||||
onToggle={onToggle}
|
||||
onBulk={(on) => onBulk(result.actions.readOnly.map((action) => action.catalogEntryId), on)}
|
||||
askFirstLevels={askFirstLevels}
|
||||
/>
|
||||
<ActionGroup
|
||||
title="Can make changes"
|
||||
actions={result.actions.canMakeChanges}
|
||||
enabled={enabled}
|
||||
onToggle={onToggle}
|
||||
onBulk={(on) => onBulk(result.actions.canMakeChanges.map((action) => action.catalogEntryId), on)}
|
||||
askFirstLevels={askFirstLevels}
|
||||
/>
|
||||
{activatedName ? (
|
||||
<p className="text-xs font-medium text-emerald-700">{activatedName} is active for all agents.</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionGroup({
|
||||
title,
|
||||
actions,
|
||||
enabled,
|
||||
onToggle,
|
||||
onBulk,
|
||||
askFirstLevels,
|
||||
}: {
|
||||
title: string;
|
||||
actions: ToolAppConnectionActionSummary[];
|
||||
enabled: Record<string, boolean>;
|
||||
onToggle: (id: string, on: boolean) => void;
|
||||
onBulk: (on: boolean) => void;
|
||||
askFirstLevels: string[];
|
||||
}) {
|
||||
if (actions.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={() => onBulk(true)}>
|
||||
Turn all on
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={() => onBulk(false)}>
|
||||
Turn all off
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border rounded-lg border border-border">
|
||||
{actions.map((action) => {
|
||||
const on = enabled[action.catalogEntryId] ?? false;
|
||||
return (
|
||||
<div key={action.catalogEntryId} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{action.title || action.toolName}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{askFirstLevels.includes(action.riskLevel) ? "Ask first when enabled" : action.riskLevel}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch checked={on} onCheckedChange={(next) => onToggle(action.catalogEntryId, next)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ToolCatalogEntry, ToolPolicy } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const toolsApiMock = vi.hoisted(() => ({
|
||||
listPolicies: vi.fn(),
|
||||
createPolicy: vi.fn(),
|
||||
reorderPolicies: vi.fn(),
|
||||
duplicatePolicy: vi.fn(),
|
||||
updatePolicy: vi.fn(),
|
||||
deletePolicy: vi.fn(),
|
||||
listTrustRules: vi.fn(),
|
||||
revokeTrustRule: vi.fn(),
|
||||
testPolicy: vi.fn(),
|
||||
listAudit: vi.fn(),
|
||||
listApplications: vi.fn(),
|
||||
listConnections: vi.fn(),
|
||||
listCatalog: vi.fn(),
|
||||
}));
|
||||
const agentsApiMock = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const projectsApiMock = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const toastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({ toolsApi: toolsApiMock }));
|
||||
vi.mock("@/api/agents", () => ({ agentsApi: agentsApiMock }));
|
||||
vi.mock("@/api/projects", () => ({ projectsApi: projectsApiMock }));
|
||||
vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ pushToast: toastMock }) }));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onSelect?: () => void;
|
||||
className?: string;
|
||||
}) => createElement("button", { type: "button", className, onClick: onSelect }, children),
|
||||
DropdownMenuSeparator: () => createElement("hr"),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open?: boolean; children: ReactNode }) => (open ? createElement("div", null, children) : null),
|
||||
DialogContent: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => createElement("p", null, children),
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => createElement("h2", null, children),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
Sheet: ({ open, children }: { open?: boolean; children: ReactNode }) => (open ? createElement("div", null, children) : null),
|
||||
SheetContent: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
SheetDescription: ({ children }: { children: ReactNode }) => createElement("p", null, children),
|
||||
SheetFooter: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
SheetHeader: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
SheetTitle: ({ children }: { children: ReactNode }) => createElement("h2", null, children),
|
||||
}));
|
||||
|
||||
import { PoliciesTab } from "./PoliciesTab";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function policy(partial: Partial<ToolPolicy> & { id: string; policyType: ToolPolicy["policyType"] }): ToolPolicy {
|
||||
return {
|
||||
id: partial.id,
|
||||
companyId: "company-1",
|
||||
name: partial.name ?? partial.id,
|
||||
description: partial.description ?? null,
|
||||
policyType: partial.policyType,
|
||||
priority: partial.priority ?? 100,
|
||||
enabled: partial.enabled ?? true,
|
||||
selectors: partial.selectors ?? {},
|
||||
conditions: partial.conditions ?? null,
|
||||
config: partial.config ?? null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function catalog(partial: Partial<ToolCatalogEntry> & { id: string; toolName: string }): ToolCatalogEntry {
|
||||
return {
|
||||
id: partial.id,
|
||||
companyId: "company-1",
|
||||
applicationId: partial.applicationId ?? "app-gmail",
|
||||
connectionId: partial.connectionId ?? "conn-gmail",
|
||||
entryKind: "tool",
|
||||
name: partial.name,
|
||||
toolName: partial.toolName,
|
||||
title: partial.title ?? partial.toolName,
|
||||
description: partial.description ?? null,
|
||||
inputSchema: null,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: partial.riskLevel ?? "read",
|
||||
isReadOnly: partial.isReadOnly ?? true,
|
||||
isWrite: partial.isWrite ?? false,
|
||||
isDestructive: partial.isDestructive ?? false,
|
||||
status: partial.status ?? "active",
|
||||
addedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
version: null,
|
||||
versionHash: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
describe("PoliciesTab", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
agentsApiMock.list.mockResolvedValue([{ id: "agent-1", name: "Fable" }]);
|
||||
projectsApiMock.list.mockResolvedValue([{ id: "project-1", name: "Launch" }]);
|
||||
toolsApiMock.listApplications.mockResolvedValue({ applications: [{ id: "app-gmail", name: "Gmail" }] });
|
||||
toolsApiMock.listConnections.mockResolvedValue({ connections: [{ id: "conn-gmail", name: "Gmail" }] });
|
||||
toolsApiMock.listCatalog.mockResolvedValue({
|
||||
catalog: [
|
||||
catalog({ id: "send", toolName: "gmail.send", title: "Send email", riskLevel: "write", isReadOnly: false, isWrite: true }),
|
||||
catalog({ id: "delete", toolName: "gmail.delete", title: "Delete email", riskLevel: "destructive", isReadOnly: false, isWrite: true, isDestructive: true }),
|
||||
],
|
||||
});
|
||||
toolsApiMock.listTrustRules.mockResolvedValue({ trustRules: [] });
|
||||
toolsApiMock.listAudit.mockResolvedValue([
|
||||
{ createdAt: new Date().toISOString(), details: { matchedPolicyIds: ["rule-1"] } },
|
||||
{ createdAt: new Date().toISOString(), details: { matchedPolicyIds: ["rule-1"] } },
|
||||
{ createdAt: new Date().toISOString(), details: { matchedPolicyIds: ["rule-1"] } },
|
||||
]);
|
||||
toolsApiMock.reorderPolicies.mockResolvedValue({ policies: [] });
|
||||
toolsApiMock.duplicatePolicy.mockResolvedValue(policy({ id: "copy", policyType: "block" }));
|
||||
toolsApiMock.updatePolicy.mockResolvedValue(policy({ id: "rule-1", policyType: "block" }));
|
||||
toolsApiMock.deletePolicy.mockResolvedValue(policy({ id: "rule-1", policyType: "block" }));
|
||||
toolsApiMock.revokeTrustRule.mockResolvedValue(policy({ id: "trust-1", policyType: "trust_rule" }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render(policies: ToolPolicy[]) {
|
||||
toolsApiMock.listPolicies.mockResolvedValue({ policies });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PoliciesTab companyId="company-1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders ordered sentence rows without exposing priority numbers", async () => {
|
||||
await render([
|
||||
policy({
|
||||
id: "rule-1",
|
||||
policyType: "require_approval",
|
||||
priority: 500,
|
||||
selectors: { agentId: "agent-1", riskLevel: "destructive" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain("Rules are checked top to bottom");
|
||||
expect(container.textContent).toContain("When Fable uses destructive actions → Ask first");
|
||||
expect(container.textContent).toContain("3 times");
|
||||
expect(container.textContent).not.toContain("priority 500");
|
||||
});
|
||||
|
||||
it("does not advertise or seed wildcard action selectors", async () => {
|
||||
await render([]);
|
||||
|
||||
expect(container.textContent).toContain("Ask first before selected actions");
|
||||
expect(container.textContent).not.toContain("Wildcard action names");
|
||||
expect(container.textContent).not.toContain("*send*");
|
||||
expect(container.textContent).not.toContain("*delete*");
|
||||
expect(container.textContent).not.toContain("Hide sensitive details");
|
||||
expect(container.textContent).not.toContain("Custom check");
|
||||
|
||||
const starter = [...container.querySelectorAll("button")].find((button) =>
|
||||
button.textContent?.includes("Ask first before selected actions")
|
||||
);
|
||||
flushSync(() => starter?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector("#tool-patterns")).toBeNull();
|
||||
expect(container.querySelector("#conditions-json")).toBeNull();
|
||||
expect(container.querySelector("#config-json")).toBeNull();
|
||||
expect([...container.querySelectorAll("input")].map((input) => input.value).join(" ")).not.toContain("*");
|
||||
});
|
||||
|
||||
it("wires duplicate, toggle, delete, and reorder actions to the Rules endpoints", async () => {
|
||||
await render([
|
||||
policy({ id: "rule-1", policyType: "block", selectors: { toolName: "gmail.delete" } }),
|
||||
policy({ id: "rule-2", policyType: "allow", selectors: { applicationId: "app-gmail" }, priority: 200 }),
|
||||
]);
|
||||
|
||||
const duplicateButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Duplicate"));
|
||||
flushSync(() => duplicateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(toolsApiMock.duplicatePolicy).toHaveBeenCalledWith("company-1", "rule-1");
|
||||
|
||||
const firstSwitch = container.querySelector<HTMLButtonElement>('[role="switch"]');
|
||||
flushSync(() => firstSwitch?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(toolsApiMock.updatePolicy).toHaveBeenCalledWith("company-1", "rule-1", { enabled: false });
|
||||
|
||||
const rows = container.querySelectorAll("tbody tr");
|
||||
flushSync(() => {
|
||||
rows[0]?.dispatchEvent(new Event("dragstart", { bubbles: true }));
|
||||
rows[1]?.dispatchEvent(new Event("drop", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(toolsApiMock.reorderPolicies).toHaveBeenCalledWith("company-1", { policyIds: ["rule-2", "rule-1"] });
|
||||
|
||||
const deleteButton = [...container.querySelectorAll("button")].find((button) =>
|
||||
button.textContent?.includes("Delete") && button.className.includes("text-destructive")
|
||||
);
|
||||
flushSync(() => deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("matched 3 times in the last 24 hours");
|
||||
|
||||
const confirmDelete = [...container.querySelectorAll("button")].filter((button) => button.textContent?.includes("Delete")).at(-1);
|
||||
flushSync(() => confirmDelete?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(toolsApiMock.deletePolicy).toHaveBeenCalledWith("company-1", "rule-1");
|
||||
});
|
||||
|
||||
it("shows remembered approvals with Forget confirmation", async () => {
|
||||
toolsApiMock.listTrustRules.mockResolvedValue({
|
||||
trustRules: [
|
||||
policy({
|
||||
id: "trust-1",
|
||||
policyType: "trust_rule",
|
||||
selectors: { agentId: "agent-1", toolName: "gmail.send" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
await render([]);
|
||||
|
||||
expect(container.textContent).toContain("Remembered approvals");
|
||||
expect(container.textContent).toContain("When Fable uses Send email → Allow");
|
||||
|
||||
const forget = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Forget"));
|
||||
flushSync(() => forget?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Paperclip will ask again");
|
||||
|
||||
const confirmForget = [...container.querySelectorAll("button")].filter((button) => button.textContent?.includes("Forget")).at(-1);
|
||||
flushSync(() => confirmForget?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(toolsApiMock.revokeTrustRule).toHaveBeenCalledWith("company-1", "trust-1");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,195 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
ToolCatalogEntry,
|
||||
ToolProfileEntry,
|
||||
ToolProfileWithDetails,
|
||||
} from "@paperclipai/shared";
|
||||
import { resolveAllowList } from "./ProfilesTab";
|
||||
|
||||
function tool(partial: Partial<ToolCatalogEntry> & { id: string; toolName: string }): ToolCatalogEntry {
|
||||
return {
|
||||
companyId: "c1",
|
||||
applicationId: "app-slack",
|
||||
connectionId: "conn-1",
|
||||
entryKind: "tool",
|
||||
title: null,
|
||||
description: null,
|
||||
inputSchema: null,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
version: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date(),
|
||||
lastSeenAt: new Date(),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...partial,
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
function entry(partial: Partial<ToolProfileEntry> & Pick<ToolProfileEntry, "selectorType">): ToolProfileEntry {
|
||||
return {
|
||||
id: `e-${Math.round(Math.random() * 1e9)}`,
|
||||
companyId: "c1",
|
||||
profileId: "p1",
|
||||
effect: "include",
|
||||
applicationId: null,
|
||||
connectionId: null,
|
||||
catalogEntryId: null,
|
||||
toolName: null,
|
||||
riskLevel: null,
|
||||
conditions: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...partial,
|
||||
} as ToolProfileEntry;
|
||||
}
|
||||
|
||||
function profile(partial: Partial<ToolProfileWithDetails>): ToolProfileWithDetails {
|
||||
return {
|
||||
id: "p1",
|
||||
companyId: "c1",
|
||||
profileKey: "k",
|
||||
name: "Profile",
|
||||
description: null,
|
||||
status: "active",
|
||||
defaultAction: "deny",
|
||||
newToolsReviewedAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
entries: [],
|
||||
bindings: [],
|
||||
summary: {
|
||||
accessMode: "selected",
|
||||
allowedToolCount: 0,
|
||||
allowedApplicationCount: 0,
|
||||
excludedToolCount: 0,
|
||||
totalToolCount: 0,
|
||||
assignmentCount: 0,
|
||||
appliesToAgentCount: 0,
|
||||
isCompanyDefault: false,
|
||||
},
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const appsById = new Map([["app-slack", "Slack"]]);
|
||||
const connsById = new Map([["conn-1", "Slack prod"]]);
|
||||
|
||||
const catalog: ToolCatalogEntry[] = [
|
||||
tool({ id: "t-list-channels", toolName: "slack.list_channels", riskLevel: "read" }),
|
||||
tool({ id: "t-list-users", toolName: "slack.list_users", riskLevel: "read" }),
|
||||
tool({
|
||||
id: "t-post",
|
||||
toolName: "slack.post_message",
|
||||
riskLevel: "medium",
|
||||
isReadOnly: false,
|
||||
isWrite: true,
|
||||
}),
|
||||
];
|
||||
|
||||
describe("resolveAllowList", () => {
|
||||
it("marks an exact tool_name include as explicit", () => {
|
||||
const rows = resolveAllowList(
|
||||
profile({ entries: [entry({ selectorType: "tool_name", toolName: "slack.post_message" })] }),
|
||||
catalog,
|
||||
appsById,
|
||||
connsById,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].toolName).toBe("slack.post_message");
|
||||
expect(rows[0].source).toEqual({ kind: "explicit" });
|
||||
expect(rows[0].applicationName).toBe("Slack");
|
||||
expect(rows[0].isWrite).toBe(true);
|
||||
});
|
||||
|
||||
it("flags wildcard tool_name matches as a pattern source", () => {
|
||||
const rows = resolveAllowList(
|
||||
profile({ entries: [entry({ selectorType: "tool_name", toolName: "slack.list_*" })] }),
|
||||
catalog,
|
||||
appsById,
|
||||
connsById,
|
||||
);
|
||||
const names = rows.map((r) => r.toolName).sort();
|
||||
expect(names).toEqual(["slack.list_channels", "slack.list_users"]);
|
||||
expect(rows.every((r) => r.source.kind === "pattern" && r.source.label === "slack.list_*")).toBe(true);
|
||||
});
|
||||
|
||||
it("labels application selectors as pattern app:<name>", () => {
|
||||
const rows = resolveAllowList(
|
||||
profile({ entries: [entry({ selectorType: "application", applicationId: "app-slack" })] }),
|
||||
catalog,
|
||||
appsById,
|
||||
connsById,
|
||||
);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[0].source).toEqual({ kind: "pattern", label: "app:Slack" });
|
||||
});
|
||||
|
||||
it("prefers an explicit grant over an overlapping pattern", () => {
|
||||
const rows = resolveAllowList(
|
||||
profile({
|
||||
entries: [
|
||||
entry({ selectorType: "application", applicationId: "app-slack" }),
|
||||
entry({ selectorType: "tool_name", toolName: "slack.post_message" }),
|
||||
],
|
||||
}),
|
||||
catalog,
|
||||
appsById,
|
||||
connsById,
|
||||
);
|
||||
const post = rows.find((r) => r.toolName === "slack.post_message");
|
||||
expect(post?.source).toEqual({ kind: "explicit" });
|
||||
});
|
||||
|
||||
it("removes excluded tools even when a pattern would include them", () => {
|
||||
const rows = resolveAllowList(
|
||||
profile({
|
||||
entries: [
|
||||
entry({ selectorType: "application", applicationId: "app-slack" }),
|
||||
entry({ selectorType: "tool_name", toolName: "slack.post_message", effect: "exclude" }),
|
||||
],
|
||||
}),
|
||||
catalog,
|
||||
appsById,
|
||||
connsById,
|
||||
);
|
||||
expect(rows.find((r) => r.toolName === "slack.post_message")).toBeUndefined();
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("includes the whole catalog by default when defaultAction is allow", () => {
|
||||
const rows = resolveAllowList(
|
||||
profile({ defaultAction: "allow" }),
|
||||
catalog,
|
||||
appsById,
|
||||
connsById,
|
||||
);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.every((r) => r.source.kind === "default")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an explicit grant visible even when the catalog has no match", () => {
|
||||
const rows = resolveAllowList(
|
||||
profile({ entries: [entry({ selectorType: "tool_name", toolName: "github.create_pr" })] }),
|
||||
catalog,
|
||||
appsById,
|
||||
connsById,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].toolName).toBe("github.create_pr");
|
||||
expect(rows[0].source).toEqual({ kind: "explicit" });
|
||||
expect(rows[0].risk).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,290 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Lock, Plus, ShieldCheck, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { LoadingState, ErrorState, RelativeTime } from "./shared";
|
||||
|
||||
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/i;
|
||||
|
||||
/** Slugify a display name into a `safeKeyPattern`-valid template id. */
|
||||
function toTemplateId(name: string): string {
|
||||
const slug = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._:-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "");
|
||||
return slug;
|
||||
}
|
||||
|
||||
/** Split a typed command line into command + args on whitespace. */
|
||||
function splitCommand(raw: string): { command: string; args: string[] } {
|
||||
const parts = raw.trim().split(/\s+/).filter(Boolean);
|
||||
return { command: parts[0] ?? "", args: parts.slice(1) };
|
||||
}
|
||||
|
||||
type KeyRow = { id: number; value: string };
|
||||
|
||||
/**
|
||||
* M8b — "Run your own" tab on the Advanced door (PAP-10862, plan D8).
|
||||
*
|
||||
* Admin-only surface over P5a's command-template routes
|
||||
* (`POST /companies/:id/tools/stdio-templates`). Registers a command that
|
||||
* Paperclip will run in the company's isolated workspace and the keys it
|
||||
* expects. One of the two M8 screens where "MCP" vocabulary is allowed.
|
||||
*/
|
||||
export function RunYourOwnTab({ companyId }: { companyId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [command, setCommand] = useState("");
|
||||
const [keyRows, setKeyRows] = useState<KeyRow[]>([]);
|
||||
const [nextRowId, setNextRowId] = useState(1);
|
||||
|
||||
const templates = useQuery({
|
||||
queryKey: queryKeys.tools.stdioTemplates(companyId),
|
||||
queryFn: () => toolsApi.listStdioTemplates(companyId),
|
||||
});
|
||||
|
||||
const envKeys = useMemo(
|
||||
() => keyRows.map((row) => row.value.trim()).filter(Boolean),
|
||||
[keyRows],
|
||||
);
|
||||
const invalidKeys = envKeys.filter((key) => !ENV_KEY_RE.test(key));
|
||||
const parsed = splitCommand(command);
|
||||
const templateId = toTemplateId(name);
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
parsed.command.length > 0 &&
|
||||
templateId.length > 0 &&
|
||||
invalidKeys.length === 0;
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
toolsApi.createStdioTemplate(companyId, {
|
||||
templateId,
|
||||
name: name.trim(),
|
||||
command: parsed.command,
|
||||
args: parsed.args,
|
||||
envKeys,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Tool added", body: `"${name.trim()}" is ready to connect.`, tone: "success" });
|
||||
setName("");
|
||||
setCommand("");
|
||||
setKeyRows([]);
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.stdioTemplates(companyId) });
|
||||
},
|
||||
});
|
||||
|
||||
const addKeyRow = () => {
|
||||
setKeyRows((rows) => [...rows, { id: nextRowId, value: "" }]);
|
||||
setNextRowId((id) => id + 1);
|
||||
};
|
||||
|
||||
const adminTemplates = (templates.data?.templates ?? []).filter((t) => t.source === "admin");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
For a tool that runs from a command. Paperclip runs it in your company's own isolated workspace.
|
||||
Administrators only.
|
||||
</p>
|
||||
|
||||
<div className="space-y-5 rounded-lg border border-border bg-card p-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ryo-name">Name</Label>
|
||||
<Input
|
||||
id="ryo-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Acme tools"
|
||||
maxLength={160}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">What you'll call this tool in your apps list.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ryo-command">Command</Label>
|
||||
<Input
|
||||
id="ryo-command"
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder="npx -y @acme/mcp-tool"
|
||||
spellCheck={false}
|
||||
className="bg-slate-900 font-mono text-(length:--text-compact) text-slate-100 placeholder:text-slate-500 focus-visible:ring-slate-400"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">The command that runs the tool. From the tool's README.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<Label>Keys it needs</Label>
|
||||
<span className="text-xs text-muted-foreground">· optional, depends on the tool</span>
|
||||
</div>
|
||||
{keyRows.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{keyRows.map((row) => {
|
||||
const value = row.value.trim();
|
||||
const invalid = value.length > 0 && !ENV_KEY_RE.test(value);
|
||||
return (
|
||||
<div key={row.id} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(event) =>
|
||||
setKeyRows((rows) =>
|
||||
rows.map((r) => (r.id === row.id ? { ...r, value: event.target.value } : r)),
|
||||
)
|
||||
}
|
||||
placeholder="API_KEY"
|
||||
spellCheck={false}
|
||||
className={`font-mono text-(length:--text-compact) ${invalid ? "border-destructive" : ""}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Remove key"
|
||||
onClick={() => setKeyRows((rows) => rows.filter((r) => r.id !== row.id))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{invalid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Use letters, numbers and underscores, starting with a letter or underscore (e.g. API_KEY).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" size="sm" onClick={addKeyRow} className="gap-1.5">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add a key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2.5 rounded-md bg-muted/50 px-3 py-2.5">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
|
||||
<div className="text-xs">
|
||||
<p className="font-medium text-foreground">
|
||||
This runs in your company's own workspace, isolated from everything else.
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
Only administrators see this option.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createMutation.isError ? <ErrorState error={createMutation.error} /> : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => createMutation.mutate()} disabled={!canSubmit || createMutation.isPending}>
|
||||
{createMutation.isPending ? "Adding…" : "Check & continue"}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Paperclip will register the command and the keys it needs.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Your own tools</h3>
|
||||
{templates.isLoading ? (
|
||||
<LoadingState />
|
||||
) : templates.isError ? (
|
||||
<ErrorState error={templates.error} onRetry={() => templates.refetch()} />
|
||||
) : adminTemplates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">You haven't added any of your own tools yet.</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-2.5">Name</th>
|
||||
<th className="px-4 py-2.5">Command</th>
|
||||
<th className="px-4 py-2.5">Keys</th>
|
||||
<th className="px-4 py-2.5">Added</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adminTemplates.map((template) => (
|
||||
<RunYourOwnRow key={template.templateId} companyId={companyId} template={template} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunYourOwnRow({
|
||||
companyId,
|
||||
template,
|
||||
}: {
|
||||
companyId: string;
|
||||
template: import("@/api/tools").StdioTemplateSummary;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: () => toolsApi.disableStdioTemplate(companyId, template.templateId),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Tool turned off", tone: "success" });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.stdioTemplates(companyId) });
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Couldn't turn it off",
|
||||
body: error instanceof Error ? error.message : undefined,
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const disabled = template.status === "disabled";
|
||||
const fullCommand = [template.command ?? "", ...(template.args ?? [])].join(" ").trim();
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{template.name}</div>
|
||||
{disabled ? <Badge variant="outline">off</Badge> : null}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="font-mono text-(length:--text-micro) text-muted-foreground">{fullCommand || "—"}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{template.envKeys.length > 0 ? template.envKeys.join(", ") : "none"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RelativeTime value={template.createdAt} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{disabled ? null : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => disableMutation.mutate()}
|
||||
disabled={disableMutation.isPending}
|
||||
>
|
||||
Turn off
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { RuntimeTab } from "./RuntimeTab";
|
||||
|
||||
const listRuntimeSlotsMock = vi.hoisted(() => vi.fn());
|
||||
const getRuntimeHealthMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionsMock = vi.hoisted(() => vi.fn());
|
||||
const stopRuntimeSlotMock = vi.hoisted(() => vi.fn());
|
||||
const restartRuntimeSlotMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listRuntimeSlots: (companyId: string) => listRuntimeSlotsMock(companyId),
|
||||
getRuntimeHealth: (companyId: string) => getRuntimeHealthMock(companyId),
|
||||
listConnections: (companyId: string) => listConnectionsMock(companyId),
|
||||
stopRuntimeSlot: (companyId: string, slotId: string) => stopRuntimeSlotMock(companyId, slotId),
|
||||
restartRuntimeSlot: (companyId: string, slotId: string) => restartRuntimeSlotMock(companyId, slotId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function slot(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "slot-1",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-1",
|
||||
connectionId: "conn-1",
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
ownerScopeType: "company",
|
||||
ownerScopeId: null,
|
||||
runtimeKind: "local_stdio",
|
||||
slotKey: "gmail-stdio-local",
|
||||
status: "running",
|
||||
reuseKey: null,
|
||||
workspaceScope: null,
|
||||
credentialScopeHash: null,
|
||||
provider: null,
|
||||
providerRef: null,
|
||||
processId: 41832,
|
||||
commandTemplateKey: "gmail",
|
||||
healthStatus: "healthy",
|
||||
lastHealthCheckAt: null,
|
||||
idleExpiresAt: null,
|
||||
startedAt: new Date("2026-06-13T10:00:00Z"),
|
||||
stoppedAt: null,
|
||||
lastUsedAt: new Date("2026-06-13T12:55:00Z"),
|
||||
lastError: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-06-13T10:00:00Z"),
|
||||
updatedAt: new Date("2026-06-13T10:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function connection(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "conn-1",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-1",
|
||||
name: "Gmail",
|
||||
connectionKind: "managed",
|
||||
transport: "local_stdio",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "healthy",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-13T10:00:00Z"),
|
||||
updatedAt: new Date("2026-06-13T10:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function alert(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "mcp_runtime_connection_health_degraded",
|
||||
severity: "critical",
|
||||
status: "ok",
|
||||
threshold: "Any degraded connection.",
|
||||
observed: "1 degraded connection(s), 0 disabled connection(s).",
|
||||
description: "A configured MCP connection is not healthy or has been disabled.",
|
||||
firstResponderAction: "Run a connection health check.",
|
||||
runbookSection: "runbook#health",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function health(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
status: "ok",
|
||||
generatedAt: new Date("2026-06-13T13:00:00Z"),
|
||||
runbookPath: "docs/runbook.md",
|
||||
metrics: {
|
||||
averageToolLatencyMsLastHour: 1200,
|
||||
p95ToolLatencyMsLastHour: 2400,
|
||||
timeoutRateLastHour: 0,
|
||||
toolFailuresLastHour: 0,
|
||||
toolTimeoutsLastHour: 0,
|
||||
capacityDeferralsLastHour: 0,
|
||||
activeSlots: 1,
|
||||
runningSlots: 1,
|
||||
},
|
||||
supportMatrix: {},
|
||||
alerts: [],
|
||||
recommendations: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RuntimeTab", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listRuntimeSlotsMock.mockResolvedValue({ runtimeSlots: [slot()] });
|
||||
getRuntimeHealthMock.mockResolvedValue(health());
|
||||
listConnectionsMock.mockResolvedValue({ connections: [connection()] });
|
||||
stopRuntimeSlotMock.mockResolvedValue(slot({ status: "stopped" }));
|
||||
restartRuntimeSlotMock.mockResolvedValue(slot());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TooltipProvider>
|
||||
<RuntimeTab companyId="company-1" />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("shows the plain-words summary strip and a Working row linked to the app page", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Apps running");
|
||||
expect(container.textContent).toContain("1 of 1");
|
||||
expect(container.textContent).toContain("about 1.2s");
|
||||
expect(container.textContent).toContain("Working");
|
||||
|
||||
const appLink = container.querySelector<HTMLAnchorElement>('a[href="/apps/conn-1"]');
|
||||
expect(appLink?.textContent).toContain("Gmail");
|
||||
// No ops vocabulary on the primary surface.
|
||||
expect(container.textContent).not.toContain("P95 latency");
|
||||
expect(container.textContent).not.toContain("local_stdio");
|
||||
});
|
||||
|
||||
it("renders a plain needs-attention card for a firing alert and marks the row", async () => {
|
||||
getRuntimeHealthMock.mockResolvedValue(
|
||||
health({ status: "degraded", alerts: [alert({ status: "firing" })] }),
|
||||
);
|
||||
listConnectionsMock.mockResolvedValue({ connections: [connection({ healthStatus: "degraded" })] });
|
||||
|
||||
await render();
|
||||
|
||||
// Plain title, not the raw alert name / runbook on the surface.
|
||||
expect(container.textContent).toContain("An app needs reconnecting");
|
||||
expect(container.textContent).toContain("Needs attention");
|
||||
expect(container.textContent).not.toContain("mcp_runtime_connection_health_degraded");
|
||||
});
|
||||
|
||||
it("opens a confirm dialog before restarting and only mutates after confirm", async () => {
|
||||
await render();
|
||||
|
||||
const restartButton = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.trim() === "Restart",
|
||||
);
|
||||
expect(restartButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
restartButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// Confirm modal copy is present; nothing has been restarted yet.
|
||||
expect(document.body.textContent).toContain("Restart Gmail?");
|
||||
expect(restartRuntimeSlotMock).not.toHaveBeenCalled();
|
||||
|
||||
const confirmButton = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Restart" && b.closest('[data-slot="dialog-content"]'),
|
||||
);
|
||||
await act(async () => {
|
||||
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(restartRuntimeSlotMock).toHaveBeenCalledWith("company-1", "slot-1");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,660 @@
|
|||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronRight, Loader2, RotateCw, Server, Square } from "lucide-react";
|
||||
import type {
|
||||
ToolConnection,
|
||||
ToolRuntimeAlertRecommendation,
|
||||
ToolRuntimeMetricSnapshot,
|
||||
ToolRuntimeSlot,
|
||||
} from "@paperclipai/shared";
|
||||
import { humanizeConnectionDisplayName, isToolConnectionAttentionHealth } from "@paperclipai/shared";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { ToolsPageHeader, LoadingState, ErrorState, RelativeTime } from "./shared";
|
||||
|
||||
/** Working / Needs attention / Off — the only status vocabulary on this surface. */
|
||||
type RowStatus = "working" | "attention" | "off";
|
||||
|
||||
/**
|
||||
* A running-app row: a runtime slot joined to the connection it powers so we can
|
||||
* humanize its name and link to its `/apps/:connectionId` page. Status is derived
|
||||
* from the connection's health via `isToolConnectionAttentionHealth()` (with a
|
||||
* slot-health fallback) so the Apps index, app detail, and Health never disagree.
|
||||
*/
|
||||
interface RuntimeRow {
|
||||
slot: ToolRuntimeSlot;
|
||||
connection: ToolConnection | null;
|
||||
name: string;
|
||||
isLocal: boolean;
|
||||
status: RowStatus;
|
||||
}
|
||||
|
||||
/** A health value that means the runtime slot itself is unhealthy. */
|
||||
function slotHealthNeedsAttention(health: string | null | undefined): boolean {
|
||||
return health === "error" || health === "unhealthy" || health === "failed" || health === "degraded";
|
||||
}
|
||||
|
||||
function rowStatusFor(slot: ToolRuntimeSlot, connection: ToolConnection | null): RowStatus {
|
||||
if (slot.status === "stopped" || slot.status === "disabled") return "off";
|
||||
if (connection && isToolConnectionAttentionHealth(connection.healthStatus)) return "attention";
|
||||
if (slot.status === "failed" || slot.status === "error") return "attention";
|
||||
if (slotHealthNeedsAttention(slot.healthStatus)) return "attention";
|
||||
return "working";
|
||||
}
|
||||
|
||||
const STATUS_WORD: Record<RowStatus, string> = {
|
||||
working: "Working",
|
||||
attention: "Needs attention",
|
||||
off: "Off",
|
||||
};
|
||||
|
||||
/** Filled dot (working) / triangle (needs attention) / hollow dot (off). */
|
||||
function StatusMarker({ status }: { status: RowStatus }) {
|
||||
if (status === "attention") {
|
||||
return <span className="text-amber-600 dark:text-amber-400">▲</span>;
|
||||
}
|
||||
if (status === "off") {
|
||||
return <span className="inline-block h-2.5 w-2.5 rounded-full border border-muted-foreground/50" />;
|
||||
}
|
||||
return <span className="inline-block h-2.5 w-2.5 rounded-full bg-emerald-500" />;
|
||||
}
|
||||
|
||||
function humanizeRowName(slot: ToolRuntimeSlot, connection: ToolConnection | null): string {
|
||||
if (connection) return humanizeConnectionDisplayName(connection);
|
||||
return humanizeConnectionDisplayName(slot.commandTemplateKey ?? slot.providerRef ?? slot.id.slice(0, 8));
|
||||
}
|
||||
|
||||
/** Plain-words latency: "about 1.2s" / "about 240ms" / "—". */
|
||||
function formatTypicalLatency(ms: number | null | undefined): string {
|
||||
if (typeof ms !== "number" || Number.isNaN(ms)) return "—";
|
||||
if (ms >= 950) return `about ${(ms / 1000).toFixed(1)}s`;
|
||||
return `about ${Math.round(ms)}ms`;
|
||||
}
|
||||
|
||||
/** How the slot runs, in plain words. */
|
||||
function howItRuns(slot: ToolRuntimeSlot): string {
|
||||
return slot.runtimeKind === "local_stdio" ? "Runs on this machine" : "Connects over the internet";
|
||||
}
|
||||
|
||||
/** Humanize the owner scope into a plain phrase. */
|
||||
function scopeLabel(scope: string | null | undefined): string {
|
||||
switch (scope) {
|
||||
case "company":
|
||||
return "Whole company";
|
||||
case "project":
|
||||
case "project_workspace":
|
||||
return "This project";
|
||||
case "execution_workspace":
|
||||
case "issue":
|
||||
return "This task";
|
||||
case "agent":
|
||||
return "A single agent";
|
||||
default:
|
||||
return scope ? scope.replace(/[_-]+/g, " ") : "—";
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain-words trust tier — quarantined local code reads as such; remote is provider-side. */
|
||||
function trustTierLabel(slot: ToolRuntimeSlot): string {
|
||||
if (slot.runtimeKind !== "local_stdio") return "Provider-verified";
|
||||
const quarantined =
|
||||
slot.status === "failed" ||
|
||||
slot.status === "error" ||
|
||||
slot.healthStatus === "error" ||
|
||||
slot.healthStatus === "unhealthy";
|
||||
return quarantined ? "Quarantined" : "Trusted (runs locally)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-language translation for each supervisor alert. The runbook/severity
|
||||
* vocabulary stays out of these — it lives in the card's "Technical details".
|
||||
* `action` picks the one suggested button: restart the failing app, or a link to
|
||||
* the surface where the admin resolves it.
|
||||
*/
|
||||
type AlertAction = "restart" | "reviewApps" | "reviewActivity";
|
||||
const ALERT_COPY: Record<string, { title: string; body: (a: ToolRuntimeAlertRecommendation) => string; action: AlertAction }> = {
|
||||
mcp_runtime_stuck_starting_slot: {
|
||||
title: "An app is stuck starting up",
|
||||
body: () => "It began starting but never came online. Restarting usually clears this.",
|
||||
action: "restart",
|
||||
},
|
||||
mcp_runtime_stuck_running_slot: {
|
||||
title: "An app stopped responding",
|
||||
body: () => "The process is still running but isn't answering. Restarting usually clears this.",
|
||||
action: "restart",
|
||||
},
|
||||
mcp_runtime_high_timeout_rate: {
|
||||
title: "Apps are responding slowly",
|
||||
body: (a) => `Some actions are timing out (${a.observed.toLowerCase()}). Check the apps involved or try again shortly.`,
|
||||
action: "reviewActivity",
|
||||
},
|
||||
mcp_runtime_high_error_rate: {
|
||||
title: "Apps are failing more than usual",
|
||||
body: (a) => `Recent actions failed after they were allowed (${a.observed.toLowerCase()}).`,
|
||||
action: "reviewActivity",
|
||||
},
|
||||
mcp_runtime_capacity_deferrals_repeated: {
|
||||
title: "Too many apps running at once",
|
||||
body: (a) => `Some actions had to wait for a free slot (${a.observed.toLowerCase()}).`,
|
||||
action: "reviewActivity",
|
||||
},
|
||||
mcp_runtime_restart_storm: {
|
||||
title: "An app keeps restarting",
|
||||
body: (a) => `It has restarted repeatedly (${a.observed.toLowerCase()}). It may be misconfigured or offline.`,
|
||||
action: "restart",
|
||||
},
|
||||
mcp_runtime_connection_health_degraded: {
|
||||
title: "An app needs reconnecting",
|
||||
body: () => "A connected app isn't healthy. Open it to check the key or reconnect.",
|
||||
action: "reviewApps",
|
||||
},
|
||||
mcp_runtime_missing_secret_failures: {
|
||||
title: "An app is missing a key",
|
||||
body: () => "A saved key couldn't be found, so some actions failed. Reconnect the app to fix it.",
|
||||
action: "reviewApps",
|
||||
},
|
||||
mcp_runtime_audit_write_failures: {
|
||||
title: "Activity logging hit a problem",
|
||||
body: () => "Some actions may not have been recorded. This needs an administrator to look into it.",
|
||||
action: "reviewActivity",
|
||||
},
|
||||
};
|
||||
|
||||
function plainAlertTitle(alert: ToolRuntimeAlertRecommendation): string {
|
||||
return ALERT_COPY[alert.name]?.title ?? alert.description;
|
||||
}
|
||||
function plainAlertBody(alert: ToolRuntimeAlertRecommendation): string {
|
||||
return ALERT_COPY[alert.name]?.body(alert) ?? alert.observed;
|
||||
}
|
||||
function alertAction(alert: ToolRuntimeAlertRecommendation): AlertAction {
|
||||
return ALERT_COPY[alert.name]?.action ?? "reviewActivity";
|
||||
}
|
||||
|
||||
interface ConfirmTarget {
|
||||
kind: "stop" | "restart";
|
||||
slotId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** One plain-number summary card with an optional ops-vocabulary tooltip. */
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
note?: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
const labelEl = detail ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help border-b border-dotted border-muted-foreground/40 text-xs font-semibold text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{detail}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-muted-foreground">{label}</span>
|
||||
);
|
||||
return (
|
||||
<Card className="py-0">
|
||||
<CardContent className="space-y-1.5 px-5 py-4">
|
||||
<div>{labelEl}</div>
|
||||
<div className="text-2xl font-bold tracking-tight text-foreground tabular-nums">{value}</div>
|
||||
<div className="text-xs text-muted-foreground">{note ?? " "}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LivePill() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-foreground">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||
Live
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Updates automatically every 15 seconds.</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Card-level "Technical details" / row-level expander toggle. */
|
||||
function Disclosure({ open, label }: { open: boolean; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuntimeTab({ companyId }: { companyId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [openAlertDetails, setOpenAlertDetails] = useState<Record<string, boolean>>({});
|
||||
const [confirm, setConfirm] = useState<ConfirmTarget | null>(null);
|
||||
|
||||
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,
|
||||
});
|
||||
const connections = useQuery({
|
||||
queryKey: queryKeys.tools.connections(companyId),
|
||||
queryFn: () => toolsApi.listConnections(companyId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const invalidateRuntime = () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeSlots(companyId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeHealth(companyId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) });
|
||||
};
|
||||
|
||||
const stopSlot = useMutation({
|
||||
mutationFn: (slotId: string) => toolsApi.stopRuntimeSlot(companyId, slotId),
|
||||
onSuccess: () => {
|
||||
invalidateRuntime();
|
||||
pushToast({ title: "App stopped", tone: "success" });
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({ title: "Stop failed", body: err instanceof ApiError ? err.message : String(err), tone: "error" }),
|
||||
onSettled: () => setConfirm(null),
|
||||
});
|
||||
|
||||
const restartSlot = useMutation({
|
||||
mutationFn: (slotId: string) => toolsApi.restartRuntimeSlot(companyId, slotId),
|
||||
onSuccess: () => {
|
||||
invalidateRuntime();
|
||||
pushToast({ title: "App restarted", tone: "success" });
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({ title: "Restart failed", body: err instanceof ApiError ? err.message : String(err), tone: "error" }),
|
||||
onSettled: () => setConfirm(null),
|
||||
});
|
||||
|
||||
const rows = useMemo<RuntimeRow[]>(() => {
|
||||
const list = slots.data?.runtimeSlots ?? [];
|
||||
const byId = new Map((connections.data?.connections ?? []).map((c) => [c.id, c] as const));
|
||||
return list.map((slot) => {
|
||||
const connection = slot.connectionId ? byId.get(slot.connectionId) ?? null : null;
|
||||
return {
|
||||
slot,
|
||||
connection,
|
||||
name: humanizeRowName(slot, connection),
|
||||
isLocal: slot.runtimeKind === "local_stdio",
|
||||
status: rowStatusFor(slot, connection),
|
||||
};
|
||||
});
|
||||
}, [slots.data, connections.data]);
|
||||
|
||||
if (slots.isLoading || health.isLoading || connections.isLoading) return <LoadingState />;
|
||||
if (slots.error || health.error) {
|
||||
return (
|
||||
<ErrorState
|
||||
error={slots.error ?? health.error}
|
||||
onRetry={() => {
|
||||
slots.refetch();
|
||||
health.refetch();
|
||||
connections.refetch();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = health.data?.metrics as ToolRuntimeMetricSnapshot | undefined;
|
||||
const firingAlerts = (health.data?.alerts ?? []).filter((a) => a.status === "firing");
|
||||
|
||||
const workingCount = rows.filter((r) => r.status === "working").length;
|
||||
const attentionCount = rows.filter((r) => r.status === "attention").length;
|
||||
const totalCount = rows.length;
|
||||
const localAttentionRow = rows.find((r) => r.status === "attention" && r.isLocal) ?? null;
|
||||
|
||||
const errors = (metrics?.toolFailuresLastHour ?? 0) + (metrics?.toolTimeoutsLastHour ?? 0);
|
||||
|
||||
const beginRestart = (row: RuntimeRow) =>
|
||||
setConfirm({ kind: "restart", slotId: row.slot.id, name: row.name });
|
||||
const beginStop = (row: RuntimeRow) => setConfirm({ kind: "stop", slotId: row.slot.id, name: row.name });
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<ToolsPageHeader title="Health" description="How your apps are doing right now." />
|
||||
<LivePill />
|
||||
</div>
|
||||
|
||||
{/* Summary strip — plain words; ops vocabulary lives in tooltips. */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<SummaryCard
|
||||
label="Apps running"
|
||||
value={totalCount === 0 ? "None" : `${workingCount} of ${totalCount}`}
|
||||
note={
|
||||
totalCount === 0
|
||||
? "Apps start when an agent first needs them"
|
||||
: attentionCount > 0
|
||||
? `${attentionCount} need${attentionCount === 1 ? "s" : ""} attention`
|
||||
: "All working"
|
||||
}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Typical response time"
|
||||
value={formatTypicalLatency(metrics?.averageToolLatencyMsLastHour)}
|
||||
note={
|
||||
metrics?.averageToolLatencyMsLastHour == null
|
||||
? "No calls in the last hour"
|
||||
: (metrics?.timeoutRateLastHour ?? 0) >= 10
|
||||
? "slower than usual"
|
||||
: "across all apps"
|
||||
}
|
||||
detail={`Slowest 5% (P95): ${formatTypicalLatency(metrics?.p95ToolLatencyMsLastHour)} · timeout rate ${metrics?.timeoutRateLastHour ?? 0}%`}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Errors in the last hour"
|
||||
value={String(errors)}
|
||||
note={errors === 0 ? "None" : "across your apps"}
|
||||
detail={`${metrics?.toolFailuresLastHour ?? 0} failed · ${metrics?.toolTimeoutsLastHour ?? 0} timed out · ${metrics?.capacityDeferralsLastHour ?? 0} waited for capacity`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Needs-attention cards — one per firing supervisor alert, in plain words. */}
|
||||
{firingAlerts.map((alert) => {
|
||||
const action = alertAction(alert);
|
||||
const detailsOpen = openAlertDetails[alert.name] ?? false;
|
||||
return (
|
||||
<Card key={alert.name} className="overflow-hidden border-foreground/30 py-0">
|
||||
<CardContent className="relative space-y-3 py-4 pl-6">
|
||||
<span className="absolute inset-y-0 left-0 w-1.5 bg-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-bold text-foreground">▲ {plainAlertTitle(alert)}</p>
|
||||
<p className="mt-1 max-w-2xl text-sm text-foreground/80">{plainAlertBody(alert)}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{action === "restart" && localAttentionRow ? (
|
||||
<Button size="sm" onClick={() => beginRestart(localAttentionRow)}>
|
||||
<RotateCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Restart {localAttentionRow.name}
|
||||
</Button>
|
||||
) : action === "reviewApps" ? (
|
||||
<Button size="sm" asChild>
|
||||
<Link to="/apps/attention">Review apps</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" asChild>
|
||||
<Link to="/apps/advanced/audit">Review activity</Link>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-left"
|
||||
onClick={() => setOpenAlertDetails((s) => ({ ...s, [alert.name]: !detailsOpen }))}
|
||||
>
|
||||
<Disclosure open={detailsOpen} label="Technical details" />
|
||||
</button>
|
||||
</div>
|
||||
{detailsOpen ? (
|
||||
<dl className="grid grid-cols-1 gap-x-8 gap-y-2 rounded-md bg-muted/40 p-3 text-xs sm:grid-cols-2">
|
||||
<Fact label="Alert" value={<span className="font-mono">{alert.name}</span>} />
|
||||
<Fact label="Severity" value={alert.severity} />
|
||||
<Fact label="Threshold" value={alert.threshold} />
|
||||
<Fact label="Observed" value={alert.observed} />
|
||||
<Fact label="First responder" value={alert.firstResponderAction} />
|
||||
<Fact label="Runbook" value={<span className="font-mono">{alert.runbookSection || health.data?.runbookPath}</span>} />
|
||||
</dl>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Status table — one row per running app. */}
|
||||
{totalCount === 0 ? (
|
||||
<EmptyState
|
||||
icon={Server}
|
||||
message="No apps running right now"
|
||||
description="Apps that run on this machine start automatically the first time an agent needs them. Apps that connect over the internet don't use a local process."
|
||||
/>
|
||||
) : (
|
||||
<Card className="py-0">
|
||||
<CardContent className="px-0 py-0">
|
||||
<div className="px-5 pb-1 pt-4">
|
||||
<h3 className="text-base font-bold text-foreground">Running apps</h3>
|
||||
<p className="text-xs text-muted-foreground">Click a row to see how the connection is wired up.</p>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs font-medium text-muted-foreground">
|
||||
<th className="px-5 py-2.5">App</th>
|
||||
<th className="px-3 py-2.5">Status</th>
|
||||
<th className="px-3 py-2.5">Last used</th>
|
||||
<th className="px-5 py-2.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((row) => {
|
||||
const open = expanded[row.slot.id] ?? false;
|
||||
const busy =
|
||||
(stopSlot.isPending && stopSlot.variables === row.slot.id) ||
|
||||
(restartSlot.isPending && restartSlot.variables === row.slot.id);
|
||||
return (
|
||||
<RuntimeRowView
|
||||
key={row.slot.id}
|
||||
row={row}
|
||||
open={open}
|
||||
busy={busy}
|
||||
onToggle={() => setExpanded((s) => ({ ...s, [row.slot.id]: !open }))}
|
||||
onRestart={() => beginRestart(row)}
|
||||
onStop={() => beginStop(row)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Apps that "connect over the internet" hide Stop and Restart — those run on the provider's side, so there's
|
||||
no local process to control here.
|
||||
</p>
|
||||
|
||||
<ConfirmDialog
|
||||
target={confirm}
|
||||
pending={stopSlot.isPending || restartSlot.isPending}
|
||||
onCancel={() => setConfirm(null)}
|
||||
onConfirm={() => {
|
||||
if (!confirm) return;
|
||||
if (confirm.kind === "restart") restartSlot.mutate(confirm.slotId);
|
||||
else stopSlot.mutate(confirm.slotId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="font-semibold text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-0.5 text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeRowView({
|
||||
row,
|
||||
open,
|
||||
busy,
|
||||
onToggle,
|
||||
onRestart,
|
||||
onStop,
|
||||
}: {
|
||||
row: RuntimeRow;
|
||||
open: boolean;
|
||||
busy: boolean;
|
||||
onToggle: () => void;
|
||||
onRestart: () => void;
|
||||
onStop: () => void;
|
||||
}) {
|
||||
const { slot, connection, name, isLocal, status } = row;
|
||||
const canControl = isLocal && status !== "off";
|
||||
return (
|
||||
<>
|
||||
<tr className="cursor-pointer align-middle hover:bg-accent/40" onClick={onToggle}>
|
||||
<td className="px-5 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${open ? "rotate-90" : ""}`} />
|
||||
<StatusMarker status={status} />
|
||||
{connection ? (
|
||||
<Link
|
||||
to={`/apps/${connection.id}`}
|
||||
className="font-semibold text-foreground hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-semibold text-foreground">{name}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={status === "attention" ? "font-semibold text-foreground" : "text-foreground"}>
|
||||
{STATUS_WORD[status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<RelativeTime value={slot.lastUsedAt} />
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
{isLocal ? (
|
||||
<Button size="sm" variant="outline" disabled={busy || status === "off"} onClick={onRestart}>
|
||||
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <RotateCw className="mr-1.5 h-3.5 w-3.5" />}
|
||||
Restart
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Runs on the provider's side</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{open ? (
|
||||
<tr className="bg-muted/40">
|
||||
<td colSpan={4} className="px-5 py-4">
|
||||
<dl className="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-3">
|
||||
<Fact label="Slot key" value={<span className="font-mono text-xs">{slot.slotKey ?? slot.commandTemplateKey ?? slot.id}</span>} />
|
||||
<Fact label="How it runs" value={howItRuns(slot)} />
|
||||
<Fact label="Process ID" value={slot.processId ?? "—"} />
|
||||
<Fact label="Scope" value={scopeLabel(slot.ownerScopeType)} />
|
||||
<Fact label="Trust tier" value={trustTierLabel(slot)} />
|
||||
<Fact label="Started" value={<RelativeTime value={slot.lastStartedAt ?? slot.startedAt} />} />
|
||||
</dl>
|
||||
{slot.lastError ? (
|
||||
<p className="mt-3 text-xs text-destructive">Last error: {slot.lastError}</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
{canControl ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={onStop}>
|
||||
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <Square className="mr-1.5 h-3.5 w-3.5" fill="currentColor" />}
|
||||
Stop
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={onRestart}>
|
||||
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <RotateCw className="mr-1.5 h-3.5 w-3.5" />}
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
) : !isLocal ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This app runs on the provider's side — there's nothing to stop or restart here.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">This app is off. It will start again when an agent needs it.</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmDialog({
|
||||
target,
|
||||
pending,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
target: ConfirmTarget | null;
|
||||
pending: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const isRestart = target?.kind === "restart";
|
||||
return (
|
||||
<Dialog open={!!target} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isRestart ? "Restart" : "Stop"} {target?.name}?
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 text-sm text-foreground">
|
||||
{isRestart ? (
|
||||
<>
|
||||
<p>
|
||||
Anything in progress will stop. Agents using {target?.name} right now will see a Failed result on
|
||||
their action.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Restart usually takes 2–3 seconds.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{target?.name} will stop running. Agents won't be able to use it until it starts again.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
It starts again automatically the next time an agent needs it.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={pending}>
|
||||
{pending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{isRestart ? "Restart" : "Stop"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SmokeLabTab } from "./SmokeLabTab";
|
||||
|
||||
const getExperimentalMock = vi.hoisted(() => vi.fn());
|
||||
const listServicesMock = vi.hoisted(() => vi.fn());
|
||||
const listRunsMock = vi.hoisted(() => vi.fn());
|
||||
const getRunMock = vi.hoisted(() => vi.fn());
|
||||
const createRunMock = vi.hoisted(() => vi.fn());
|
||||
const startServicesMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: { getExperimental: () => getExperimentalMock() },
|
||||
}));
|
||||
|
||||
vi.mock("@/api/smokeLab", () => ({
|
||||
smokeLabApi: {
|
||||
listServices: (c: string) => listServicesMock(c),
|
||||
listRuns: (c: string) => listRunsMock(c),
|
||||
getRun: (c: string, r: string) => getRunMock(c, r),
|
||||
createRun: (c: string, i: unknown) => createRunMock(c, i),
|
||||
startServices: (c: string) => startServicesMock(c),
|
||||
stopServices: vi.fn(),
|
||||
installFixtures: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children: ReactNode }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const RUN = {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
trigger: "manual",
|
||||
status: "passed",
|
||||
startedAt: "2026-07-10T00:00:00Z",
|
||||
finishedAt: "2026-07-10T00:05:00Z",
|
||||
summary: {},
|
||||
createdAt: "2026-07-10T00:00:00Z",
|
||||
updatedAt: "2026-07-10T00:05:00Z",
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
id: "s1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
path: "P1",
|
||||
scenarioStep: "oauth-login",
|
||||
status: "pass",
|
||||
detail: "Signed in via fake OAuth consent page",
|
||||
screenshotArtifactRef: null,
|
||||
durationMs: 812,
|
||||
createdAt: "2026-07-10T00:00:01Z",
|
||||
updatedAt: "2026-07-10T00:00:01Z",
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
path: "P7",
|
||||
scenarioStep: "schema-change-quarantine",
|
||||
status: "fail",
|
||||
detail: "Quarantine not enforced",
|
||||
screenshotArtifactRef: null,
|
||||
durationMs: 240,
|
||||
createdAt: "2026-07-10T00:00:02Z",
|
||||
updatedAt: "2026-07-10T00:00:02Z",
|
||||
},
|
||||
];
|
||||
|
||||
describe("SmokeLabTab", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
getExperimentalMock.mockResolvedValue({ enableSmokeLab: true });
|
||||
listServicesMock.mockResolvedValue({
|
||||
services: [
|
||||
{
|
||||
id: "fake-oauth",
|
||||
label: "Fake OAuth 2.0 provider",
|
||||
status: "running",
|
||||
url: "http://127.0.0.1:3100/api/companies/company-1/smoke-lab/oauth/authorize",
|
||||
health: { ok: true },
|
||||
detail: "In-process deterministic OAuth provider.",
|
||||
},
|
||||
],
|
||||
});
|
||||
listRunsMock.mockResolvedValue({ runs: [RUN] });
|
||||
getRunMock.mockResolvedValue({ run: RUN, steps: STEPS });
|
||||
createRunMock.mockResolvedValue({ run: { ...RUN, id: "run-2", status: "running" } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SmokeLabTab companyId="company-1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("hides the lab and shows a flag-off notice when the flag is disabled", async () => {
|
||||
getExperimentalMock.mockResolvedValue({ enableSmokeLab: false });
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Smoke Lab is turned off");
|
||||
expect(container.querySelector('[data-testid="smoke-lab-tab"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain("Integration matrix");
|
||||
expect(listServicesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders services with the resolved URL, demo credentials, and the P1–P7 matrix when enabled", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.querySelector('[data-testid="smoke-lab-tab"]')).not.toBeNull();
|
||||
// Resolved URL printed verbatim — never an assumed port.
|
||||
expect(container.textContent).toContain(
|
||||
"http://127.0.0.1:3100/api/companies/company-1/smoke-lab/oauth/authorize",
|
||||
);
|
||||
// Demo credentials for the fake OAuth login.
|
||||
expect(container.textContent).toContain("smoke@paperclip.test");
|
||||
expect(container.textContent).toContain("smoke-password");
|
||||
// Matrix rows for every path + a governed lifecycle column.
|
||||
expect(container.textContent).toContain("Integration matrix");
|
||||
expect(container.textContent).toContain("Remote HTTP · OAuth");
|
||||
expect(container.textContent).toContain("Governance surfaces");
|
||||
expect(container.textContent).toContain("Schema-change quarantine");
|
||||
// Failing path surfaced from the recorded steps.
|
||||
expect(container.textContent).toContain("failing: P7");
|
||||
// Step drill-down shows the raw scenario step.
|
||||
expect(container.textContent).toContain("oauth-login");
|
||||
});
|
||||
|
||||
it("starts a manual run when 'Run browser smoke now' is clicked", async () => {
|
||||
await render();
|
||||
|
||||
const runButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.includes("Run browser smoke now"),
|
||||
);
|
||||
expect(runButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
runButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(createRunMock).toHaveBeenCalledWith("company-1", { trigger: "manual", summary: {} });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,494 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
CircleSlash,
|
||||
FlaskConical,
|
||||
Loader2,
|
||||
Minus,
|
||||
Play,
|
||||
Power,
|
||||
RotateCcw,
|
||||
ServerCog,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { SmokeRun, SmokeRunStep } from "@paperclipai/shared";
|
||||
import { smokeLabApi } from "@/api/smokeLab";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import {
|
||||
LIFECYCLE_STAGES,
|
||||
SMOKE_PATH_LABELS,
|
||||
SMOKE_PATHS,
|
||||
buildSmokeMatrix,
|
||||
cellKey,
|
||||
failingPaths,
|
||||
runHealth,
|
||||
type CellStatus,
|
||||
} from "./smoke-lab-matrix";
|
||||
|
||||
// Public, non-secret fixture credentials for the fake OAuth provider. Kept in
|
||||
// sync with SMOKE_LAB_DEMO_EMAIL / SMOKE_LAB_DEMO_PASSWORD in
|
||||
// server/src/services/smoke-lab.ts — deterministic demo values, never real.
|
||||
const DEMO_EMAIL = "smoke@paperclip.test";
|
||||
const DEMO_PASSWORD = "smoke-password";
|
||||
|
||||
function formatTime(value: string | Date | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value as string | Date);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
|
||||
function serviceTone(status: string): "success" | "warn" | "error" | "muted" {
|
||||
if (status === "running") return "success";
|
||||
if (status === "error") return "error";
|
||||
return "muted";
|
||||
}
|
||||
|
||||
function CellGlyph({ status }: { status: CellStatus }) {
|
||||
if (status === "pass") return <Check className="mx-auto h-4 w-4 text-emerald-600 dark:text-emerald-400" aria-label="pass" />;
|
||||
if (status === "fail") return <X className="mx-auto h-4 w-4 text-destructive" aria-label="fail" />;
|
||||
if (status === "skipped") return <Minus className="mx-auto h-4 w-4 text-amber-500" aria-label="skipped" />;
|
||||
return <span className="mx-auto block h-1.5 w-1.5 rounded-full bg-muted-foreground/30" aria-label="not run" />;
|
||||
}
|
||||
|
||||
const HEALTH_STYLES: Record<string, string> = {
|
||||
green: "bg-emerald-500",
|
||||
amber: "bg-amber-500",
|
||||
red: "bg-destructive",
|
||||
unknown: "bg-muted-foreground/40",
|
||||
};
|
||||
|
||||
export function SmokeLabTab({ companyId }: { companyId: string }) {
|
||||
const { enabled, loaded } = useSmokeLabEnabled();
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
|
||||
const servicesQuery = useQuery({
|
||||
queryKey: queryKeys.smokeLab.services(companyId),
|
||||
queryFn: () => smokeLabApi.listServices(companyId),
|
||||
enabled: enabled && loaded,
|
||||
refetchInterval: enabled ? 10_000 : false,
|
||||
});
|
||||
|
||||
const runsQuery = useQuery({
|
||||
queryKey: queryKeys.smokeLab.runs(companyId),
|
||||
queryFn: () => smokeLabApi.listRuns(companyId),
|
||||
enabled: enabled && loaded,
|
||||
refetchInterval: enabled ? 10_000 : false,
|
||||
});
|
||||
|
||||
const runs = runsQuery.data?.runs ?? [];
|
||||
const activeRunId = selectedRunId ?? runs[0]?.id ?? null;
|
||||
|
||||
const runDetailQuery = useQuery({
|
||||
queryKey: queryKeys.smokeLab.run(companyId, activeRunId ?? "__none__"),
|
||||
queryFn: () => smokeLabApi.getRun(companyId, activeRunId!),
|
||||
enabled: enabled && loaded && !!activeRunId,
|
||||
refetchInterval: enabled && !!activeRunId ? 10_000 : false,
|
||||
});
|
||||
|
||||
const steps = useMemo<SmokeRunStep[]>(() => runDetailQuery.data?.steps ?? [], [runDetailQuery.data]);
|
||||
const matrix = useMemo(() => buildSmokeMatrix(steps), [steps]);
|
||||
const activeRun = runDetailQuery.data?.run;
|
||||
|
||||
function refresh() {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.smokeLab.services(companyId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.smokeLab.runs(companyId) });
|
||||
if (activeRunId) qc.invalidateQueries({ queryKey: queryKeys.smokeLab.run(companyId, activeRunId) });
|
||||
}
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => smokeLabApi.startServices(companyId),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Smoke services started", tone: "success" });
|
||||
refresh();
|
||||
},
|
||||
onError: (e: Error) => pushToast({ title: "Couldn't start services", body: e.message, tone: "error" }),
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: () => smokeLabApi.stopServices(companyId),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Smoke services stopped", tone: "info" });
|
||||
refresh();
|
||||
},
|
||||
onError: (e: Error) => pushToast({ title: "Couldn't stop services", body: e.message, tone: "error" }),
|
||||
});
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: () => smokeLabApi.installFixtures(companyId),
|
||||
onSuccess: (r) => {
|
||||
pushToast({
|
||||
title: r.created ? "Fixture apps installed" : "Fixture apps already present",
|
||||
tone: "success",
|
||||
});
|
||||
refresh();
|
||||
},
|
||||
onError: (e: Error) => pushToast({ title: "Couldn't install fixtures", body: e.message, tone: "error" }),
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: () => smokeLabApi.reset(companyId),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Smoke Lab reset", tone: "info" });
|
||||
setSelectedRunId(null);
|
||||
refresh();
|
||||
},
|
||||
onError: (e: Error) => pushToast({ title: "Couldn't reset", body: e.message, tone: "error" }),
|
||||
});
|
||||
|
||||
const runSmokeMutation = useMutation({
|
||||
mutationFn: () => smokeLabApi.createRun(companyId, { trigger: "manual", summary: {} }),
|
||||
onSuccess: (r) => {
|
||||
pushToast({
|
||||
title: "Smoke run started",
|
||||
body: "The browser runner records each step as it completes.",
|
||||
tone: "success",
|
||||
});
|
||||
setSelectedRunId(r.run.id);
|
||||
refresh();
|
||||
},
|
||||
onError: (e: Error) => pushToast({ title: "Couldn't start a run", body: e.message, tone: "error" }),
|
||||
});
|
||||
|
||||
const anyMutating =
|
||||
startMutation.isPending ||
|
||||
stopMutation.isPending ||
|
||||
installMutation.isPending ||
|
||||
resetMutation.isPending ||
|
||||
runSmokeMutation.isPending;
|
||||
|
||||
// Flag off — hidden. The server is authoritative; this is the friendly UX gate.
|
||||
if (loaded && !enabled) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<FlaskConical className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold">Smoke Lab is turned off</h2>
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-xl text-sm text-muted-foreground">
|
||||
The Smoke Lab is an experimental developer surface for exercising the integration paths
|
||||
against deterministic local fixtures. Turn on <code className="rounded bg-muted px-1 py-0.5 text-xs">Smoke Lab</code>{" "}
|
||||
under Instance settings → Experimental to enable it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Loading Smoke Lab…</div>;
|
||||
}
|
||||
|
||||
const services = servicesQuery.data?.services ?? [];
|
||||
const health = runHealth(activeRun, steps);
|
||||
const failing = failingPaths(steps);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" data-testid="smoke-lab-tab">
|
||||
<header>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-xl font-bold text-foreground">Smoke Lab</h1>
|
||||
<Badge variant="outline">Experimental</Badge>
|
||||
<a
|
||||
href="https://github.com/paperclipai/paperclip/blob/master/doc/connections/SMOKE-LAB-TUTORIAL.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" /> Hands-on tutorial
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-3xl text-sm text-muted-foreground">
|
||||
Exercise every integration path (P1–P7) end-to-end against deterministic local fixtures —
|
||||
a fake OAuth provider and loopback MCP servers. Nothing here touches a real vendor or a
|
||||
real credential. Start the services, install the fixture apps, then drive the governed
|
||||
lifecycle from a browser smoke run. New here? Follow the{" "}
|
||||
<a
|
||||
href="https://github.com/paperclipai/paperclip/blob/master/doc/connections/SMOKE-LAB-TUTORIAL.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
hands-on tutorial
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Services */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerCog className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Fixture services</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => startMutation.mutate()}
|
||||
disabled={anyMutating}
|
||||
>
|
||||
<Power className="h-4 w-4" /> Start services
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => stopMutation.mutate()}
|
||||
disabled={anyMutating}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => installMutation.mutate()}
|
||||
disabled={anyMutating}
|
||||
>
|
||||
Install fixture apps
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={anyMutating}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" /> Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{services.map((service) => (
|
||||
<div key={service.id} className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">{service.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{service.detail ?? service.id}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
service.status === "running"
|
||||
? "bg-emerald-500"
|
||||
: service.status === "error"
|
||||
? "bg-destructive"
|
||||
: "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
serviceTone(service.status) === "success" && "text-emerald-600 dark:text-emerald-400",
|
||||
serviceTone(service.status) === "error" && "text-destructive",
|
||||
serviceTone(service.status) === "muted" && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{service.status}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-3 space-y-1 text-xs">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="w-16 shrink-0 text-muted-foreground">URL</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-foreground">
|
||||
{service.url ?? <span className="text-muted-foreground">not running</span>}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
{services.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No services reported. Start the fixture services above.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Demo credentials for the fake OAuth login */}
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-3">
|
||||
<p className="text-xs font-semibold text-foreground">Fake OAuth demo credentials</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Type these into the fake provider's real consent page during a P1 (OAuth) smoke. Fixed
|
||||
fixture values — safe to show.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-x-6 gap-y-1 font-mono text-xs text-foreground">
|
||||
<span>email: {DEMO_EMAIL}</span>
|
||||
<span>password: {DEMO_PASSWORD}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Results matrix */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">Integration matrix</h2>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1"><Check className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" /> pass</span>
|
||||
<span className="inline-flex items-center gap-1"><X className="h-3.5 w-3.5 text-destructive" /> fail</span>
|
||||
<span className="inline-flex items-center gap-1"><Minus className="h-3.5 w-3.5 text-amber-500" /> skipped</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30" /> not run</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40">
|
||||
<th className="sticky left-0 z-10 bg-muted/40 px-3 py-2 text-left font-semibold text-foreground">Path</th>
|
||||
{LIFECYCLE_STAGES.map((stage) => (
|
||||
<th key={stage.key} className="px-2 py-2 text-center font-medium text-muted-foreground">
|
||||
{stage.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{SMOKE_PATHS.map((path) => (
|
||||
<tr key={path} className="border-b border-border last:border-0">
|
||||
<th scope="row" className="sticky left-0 z-10 bg-card px-3 py-2 text-left">
|
||||
<span className="font-mono font-semibold text-foreground">{path}</span>
|
||||
<span className="ml-2 text-foreground">{SMOKE_PATH_LABELS[path].title}</span>
|
||||
<span className="block text-(length:--text-micro) font-normal text-muted-foreground">
|
||||
{SMOKE_PATH_LABELS[path].detail}
|
||||
</span>
|
||||
</th>
|
||||
{LIFECYCLE_STAGES.map((stage) => {
|
||||
const cell = matrix.get(cellKey(path, stage.key));
|
||||
return (
|
||||
<td key={stage.key} className="px-2 py-2 text-center">
|
||||
<CellGlyph status={cell?.status ?? "not-run"} />
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{steps.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No steps recorded for the selected run yet. Run a browser smoke to populate the matrix.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Run history + drill-down */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">Runs</h2>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={cn("h-2 w-2 rounded-full", HEALTH_STYLES[health])} />
|
||||
{health === "unknown" ? "no runs yet" : health}
|
||||
{failing.length > 0 && ` · failing: ${failing.join(", ")}`}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => runSmokeMutation.mutate()} disabled={anyMutating}>
|
||||
{runSmokeMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
Run browser smoke now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-(--gtc-64)">
|
||||
<div className="rounded-lg border border-border">
|
||||
{runs.length === 0 && (
|
||||
<p className="p-4 text-sm text-muted-foreground">No runs recorded yet.</p>
|
||||
)}
|
||||
<ul className="divide-y divide-border">
|
||||
{runs.map((run: SmokeRun) => {
|
||||
const isActive = run.id === activeRunId;
|
||||
return (
|
||||
<li key={run.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRunId(run.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left transition-colors hover:bg-accent/50",
|
||||
isActive && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-medium text-foreground">{formatTime(run.startedAt)}</span>
|
||||
<span className="block text-(length:--text-micro) text-muted-foreground">{run.trigger}</span>
|
||||
</span>
|
||||
<StatusBadge status={run.status} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 rounded-lg border border-border">
|
||||
{!activeRun && (
|
||||
<p className="p-4 text-sm text-muted-foreground">Select a run to see its steps.</p>
|
||||
)}
|
||||
{activeRun && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Started {formatTime(activeRun.startedAt)} · finished {formatTime(activeRun.finishedAt)}
|
||||
</span>
|
||||
<StatusBadge status={activeRun.status} />
|
||||
</div>
|
||||
{steps.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">No steps recorded for this run.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{steps.map((step) => (
|
||||
<li key={step.id} className="flex items-start gap-3 px-4 py-2.5">
|
||||
<span className="mt-0.5">
|
||||
{step.status === "pass" ? (
|
||||
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
) : step.status === "fail" ? (
|
||||
<X className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<CircleSlash className="h-4 w-4 text-amber-500" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
<span className="font-mono">{step.path}</span> · {step.scenarioStep}
|
||||
</p>
|
||||
{step.detail && (
|
||||
<p className="mt-0.5 break-words text-(length:--text-micro) text-muted-foreground">{step.detail}</p>
|
||||
)}
|
||||
{step.screenshotArtifactRef && typeof step.screenshotArtifactRef.url === "string" && (
|
||||
<a
|
||||
href={step.screenshotArtifactRef.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-1 inline-block text-(length:--text-micro) font-medium text-primary hover:underline"
|
||||
>
|
||||
View screenshot
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{typeof step.durationMs === "number" && (
|
||||
<span className="shrink-0 text-(length:--text-micro) tabular-nums text-muted-foreground">
|
||||
{step.durationMs}ms
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ToolsAccess } from "./ToolsAccess";
|
||||
|
||||
const mockParams = vi.hoisted(() => ({ tab: undefined as string | undefined }));
|
||||
const navigateMock = vi.hoisted(() => vi.fn(({ to }: { to: string }) => <div data-navigate={to} />));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
Navigate: (props: { to: string; replace?: boolean }) => navigateMock(props),
|
||||
useParams: () => mockParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
selectedCompany: { id: "company-1", name: "Paperclip" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./profiles/ProfilesIndex", () => ({
|
||||
ProfilesIndex: () => <section>Tool profiles</section>,
|
||||
}));
|
||||
|
||||
vi.mock("./PoliciesTab", () => ({
|
||||
PoliciesTab: () => <section>Policies tab</section>,
|
||||
}));
|
||||
|
||||
vi.mock("./RuntimeTab", () => ({
|
||||
RuntimeTab: () => <section>Runtime tab</section>,
|
||||
}));
|
||||
|
||||
vi.mock("./AuditTab", () => ({
|
||||
AuditTab: () => <section>Audit tab</section>,
|
||||
}));
|
||||
|
||||
vi.mock("./PasteConfigTab", () => ({
|
||||
PasteConfigTab: () => <section>Paste tab</section>,
|
||||
}));
|
||||
|
||||
vi.mock("./RunYourOwnTab", () => ({
|
||||
RunYourOwnTab: () => <section>Run your own tab</section>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flushReact() {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("ToolsAccess", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
mockParams.tab = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
await act(async () => {
|
||||
root.render(<ToolsAccess />);
|
||||
await flushReact();
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["applications", "connections", "overview", "examples"])(
|
||||
"redirects retired %s tab links to All apps",
|
||||
async (tab) => {
|
||||
mockParams.tab = tab;
|
||||
await render();
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps", replace: true }));
|
||||
},
|
||||
);
|
||||
|
||||
it("uses Profiles as the developer surface entry point", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.querySelector('a[href="/apps/advanced/profiles"]')?.textContent).toContain(
|
||||
"Open developer tools",
|
||||
);
|
||||
|
||||
mockParams.tab = "profiles";
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Developer tools");
|
||||
expect(container.textContent).toContain("Tool profiles");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { useEffect } from "react";
|
||||
import { Settings2, Wrench } from "lucide-react";
|
||||
import { Link, Navigate, useParams } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { ProfilesIndex } from "./profiles/ProfilesIndex";
|
||||
import { PoliciesTab } from "./PoliciesTab";
|
||||
import { RuntimeTab } from "./RuntimeTab";
|
||||
import { AuditTab } from "./AuditTab";
|
||||
import { GatewaysTab } from "./GatewaysTab";
|
||||
import { PasteConfigTab } from "./PasteConfigTab";
|
||||
import { RunYourOwnTab } from "./RunYourOwnTab";
|
||||
import { SmokeLabTab } from "./SmokeLabTab";
|
||||
import {
|
||||
ADVANCED_TABS,
|
||||
TOOL_TABS,
|
||||
advancedTabHref,
|
||||
isAdvancedSetupTab,
|
||||
type ToolTabKey,
|
||||
} from "./tool-tabs";
|
||||
|
||||
function renderTab(tab: ToolTabKey, companyId: string) {
|
||||
switch (tab) {
|
||||
case "profiles":
|
||||
return <ProfilesIndex companyId={companyId} />;
|
||||
case "policies":
|
||||
return <PoliciesTab companyId={companyId} />;
|
||||
case "runtime":
|
||||
return <RuntimeTab companyId={companyId} />;
|
||||
case "audit":
|
||||
return <AuditTab companyId={companyId} />;
|
||||
case "gateways":
|
||||
return <GatewaysTab companyId={companyId} />;
|
||||
case "smoke-lab":
|
||||
return <SmokeLabTab companyId={companyId} />;
|
||||
case "paste-config":
|
||||
return <PasteConfigTab companyId={companyId} />;
|
||||
case "run-your-own":
|
||||
default:
|
||||
return <RunYourOwnTab companyId={companyId} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolsAccess() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const params = useParams<{ tab?: string }>();
|
||||
const activeTab = (TOOL_TABS.find((t) => t.key === params.tab)?.key ?? "run-your-own") as ToolTabKey;
|
||||
const advanced = isAdvancedSetupTab(activeTab);
|
||||
const tabLabel = TOOL_TABS.find((t) => t.key === activeTab)?.label;
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
...(advanced
|
||||
? [{ label: "Advanced setup" }]
|
||||
: [
|
||||
{ label: "Advanced setup", href: advancedTabHref("run-your-own") },
|
||||
{ label: tabLabel ?? "Developer tools" },
|
||||
]),
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name, advanced, tabLabel]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select a company to open advanced setup.</div>;
|
||||
}
|
||||
|
||||
// Retired developer tabs (PAP-10915/PAP-10928) — keep old links working.
|
||||
if (
|
||||
params.tab === "applications" ||
|
||||
params.tab === "connections" ||
|
||||
params.tab === "overview" ||
|
||||
params.tab === "examples"
|
||||
) {
|
||||
return <Navigate to="/apps" replace />;
|
||||
}
|
||||
|
||||
if (advanced) {
|
||||
// M8a/M8b chrome (PAP-10839 wires): Advanced badge, plain-words subtitle,
|
||||
// and a two-tab switcher. The developer surface stays behind a quiet link.
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5 p-4 sm:p-6">
|
||||
<header>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-xl font-bold text-foreground">Advanced setup</h1>
|
||||
<span className="inline-flex items-center rounded-full bg-foreground px-2.5 py-0.5 text-(length:--text-micro) font-bold text-background">
|
||||
Advanced
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
For tools that aren't in the gallery. You'll need details from the tool's documentation.
|
||||
Most people never need this — if the app you want is in the gallery,{" "}
|
||||
<Link to="/apps" className="font-medium text-primary hover:underline">
|
||||
connect it there instead
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav className="flex items-center gap-6 border-b border-border">
|
||||
{ADVANCED_TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.key}
|
||||
to={advancedTabHref(tab.key)}
|
||||
className={cn(
|
||||
"-mb-px border-b-2 pb-2 text-sm transition-colors",
|
||||
tab.key === activeTab
|
||||
? "border-foreground font-bold text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="min-h-(--sz-300px)">{renderTab(activeTab, selectedCompanyId)}</div>
|
||||
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
Looking for the developer surface?{" "}
|
||||
<Link to={advancedTabHref("profiles")} className="font-medium text-primary hover:underline">
|
||||
Open developer tools
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-4 sm:p-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-xl font-bold text-foreground">Developer tools</h1>
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-2xl text-sm text-muted-foreground">
|
||||
Apps is the simple way to connect tools. This Developer area is for wiring your own
|
||||
servers, tokens, and rules by hand — most teams never need it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-(--sz-300px)">{renderTab(activeTab, selectedCompanyId)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,567 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Stethoscope, Trash2, Vault } from "lucide-react";
|
||||
import type {
|
||||
CompanySecret,
|
||||
McpConnectionCredentialRef,
|
||||
ToolConnection,
|
||||
} from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi, type CreateToolConnectionInput } from "@/api/tools";
|
||||
import { secretsApi } from "@/api/secrets";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { redactUrlSecrets } from "@/lib/redact-url-secrets";
|
||||
import {
|
||||
LoadingState,
|
||||
ErrorState,
|
||||
HealthBadge,
|
||||
RiskBadge,
|
||||
CapabilityBadges,
|
||||
QuarantineBadge,
|
||||
} from "./shared";
|
||||
|
||||
export const TRANSPORT_LABEL: Record<string, string> = {
|
||||
remote_http: "remote http",
|
||||
local_stdio: "local stdio",
|
||||
};
|
||||
|
||||
/** Mono URL (remote) or command-template (stdio) subtitle for a connection row. */
|
||||
export function connectionEndpoint(conn: ToolConnection): string | null {
|
||||
const config = { ...(conn.transportConfig ?? {}), ...(conn.config ?? {}) } as Record<string, unknown>;
|
||||
const url = config.url ?? config.endpoint ?? config.endpointUrl;
|
||||
if (typeof url === "string" && url.trim()) return redactUrlSecrets(url);
|
||||
const template = config.templateId ?? config.template ?? config.command;
|
||||
if (typeof template === "string" && template.trim()) return template.trim();
|
||||
if (Array.isArray(config.command)) return config.command.join(" ");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only vault reference for a credential. The persisted shape is the
|
||||
* structured {@link McpConnectionCredentialRef} (secretId + version) — this is
|
||||
* just the human-readable `vault://provider/key@version` rendering of it so the
|
||||
* operator can confirm *which* vault entry resolves at gateway time. Free-text
|
||||
* secrets are never accepted; only references to the secret vault.
|
||||
*/
|
||||
function vaultRef(secret: CompanySecret | undefined, version: number | "latest" = "latest"): string {
|
||||
if (!secret) return "vault://…";
|
||||
const v = version === "latest" || version === undefined ? "latest" : `v${version}`;
|
||||
return `vault://${secret.provider}/${secret.key}@${v}`;
|
||||
}
|
||||
|
||||
export function CatalogDialog({ connection, onClose }: { connection: ToolConnection; onClose: () => void }) {
|
||||
const catalog = useQuery({
|
||||
queryKey: queryKeys.tools.catalog(connection.id),
|
||||
queryFn: () => toolsApi.listCatalog(connection.id),
|
||||
});
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tool catalog — {connection.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{catalog.isLoading ? (
|
||||
<LoadingState />
|
||||
) : catalog.error ? (
|
||||
<ErrorState error={catalog.error} onRetry={() => catalog.refetch()} />
|
||||
) : (catalog.data?.catalog ?? []).length === 0 ? (
|
||||
<p className="py-6 text-sm text-muted-foreground">
|
||||
No tools discovered yet. Use “Refresh catalog” to discover tools from this connection.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-(--sz-60vh) divide-y divide-border overflow-y-auto">
|
||||
{(catalog.data?.catalog ?? []).map((entry) => (
|
||||
<li key={entry.id} className="flex flex-wrap items-center gap-2 py-2.5">
|
||||
<span className="font-mono text-sm text-foreground">{entry.toolName}</span>
|
||||
<RiskBadge risk={entry.riskLevel} />
|
||||
<CapabilityBadges
|
||||
isReadOnly={entry.isReadOnly}
|
||||
isWrite={entry.isWrite}
|
||||
isDestructive={entry.isDestructive}
|
||||
/>
|
||||
{entry.status === "quarantined" ? <QuarantineBadge /> : null}
|
||||
{entry.description ? (
|
||||
<p className="w-full truncate text-xs text-muted-foreground">{entry.description}</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type CredentialDraft = { secretId: string; headerName: string };
|
||||
|
||||
/** Probe outcome captured before activation: health + discovered tool count + round-trip latency. */
|
||||
type ProbeResult = {
|
||||
connection: ToolConnection;
|
||||
toolCount: number | null;
|
||||
quarantinedCount: number;
|
||||
latencyMs: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* New-connection dialog. Enforces secret *references* (no free-text token field)
|
||||
* and runs a live gateway probe (health-check + catalog discovery) against the
|
||||
* draft before the operator activates it — per the Phase 0B spec surface map.
|
||||
*/
|
||||
export function AddConnectionDialog({
|
||||
companyId,
|
||||
defaultApplicationId,
|
||||
onClose,
|
||||
}: {
|
||||
companyId: string;
|
||||
defaultApplicationId?: string | null;
|
||||
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 [step, setStep] = useState<1 | 2>(defaultApplicationId ? 2 : 1);
|
||||
const [applicationMode, setApplicationMode] = useState<"existing" | "new">(
|
||||
"existing",
|
||||
);
|
||||
const [applicationId, setApplicationId] = useState(defaultApplicationId ?? "");
|
||||
const [applicationName, setApplicationName] = 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 [probeResult, setProbeResult] = useState<ProbeResult | null>(null);
|
||||
|
||||
const secretById = (id: string) => secrets.data?.find((s) => s.id === id);
|
||||
const secretName = (id: string) => secretById(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],
|
||||
);
|
||||
|
||||
// Probe runs a real gateway health-check and then a catalog discovery so the
|
||||
// pre-activation panel can show status + tool count. Latency is the measured
|
||||
// round-trip of the health-check (a single sample — aggregate p95 across
|
||||
// traffic is surfaced on the Runtime tab once the connection is live).
|
||||
const runProbe = async (id: string): Promise<ProbeResult> => {
|
||||
const startedAt = performance.now();
|
||||
const health = await toolsApi.checkConnectionHealth(id);
|
||||
const latencyMs = Math.round(performance.now() - startedAt);
|
||||
try {
|
||||
const refreshed = await toolsApi.refreshCatalog(id);
|
||||
return {
|
||||
connection: refreshed.connection,
|
||||
toolCount: refreshed.discoveredCount,
|
||||
quarantinedCount: refreshed.quarantinedCount,
|
||||
latencyMs,
|
||||
};
|
||||
} catch {
|
||||
// Health may be fine while discovery is not yet possible (e.g. auth pending) —
|
||||
// keep the health result and report tools as unknown rather than failing the probe.
|
||||
return { connection: health.connection, toolCount: null, quarantinedCount: 0, latencyMs };
|
||||
}
|
||||
};
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => {
|
||||
const config: Record<string, unknown> =
|
||||
transport === "remote_http" ? { url: endpointUrl.trim() } : { templateId };
|
||||
const input: CreateToolConnectionInput = {
|
||||
...(applicationMode === "existing" ? { applicationId } : { applicationName: applicationName.trim() }),
|
||||
name: name.trim(),
|
||||
transport,
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
config,
|
||||
credentialRefs,
|
||||
};
|
||||
return toolsApi.createConnection(companyId, input);
|
||||
},
|
||||
onSuccess: (conn) => {
|
||||
setDraft(conn);
|
||||
probe.mutate(conn.id);
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({
|
||||
title: "Could not create connection",
|
||||
body: err instanceof ApiError ? err.message : String(err),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const probe = useMutation({
|
||||
mutationFn: (id: string) => runProbe(id),
|
||||
onSuccess: (res) => {
|
||||
setDraft(res.connection);
|
||||
setProbeResult(res);
|
||||
},
|
||||
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) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.applications(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 appChoiceValid = applicationMode === "existing" ? !!applicationId : applicationName.trim().length > 0;
|
||||
const canCreate = appChoiceValid && name.trim().length > 0 && transportConfigValid && !create.isPending;
|
||||
const locked = !!draft;
|
||||
const inferredType = transport === "remote_http" ? "MCP HTTP" : "MCP stdio";
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add application</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose an existing application or create one as part of the same connection flow. Credentials stay as
|
||||
vault references and the connection is probed before activation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={step === 1 ? "font-medium text-foreground" : ""}>1 Application</span>
|
||||
<span>/</span>
|
||||
<span className={step === 2 ? "font-medium text-foreground" : ""}>2 Connection</span>
|
||||
</div>
|
||||
|
||||
{step === 1 && !locked ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Application</Label>
|
||||
<Select value={applicationMode} onValueChange={(v) => setApplicationMode(v as "existing" | "new")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="existing">Use existing application</SelectItem>
|
||||
<SelectItem value="new">Create new application</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{applicationMode === "existing" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Existing application</Label>
|
||||
<Select value={applicationId} onValueChange={setApplicationId}>
|
||||
<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="app-name">New application name</Label>
|
||||
<Input
|
||||
id="app-name"
|
||||
value={applicationName}
|
||||
onChange={(e) => setApplicationName(e.target.value)}
|
||||
placeholder="e.g. GitHub Triage"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Application type is inferred from the transport you choose next.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{step === 2 || locked ? (
|
||||
<>
|
||||
{applicationMode === "new" ? (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{applicationName.trim()}</span> will be created as{" "}
|
||||
{inferredType}.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="conn-name">Connection name</Label>
|
||||
<Input
|
||||
id="conn-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Production GitHub"
|
||||
disabled={locked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Transport</Label>
|
||||
<Select
|
||||
value={transport}
|
||||
onValueChange={(v) => setTransport(v as "remote_http" | "local_stdio")}
|
||||
disabled={locked}
|
||||
>
|
||||
<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={locked}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Command template</Label>
|
||||
<Select value={templateId} onValueChange={setTemplateId} disabled={locked}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an approved template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(templates.data?.templates ?? []).map((t) => (
|
||||
<SelectItem key={t.templateId} value={t.templateId}>
|
||||
{t.name ?? 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>
|
||||
)}
|
||||
|
||||
{/* Vault-reference credential 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 rounded-md border border-border bg-muted/30 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<KeyRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="font-mono text-xs">{c.headerName}</span>
|
||||
<span className="truncate font-mono text-xs text-primary" title={vaultRef(secretById(c.secretId))}>
|
||||
→ {vaultRef(secretById(c.secretId))}
|
||||
</span>
|
||||
{!locked ? (
|
||||
<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 for ${secretName(c.secretId)}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{!locked ? (
|
||||
<>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Select value={pendingSecretId} onValueChange={setPendingSecretId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a vault 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>
|
||||
{pendingSecretId ? (
|
||||
<p className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
|
||||
<Vault className="h-3 w-3" />
|
||||
{vaultRef(secretById(pendingSecretId))}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Free-text secrets are not accepted — pick a vault entry; Paperclip stores only the
|
||||
<span className="font-mono"> vault://</span> reference and resolves it at gateway use time.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Inline probe panel — runs before activation, follows the Loading/Error rhythm. */}
|
||||
{locked ? (
|
||||
probe.isPending ? (
|
||||
<div className="rounded-md border border-border bg-muted/40 p-3">
|
||||
<LoadingState label="Probing connection…" />
|
||||
</div>
|
||||
) : probe.isError ? (
|
||||
<ErrorState error={probe.error} onRetry={() => draft && probe.mutate(draft.id)} />
|
||||
) : probeResult ? (
|
||||
<div className="rounded-md border border-border bg-muted/40 p-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">Probe result</span>
|
||||
<HealthBadge status={probeResult.connection.healthStatus} />
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<p className="text-lg font-semibold tabular-nums text-foreground">
|
||||
{probeResult.toolCount ?? "—"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">tools discovered</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold tabular-nums text-foreground">
|
||||
{probeResult.latencyMs != null ? `${probeResult.latencyMs}ms` : "—"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">probe latency</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold tabular-nums text-foreground">
|
||||
{probeResult.quarantinedCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">quarantined</p>
|
||||
</div>
|
||||
</div>
|
||||
{probeResult.connection.healthMessage ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{probeResult.connection.healthMessage}</p>
|
||||
) : null}
|
||||
{probeResult.connection.lastError ? (
|
||||
<p className="mt-1 text-xs text-destructive">{probeResult.connection.lastError}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-(length:--text-micro) text-muted-foreground">
|
||||
Probe latency is a single round-trip sample. Aggregate p95 latency across traffic is tracked on
|
||||
the Runtime tab once the connection is live.
|
||||
</p>
|
||||
</div>
|
||||
) : null
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{step === 1 && !locked ? (
|
||||
<Button disabled={!appChoiceValid} onClick={() => setStep(2)}>
|
||||
Continue
|
||||
</Button>
|
||||
) : !locked ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setStep(1)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button disabled={!canCreate} onClick={() => create.mutate()}>
|
||||
{create.isPending ? "Creating draft…" : "Create & probe"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" disabled={probe.isPending} onClick={() => draft && probe.mutate(draft.id)}>
|
||||
<Stethoscope className="mr-1 h-3.5 w-3.5" />
|
||||
{probe.isPending ? "Probing…" : "Re-probe"}
|
||||
</Button>
|
||||
<Button disabled={activate.isPending || probe.isPending} onClick={() => draft && activate.mutate(draft.id)}>
|
||||
{activate.isPending ? "Activating…" : "Activate"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { AlertTriangle } from "lucide-react";
|
||||
import type { ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export type ProfileActionDialogKind = "archive" | "delete" | "restore";
|
||||
|
||||
export function ProfileActionDialog({
|
||||
kind,
|
||||
profile,
|
||||
pending,
|
||||
onClose,
|
||||
onArchive,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}: {
|
||||
kind: ProfileActionDialogKind | null;
|
||||
profile: ToolProfileWithDetails | null;
|
||||
pending: boolean;
|
||||
onClose: () => void;
|
||||
onArchive: () => void;
|
||||
onRestore: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
if (!kind || !profile) return null;
|
||||
|
||||
const defaultDeleteBlocked = kind === "delete" && profile.summary.isCompanyDefault;
|
||||
const copy = {
|
||||
archive: {
|
||||
title: "Archive profile",
|
||||
body: `This profile stops applying to ${profile.summary.appliesToAgentCount} ${profile.summary.appliesToAgentCount === 1 ? "agent" : "agents"}. You can restore it later.`,
|
||||
confirm: "Archive",
|
||||
action: onArchive,
|
||||
},
|
||||
restore: {
|
||||
title: "Restore profile",
|
||||
body: "This profile will be active again and can be assigned to agents.",
|
||||
confirm: "Restore",
|
||||
action: onRestore,
|
||||
},
|
||||
delete: {
|
||||
title: "Delete profile",
|
||||
body: defaultDeleteBlocked
|
||||
? "This profile is the company default. Reassign the company default to another profile before deleting it."
|
||||
: `This permanently deletes the profile and removes ${profile.summary.assignmentCount} ${profile.summary.assignmentCount === 1 ? "assignment" : "assignments"}.`,
|
||||
confirm: "Delete",
|
||||
action: onDelete,
|
||||
},
|
||||
}[kind];
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.title}</DialogTitle>
|
||||
<DialogDescription>{copy.body}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{defaultDeleteBlocked ? (
|
||||
<div className="flex gap-3 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>Choose another access profile and make it the company default first.</span>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant={kind === "delete" ? "destructive" : "default"}
|
||||
disabled={pending || defaultDeleteBlocked}
|
||||
onClick={copy.action}
|
||||
>
|
||||
{copy.confirm}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
ToolCatalogEntry,
|
||||
ToolConnection,
|
||||
ToolProfileEntry,
|
||||
ToolProfileSummary,
|
||||
ToolProfileWithDetails,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigate = vi.hoisted(() => vi.fn());
|
||||
const setSearchParams = vi.hoisted(() => vi.fn());
|
||||
const api = vi.hoisted(() => ({
|
||||
getProfileNewTools: vi.fn(),
|
||||
reviewProfileNewTools: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
duplicateProfile: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
unbindProfile: vi.fn(),
|
||||
}));
|
||||
const profilesData = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => navigate,
|
||||
useSearchParams: () => [new URLSearchParams(), setSearchParams],
|
||||
Link: ({ to, children }: { to: string; children: unknown }) => createElement("a", { href: to }, children as never),
|
||||
}));
|
||||
vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ pushToast: vi.fn() }) }));
|
||||
vi.mock("@/api/tools", () => ({ toolsApi: api }));
|
||||
vi.mock("./useProfilesData", () => ({ useProfilesData: () => profilesData.current }));
|
||||
|
||||
import { ProfileDetail } from "./ProfileDetail";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function summary(partial: Partial<ToolProfileSummary> = {}): ToolProfileSummary {
|
||||
return {
|
||||
accessMode: "selected",
|
||||
allowedToolCount: 1,
|
||||
allowedApplicationCount: 1,
|
||||
excludedToolCount: 0,
|
||||
totalToolCount: 2,
|
||||
assignmentCount: 1,
|
||||
appliesToAgentCount: 1,
|
||||
isCompanyDefault: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function entry(partial: Partial<ToolProfileEntry>): ToolProfileEntry {
|
||||
return {
|
||||
id: partial.id ?? "entry-1",
|
||||
companyId: "c1",
|
||||
profileId: "p1",
|
||||
selectorType: "application",
|
||||
effect: "include",
|
||||
applicationId: "app-gmail",
|
||||
connectionId: null,
|
||||
catalogEntryId: null,
|
||||
toolName: null,
|
||||
riskLevel: null,
|
||||
conditions: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function profile(partial: Partial<ToolProfileWithDetails> & { id?: string; name?: string } = {}): ToolProfileWithDetails {
|
||||
return {
|
||||
id: partial.id ?? "p1",
|
||||
companyId: "c1",
|
||||
profileKey: "everyday",
|
||||
name: partial.name ?? "Everyday work",
|
||||
description: "Routine work tools.",
|
||||
status: "active",
|
||||
defaultAction: "deny",
|
||||
newToolsReviewedAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-06-10T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-11T00:00:00Z"),
|
||||
entries: [entry({})],
|
||||
bindings: [
|
||||
{
|
||||
id: "b1",
|
||||
companyId: "c1",
|
||||
profileId: "p1",
|
||||
targetType: "agent",
|
||||
targetId: "a1",
|
||||
priority: 0,
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
summary: summary(),
|
||||
...partial,
|
||||
} as ToolProfileWithDetails;
|
||||
}
|
||||
|
||||
function tool(partial: Partial<ToolCatalogEntry>): ToolCatalogEntry {
|
||||
return {
|
||||
id: partial.id ?? "t1",
|
||||
companyId: "c1",
|
||||
applicationId: "app-gmail",
|
||||
connectionId: "conn-gmail",
|
||||
entryKind: "tool",
|
||||
toolName: "gmail.read",
|
||||
title: "Read mail",
|
||||
description: null,
|
||||
inputSchema: null,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
addedAt: new Date(),
|
||||
version: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date(),
|
||||
lastSeenAt: new Date(),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...partial,
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
function newTool(partial: Record<string, unknown> = {}) {
|
||||
return {
|
||||
catalogEntryId: partial.catalogEntryId ?? "new-1",
|
||||
applicationId: "app-gmail",
|
||||
applicationName: "Gmail",
|
||||
connectionId: "conn-gmail",
|
||||
connectionName: "Gmail",
|
||||
toolName: partial.toolName ?? "gmail.send",
|
||||
title: partial.title ?? "Send mail",
|
||||
description: partial.description ?? "Send a message from Gmail.",
|
||||
capability: partial.capability ?? "write",
|
||||
riskLevel: partial.riskLevel ?? "write",
|
||||
addedAt: partial.addedAt ?? new Date("2026-05-28T00:00:00Z"),
|
||||
firstSeenAt: partial.firstSeenAt ?? new Date("2026-05-28T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function setData(profiles: ToolProfileWithDetails[], catalog: ToolCatalogEntry[] = [tool({})], connections: Partial<ToolConnection>[] = []) {
|
||||
profilesData.current = {
|
||||
profiles: { isLoading: false, isError: false, data: { profiles }, refetch: vi.fn() },
|
||||
connections: { data: { connections: [{ id: "conn-gmail", name: "Gmail", status: "active", healthStatus: "ok", ...connections[0] }] } },
|
||||
catalog,
|
||||
maps: {
|
||||
applicationsById: new Map([["app-gmail", "Gmail"]]),
|
||||
connectionsById: new Map([["conn-gmail", "Gmail"]]),
|
||||
agentsById: new Map([["a1", "Sage"]]),
|
||||
projectsById: new Map(),
|
||||
routinesById: new Map(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
|
||||
setter?.call(el, value);
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("ProfileDetail", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
api.getProfileNewTools.mockResolvedValue({ profileId: "p1", reviewedAt: null, pendingCount: 0, tools: [] });
|
||||
api.reviewProfileNewTools.mockResolvedValue({ reviewedAt: new Date(), allowedCount: 0, keptBlockedCount: 0, entriesCreated: [], reviewedCatalogEntryIds: [], profile: profile() });
|
||||
api.updateProfile.mockResolvedValue(profile());
|
||||
api.duplicateProfile.mockResolvedValue(profile({ id: "copy", name: "Copy" }));
|
||||
api.deleteProfile.mockResolvedValue({ deleted: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ProfileDetail companyId="c1" profileId="p1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
it("renders detail sections with resolved allowed tools and assignments", async () => {
|
||||
setData([profile()]);
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Everyday work");
|
||||
expect(container.textContent).toContain("What it allows");
|
||||
expect(container.textContent).toContain("Read mail");
|
||||
expect(container.textContent).toContain("added by rule: all Gmail");
|
||||
expect(container.textContent).toContain("Who has it");
|
||||
expect(container.textContent).toContain("Sage");
|
||||
expect(container.textContent).toContain("New tools that appear later");
|
||||
});
|
||||
|
||||
it("shows degraded app rows and the 0-tools warning state", async () => {
|
||||
setData([profile()], [tool({})], [
|
||||
{ status: "disabled", healthStatus: "error" },
|
||||
]);
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Gmail is disconnected");
|
||||
expect(container.textContent).toContain("Reconnect");
|
||||
});
|
||||
|
||||
it("shows the 0-tools and unassigned warning states", async () => {
|
||||
setData([profile({ summary: summary({ allowedToolCount: 0, assignmentCount: 0, appliesToAgentCount: 0 }), entries: [], bindings: [] })], [tool({})]);
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("allows 0 tools");
|
||||
expect(container.textContent).toContain("Not assigned yet");
|
||||
expect(container.textContent).toContain("Assign this profile before it changes access.");
|
||||
});
|
||||
|
||||
it("shows pending new tools and submits per-tool review decisions", async () => {
|
||||
const tools = [
|
||||
newTool({ catalogEntryId: "new-send", toolName: "gmail.send", title: "Send mail" }),
|
||||
newTool({ catalogEntryId: "new-label", toolName: "gmail.label", title: "Manage labels", capability: "write", riskLevel: "write" }),
|
||||
newTool({ catalogEntryId: "new-delete", toolName: "gmail.delete", title: "Delete mail", capability: "destructive", riskLevel: "destructive" }),
|
||||
];
|
||||
api.getProfileNewTools.mockResolvedValue({ profileId: "p1", reviewedAt: null, pendingCount: 3, tools });
|
||||
setData([profile({ newToolsPendingCount: 3 })]);
|
||||
await render();
|
||||
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
expect(container.textContent).toContain("Gmail added 3 new tools since your last review");
|
||||
|
||||
const review = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Review");
|
||||
flushSync(() => {
|
||||
review?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(document.body.textContent).toContain("Send mail");
|
||||
expect(document.body.textContent).toContain("Keep blocked");
|
||||
|
||||
const firstAllow = document.body.querySelector('input[name="review-new-send"][type="radio"]') as HTMLInputElement;
|
||||
flushSync(() => {
|
||||
firstAllow.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
const submit = [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Submit review");
|
||||
flushSync(() => {
|
||||
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(api.reviewProfileNewTools).toHaveBeenCalledWith("p1", {
|
||||
decisions: [
|
||||
{ catalogEntryId: "new-send", decision: "allow" },
|
||||
{ catalogEntryId: "new-label", decision: "keep_blocked" },
|
||||
{ catalogEntryId: "new-delete", decision: "keep_blocked" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows recently auto-added tools in the source column", async () => {
|
||||
const recent = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
|
||||
setData([profile({ defaultAction: "allow" })], [tool({ addedAt: recent, firstSeenAt: recent })]);
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("added automatically");
|
||||
});
|
||||
|
||||
it("validates duplicate profile names in the edit dialog", async () => {
|
||||
setData([profile(), profile({ id: "p2", name: "Existing name" })]);
|
||||
await render();
|
||||
|
||||
const edit = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Edit");
|
||||
flushSync(() => {
|
||||
edit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
const input = document.body.querySelector("#edit-profile-name") as HTMLInputElement;
|
||||
flushSync(() => {
|
||||
setNativeValue(input, "Existing name");
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(document.body.textContent).toContain("Another profile already uses this name.");
|
||||
});
|
||||
|
||||
it("shows archive and delete confirmation copy", async () => {
|
||||
setData([profile({ summary: summary({ assignmentCount: 2, appliesToAgentCount: 2, isCompanyDefault: true }) })]);
|
||||
await render();
|
||||
|
||||
const archive = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Archive");
|
||||
flushSync(() => {
|
||||
archive?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(document.body.textContent).toContain("This profile stops applying to 2 agents");
|
||||
|
||||
flushSync(() => {
|
||||
document.body.querySelector('[role="dialog"] button')?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it("blocks company-default delete from the detail dialog before the API call", async () => {
|
||||
setData([profile({ summary: summary({ assignmentCount: 0, isCompanyDefault: true }) })]);
|
||||
await render();
|
||||
|
||||
const deleteButton = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Delete");
|
||||
flushSync(() => {
|
||||
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(document.body.textContent).toContain("Reassign the company default to another profile before deleting it.");
|
||||
const dialogDelete = [...document.body.querySelectorAll('[role="dialog"] button')].find((b) => b.textContent?.trim() === "Delete") as HTMLButtonElement | undefined;
|
||||
expect(dialogDelete?.disabled).toBe(true);
|
||||
expect(api.deleteProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,848 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArchiveRestore, Copy, Pencil, PlugZap, ShieldCheck, Trash2, UserMinus } from "lucide-react";
|
||||
import type {
|
||||
ToolCatalogEntry,
|
||||
ToolProfileBinding,
|
||||
ToolProfileDefaultAction,
|
||||
ToolProfileEntry,
|
||||
ToolProfileNewToolReviewDecision,
|
||||
ToolProfileNewToolReviewItem,
|
||||
ToolProfileWithDetails,
|
||||
} from "@paperclipai/shared";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn, formatShortDate } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { ErrorState, LoadingState, RelativeTime, ToolsPageHeader } from "../shared";
|
||||
import { ProfileActionDialog, type ProfileActionDialogKind } from "./ProfileActionDialog";
|
||||
import { allowsLabel, STATUS_LABEL } from "./profile-summary";
|
||||
import { useProfilesData } from "./useProfilesData";
|
||||
|
||||
type DialogKind = "edit" | "duplicate" | "archive" | "delete" | "restore" | null;
|
||||
|
||||
interface AllowRow {
|
||||
id: string;
|
||||
app: string;
|
||||
tool: string;
|
||||
capabilities: string;
|
||||
source: string;
|
||||
autoAddedAt: Date | string | null;
|
||||
degraded: boolean;
|
||||
connectionId: string | null;
|
||||
}
|
||||
|
||||
export function ProfileDetail({
|
||||
companyId,
|
||||
profileId,
|
||||
initialCreated,
|
||||
initialReviewOpen,
|
||||
}: {
|
||||
companyId: string;
|
||||
profileId: string;
|
||||
initialCreated?: boolean;
|
||||
initialReviewOpen?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const data = useProfilesData(companyId);
|
||||
const [dialog, setDialog] = useState<DialogKind>(null);
|
||||
const [assignmentToRemove, setAssignmentToRemove] = useState<ToolProfileBinding | null>(null);
|
||||
const [reviewOpen, setReviewOpen] = useState(Boolean(initialReviewOpen));
|
||||
const [reviewDecisions, setReviewDecisions] = useState<Record<string, ToolProfileNewToolReviewDecision>>({});
|
||||
|
||||
const profile = (data.profiles.data?.profiles ?? []).find((p) => p.id === profileId) ?? null;
|
||||
const created = initialCreated ?? searchParams.get("created") === "1";
|
||||
const pendingNewTools = profile?.newToolsPendingCount ?? 0;
|
||||
const newTools = useQuery({
|
||||
queryKey: queryKeys.tools.profileNewTools(profileId),
|
||||
queryFn: () => toolsApi.getProfileNewTools(profileId),
|
||||
enabled: pendingNewTools > 0,
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
|
||||
const errorBody = (error: unknown) => String((error as Error)?.message ?? error);
|
||||
|
||||
const allowRows = useMemo(
|
||||
() => (profile ? buildAllowRows(profile, data.catalog, data.maps.applicationsById, data.maps.connectionsById, data.connections.data?.connections ?? []) : []),
|
||||
[profile, data.catalog, data.maps.applicationsById, data.maps.connectionsById, data.connections.data?.connections],
|
||||
);
|
||||
const reviewItems = newTools.data?.tools ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("review") === "new-tools" && pendingNewTools > 0) setReviewOpen(true);
|
||||
}, [pendingNewTools, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reviewItems.length === 0) return;
|
||||
setReviewDecisions((current) => {
|
||||
const next = { ...current };
|
||||
for (const tool of reviewItems) {
|
||||
if (!next[tool.catalogEntryId]) next[tool.catalogEntryId] = "keep_blocked";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [reviewItems]);
|
||||
|
||||
const updateProfile = useMutation({
|
||||
mutationFn: (input: Parameters<typeof toolsApi.updateProfile>[1]) => toolsApi.updateProfile(profileId, input),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
pushToast({ title: "Profile updated", tone: "success" });
|
||||
},
|
||||
onError: (error: unknown) => pushToast({ title: "Could not update profile", body: errorBody(error), tone: "error" }),
|
||||
});
|
||||
|
||||
const duplicateProfile = useMutation({
|
||||
mutationFn: (input: { name: string; includeAssignments: boolean }) => toolsApi.duplicateProfile(profileId, input),
|
||||
onSuccess: (copy) => {
|
||||
invalidate();
|
||||
pushToast({ title: "Profile duplicated", body: "The copy is not assigned to anyone yet.", tone: "success" });
|
||||
navigate(`/apps/advanced/profiles/${copy.id}?created=1`);
|
||||
},
|
||||
onError: (error: unknown) => pushToast({ title: "Could not duplicate", body: errorBody(error), tone: "error" }),
|
||||
});
|
||||
|
||||
const deleteProfile = useMutation({
|
||||
mutationFn: () => toolsApi.deleteProfile(profileId),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
pushToast({ title: "Profile deleted", tone: "success" });
|
||||
navigate("/apps/advanced/profiles");
|
||||
},
|
||||
onError: (error: unknown) => pushToast({ title: "Could not delete", body: errorBody(error), tone: "error" }),
|
||||
});
|
||||
|
||||
const removeAssignment = useMutation({
|
||||
mutationFn: (binding: ToolProfileBinding) =>
|
||||
toolsApi.unbindProfile(companyId, profileId, { targetType: binding.targetType, targetId: binding.targetId }),
|
||||
onSuccess: () => {
|
||||
setAssignmentToRemove(null);
|
||||
invalidate();
|
||||
pushToast({ title: "Assignment removed", tone: "success" });
|
||||
},
|
||||
onError: (error: unknown) => pushToast({ title: "Could not remove assignment", body: errorBody(error), tone: "error" }),
|
||||
});
|
||||
|
||||
const reviewNewTools = useMutation({
|
||||
mutationFn: () =>
|
||||
toolsApi.reviewProfileNewTools(profileId, {
|
||||
decisions: reviewItems.map((tool) => ({
|
||||
catalogEntryId: tool.catalogEntryId,
|
||||
decision: reviewDecisions[tool.catalogEntryId] ?? "keep_blocked",
|
||||
})),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setReviewOpen(false);
|
||||
setSearchParams({});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profileNewTools(profileId) });
|
||||
pushToast({ title: "New tools reviewed", tone: "success" });
|
||||
},
|
||||
onError: (error: unknown) => pushToast({ title: "Could not submit review", body: errorBody(error), tone: "error" }),
|
||||
});
|
||||
|
||||
if (data.profiles.isLoading) return <LoadingState label="Loading profile..." />;
|
||||
if (data.profiles.isError) return <ErrorState error={data.profiles.error} onRetry={() => data.profiles.refetch()} />;
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ToolsPageHeader title="Profile not found" description="This access profile may have been deleted." />
|
||||
<Button variant="outline" onClick={() => navigate("/apps/advanced/profiles")}>Back to profiles</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const archived = profile.status === "archived";
|
||||
const unassigned = profile.summary.assignmentCount === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ToolsPageHeader
|
||||
title={profile.name}
|
||||
description={profile.description ?? "No description yet."}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={archived} onClick={() => setDialog("edit")}>
|
||||
<Pencil className="mr-1.5 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outline" disabled={archived} onClick={() => setDialog("duplicate")}>
|
||||
<Copy className="mr-1.5 h-4 w-4" />
|
||||
Duplicate
|
||||
</Button>
|
||||
{archived ? (
|
||||
<Button variant="outline" onClick={() => setDialog("restore")}>
|
||||
<ArchiveRestore className="mr-1.5 h-4 w-4" />
|
||||
Restore
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setDialog("archive")}>Archive</Button>
|
||||
)}
|
||||
<Button variant="outline" className="text-destructive hover:text-destructive" onClick={() => setDialog("delete")}>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<Badge variant={archived ? "outline" : "default"}>{STATUS_LABEL[profile.status]}</Badge>
|
||||
<span className="text-muted-foreground">Updated <RelativeTime value={profile.updatedAt} /></span>
|
||||
<span className="text-muted-foreground">{allowsLabel(profile.summary)}</span>
|
||||
</div>
|
||||
|
||||
{created ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-primary/30 bg-primary/5 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Profile saved</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{unassigned ? "Assign it to agents before it changes their access." : "Assignments are active now."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => navigate(`/apps/advanced/profiles/${profile.id}/edit?step=3`)}>
|
||||
Assign
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setSearchParams({})}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{archived ? (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
This profile is archived. It does not apply to agents until it is restored.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pendingNewTools > 0 ? (
|
||||
<NewToolsReviewBanner
|
||||
count={pendingNewTools}
|
||||
tools={reviewItems}
|
||||
loading={newTools.isLoading}
|
||||
onReview={() => setReviewOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">What it allows</h2>
|
||||
<Button variant="outline" size="sm" disabled={archived} onClick={() => navigate(`/apps/advanced/profiles/${profile.id}/edit?step=2`)}>
|
||||
Edit tools
|
||||
</Button>
|
||||
</div>
|
||||
<AllowList rows={allowRows} total={profile.summary.totalToolCount} />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Who has it</h2>
|
||||
<Button variant="outline" size="sm" disabled={archived} onClick={() => navigate(`/apps/advanced/profiles/${profile.id}/edit?step=3`)}>
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
<Assignments
|
||||
profile={profile}
|
||||
companyId={companyId}
|
||||
maps={data.maps}
|
||||
archived={archived}
|
||||
onRemove={setAssignmentToRemove}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-semibold text-foreground">New tools that appear later</h2>
|
||||
<NewToolsSetting
|
||||
value={profile.defaultAction}
|
||||
disabled={archived || updateProfile.isPending}
|
||||
onChange={(defaultAction) => updateProfile.mutate({ defaultAction })}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Button variant="link" className="h-auto px-0" onClick={() => navigate("/apps/advanced/profiles?check=1")}>
|
||||
<ShieldCheck className="mr-1.5 h-4 w-4" />
|
||||
Check what an agent can actually do
|
||||
</Button>
|
||||
|
||||
<ProfileDialogs
|
||||
kind={dialog}
|
||||
profile={profile}
|
||||
allProfiles={data.profiles.data?.profiles ?? []}
|
||||
pending={updateProfile.isPending || duplicateProfile.isPending || deleteProfile.isPending}
|
||||
onClose={() => setDialog(null)}
|
||||
onUpdate={(input) => updateProfile.mutate(input, { onSuccess: () => setDialog(null) })}
|
||||
onDuplicate={(input) => duplicateProfile.mutate(input, { onSuccess: () => setDialog(null) })}
|
||||
onArchive={() => updateProfile.mutate({ status: "archived" }, { onSuccess: () => setDialog(null) })}
|
||||
onRestore={() => updateProfile.mutate({ status: "active" }, { onSuccess: () => setDialog(null) })}
|
||||
onDelete={() => deleteProfile.mutate(undefined, { onSuccess: () => setDialog(null) })}
|
||||
/>
|
||||
|
||||
<RemoveAssignmentDialog
|
||||
binding={assignmentToRemove}
|
||||
label={assignmentToRemove ? assignmentLabel(assignmentToRemove, companyId, data.maps) : ""}
|
||||
pending={removeAssignment.isPending}
|
||||
onClose={() => setAssignmentToRemove(null)}
|
||||
onConfirm={() => assignmentToRemove && removeAssignment.mutate(assignmentToRemove)}
|
||||
/>
|
||||
|
||||
<NewToolsReviewDialog
|
||||
open={reviewOpen}
|
||||
tools={reviewItems}
|
||||
loading={newTools.isLoading}
|
||||
error={newTools.error}
|
||||
decisions={reviewDecisions}
|
||||
pending={reviewNewTools.isPending}
|
||||
onClose={() => setReviewOpen(false)}
|
||||
onRetry={() => newTools.refetch()}
|
||||
onDecision={(catalogEntryId, decision) =>
|
||||
setReviewDecisions((current) => ({ ...current, [catalogEntryId]: decision }))
|
||||
}
|
||||
onSubmit={() => reviewNewTools.mutate()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewToolsReviewBanner({
|
||||
count,
|
||||
tools,
|
||||
loading,
|
||||
onReview,
|
||||
}: {
|
||||
count: number;
|
||||
tools: ToolProfileNewToolReviewItem[];
|
||||
loading: boolean;
|
||||
onReview: () => void;
|
||||
}) {
|
||||
const appLabel = newToolsAppLabel(tools);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{loading ? "New tools need review" : `${appLabel} added ${count} new ${count === 1 ? "tool" : "tools"} since your last review`}
|
||||
</p>
|
||||
<p className="text-amber-900/80">Choose which ones this profile should allow.</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={onReview}>Review</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewToolsReviewDialog({
|
||||
open,
|
||||
tools,
|
||||
loading,
|
||||
error,
|
||||
decisions,
|
||||
pending,
|
||||
onClose,
|
||||
onRetry,
|
||||
onDecision,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
tools: ToolProfileNewToolReviewItem[];
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
decisions: Record<string, ToolProfileNewToolReviewDecision>;
|
||||
pending: boolean;
|
||||
onClose: () => void;
|
||||
onRetry: () => void;
|
||||
onDecision: (catalogEntryId: string, decision: ToolProfileNewToolReviewDecision) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review new tools</DialogTitle>
|
||||
<DialogDescription>
|
||||
Allow the tools this profile should use. Keep the rest blocked.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loading ? (
|
||||
<LoadingState label="Loading new tools..." />
|
||||
) : error ? (
|
||||
<ErrorState error={error} onRetry={onRetry} />
|
||||
) : tools.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||
There are no new tools waiting for review.
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-(--sz-52vh) divide-y divide-border overflow-y-auto rounded-lg border border-border">
|
||||
{tools.map((tool) => (
|
||||
<div key={tool.catalogEntryId} className="grid gap-3 px-3 py-3 sm:grid-cols-[1fr_auto]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{tool.title || tool.toolName}
|
||||
</p>
|
||||
<Badge variant="secondary">{capabilityText(tool)}</Badge>
|
||||
</div>
|
||||
{tool.description ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{tool.description}</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{tool.applicationName ?? tool.connectionName ?? "App tool"} · added {formatShortDate(tool.addedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
<label className="inline-flex items-center gap-1.5 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name={`review-${tool.catalogEntryId}`}
|
||||
checked={(decisions[tool.catalogEntryId] ?? "keep_blocked") === "allow"}
|
||||
onChange={() => onDecision(tool.catalogEntryId, "allow")}
|
||||
/>
|
||||
Allow
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name={`review-${tool.catalogEntryId}`}
|
||||
checked={(decisions[tool.catalogEntryId] ?? "keep_blocked") === "keep_blocked"}
|
||||
onChange={() => onDecision(tool.catalogEntryId, "keep_blocked")}
|
||||
/>
|
||||
Keep blocked
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button disabled={pending || loading || tools.length === 0} onClick={onSubmit}>
|
||||
Submit review
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AllowList({ rows, total }: { rows: AllowRow[]; total: number }) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
This profile allows 0 tools. Agents with only this profile will not be able to use app tools.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-xs font-medium text-muted-foreground">
|
||||
<th className="px-3 py-2 font-medium">Tool</th>
|
||||
<th className="px-3 py-2 font-medium">App</th>
|
||||
<th className="px-3 py-2 font-medium">Capabilities</th>
|
||||
<th className="px-3 py-2 font-medium">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 80).map((row) => (
|
||||
<tr key={row.id} className={cn("border-b border-border last:border-0", row.degraded && "bg-muted/30 text-muted-foreground")}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-foreground">{row.tool}</span>
|
||||
{row.degraded ? (
|
||||
<a className="mt-0.5 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline" href={`/apps/${row.connectionId}`}>
|
||||
<PlugZap className="h-3 w-3" />
|
||||
Reconnect
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span>{row.app}</span>
|
||||
{row.degraded ? <span className="ml-2 text-xs text-muted-foreground">{row.app} is disconnected</span> : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{row.capabilities}</td>
|
||||
<td className="px-3 py-2">
|
||||
{row.source.startsWith("added by rule") ? (
|
||||
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-950">{row.source}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{row.source}</span>
|
||||
)}
|
||||
{row.autoAddedAt ? (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
added automatically · {formatShortDate(row.autoAddedAt)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length > 80 ? (
|
||||
<p className="border-t border-border px-3 py-2 text-xs text-muted-foreground">
|
||||
Showing 80 of {rows.length} allowed tools.
|
||||
</p>
|
||||
) : (
|
||||
<p className="border-t border-border px-3 py-2 text-xs text-muted-foreground">
|
||||
Allows {rows.length} of {total} known tools.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Assignments({
|
||||
profile,
|
||||
companyId,
|
||||
maps,
|
||||
archived,
|
||||
onRemove,
|
||||
}: {
|
||||
profile: ToolProfileWithDetails;
|
||||
companyId: string;
|
||||
maps: ReturnType<typeof useProfilesData>["maps"];
|
||||
archived: boolean;
|
||||
onRemove: (binding: ToolProfileBinding) => void;
|
||||
}) {
|
||||
if (profile.bindings.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border px-4 py-5">
|
||||
<p className="text-sm font-medium text-foreground">Not assigned yet</p>
|
||||
<p className="text-sm text-muted-foreground">Assign this profile before it changes access.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
{profile.bindings.map((binding) => (
|
||||
<div key={binding.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{binding.targetType === "company" ? "Co" : binding.targetType.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">{assignmentLabel(binding, companyId, maps)}</p>
|
||||
<p className="text-xs text-muted-foreground">{assignmentTypeLabel(binding.targetType)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" disabled={archived} onClick={() => onRemove(binding)}>
|
||||
<UserMinus className="mr-1.5 h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewToolsSetting({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: ToolProfileDefaultAction;
|
||||
disabled: boolean;
|
||||
onChange: (value: ToolProfileDefaultAction) => void;
|
||||
}) {
|
||||
const options: Array<{ value: ToolProfileDefaultAction; title: string; body: string }> = [
|
||||
{ value: "deny", title: "Stay blocked until reviewed", body: "New tools do not become available automatically." },
|
||||
{ value: "allow", title: "Allowed automatically", body: "New tools from selected apps become available right away." },
|
||||
];
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3",
|
||||
value === option.value ? "border-primary bg-primary/5" : "border-border",
|
||||
disabled && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-1"
|
||||
disabled={disabled}
|
||||
checked={value === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-foreground">{option.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">{option.body}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileDialogs({
|
||||
kind,
|
||||
profile,
|
||||
allProfiles,
|
||||
pending,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onDuplicate,
|
||||
onArchive,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}: {
|
||||
kind: DialogKind;
|
||||
profile: ToolProfileWithDetails;
|
||||
allProfiles: ToolProfileWithDetails[];
|
||||
pending: boolean;
|
||||
onClose: () => void;
|
||||
onUpdate: (input: { name: string; description: string | null; profileKey: string }) => void;
|
||||
onDuplicate: (input: { name: string; includeAssignments: boolean }) => void;
|
||||
onArchive: () => void;
|
||||
onRestore: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(profile.name);
|
||||
const [description, setDescription] = useState(profile.description ?? "");
|
||||
const [profileKey, setProfileKey] = useState(profile.profileKey);
|
||||
const [copyName, setCopyName] = useState(`${profile.name} copy`);
|
||||
const [copyAssignments, setCopyAssignments] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const duplicateName = allProfiles.some((p) => p.id !== profile.id && p.name.trim().toLowerCase() === name.trim().toLowerCase());
|
||||
const duplicateCopyName = allProfiles.some((p) => p.name.trim().toLowerCase() === copyName.trim().toLowerCase());
|
||||
|
||||
if (!kind) return null;
|
||||
const open = Boolean(kind);
|
||||
|
||||
if (kind === "edit") {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit profile</DialogTitle>
|
||||
<DialogDescription>Update the profile name and description.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="edit-profile-name">Name</Label>
|
||||
<Input id="edit-profile-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
{duplicateName ? <p className="text-xs text-destructive">Another profile already uses this name.</p> : null}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="edit-profile-description">Description</Label>
|
||||
<Textarea id="edit-profile-description" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
</div>
|
||||
<button type="button" className="text-sm font-medium text-muted-foreground hover:text-foreground" onClick={() => setAdvancedOpen((v) => !v)}>
|
||||
Advanced
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="edit-profile-key">Identifier</Label>
|
||||
<Input id="edit-profile-key" value={profileKey} onChange={(e) => setProfileKey(e.target.value)} className="font-mono text-xs" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button disabled={!name.trim() || duplicateName || pending} onClick={() => onUpdate({ name: name.trim(), description: description.trim() || null, profileKey: profileKey.trim() })}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "duplicate") {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duplicate profile</DialogTitle>
|
||||
<DialogDescription>The copy starts unassigned unless you choose to copy assignments too.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="copy-profile-name">Name</Label>
|
||||
<Input id="copy-profile-name" value={copyName} onChange={(e) => setCopyName(e.target.value)} />
|
||||
{duplicateCopyName ? <p className="text-xs text-destructive">Another profile already uses this name.</p> : null}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={copyAssignments} onChange={(e) => setCopyAssignments(e.target.checked)} />
|
||||
Also copy assignments?
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button disabled={!copyName.trim() || duplicateCopyName || pending} onClick={() => onDuplicate({ name: copyName.trim(), includeAssignments: copyAssignments })}>
|
||||
Duplicate
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProfileActionDialog
|
||||
kind={kind as ProfileActionDialogKind}
|
||||
profile={profile}
|
||||
pending={pending}
|
||||
onClose={onClose}
|
||||
onArchive={onArchive}
|
||||
onRestore={onRestore}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveAssignmentDialog({
|
||||
binding,
|
||||
label,
|
||||
pending,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
binding: ToolProfileBinding | null;
|
||||
label: string;
|
||||
pending: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={Boolean(binding)} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove assignment</DialogTitle>
|
||||
<DialogDescription>
|
||||
{binding?.targetType === "company"
|
||||
? "Removing the company default changes access for every agent that relies on it."
|
||||
: `Remove this profile from ${label}.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button disabled={pending} onClick={onConfirm}>Remove</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function buildAllowRows(
|
||||
profile: ToolProfileWithDetails,
|
||||
catalog: ToolCatalogEntry[],
|
||||
appNames: Map<string, string>,
|
||||
connectionNames: Map<string, string>,
|
||||
connections: Array<{ id: string; status?: string; healthStatus?: string }>,
|
||||
): AllowRow[] {
|
||||
const excluded = profile.entries.filter((entry) => entry.effect === "exclude");
|
||||
const included = profile.entries.filter((entry) => entry.effect === "include");
|
||||
const includeAllExcept = profile.summary.accessMode === "all_except";
|
||||
return catalog
|
||||
.filter((tool) => !excluded.some((entry) => entryMatchesTool(entry, tool)))
|
||||
.filter((tool) => includeAllExcept || included.some((entry) => entryMatchesTool(entry, tool)))
|
||||
.map((tool) => {
|
||||
const match = includeAllExcept ? null : included.find((entry) => entryMatchesTool(entry, tool)) ?? null;
|
||||
const app = appNames.get(tool.applicationId ?? "") ?? connectionNames.get(tool.connectionId) ?? "Unknown app";
|
||||
const connection = connections.find((item) => item.id === tool.connectionId);
|
||||
return {
|
||||
id: tool.id,
|
||||
app,
|
||||
tool: tool.title || tool.toolName,
|
||||
capabilities: capabilityLabel(tool),
|
||||
source: sourceLabel(match, app),
|
||||
autoAddedAt: profile.defaultAction === "allow" && isRecentTool(tool) ? (tool.addedAt ?? tool.firstSeenAt) : null,
|
||||
degraded: Boolean(connection && (connection.status !== "active" || connection.healthStatus === "error")),
|
||||
connectionId: tool.connectionId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function entryMatchesTool(entry: ToolProfileEntry, tool: ToolCatalogEntry): boolean {
|
||||
switch (entry.selectorType) {
|
||||
case "application":
|
||||
return Boolean(entry.applicationId && entry.applicationId === tool.applicationId);
|
||||
case "connection":
|
||||
return Boolean(entry.connectionId && entry.connectionId === tool.connectionId);
|
||||
case "catalog_entry":
|
||||
return Boolean(entry.catalogEntryId && entry.catalogEntryId === tool.id);
|
||||
case "tool_name":
|
||||
return Boolean(entry.toolName && entry.toolName === tool.toolName);
|
||||
case "risk_level":
|
||||
return Boolean(entry.riskLevel && entry.riskLevel === tool.riskLevel);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(entry: ToolProfileEntry | null, app: string): string {
|
||||
if (!entry) return "added directly";
|
||||
if (entry.selectorType === "application" || entry.selectorType === "connection") return `added by rule: all ${app}`;
|
||||
if (entry.selectorType === "risk_level" && entry.riskLevel) return `added by rule: ${entry.riskLevel} tools`;
|
||||
return "added directly";
|
||||
}
|
||||
|
||||
function capabilityLabel(tool: ToolCatalogEntry): string {
|
||||
if (tool.isDestructive) return "Destructive";
|
||||
if (tool.isWrite) return "Write";
|
||||
return "Read";
|
||||
}
|
||||
|
||||
function capabilityText(tool: ToolProfileNewToolReviewItem): string {
|
||||
if (tool.riskLevel === "destructive") return "Destructive";
|
||||
if (tool.riskLevel === "write") return "Write";
|
||||
if (tool.riskLevel === "read") return "Read";
|
||||
return tool.capability;
|
||||
}
|
||||
|
||||
function newToolsAppLabel(tools: ToolProfileNewToolReviewItem[]): string {
|
||||
const names = [...new Set(tools.map((tool) => tool.applicationName ?? tool.connectionName).filter(Boolean))] as string[];
|
||||
if (names.length === 0) return "An app";
|
||||
if (names.length === 1) return names[0] ?? "An app";
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
||||
return `${names[0]} and ${names.length - 1} more apps`;
|
||||
}
|
||||
|
||||
function isRecentTool(tool: ToolCatalogEntry): boolean {
|
||||
const value = tool.addedAt ?? tool.firstSeenAt;
|
||||
const added = new Date(value).getTime();
|
||||
if (!Number.isFinite(added)) return false;
|
||||
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
|
||||
return Date.now() - added <= thirtyDaysMs;
|
||||
}
|
||||
|
||||
function assignmentLabel(
|
||||
binding: ToolProfileBinding,
|
||||
companyId: string,
|
||||
maps: ReturnType<typeof useProfilesData>["maps"],
|
||||
): string {
|
||||
if (binding.targetType === "company") return "Company default";
|
||||
if (binding.targetType === "agent") return maps.agentsById.get(binding.targetId) ?? "Unknown agent";
|
||||
if (binding.targetType === "project") return maps.projectsById.get(binding.targetId) ?? "Unknown project";
|
||||
if (binding.targetType === "routine") return maps.routinesById.get(binding.targetId) ?? "Unknown routine";
|
||||
if (binding.targetId === companyId) return "Company";
|
||||
return binding.targetId;
|
||||
}
|
||||
|
||||
function assignmentTypeLabel(type: ToolProfileBinding["targetType"]): string {
|
||||
if (type === "company") return "Company default";
|
||||
if (type === "agent") return "Agent";
|
||||
if (type === "project") return "Project";
|
||||
if (type === "routine") return "Routine";
|
||||
return "Scoped assignment";
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { useEffect } from "react";
|
||||
import { useParams } from "@/lib/router";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { advancedTabHref } from "../tool-tabs";
|
||||
import { ToolsAdminGate } from "./ToolsAdminGate";
|
||||
import { ProfileDetail } from "./ProfileDetail";
|
||||
|
||||
export function ProfileDetailRoute() {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const params = useParams<{ profileId?: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Access profiles", href: advancedTabHref("profiles") },
|
||||
{ label: "Profile detail" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
|
||||
if (!selectedCompanyId || !params.profileId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select a company and profile.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolsAdminGate>
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-4 sm:p-6">
|
||||
<ProfileDetail companyId={selectedCompanyId} profileId={params.profileId} />
|
||||
</div>
|
||||
</ToolsAdminGate>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ToolCatalogEntry, ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigate = vi.hoisted(() => vi.fn());
|
||||
const api = vi.hoisted(() => ({
|
||||
createProfile: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
bindProfile: vi.fn(),
|
||||
unbindProfile: vi.fn(),
|
||||
}));
|
||||
const profilesData = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => navigate,
|
||||
Link: ({ to, children }: { to: string; children: unknown }) => createElement("a", { href: to }, children as never),
|
||||
}));
|
||||
vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ pushToast: vi.fn() }) }));
|
||||
vi.mock("@/api/tools", () => ({ toolsApi: api }));
|
||||
vi.mock("./useProfilesData", () => ({ useProfilesData: () => profilesData.current }));
|
||||
|
||||
import { ProfileWizard } from "./ProfileWizard";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function tool(id: string, toolName: string): ToolCatalogEntry {
|
||||
return {
|
||||
id,
|
||||
toolName,
|
||||
title: toolName,
|
||||
description: null,
|
||||
applicationId: "app1",
|
||||
connectionId: "conn1",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
riskLevel: "read",
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
const APP_GROUP = {
|
||||
appKey: "app1",
|
||||
applicationId: "app1",
|
||||
connectionId: "conn1",
|
||||
name: "Gmail",
|
||||
tools: [tool("t1", "gmail.read"), tool("t2", "gmail.send")],
|
||||
};
|
||||
|
||||
function setData(profiles: ToolProfileWithDetails[] = []) {
|
||||
profilesData.current = {
|
||||
appGroups: [APP_GROUP],
|
||||
catalog: APP_GROUP.tools,
|
||||
catalogLoading: false,
|
||||
profiles: { isLoading: false, data: { profiles } },
|
||||
agents: { data: [{ id: "a1", name: "Sage" }] },
|
||||
};
|
||||
}
|
||||
|
||||
function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
|
||||
setter?.call(el, value);
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("ProfileWizard", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
api.createProfile.mockResolvedValue({ id: "new-1", metadata: null, bindings: [] });
|
||||
api.updateProfile.mockResolvedValue({ id: "new-1", metadata: null, bindings: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render(props: { profileId?: string; initialTemplate?: "everyday" }) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ProfileWizard companyId="c1" {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("creates a draft and advances to Choose tools on Continue", async () => {
|
||||
setData([]);
|
||||
await render({ initialTemplate: "everyday" });
|
||||
|
||||
const nameInput = container.querySelector("#profile-name") as HTMLInputElement;
|
||||
await act(async () => {
|
||||
setNativeValue(nameInput, "Everyday work");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const continueBtn = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Continue");
|
||||
await act(async () => {
|
||||
continueBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(api.createProfile).toHaveBeenCalledTimes(1);
|
||||
const [, input] = api.createProfile.mock.calls[0];
|
||||
expect(input.status).toBe("draft");
|
||||
expect(input.name).toBe("Everyday work");
|
||||
// Step 2 is now visible.
|
||||
expect(container.textContent).toContain("New tools that appear later");
|
||||
});
|
||||
|
||||
it("resumes a draft at the first unfinished step", async () => {
|
||||
const draft: ToolProfileWithDetails = {
|
||||
id: "d1",
|
||||
companyId: "c1",
|
||||
profileKey: "everyday",
|
||||
name: "Everyday work",
|
||||
description: null,
|
||||
status: "draft",
|
||||
defaultAction: "deny",
|
||||
newToolsReviewedAt: null,
|
||||
metadata: { wizard: { lastCompletedStep: 1, template: "everyday" } },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
entries: [],
|
||||
bindings: [],
|
||||
summary: {
|
||||
accessMode: "selected",
|
||||
allowedToolCount: 0,
|
||||
allowedApplicationCount: 0,
|
||||
excludedToolCount: 0,
|
||||
totalToolCount: 0,
|
||||
assignmentCount: 0,
|
||||
appliesToAgentCount: 0,
|
||||
isCompanyDefault: false,
|
||||
},
|
||||
};
|
||||
setData([draft]);
|
||||
await render({ profileId: "d1" });
|
||||
|
||||
// lastCompletedStep 1 -> resume on step 2 (Choose tools), not step 1.
|
||||
expect(container.textContent).toContain("New tools that appear later");
|
||||
expect(container.querySelector("#profile-name")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,697 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
||||
import type { ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { toolsApi, type ToolProfileBindingInput } from "@/api/tools";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AgentMultiSelect, type AgentMultiSelectOption } from "@/components/AgentMultiSelect";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { LoadingState } from "../shared";
|
||||
import {
|
||||
buildEntries,
|
||||
countAllowedTools,
|
||||
parseEntries,
|
||||
templateSelections,
|
||||
TEMPLATES,
|
||||
type AdvancedRule,
|
||||
type TemplateKey,
|
||||
type WizardSelections,
|
||||
} from "./profile-model";
|
||||
import { useProfilesData } from "./useProfilesData";
|
||||
import { WizardToolsStep } from "./WizardToolsStep";
|
||||
import { readWizardMeta, resumeStep, withWizardMeta, type WizardStep } from "./wizard-draft";
|
||||
|
||||
function slugifyProfileKey(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._:-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 160);
|
||||
}
|
||||
|
||||
const STEP_LABELS: Array<{ step: WizardStep; label: string }> = [
|
||||
{ step: 1, label: "Name" },
|
||||
{ step: 2, label: "Choose tools" },
|
||||
{ step: 3, label: "Assign" },
|
||||
];
|
||||
|
||||
export function ProfileWizard({
|
||||
companyId,
|
||||
profileId,
|
||||
initialTemplate,
|
||||
initialStep,
|
||||
}: {
|
||||
companyId: string;
|
||||
profileId?: string;
|
||||
initialTemplate?: TemplateKey;
|
||||
initialStep?: WizardStep;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const data = useProfilesData(companyId);
|
||||
const { appGroups, catalog, profiles, agents } = data;
|
||||
|
||||
const allProfiles = profiles.data?.profiles ?? [];
|
||||
const existing = profileId ? allProfiles.find((p) => p.id === profileId) ?? null : null;
|
||||
|
||||
const [step, setStep] = useState<WizardStep>(1);
|
||||
const [draftId, setDraftId] = useState<string | null>(profileId ?? null);
|
||||
const [template, setTemplate] = useState<TemplateKey | null>(initialTemplate ?? null);
|
||||
const [copyFromId, setCopyFromId] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [profileKey, setProfileKey] = useState("");
|
||||
const [keyEdited, setKeyEdited] = useState(false);
|
||||
const [selections, setSelections] = useState<WizardSelections>({});
|
||||
const [advancedRules, setAdvancedRules] = useState<AdvancedRule[]>([]);
|
||||
const [newToolsAction, setNewToolsAction] = useState<"deny" | "allow">("deny");
|
||||
const [selectedAgentIds, setSelectedAgentIds] = useState<Set<string>>(new Set());
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set());
|
||||
const [selectedRoutineIds, setSelectedRoutineIds] = useState<Set<string>>(new Set());
|
||||
const [companyDefault, setCompanyDefault] = useState(false);
|
||||
|
||||
const toggleIn = (setter: typeof setSelectedAgentIds) => (id: string) =>
|
||||
setter((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Hydrate once when resuming an existing draft and its catalog is loaded.
|
||||
const hydrated = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hydrated.current || !existing || appGroups.length === 0) return;
|
||||
if (existing.id !== profileId) return;
|
||||
hydrated.current = true;
|
||||
setName(existing.name);
|
||||
setDescription(existing.description ?? "");
|
||||
setProfileKey(existing.profileKey);
|
||||
setKeyEdited(true);
|
||||
setNewToolsAction(existing.defaultAction);
|
||||
const parsed = parseEntries(appGroups, existing.entries);
|
||||
setSelections(parsed.selections);
|
||||
setAdvancedRules(parsed.advancedRules);
|
||||
const meta = readWizardMeta(existing);
|
||||
setTemplate(meta?.template ?? null);
|
||||
// Drafts resume at the first unfinished step; a finished profile being
|
||||
// edited starts at step 1 so the admin can review the whole thing.
|
||||
setStep(initialStep ?? (existing.status === "draft" ? resumeStep(meta) : 1));
|
||||
const targetIds = (type: string) =>
|
||||
new Set(existing.bindings.filter((b) => b.targetType === type).map((b) => b.targetId));
|
||||
setSelectedAgentIds(targetIds("agent"));
|
||||
setSelectedProjectIds(targetIds("project"));
|
||||
setSelectedRoutineIds(targetIds("routine"));
|
||||
setCompanyDefault(existing.bindings.some((b) => b.targetType === "company"));
|
||||
}, [existing, appGroups, profileId, initialStep]);
|
||||
|
||||
// Key auto-derives from the name until the admin overrides it in Advanced.
|
||||
useEffect(() => {
|
||||
if (!keyEdited) setProfileKey(slugifyProfileKey(name));
|
||||
}, [name, keyEdited]);
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
|
||||
};
|
||||
|
||||
const live = useMemo(
|
||||
() => countAllowedTools(appGroups, selections, newToolsAction, catalog.length),
|
||||
[appGroups, selections, newToolsAction, catalog.length],
|
||||
);
|
||||
|
||||
// Map step-1 template choice onto concrete per-app selections (needs the catalog).
|
||||
const seedSelections = (): WizardSelections => {
|
||||
if (template === "copy" && copyFromId) {
|
||||
const source = allProfiles.find((p) => p.id === copyFromId);
|
||||
if (source) return parseEntries(appGroups, source.entries).selections;
|
||||
}
|
||||
if (template && template !== "copy") return templateSelections(template, appGroups);
|
||||
return selections;
|
||||
};
|
||||
|
||||
const saveDraft = useMutation({
|
||||
mutationFn: async (input: {
|
||||
goToStep: WizardStep;
|
||||
completedStep: WizardStep;
|
||||
seed?: WizardSelections;
|
||||
}) => {
|
||||
const effSelections = input.seed ?? selections;
|
||||
const entries = buildEntries(appGroups, effSelections, advancedRules, newToolsAction);
|
||||
const metadata = withWizardMeta(existing?.metadata ?? null, {
|
||||
lastCompletedStep: input.completedStep,
|
||||
template,
|
||||
});
|
||||
if (!draftId) {
|
||||
const created = await toolsApi.createProfile(companyId, {
|
||||
profileKey: profileKey || slugifyProfileKey(name) || "profile",
|
||||
name: name.trim() || "Untitled profile",
|
||||
description: description.trim() || null,
|
||||
status: "draft",
|
||||
defaultAction: newToolsAction,
|
||||
entries,
|
||||
metadata,
|
||||
});
|
||||
return { created, goToStep: input.goToStep, seed: input.seed };
|
||||
}
|
||||
const updated = await toolsApi.updateProfile(draftId, {
|
||||
profileKey: profileKey || undefined,
|
||||
name: name.trim() || "Untitled profile",
|
||||
description: description.trim() || null,
|
||||
defaultAction: newToolsAction,
|
||||
entries,
|
||||
metadata,
|
||||
});
|
||||
return { created: updated, goToStep: input.goToStep, seed: input.seed };
|
||||
},
|
||||
onSuccess: ({ created, goToStep, seed }) => {
|
||||
setDraftId(created.id);
|
||||
if (seed) setSelections(seed);
|
||||
setStep(goToStep);
|
||||
invalidate();
|
||||
},
|
||||
onError: (error: unknown) =>
|
||||
pushToast({ title: "Could not save", body: String((error as Error)?.message ?? error), tone: "error" }),
|
||||
});
|
||||
|
||||
const finish = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!draftId) throw new Error("No draft to finish");
|
||||
const entries = buildEntries(appGroups, selections, advancedRules, newToolsAction);
|
||||
const profile = await toolsApi.updateProfile(draftId, {
|
||||
defaultAction: newToolsAction,
|
||||
entries,
|
||||
metadata: withWizardMeta(existing?.metadata ?? null, { lastCompletedStep: 3, template }),
|
||||
});
|
||||
await reconcileBindings(companyId, profile, {
|
||||
agentIds: [...selectedAgentIds],
|
||||
projectIds: [...selectedProjectIds],
|
||||
routineIds: [...selectedRoutineIds],
|
||||
companyDefault,
|
||||
});
|
||||
return toolsApi.updateProfile(draftId, { status: "active" });
|
||||
},
|
||||
onSuccess: (profile) => {
|
||||
pushToast({ title: "Profile saved", tone: "success" });
|
||||
invalidate();
|
||||
navigate(`/apps/advanced/profiles/${profile.id}${selectedAgentIds.size === 0 && !companyDefault ? "?created=1" : ""}`);
|
||||
},
|
||||
onError: (error: unknown) =>
|
||||
pushToast({ title: "Could not save profile", body: String((error as Error)?.message ?? error), tone: "error" }),
|
||||
});
|
||||
|
||||
const saveAndExit = () => {
|
||||
const completed: WizardStep = step;
|
||||
saveDraft.mutate(
|
||||
{ goToStep: step, completedStep: completed },
|
||||
{
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Draft saved", body: "Pick it back up from the profiles list.", tone: "success" });
|
||||
navigate("/apps/advanced/profiles");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const busy = saveDraft.isPending || finish.isPending;
|
||||
const step1Valid = name.trim().length > 0 && (template !== "copy" || Boolean(copyFromId));
|
||||
|
||||
if (profileId && profiles.isLoading) return <LoadingState label="Loading draft…" />;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 pb-24">
|
||||
<Stepper current={step} />
|
||||
|
||||
<div className="min-h-(--sz-320px)">
|
||||
{step === 1 ? (
|
||||
<StepName
|
||||
template={template}
|
||||
onTemplate={setTemplate}
|
||||
copyFromId={copyFromId}
|
||||
onCopyFrom={setCopyFromId}
|
||||
copyOptions={allProfiles.filter((p) => p.status !== "archived" && p.id !== profileId)}
|
||||
name={name}
|
||||
onName={setName}
|
||||
description={description}
|
||||
onDescription={setDescription}
|
||||
profileKey={profileKey}
|
||||
onProfileKey={(v) => {
|
||||
setKeyEdited(true);
|
||||
setProfileKey(v);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<WizardToolsStep
|
||||
appGroups={appGroups}
|
||||
catalogLoading={data.catalogLoading}
|
||||
selections={selections}
|
||||
onSelectionsChange={setSelections}
|
||||
advancedRules={advancedRules}
|
||||
onAdvancedRulesChange={setAdvancedRules}
|
||||
newToolsAction={newToolsAction}
|
||||
onNewToolsActionChange={setNewToolsAction}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<StepAssign
|
||||
agents={(agents.data ?? []).map((a) => ({ id: a.id, name: a.name, title: a.title, icon: a.icon }))}
|
||||
projects={(data.projects.data ?? []).map((p) => ({ id: p.id, name: p.name }))}
|
||||
routines={(data.routines.data ?? []).map((r) => ({ id: r.id, name: r.title }))}
|
||||
profiles={allProfiles}
|
||||
selectedAgentIds={selectedAgentIds}
|
||||
onToggleAgent={toggleIn(setSelectedAgentIds)}
|
||||
selectedProjectIds={selectedProjectIds}
|
||||
onToggleProject={toggleIn(setSelectedProjectIds)}
|
||||
selectedRoutineIds={selectedRoutineIds}
|
||||
onToggleRoutine={toggleIn(setSelectedRoutineIds)}
|
||||
companyDefault={companyDefault}
|
||||
onCompanyDefault={setCompanyDefault}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Sticky footer: live count + navigation. */}
|
||||
<div className="fixed inset-x-0 bottom-0 z-10 border-t border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
{step >= 2 ? (
|
||||
<span>
|
||||
Allows <span className="font-medium text-foreground">{live.allowed}</span> of {live.total}{" "}
|
||||
tools
|
||||
</span>
|
||||
) : null}
|
||||
{draftId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveAndExit}
|
||||
disabled={busy}
|
||||
className="font-medium text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
Save & finish later
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{step > 1 ? (
|
||||
<Button variant="outline" disabled={busy} onClick={() => setStep((s) => (s - 1) as WizardStep)}>
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" disabled={busy} onClick={() => navigate("/apps/advanced/profiles")}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step === 1 ? (
|
||||
<Button
|
||||
disabled={!step1Valid || busy}
|
||||
onClick={() =>
|
||||
saveDraft.mutate({ goToStep: 2, completedStep: 1, seed: seedSelections() })
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
|
||||
Continue
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => saveDraft.mutate({ goToStep: 3, completedStep: 2 })}
|
||||
>
|
||||
{busy ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
|
||||
Continue
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<Button disabled={busy} onClick={() => finish.mutate()}>
|
||||
{busy ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
|
||||
Save profile
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reconcile the profile's assignments against the wizard's step-3 choices. */
|
||||
async function reconcileBindings(
|
||||
companyId: string,
|
||||
profile: ToolProfileWithDetails,
|
||||
desired: { agentIds: string[]; projectIds: string[]; routineIds: string[]; companyDefault: boolean },
|
||||
) {
|
||||
const want = new Map<string, ToolProfileBindingInput>();
|
||||
if (desired.companyDefault) want.set(`company:${companyId}`, { targetType: "company", targetId: companyId });
|
||||
for (const id of desired.agentIds) want.set(`agent:${id}`, { targetType: "agent", targetId: id });
|
||||
for (const id of desired.projectIds) want.set(`project:${id}`, { targetType: "project", targetId: id });
|
||||
for (const id of desired.routineIds) want.set(`routine:${id}`, { targetType: "routine", targetId: id });
|
||||
|
||||
// Only the target types the wizard manages are reconciled — leave any
|
||||
// issue-scoped or other bindings untouched.
|
||||
const managed = new Set(["company", "agent", "project", "routine"]);
|
||||
const have = new Set(profile.bindings.map((b) => `${b.targetType}:${b.targetId}`));
|
||||
|
||||
const operations = [
|
||||
...[...want.entries()]
|
||||
.filter(([key]) => !have.has(key))
|
||||
.map(([key, input]) => ({
|
||||
key,
|
||||
apply: () => toolsApi.bindProfile(companyId, profile.id, input),
|
||||
rollback: () => toolsApi.unbindProfile(companyId, profile.id, input),
|
||||
})),
|
||||
...profile.bindings
|
||||
.filter((binding) => managed.has(binding.targetType) && !want.has(`${binding.targetType}:${binding.targetId}`))
|
||||
.map((binding) => {
|
||||
const input = { targetType: binding.targetType, targetId: binding.targetId } as ToolProfileBindingInput;
|
||||
return {
|
||||
key: `${binding.targetType}:${binding.targetId}`,
|
||||
apply: () => toolsApi.unbindProfile(companyId, profile.id, input),
|
||||
rollback: () => toolsApi.bindProfile(companyId, profile.id, input),
|
||||
};
|
||||
}),
|
||||
];
|
||||
const completed: typeof operations = [];
|
||||
for (const operation of operations) {
|
||||
try {
|
||||
await operation.apply();
|
||||
completed.push(operation);
|
||||
} catch (error) {
|
||||
const rollbacks = await Promise.allSettled(completed.reverse().map((done) => done.rollback()));
|
||||
const rollbackFailures = rollbacks.filter((result) => result.status === "rejected").length;
|
||||
const suffix = rollbackFailures > 0 ? `; ${rollbackFailures} rollback operation(s) also failed` : "";
|
||||
throw new Error(`Could not update assignment ${operation.key}${suffix}`, { cause: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Stepper({ current }: { current: WizardStep }) {
|
||||
return (
|
||||
<ol className="flex items-center gap-2 text-sm">
|
||||
{STEP_LABELS.map(({ step, label }, idx) => {
|
||||
const done = current > step;
|
||||
const active = current === step;
|
||||
return (
|
||||
<li key={step} className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
|
||||
active && "bg-primary text-primary-foreground",
|
||||
done && "bg-primary/20 text-primary",
|
||||
!active && !done && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="h-3.5 w-3.5" /> : step}
|
||||
</span>
|
||||
<span className={cn("font-medium", active ? "text-foreground" : "text-muted-foreground")}>
|
||||
{label}
|
||||
</span>
|
||||
{idx < STEP_LABELS.length - 1 ? <span className="mx-1 text-muted-foreground">→</span> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepName({
|
||||
template,
|
||||
onTemplate,
|
||||
copyFromId,
|
||||
onCopyFrom,
|
||||
copyOptions,
|
||||
name,
|
||||
onName,
|
||||
description,
|
||||
onDescription,
|
||||
profileKey,
|
||||
onProfileKey,
|
||||
}: {
|
||||
template: TemplateKey | null;
|
||||
onTemplate: (key: TemplateKey) => void;
|
||||
copyFromId: string | null;
|
||||
onCopyFrom: (id: string) => void;
|
||||
copyOptions: ToolProfileWithDetails[];
|
||||
name: string;
|
||||
onName: (v: string) => void;
|
||||
description: string;
|
||||
onDescription: (v: string) => void;
|
||||
profileKey: string;
|
||||
onProfileKey: (v: string) => void;
|
||||
}) {
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-foreground">Start from</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{TEMPLATES.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => onTemplate(t.key)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-1 rounded-md border px-4 py-3 text-left transition-colors",
|
||||
template === t.key
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "border-border hover:border-primary/40 hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">{t.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{t.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{template === "copy" ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-foreground">Which profile?</h3>
|
||||
{copyOptions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">You don't have another profile to copy yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{copyOptions.map((p) => (
|
||||
<label
|
||||
key={p.id}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-md border border-border px-3 py-2"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="copy-from"
|
||||
checked={copyFromId === p.id}
|
||||
onChange={() => onCopyFrom(p.id)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">{p.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-name">Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={name}
|
||||
onChange={(e) => onName(e.target.value)}
|
||||
placeholder="e.g. Everyday work"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-description">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="profile-description"
|
||||
value={description}
|
||||
onChange={(e) => onDescription(e.target.value)}
|
||||
placeholder="What is this profile for?"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform", advancedOpen && "rotate-180")} />
|
||||
Advanced
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="profile-key">Identifier</Label>
|
||||
<Input
|
||||
id="profile-key"
|
||||
value={profileKey}
|
||||
onChange={(e) => onProfileKey(e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used in exports and the API. Auto-filled from the name.
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TargetOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function StepAssign({
|
||||
agents,
|
||||
projects = [],
|
||||
routines = [],
|
||||
profiles,
|
||||
selectedAgentIds,
|
||||
onToggleAgent,
|
||||
selectedProjectIds,
|
||||
onToggleProject,
|
||||
selectedRoutineIds,
|
||||
onToggleRoutine,
|
||||
companyDefault,
|
||||
onCompanyDefault,
|
||||
}: {
|
||||
agents: AgentMultiSelectOption[];
|
||||
projects?: TargetOption[];
|
||||
routines?: TargetOption[];
|
||||
profiles: ToolProfileWithDetails[];
|
||||
selectedAgentIds: Set<string>;
|
||||
onToggleAgent: (id: string) => void;
|
||||
selectedProjectIds?: Set<string>;
|
||||
onToggleProject?: (id: string) => void;
|
||||
selectedRoutineIds?: Set<string>;
|
||||
onToggleRoutine?: (id: string) => void;
|
||||
companyDefault: boolean;
|
||||
onCompanyDefault: (v: boolean) => void;
|
||||
}) {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
// Per-agent overlap context from already-loaded bindings — no extra fetch.
|
||||
const contextByAgent = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const p of profiles) {
|
||||
for (const b of p.bindings) {
|
||||
if (b.targetType === "agent") {
|
||||
const list = map.get(b.targetId) ?? [];
|
||||
list.push(p.name);
|
||||
map.set(b.targetId, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [profiles]);
|
||||
|
||||
const defaultProfileName = profiles.find((p) => p.summary.isCompanyDefault)?.name ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<label className="flex items-start gap-3 rounded-lg border border-border p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={companyDefault}
|
||||
onChange={(e) => onCompanyDefault(e.target.checked)}
|
||||
/>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">Make this the company default</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Every agent without its own profile uses this one.
|
||||
{defaultProfileName ? ` Replaces “${defaultProfileName}”.` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-foreground">Assign to agents</h3>
|
||||
<AgentMultiSelect
|
||||
agents={agents}
|
||||
selectedAgentIds={selectedAgentIds}
|
||||
onChange={(nextAgentIds) => {
|
||||
for (const agent of agents) {
|
||||
if (selectedAgentIds.has(agent.id) !== nextAgentIds.has(agent.id)) onToggleAgent(agent.id);
|
||||
}
|
||||
}}
|
||||
getDescription={(agent) => {
|
||||
const context = contextByAgent.get(agent.id) ?? [];
|
||||
const bits = [...context];
|
||||
if (defaultProfileName) bits.push("company default");
|
||||
return bits.length > 0 ? `already has: ${bits.join(" · ")}` : "no profiles yet";
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If an agent has several profiles, it can use anything any of them allows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(projects.length > 0 || routines.length > 0) && onToggleProject && onToggleRoutine ? (
|
||||
<Collapsible open={moreOpen} onOpenChange={setMoreOpen} className="rounded-lg border border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-4 py-3 text-left">
|
||||
<span className="text-sm font-medium text-foreground">More targets</span>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", moreOpen && "rotate-180")} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 border-t border-border px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Assign this profile to a whole project or a scheduled routine instead of (or as well as)
|
||||
individual agents.
|
||||
</p>
|
||||
<TargetChecklist
|
||||
label="Projects"
|
||||
options={projects}
|
||||
selected={selectedProjectIds ?? new Set()}
|
||||
onToggle={onToggleProject}
|
||||
/>
|
||||
<TargetChecklist
|
||||
label="Routines"
|
||||
options={routines}
|
||||
selected={selectedRoutineIds ?? new Set()}
|
||||
onToggle={onToggleRoutine}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetChecklist({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
options: TargetOption[];
|
||||
selected: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
if (options.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">{label}</h4>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{options.map((option) => (
|
||||
<label key={option.id} className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" checked={selected.has(option.id)} onChange={() => onToggle(option.id)} />
|
||||
{option.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { useEffect } from "react";
|
||||
import { useParams, useSearchParams } from "@/lib/router";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { advancedTabHref } from "../tool-tabs";
|
||||
import { ToolsAdminGate } from "./ToolsAdminGate";
|
||||
import { ProfileWizard } from "./ProfileWizard";
|
||||
import { TEMPLATES, type TemplateKey } from "./profile-model";
|
||||
|
||||
/**
|
||||
* Full-page host for the access-profile create/resume wizard (PAP-10997 §B).
|
||||
* Mounted on its own routes so the three-step flow gets the whole page rather
|
||||
* than living inside the Advanced tab chrome. Guarded by the same admin gate as
|
||||
* the rest of the tool-access surface.
|
||||
*/
|
||||
export function ProfileWizardRoute({ mode }: { mode: "new" | "edit" }) {
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const params = useParams<{ profileId?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const templateParam = searchParams.get("template");
|
||||
const stepParam = Number(searchParams.get("step"));
|
||||
const initialTemplate = TEMPLATES.some((t) => t.key === templateParam)
|
||||
? (templateParam as TemplateKey)
|
||||
: undefined;
|
||||
const initialStep = stepParam === 2 || stepParam === 3 ? stepParam : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Access profiles", href: advancedTabHref("profiles") },
|
||||
{ label: mode === "edit" ? "Resume draft" : "New profile" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name, mode]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select a company to create a profile.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolsAdminGate>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5 p-4 sm:p-6">
|
||||
<header>
|
||||
<h1 className="text-xl font-bold text-foreground">
|
||||
{mode === "edit" ? "Finish your profile" : "New access profile"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Choose which tools this profile allows, then assign it to the agents that need them.
|
||||
</p>
|
||||
</header>
|
||||
<ProfileWizard
|
||||
companyId={selectedCompanyId}
|
||||
profileId={mode === "edit" ? params.profileId : undefined}
|
||||
initialTemplate={initialTemplate}
|
||||
initialStep={initialStep}
|
||||
/>
|
||||
</div>
|
||||
</ToolsAdminGate>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ToolProfileSummary, ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigate = vi.hoisted(() => vi.fn());
|
||||
const profilesData = vi.hoisted(() => ({
|
||||
current: {} as Record<string, unknown>,
|
||||
}));
|
||||
const api = vi.hoisted(() => ({
|
||||
updateProfile: vi.fn(),
|
||||
duplicateProfile: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => navigate,
|
||||
Link: ({ to, children }: { to: string; children: unknown }) => createElement("a", { href: to }, children as never),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ pushToast: vi.fn() }) }));
|
||||
|
||||
vi.mock("../ProfilesTab", () => ({ EffectiveAgentPanel: () => createElement("div", null, "resolver") }));
|
||||
|
||||
vi.mock("@/api/tools", () => ({ toolsApi: api }));
|
||||
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
Sheet: ({ open, children }: { open?: boolean; children: unknown }) => (open ? createElement("div", null, children as never) : null),
|
||||
SheetContent: ({ children }: { children: unknown }) => createElement("div", null, children as never),
|
||||
SheetHeader: ({ children }: { children: unknown }) => createElement("div", null, children as never),
|
||||
SheetTitle: ({ children }: { children: unknown }) => createElement("h2", null, children as never),
|
||||
SheetDescription: ({ children }: { children: unknown }) => createElement("p", null, children as never),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: unknown }) => createElement("div", null, children as never),
|
||||
DropdownMenuTrigger: ({ children }: { children: unknown }) => createElement("div", null, children as never),
|
||||
DropdownMenuContent: ({ children }: { children: unknown }) => createElement("div", null, children as never),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
className,
|
||||
onSelect,
|
||||
}: {
|
||||
children: unknown;
|
||||
className?: string;
|
||||
onSelect?: () => void;
|
||||
}) => createElement("button", { type: "button", className, onClick: onSelect }, children as never),
|
||||
DropdownMenuSeparator: () => createElement("hr"),
|
||||
}));
|
||||
|
||||
vi.mock("./useProfilesData", () => ({ useProfilesData: () => profilesData.current }));
|
||||
|
||||
import { ProfilesIndex } from "./ProfilesIndex";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function summary(partial: Partial<ToolProfileSummary>): ToolProfileSummary {
|
||||
return {
|
||||
accessMode: "selected",
|
||||
allowedToolCount: 0,
|
||||
allowedApplicationCount: 0,
|
||||
excludedToolCount: 0,
|
||||
totalToolCount: 0,
|
||||
assignmentCount: 0,
|
||||
appliesToAgentCount: 0,
|
||||
isCompanyDefault: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function profile(partial: Partial<ToolProfileWithDetails> & { name: string }): ToolProfileWithDetails {
|
||||
return {
|
||||
id: partial.id ?? partial.name,
|
||||
companyId: "c1",
|
||||
profileKey: "k",
|
||||
description: null,
|
||||
status: "active",
|
||||
defaultAction: "deny",
|
||||
newToolsReviewedAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-06-10T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
entries: [],
|
||||
bindings: [],
|
||||
summary: summary({}),
|
||||
...partial,
|
||||
} as ToolProfileWithDetails;
|
||||
}
|
||||
|
||||
function setData(profiles: ToolProfileWithDetails[]) {
|
||||
profilesData.current = {
|
||||
profiles: { isLoading: false, isError: false, data: { profiles }, refetch: vi.fn() },
|
||||
agents: { data: [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProfilesIndex", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
api.updateProfile.mockResolvedValue(profile({ name: "Updated" }));
|
||||
api.duplicateProfile.mockResolvedValue(profile({ name: "Copy" }));
|
||||
api.deleteProfile.mockResolvedValue({ deleted: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render(props: Partial<Parameters<typeof ProfilesIndex>[0]> = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ProfilesIndex companyId="c1" {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
it("renders a row per profile with the friendly Allows and Assigned columns", async () => {
|
||||
setData([
|
||||
profile({ name: "Everyday work", summary: summary({ allowedToolCount: 9, allowedApplicationCount: 3, appliesToAgentCount: 2 }) }),
|
||||
profile({ name: "Company baseline", summary: summary({ accessMode: "all_except", excludedToolCount: 2, isCompanyDefault: true }) }),
|
||||
]);
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Everyday work");
|
||||
expect(container.textContent).toContain("9 tools · 3 apps");
|
||||
expect(container.textContent).toContain("2 agents");
|
||||
expect(container.textContent).toContain("All except 2 tools");
|
||||
expect(container.textContent).toContain("Company default");
|
||||
});
|
||||
|
||||
it("shows a new-tools chip in the Allows column", async () => {
|
||||
setData([
|
||||
profile({
|
||||
name: "Gmail",
|
||||
newToolsPendingCount: 3,
|
||||
summary: summary({ allowedToolCount: 4, allowedApplicationCount: 1, appliesToAgentCount: 1 }),
|
||||
}),
|
||||
]);
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("4 tools · 1 app");
|
||||
expect(container.textContent).toContain("3 new");
|
||||
});
|
||||
|
||||
it("constrains long profile names to the Profile column", async () => {
|
||||
const longName = "A very long profile name that should truncate before it can overlap the Allows column";
|
||||
setData([
|
||||
profile({
|
||||
name: longName,
|
||||
summary: summary({ allowedToolCount: 4, allowedApplicationCount: 1 }),
|
||||
}),
|
||||
]);
|
||||
await render();
|
||||
|
||||
const table = container.querySelector("table");
|
||||
const profileButton = container.querySelector<HTMLButtonElement>(`button[title="${longName}"]`);
|
||||
|
||||
expect(table?.className).toContain("table-fixed");
|
||||
expect(profileButton?.className).toContain("w-full");
|
||||
expect(profileButton?.className).toContain("truncate");
|
||||
});
|
||||
|
||||
it("flags an unassigned profile as having no effect", async () => {
|
||||
setData([profile({ name: "Orphan" })]);
|
||||
await render();
|
||||
expect(container.textContent).toContain("Not assigned yet");
|
||||
expect(container.textContent).toContain("does not change access");
|
||||
});
|
||||
|
||||
it("offers a Resume affordance on draft rows", async () => {
|
||||
setData([profile({ name: "Half-built", status: "draft" })]);
|
||||
await render();
|
||||
expect(container.textContent).toContain("Draft");
|
||||
expect(container.textContent).toContain("Resume");
|
||||
});
|
||||
|
||||
it("shows archived profiles only after switching to the Archived filter", async () => {
|
||||
setData([profile({ name: "Old one", status: "archived" })]);
|
||||
await render();
|
||||
expect(container.textContent).not.toContain("Old one");
|
||||
expect(container.textContent).toContain("Create your first access profile");
|
||||
|
||||
const archived = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Archived"));
|
||||
flushSync(() => {
|
||||
archived?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(container.textContent).toContain("Old one");
|
||||
expect(container.textContent).toContain("Archived");
|
||||
});
|
||||
|
||||
it("shows the step-1 template cards as the empty state", async () => {
|
||||
setData([]);
|
||||
await render();
|
||||
expect(container.textContent).toContain("Read-only");
|
||||
expect(container.textContent).toContain("Everyday work");
|
||||
expect(container.textContent).toContain("Start from scratch");
|
||||
});
|
||||
|
||||
it("navigates to the wizard from New profile", async () => {
|
||||
setData([profile({ name: "Anything" })]);
|
||||
await render();
|
||||
const newBtn = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("New profile"));
|
||||
flushSync(() => {
|
||||
newBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(navigate).toHaveBeenCalledWith("/apps/advanced/profiles/new");
|
||||
});
|
||||
|
||||
it("uses the styled delete dialog from the row menu without native confirm", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm");
|
||||
setData([profile({ id: "p-delete", name: "Everyday work", summary: summary({ assignmentCount: 2 }) })]);
|
||||
await render();
|
||||
|
||||
const deleteItem = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Delete");
|
||||
flushSync(() => {
|
||||
deleteItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Delete profile");
|
||||
expect(document.body.textContent).toContain("removes 2 assignments");
|
||||
expect(api.deleteProfile).not.toHaveBeenCalled();
|
||||
|
||||
const dialogDelete = [...document.body.querySelectorAll('[role="dialog"] button')].find((b) => b.textContent?.trim() === "Delete");
|
||||
flushSync(() => {
|
||||
dialogDelete?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(api.deleteProfile).toHaveBeenCalledWith("p-delete");
|
||||
});
|
||||
|
||||
it("blocks company-default delete from the row-menu dialog before the API call", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm");
|
||||
setData([
|
||||
profile({
|
||||
id: "p-default",
|
||||
name: "Company baseline",
|
||||
summary: summary({ assignmentCount: 0, isCompanyDefault: true }),
|
||||
}),
|
||||
]);
|
||||
await render();
|
||||
|
||||
const deleteItem = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Delete");
|
||||
flushSync(() => {
|
||||
deleteItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Reassign the company default to another profile before deleting it.");
|
||||
const dialogDelete = [...document.body.querySelectorAll('[role="dialog"] button')].find((b) => b.textContent?.trim() === "Delete") as HTMLButtonElement | undefined;
|
||||
expect(dialogDelete?.disabled).toBe(true);
|
||||
expect(api.deleteProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the styled restore dialog from archived row menus", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm");
|
||||
setData([profile({ id: "p-archived", name: "Old one", status: "archived" })]);
|
||||
await render({ initialStatusFilter: "archived" });
|
||||
|
||||
const restoreItem = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Restore");
|
||||
flushSync(() => {
|
||||
restoreItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Restore profile");
|
||||
|
||||
const dialogRestore = [...document.body.querySelectorAll('[role="dialog"] button')].find((b) => b.textContent?.trim() === "Restore");
|
||||
flushSync(() => {
|
||||
dialogRestore?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(api.updateProfile).toHaveBeenCalledWith("p-archived", { status: "active" });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArchiveRestore, MoreHorizontal, Plus, ShieldCheck, Users } from "lucide-react";
|
||||
import type { ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { EffectiveAgentPanel } from "../ProfilesTab";
|
||||
import { ErrorState, LoadingState, RelativeTime, ToolsPageHeader } from "../shared";
|
||||
import { ProfileActionDialog, type ProfileActionDialogKind } from "./ProfileActionDialog";
|
||||
import { TEMPLATES, type TemplateKey } from "./profile-model";
|
||||
import { useProfilesData } from "./useProfilesData";
|
||||
import { allowsLabel, assignedLabel, isDraft, STATUS_LABEL } from "./profile-summary";
|
||||
|
||||
/** The wizard route for a fresh profile, optionally seeded with a template. */
|
||||
function newProfileHref(template?: TemplateKey): string {
|
||||
return template
|
||||
? `/apps/advanced/profiles/new?template=${encodeURIComponent(template)}`
|
||||
: "/apps/advanced/profiles/new";
|
||||
}
|
||||
|
||||
function statusVariant(status: ToolProfileWithDetails["status"]): "default" | "secondary" | "outline" {
|
||||
if (status === "active") return "default";
|
||||
if (status === "draft") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
export function ProfilesIndex({
|
||||
companyId,
|
||||
initialStatusFilter,
|
||||
initialResolverOpen,
|
||||
}: {
|
||||
companyId: string;
|
||||
initialStatusFilter?: "active" | "archived";
|
||||
initialResolverOpen?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const { profiles, agents } = useProfilesData(companyId);
|
||||
const [resolverOpen, setResolverOpen] = useState(Boolean(initialResolverOpen));
|
||||
const [statusFilter, setStatusFilter] = useState<"active" | "archived">(initialStatusFilter ?? "active");
|
||||
const [actionDialog, setActionDialog] = useState<{
|
||||
kind: ProfileActionDialogKind;
|
||||
profile: ToolProfileWithDetails;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (new URLSearchParams(window.location.search).get("check") === "1") setResolverOpen(true);
|
||||
}, []);
|
||||
|
||||
const agentOptions = useMemo(
|
||||
() => (agents.data ?? []).map((a) => ({ id: a.id, name: a.name })),
|
||||
[agents.data],
|
||||
);
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
|
||||
|
||||
const errorBody = (error: unknown) => String((error as Error)?.message ?? error);
|
||||
|
||||
const duplicate = useMutation({
|
||||
mutationFn: (profile: ToolProfileWithDetails) =>
|
||||
toolsApi.duplicateProfile(profile.id, { name: `${profile.name} (copy)` }),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Profile duplicated", body: "The copy is not assigned to anyone yet.", tone: "success" });
|
||||
invalidate();
|
||||
},
|
||||
onError: (error: unknown) =>
|
||||
pushToast({ title: "Could not duplicate", body: errorBody(error), tone: "error" }),
|
||||
});
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: (profile: ToolProfileWithDetails) =>
|
||||
toolsApi.updateProfile(profile.id, { status: "archived" }),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Profile archived", tone: "success" });
|
||||
invalidate();
|
||||
},
|
||||
onError: (error: unknown) => pushToast({ title: "Could not archive", body: errorBody(error), tone: "error" }),
|
||||
});
|
||||
|
||||
const restore = useMutation({
|
||||
mutationFn: (profile: ToolProfileWithDetails) =>
|
||||
toolsApi.updateProfile(profile.id, { status: "active" }),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Profile restored", tone: "success" });
|
||||
invalidate();
|
||||
},
|
||||
onError: (error: unknown) => pushToast({ title: "Could not restore", body: errorBody(error), tone: "error" }),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (profile: ToolProfileWithDetails) => toolsApi.deleteProfile(profile.id),
|
||||
onSuccess: () => {
|
||||
pushToast({ title: "Profile deleted", tone: "success" });
|
||||
invalidate();
|
||||
},
|
||||
onError: (error: unknown) => pushToast({ title: "Could not delete", body: errorBody(error), tone: "error" }),
|
||||
});
|
||||
|
||||
const header = (
|
||||
<ToolsPageHeader
|
||||
title="Access profiles"
|
||||
description="Decide which tools your agents can use. Build a profile once, then assign it to the agents that need it."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setResolverOpen(true)}>
|
||||
<ShieldCheck className="mr-1.5 h-4 w-4" />
|
||||
Check an agent's access
|
||||
</Button>
|
||||
<Button onClick={() => navigate(newProfileHref())}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New profile
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const resolverDialog = (
|
||||
<Sheet open={resolverOpen} onOpenChange={setResolverOpen}>
|
||||
<SheetContent className="w-full gap-0 p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b border-border">
|
||||
<SheetTitle>Check an agent's access</SheetTitle>
|
||||
<SheetDescription>
|
||||
See exactly which tools an agent can use right now, and which profile allows each one.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col p-4">
|
||||
<EffectiveAgentPanel companyId={companyId} agentOptions={agentOptions} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
if (profiles.isLoading) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{header}
|
||||
<LoadingState label="Loading profiles…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (profiles.isError) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{header}
|
||||
<ErrorState error={profiles.error} onRetry={() => profiles.refetch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allRows = profiles.data?.profiles ?? [];
|
||||
const rows = allRows.filter((p) => (statusFilter === "archived" ? p.status === "archived" : p.status !== "archived"));
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{header}
|
||||
|
||||
<div className="inline-flex rounded-md border border-border p-0.5">
|
||||
{(["active", "archived"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(key)}
|
||||
className={cn(
|
||||
"rounded px-3 py-1 text-sm font-medium",
|
||||
statusFilter === key ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{key === "active" ? "Active" : "Archived"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
statusFilter === "archived" ? (
|
||||
<div className="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-muted-foreground">
|
||||
No archived profiles.
|
||||
</div>
|
||||
) : (
|
||||
<EmptyTemplatePicker onPick={(key) => navigate(newProfileHref(key))} />
|
||||
)
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full table-fixed text-sm">
|
||||
<colgroup>
|
||||
<col className="w-(--sz-30pct)" />
|
||||
<col className="w-(--sz-18pct)" />
|
||||
<col className="w-(--sz-24pct)" />
|
||||
<col className="w-(--sz-12pct)" />
|
||||
<col className="w-(--sz-12pct)" />
|
||||
<col className="w-10" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-xs font-medium text-muted-foreground">
|
||||
<th className="px-3 py-2 font-medium">Profile</th>
|
||||
<th className="px-3 py-2 font-medium">Allows</th>
|
||||
<th className="px-3 py-2 font-medium">Assigned to</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Updated</th>
|
||||
<th className="w-10 px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((profile) => {
|
||||
const assigned = assignedLabel(profile.summary);
|
||||
const draft = isDraft(profile);
|
||||
const open = () => navigate(draft ? `/apps/advanced/profiles/${profile.id}/edit` : `/apps/advanced/profiles/${profile.id}`);
|
||||
return (
|
||||
<tr
|
||||
key={profile.id}
|
||||
className="group h-10 border-b border-border last:border-0 hover:bg-accent/40"
|
||||
>
|
||||
<td className="min-w-0 px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
title={profile.name}
|
||||
className="block w-full truncate text-left font-medium text-foreground hover:underline"
|
||||
>
|
||||
{profile.name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>{allowsLabel(profile.summary)}</span>
|
||||
{(profile.newToolsPendingCount ?? 0) > 0 ? (
|
||||
<Badge variant="outline" className="border-amber-500/50 bg-amber-500/10 text-amber-800 dark:text-amber-200">
|
||||
{profile.newToolsPendingCount} new
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{assigned.unassigned ? (
|
||||
<span className="text-muted-foreground">
|
||||
{assigned.text}
|
||||
<span className="ml-1 text-xs text-muted-foreground/70">— does not change access</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-foreground">
|
||||
{profile.summary.isCompanyDefault ? null : (
|
||||
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{assigned.text}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Badge variant={statusVariant(profile.status)}>{STATUS_LABEL[profile.status]}</Badge>
|
||||
{draft ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<RelativeTime value={profile.updatedAt} />
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
<RowMenu
|
||||
onEdit={open}
|
||||
onDuplicate={() => duplicate.mutate(profile)}
|
||||
onArchive={() => archive.mutate(profile)}
|
||||
onRestore={profile.status === "archived" ? () => setActionDialog({ kind: "restore", profile }) : undefined}
|
||||
onDelete={() => setActionDialog({ kind: "delete", profile })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resolverDialog}
|
||||
<ProfileActionDialog
|
||||
kind={actionDialog?.kind ?? null}
|
||||
profile={actionDialog?.profile ?? null}
|
||||
pending={restore.isPending || remove.isPending}
|
||||
onClose={() => setActionDialog(null)}
|
||||
onArchive={() => {
|
||||
if (!actionDialog) return;
|
||||
archive.mutate(actionDialog.profile, { onSuccess: () => setActionDialog(null) });
|
||||
}}
|
||||
onRestore={() => {
|
||||
if (!actionDialog) return;
|
||||
restore.mutate(actionDialog.profile, { onSuccess: () => setActionDialog(null) });
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (!actionDialog) return;
|
||||
remove.mutate(actionDialog.profile, { onSuccess: () => setActionDialog(null) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowMenu({
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onArchive,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}: {
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onArchive: () => void;
|
||||
onRestore?: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Profile actions"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={onEdit}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onDuplicate}>Duplicate</DropdownMenuItem>
|
||||
{onRestore ? (
|
||||
<DropdownMenuItem onSelect={onRestore}>
|
||||
<ArchiveRestore className="mr-1.5 h-4 w-4" />
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={onArchive}>Archive</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onDelete} className="text-destructive focus:text-destructive">
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/** Empty state (AP2): the same five step-1 template cards the wizard opens with. */
|
||||
function EmptyTemplatePicker({ onPick }: { onPick: (key: TemplateKey) => void }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border p-6">
|
||||
<div className="mb-4 max-w-2xl">
|
||||
<h3 className="text-base font-semibold text-foreground">Create your first access profile</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick a starting point. You can fine-tune exactly which tools it allows in the next step.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{TEMPLATES.map((template) => (
|
||||
<button
|
||||
key={template.key}
|
||||
type="button"
|
||||
onClick={() => onPick(template.key)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-1 rounded-md border border-border bg-card px-4 py-3 text-left transition-colors",
|
||||
"hover:border-primary hover:bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">{template.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{template.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
|
||||
/**
|
||||
* Best-effort admin gate for the access-profiles surface, mirroring
|
||||
* `AdvancedToolsRoute` (PAP-10862, plan D8). Instance admins and company
|
||||
* owners/admins pass; the server stays authoritative. Shared so the profiles
|
||||
* index and the create wizard guard identically.
|
||||
*/
|
||||
export function ToolsAdminGate({ children }: { children: ReactNode }) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const boardAccess = useQuery({
|
||||
queryKey: queryKeys.access.currentBoardAccess,
|
||||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (boardAccess.isLoading) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
const data = boardAccess.data;
|
||||
const membership = data?.memberships?.find((m) => m.companyId === selectedCompanyId);
|
||||
const isAdmin =
|
||||
Boolean(data?.isInstanceAdmin) ||
|
||||
membership?.membershipRole === "owner" ||
|
||||
membership?.membershipRole === "admin";
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border bg-card p-6">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<ShieldAlert className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Access profiles are for administrators</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access profiles decide which tools your agents can use. Ask an administrator to set these up, or
|
||||
head back to{" "}
|
||||
<Link to="/apps" className="font-medium text-primary hover:underline">
|
||||
your apps
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ToolCatalogEntry } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdvancedRule, WizardSelections } from "./profile-model";
|
||||
import { WizardToolsStep } from "./WizardToolsStep";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function tool(id: string, toolName: string): ToolCatalogEntry {
|
||||
return {
|
||||
id,
|
||||
toolName,
|
||||
title: toolName,
|
||||
description: null,
|
||||
applicationId: "app-gmail",
|
||||
connectionId: "conn-1",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
riskLevel: "read",
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
const appGroups = [
|
||||
{
|
||||
appKey: "app-gmail",
|
||||
applicationId: "app-gmail",
|
||||
connectionId: "conn-1",
|
||||
name: "Gmail",
|
||||
tools: [tool("gmail-read", "gmail.read"), tool("gmail-send", "gmail.send")],
|
||||
},
|
||||
];
|
||||
|
||||
function setNativeValue(el: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(el, value);
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("WizardToolsStep", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let selections: WizardSelections;
|
||||
let advancedRules: AdvancedRule[];
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
selections = {};
|
||||
advancedRules = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function render() {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<WizardToolsStep
|
||||
appGroups={appGroups}
|
||||
catalogLoading={false}
|
||||
selections={selections}
|
||||
onSelectionsChange={(next) => {
|
||||
selections = next;
|
||||
render();
|
||||
}}
|
||||
advancedRules={advancedRules}
|
||||
onAdvancedRulesChange={(next) => {
|
||||
advancedRules = next;
|
||||
render();
|
||||
}}
|
||||
newToolsAction="deny"
|
||||
onNewToolsActionChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("adds an advanced rule when crypto.randomUUID is unavailable", () => {
|
||||
vi.stubGlobal("crypto", {});
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_780_000_000_000);
|
||||
vi.spyOn(Math, "random").mockReturnValue(0.123456789);
|
||||
render();
|
||||
|
||||
const advancedTrigger = [...container.querySelectorAll("button")].find(
|
||||
(button) => button.textContent?.trim() === "Advanced rules",
|
||||
);
|
||||
flushSync(() => {
|
||||
advancedTrigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const patternInput = container.querySelector('input[placeholder="e.g. gmail.send*"]') as HTMLInputElement;
|
||||
flushSync(() => {
|
||||
setNativeValue(patternInput, "gmail.send*");
|
||||
});
|
||||
|
||||
const addRuleButton = [...container.querySelectorAll("button")].find(
|
||||
(button) => button.textContent?.trim() === "Add rule",
|
||||
);
|
||||
flushSync(() => {
|
||||
addRuleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(advancedRules).toEqual([
|
||||
{
|
||||
id: "rule-mppy1i4g-4fzzzxjy",
|
||||
kind: "tool_name",
|
||||
value: "gmail.send*",
|
||||
riskLevel: undefined,
|
||||
effect: "include",
|
||||
},
|
||||
]);
|
||||
expect(container.textContent).toContain("Allow tools matching gmail.send*");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Plug, Plus, Search, X } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
appCheckState,
|
||||
appSelectionLabel,
|
||||
isToolSelected,
|
||||
toggleApp,
|
||||
toggleTool,
|
||||
toolCapability,
|
||||
CAPABILITY_LABEL,
|
||||
type AdvancedRule,
|
||||
type AdvancedRuleKind,
|
||||
type AppGroup,
|
||||
type ToolCapability,
|
||||
type WizardSelections,
|
||||
} from "./profile-model";
|
||||
import { LoadingState } from "../shared";
|
||||
|
||||
type NewToolsAction = "deny" | "allow";
|
||||
|
||||
const CAPABILITY_FILTERS: ToolCapability[] = ["read", "write", "destructive"];
|
||||
|
||||
const CAPABILITY_VARIANT: Record<ToolCapability, "outline" | "secondary" | "destructive"> = {
|
||||
read: "outline",
|
||||
write: "secondary",
|
||||
destructive: "destructive",
|
||||
};
|
||||
|
||||
export interface WizardToolsStepProps {
|
||||
appGroups: AppGroup[];
|
||||
catalogLoading: boolean;
|
||||
selections: WizardSelections;
|
||||
onSelectionsChange: (next: WizardSelections) => void;
|
||||
advancedRules: AdvancedRule[];
|
||||
onAdvancedRulesChange: (next: AdvancedRule[]) => void;
|
||||
newToolsAction: NewToolsAction;
|
||||
onNewToolsActionChange: (next: NewToolsAction) => void;
|
||||
}
|
||||
|
||||
export function WizardToolsStep(props: WizardToolsStepProps) {
|
||||
const { appGroups, catalogLoading, selections, onSelectionsChange } = props;
|
||||
const [search, setSearch] = useState("");
|
||||
const [capabilityFilter, setCapabilityFilter] = useState<ToolCapability | null>(null);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return appGroups
|
||||
.map((group) => {
|
||||
const tools = group.tools.filter((tool) => {
|
||||
if (capabilityFilter && toolCapability(tool) !== capabilityFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
tool.toolName.toLowerCase().includes(q) ||
|
||||
(tool.title ?? "").toLowerCase().includes(q) ||
|
||||
group.name.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
return { group, tools };
|
||||
})
|
||||
.filter((entry) => entry.tools.length > 0);
|
||||
}, [appGroups, search, capabilityFilter]);
|
||||
|
||||
if (catalogLoading) return <LoadingState label="Loading tools…" />;
|
||||
|
||||
// Cold state A (AP17): nothing connected at all.
|
||||
if (appGroups.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<Plug className="h-6 w-6 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">App connections are coming soon</p>
|
||||
<p className="mx-auto max-w-sm text-sm text-muted-foreground">
|
||||
Profiles will be available once app connections are ready. Browse the planned integrations in the
|
||||
meantime.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/apps/browse">Browse app connections</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-(--sz-220px) flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search tools…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{CAPABILITY_FILTERS.map((cap) => (
|
||||
<button
|
||||
key={cap}
|
||||
type="button"
|
||||
onClick={() => setCapabilityFilter((cur) => (cur === cap ? null : cap))}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
capabilityFilter === cap
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{CAPABILITY_LABEL[cap]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredGroups.length === 0 ? (
|
||||
// Cold state B (AP17): a search/filter that matches nothing.
|
||||
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border py-10 text-center">
|
||||
<p className="text-sm font-medium text-foreground">No tools match “{search}”.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setCapabilityFilter(null);
|
||||
}}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
{filteredGroups.map(({ group, tools }) => (
|
||||
<AppRow
|
||||
key={group.appKey}
|
||||
group={group}
|
||||
visibleTools={tools}
|
||||
selection={selections[group.appKey]}
|
||||
onToggleApp={() =>
|
||||
onSelectionsChange({ ...selections, [group.appKey]: toggleApp(group, selections[group.appKey]) })
|
||||
}
|
||||
onToggleTool={(toolId) =>
|
||||
onSelectionsChange({
|
||||
...selections,
|
||||
[group.appKey]: toggleTool(group, selections[group.appKey], toolId),
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NewToolsRadio value={props.newToolsAction} onChange={props.onNewToolsActionChange} />
|
||||
|
||||
<AdvancedRules rules={props.advancedRules} onChange={props.onAdvancedRulesChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppRow({
|
||||
group,
|
||||
visibleTools,
|
||||
selection,
|
||||
onToggleApp,
|
||||
onToggleTool,
|
||||
}: {
|
||||
group: AppGroup;
|
||||
visibleTools: AppGroup["tools"];
|
||||
selection: WizardSelections[string] | undefined;
|
||||
onToggleApp: () => void;
|
||||
onToggleTool: (toolId: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const state = appCheckState(group, selection);
|
||||
const checked = state === "checked" ? true : state === "indeterminate" ? "indeterminate" : false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 px-3 py-2">
|
||||
<Checkbox checked={checked} onCheckedChange={onToggleApp} aria-label={`All ${group.name} tools`} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex flex-1 items-center gap-1.5 text-left"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
All {group.name} tools ({group.tools.length})
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{state === "indeterminate"
|
||||
? appSelectionLabel(group, selection)
|
||||
: "includes tools " + group.name + " adds later"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className="space-y-0.5 border-t border-border bg-muted/20 px-3 py-2 pl-10">
|
||||
{visibleTools.map((tool) => {
|
||||
const cap = toolCapability(tool);
|
||||
return (
|
||||
<label
|
||||
key={tool.id}
|
||||
className="flex cursor-pointer items-start gap-2.5 rounded-md px-1.5 py-1.5 hover:bg-accent/50"
|
||||
>
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={isToolSelected(group, selection, tool.id)}
|
||||
onCheckedChange={() => onToggleTool(tool.id)}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<code className="font-mono text-xs text-foreground">{tool.toolName}</code>
|
||||
<Badge variant={CAPABILITY_VARIANT[cap]} className="text-(length:--text-nano)">
|
||||
{CAPABILITY_LABEL[cap]}
|
||||
</Badge>
|
||||
</span>
|
||||
{tool.title || tool.description ? (
|
||||
<span className="text-xs text-muted-foreground">{tool.title ?? tool.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewToolsRadio({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: NewToolsAction;
|
||||
onChange: (next: NewToolsAction) => void;
|
||||
}) {
|
||||
const options: Array<{ value: NewToolsAction; label: string; hint: string; recommended?: boolean }> = [
|
||||
{
|
||||
value: "deny",
|
||||
label: "Stay blocked until someone allows them",
|
||||
hint: "New tools an app adds later won't be usable until you review them.",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
value: "allow",
|
||||
label: "Allowed automatically",
|
||||
hint: "Any tool an app adds later becomes usable right away.",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<fieldset className="space-y-2 rounded-lg border border-border p-4">
|
||||
<legend className="px-1 text-sm font-medium text-foreground">New tools that appear later</legend>
|
||||
<div className="space-y-2">
|
||||
{options.map((opt) => (
|
||||
<label key={opt.value} className="flex cursor-pointer items-start gap-2.5">
|
||||
<input
|
||||
type="radio"
|
||||
name="new-tools-action"
|
||||
className="mt-0.5"
|
||||
checked={value === opt.value}
|
||||
onChange={() => onChange(opt.value)}
|
||||
/>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
{opt.label}
|
||||
{opt.recommended ? (
|
||||
<Badge variant="outline" className="text-(length:--text-nano)">
|
||||
Recommended
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs font-normal text-amber-600">(risky)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{opt.hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
const RULE_KIND_OPTIONS: Array<{ value: AdvancedRuleKind; label: string }> = [
|
||||
{ value: "tool_name", label: "Tool name pattern" },
|
||||
{ value: "risk_level", label: "Risk level" },
|
||||
{ value: "catalog_entry", label: "By tool ID" },
|
||||
];
|
||||
|
||||
function createAdvancedRuleId() {
|
||||
const randomUuid = globalThis.crypto?.randomUUID?.();
|
||||
if (randomUuid) return randomUuid;
|
||||
return `rule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function ruleSummary(rule: AdvancedRule): string {
|
||||
const verb = rule.effect === "include" ? "Allow" : "Block";
|
||||
if (rule.kind === "tool_name") return `${verb} tools matching ${rule.value}`;
|
||||
if (rule.kind === "risk_level") return `${verb} ${rule.riskLevel ?? rule.value} tools`;
|
||||
return `${verb} tool ${rule.value}`;
|
||||
}
|
||||
|
||||
function AdvancedRules({
|
||||
rules,
|
||||
onChange,
|
||||
}: {
|
||||
rules: AdvancedRule[];
|
||||
onChange: (next: AdvancedRule[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [kind, setKind] = useState<AdvancedRuleKind>("tool_name");
|
||||
const [value, setValue] = useState("");
|
||||
const [effect, setEffect] = useState<"include" | "exclude">("include");
|
||||
|
||||
const addRule = () => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed && kind !== "risk_level") return;
|
||||
const rule: AdvancedRule = {
|
||||
id: createAdvancedRuleId(),
|
||||
kind,
|
||||
value: kind === "risk_level" ? (trimmed || "destructive") : trimmed,
|
||||
riskLevel: kind === "risk_level" ? ((trimmed || "destructive") as AdvancedRule["riskLevel"]) : undefined,
|
||||
effect,
|
||||
};
|
||||
onChange([...rules, rule]);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="rounded-lg border border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-4 py-3 text-left">
|
||||
<span className="text-sm font-medium text-foreground">Advanced rules</span>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 border-t border-border px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Match tools by a name pattern, a risk level, or a specific tool ID. These run on top of the choices
|
||||
above.
|
||||
</p>
|
||||
|
||||
{rules.length > 0 ? (
|
||||
<ul className="space-y-1.5">
|
||||
{rules.map((rule) => (
|
||||
<li
|
||||
key={rule.id}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span className="text-foreground">{ruleSummary(rule)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove rule"
|
||||
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Select value={effect} onValueChange={(v) => setEffect(v as "include" | "exclude")}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="include">Allow</SelectItem>
|
||||
<SelectItem value="exclude">Block</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={kind} onValueChange={(v) => setKind(v as AdvancedRuleKind)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RULE_KIND_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{kind === "risk_level" ? (
|
||||
<Select value={value || "destructive"} onValueChange={setValue}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="read">Read-only</SelectItem>
|
||||
<SelectItem value="write">Makes changes</SelectItem>
|
||||
<SelectItem value="destructive">Destructive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={kind === "tool_name" ? "e.g. gmail.send*" : "tool ID"}
|
||||
className="w-44"
|
||||
/>
|
||||
)}
|
||||
<Button type="button" variant="outline" size="sm" onClick={addRule}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add rule
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolCatalogEntry, ToolProfileEntry } from "@paperclipai/shared";
|
||||
import {
|
||||
appCheckState,
|
||||
appSelectionLabel,
|
||||
buildEntries,
|
||||
countAllowedTools,
|
||||
groupCatalogByApp,
|
||||
parseEntries,
|
||||
templateSelections,
|
||||
toggleApp,
|
||||
toggleTool,
|
||||
type AppGroup,
|
||||
} from "./profile-model";
|
||||
|
||||
function tool(partial: Partial<ToolCatalogEntry> & { id: string; toolName: string }): ToolCatalogEntry {
|
||||
return {
|
||||
companyId: "c1",
|
||||
applicationId: "app-gmail",
|
||||
connectionId: "conn-1",
|
||||
entryKind: "tool",
|
||||
title: null,
|
||||
description: null,
|
||||
inputSchema: null,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: "read",
|
||||
isReadOnly: true,
|
||||
isWrite: false,
|
||||
isDestructive: false,
|
||||
status: "active",
|
||||
addedAt: new Date(),
|
||||
version: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date(),
|
||||
lastSeenAt: new Date(),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...partial,
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
function entry(partial: Partial<ToolProfileEntry> & Pick<ToolProfileEntry, "selectorType">): ToolProfileEntry {
|
||||
return {
|
||||
id: `e-${partial.selectorType}-${partial.catalogEntryId ?? partial.applicationId ?? partial.toolName ?? "x"}`,
|
||||
companyId: "c1",
|
||||
profileId: "p1",
|
||||
effect: "include",
|
||||
applicationId: null,
|
||||
connectionId: null,
|
||||
catalogEntryId: null,
|
||||
toolName: null,
|
||||
riskLevel: null,
|
||||
conditions: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...partial,
|
||||
} as ToolProfileEntry;
|
||||
}
|
||||
|
||||
const appsById = new Map([
|
||||
["app-gmail", "Gmail"],
|
||||
["app-slack", "Slack"],
|
||||
]);
|
||||
const connsById = new Map([["conn-1", "Gmail prod"]]);
|
||||
|
||||
// 4 Gmail tools: 2 read, 1 write, 1 destructive.
|
||||
const catalog: ToolCatalogEntry[] = [
|
||||
tool({ id: "g-list", toolName: "gmail.list", riskLevel: "read", isReadOnly: true }),
|
||||
tool({ id: "g-read", toolName: "gmail.read", riskLevel: "read", isReadOnly: true }),
|
||||
tool({ id: "g-send", toolName: "gmail.send", riskLevel: "write", isReadOnly: false, isWrite: true }),
|
||||
tool({ id: "g-delete", toolName: "gmail.delete", riskLevel: "destructive", isReadOnly: false, isWrite: true, isDestructive: true }),
|
||||
];
|
||||
|
||||
function gmail(): AppGroup {
|
||||
return groupCatalogByApp(catalog, appsById, connsById)[0];
|
||||
}
|
||||
|
||||
describe("groupCatalogByApp", () => {
|
||||
it("groups by application and names from the app map", () => {
|
||||
const groups = groupCatalogByApp(catalog, appsById, connsById);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].name).toBe("Gmail");
|
||||
expect(groups[0].tools.map((t) => t.id)).toEqual(["g-delete", "g-list", "g-read", "g-send"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleApp / toggleTool", () => {
|
||||
it("app checkbox flips between all and none", () => {
|
||||
const g = gmail();
|
||||
let sel = toggleApp(g, undefined);
|
||||
expect(sel).toEqual({ kind: "all" });
|
||||
expect(appCheckState(g, sel)).toBe("checked");
|
||||
sel = toggleApp(g, sel);
|
||||
expect(sel).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("unchecking one tool from all yields all_except with except-count math", () => {
|
||||
const g = gmail();
|
||||
let sel = toggleApp(g, undefined); // all
|
||||
sel = toggleTool(g, sel, "g-delete");
|
||||
expect(sel).toEqual({ kind: "all_except", excluded: ["g-delete"] });
|
||||
expect(appSelectionLabel(g, sel)).toBe("All Gmail except 1");
|
||||
expect(appCheckState(g, sel)).toBe("indeterminate");
|
||||
// re-check it -> back to all
|
||||
sel = toggleTool(g, sel, "g-delete");
|
||||
expect(sel).toEqual({ kind: "all" });
|
||||
});
|
||||
|
||||
it("checking tools from empty yields explicit some-selection", () => {
|
||||
const g = gmail();
|
||||
let sel = toggleTool(g, undefined, "g-list");
|
||||
sel = toggleTool(g, sel, "g-read");
|
||||
expect(sel).toEqual({ kind: "some", included: ["g-list", "g-read"] });
|
||||
expect(appSelectionLabel(g, sel)).toBe("2 of 4 Gmail tools");
|
||||
});
|
||||
|
||||
it("keeps an explicit selection when every current tool is checked", () => {
|
||||
const g = gmail();
|
||||
let sel: ReturnType<typeof toggleTool> | undefined;
|
||||
for (const id of ["g-list", "g-read", "g-send", "g-delete"]) sel = toggleTool(g, sel, id);
|
||||
expect(sel).toEqual({ kind: "some", included: ["g-delete", "g-list", "g-read", "g-send"] });
|
||||
});
|
||||
|
||||
it("excluding every tool collapses to none", () => {
|
||||
const g = gmail();
|
||||
let sel: ReturnType<typeof toggleApp> = { kind: "all" };
|
||||
for (const id of ["g-list", "g-read", "g-send", "g-delete"]) sel = toggleTool(g, sel, id);
|
||||
expect(sel).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildEntries", () => {
|
||||
it("maps all -> application include", () => {
|
||||
const g = gmail();
|
||||
expect(buildEntries([g], { [g.appKey]: { kind: "all" } })).toEqual([
|
||||
{ selectorType: "application", effect: "include", applicationId: "app-gmail" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps all_except -> app include plus catalog excludes", () => {
|
||||
const g = gmail();
|
||||
expect(buildEntries([g], { [g.appKey]: { kind: "all_except", excluded: ["g-delete"] } })).toEqual([
|
||||
{ selectorType: "application", effect: "include", applicationId: "app-gmail" },
|
||||
{ selectorType: "catalog_entry", effect: "exclude", catalogEntryId: "g-delete" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps some -> per-tool catalog includes", () => {
|
||||
const g = gmail();
|
||||
expect(buildEntries([g], { [g.appKey]: { kind: "some", included: ["g-list"] } })).toEqual([
|
||||
{ selectorType: "catalog_entry", effect: "include", catalogEntryId: "g-list" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps bounded allow-default selections to catalog excludes", () => {
|
||||
const g = gmail();
|
||||
expect(
|
||||
buildEntries([g], { [g.appKey]: { kind: "some", included: ["g-list"] } }, [], "allow"),
|
||||
).toEqual([
|
||||
{ selectorType: "catalog_entry", effect: "exclude", catalogEntryId: "g-delete" },
|
||||
{ selectorType: "catalog_entry", effect: "exclude", catalogEntryId: "g-read" },
|
||||
{ selectorType: "catalog_entry", effect: "exclude", catalogEntryId: "g-send" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps none under allow-default to exclusions for every catalog tool", () => {
|
||||
const g = gmail();
|
||||
expect(buildEntries([g], { [g.appKey]: { kind: "none" } }, [], "allow")).toEqual(
|
||||
g.tools.map((tool) => ({
|
||||
selectorType: "catalog_entry",
|
||||
effect: "exclude",
|
||||
catalogEntryId: tool.id,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it("appends advanced rules", () => {
|
||||
const g = gmail();
|
||||
const out = buildEntries([g], { [g.appKey]: { kind: "none" } }, [
|
||||
{ id: "r1", kind: "tool_name", value: "gmail.*", effect: "exclude" },
|
||||
]);
|
||||
expect(out).toEqual([{ selectorType: "tool_name", effect: "exclude", toolName: "gmail.*" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseEntries round-trips buildEntries", () => {
|
||||
it("round-trips all_except", () => {
|
||||
const g = gmail();
|
||||
const sel = { [g.appKey]: { kind: "all_except" as const, excluded: ["g-delete"] } };
|
||||
const entries = buildEntries([g], sel).map((e, i) => entry({ ...e, id: `e${i}` } as never));
|
||||
const parsed = parseEntries([g], entries);
|
||||
expect(parsed.selections[g.appKey]).toEqual({ kind: "all_except", excluded: ["g-delete"] });
|
||||
expect(parsed.advancedRules).toEqual([]);
|
||||
});
|
||||
|
||||
it("round-trips some", () => {
|
||||
const g = gmail();
|
||||
const entries = buildEntries([g], { [g.appKey]: { kind: "some", included: ["g-list", "g-read"] } }).map(
|
||||
(e, i) => entry({ ...e, id: `e${i}` } as never),
|
||||
);
|
||||
const parsed = parseEntries([g], entries);
|
||||
expect(parsed.selections[g.appKey]).toEqual({ kind: "some", included: ["g-list", "g-read"] });
|
||||
});
|
||||
|
||||
it("keeps all explicit catalog entries as a bounded selection", () => {
|
||||
const g = gmail();
|
||||
const entries = g.tools.map((tool, index) => entry({
|
||||
id: `e${index}`,
|
||||
selectorType: "catalog_entry",
|
||||
catalogEntryId: tool.id,
|
||||
effect: "include",
|
||||
}));
|
||||
|
||||
const parsed = parseEntries([g], entries);
|
||||
|
||||
expect(parsed.selections[g.appKey]).toEqual({
|
||||
kind: "some",
|
||||
included: ["g-delete", "g-list", "g-read", "g-send"],
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a wildcard rule as an advanced rule", () => {
|
||||
const g = gmail();
|
||||
const parsed = parseEntries([g], [entry({ selectorType: "tool_name", toolName: "gmail.*", effect: "exclude" })]);
|
||||
expect(parsed.advancedRules).toEqual([
|
||||
{ id: "e-tool_name-gmail.*", kind: "tool_name", value: "gmail.*", effect: "exclude" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countAllowedTools", () => {
|
||||
it("counts selected tools in deny mode", () => {
|
||||
const g = gmail();
|
||||
expect(countAllowedTools([g], { [g.appKey]: { kind: "all_except", excluded: ["g-delete"] } }, "deny", 4)).toEqual({
|
||||
allowed: 3,
|
||||
total: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts everything minus opt-outs in allow mode", () => {
|
||||
const g = gmail();
|
||||
expect(countAllowedTools([g], { [g.appKey]: { kind: "all_except", excluded: ["g-delete", "g-send"] } }, "allow", 4)).toEqual({
|
||||
allowed: 2,
|
||||
total: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("templateSelections", () => {
|
||||
it("read-only selects only read tools", () => {
|
||||
const g = gmail();
|
||||
const sel = templateSelections("read_only", [g]);
|
||||
expect(sel[g.appKey]).toEqual({ kind: "some", included: ["g-list", "g-read"] });
|
||||
});
|
||||
|
||||
it("everyday excludes destructive", () => {
|
||||
const g = gmail();
|
||||
const sel = templateSelections("everyday", [g]);
|
||||
expect(sel[g.appKey]).toEqual({ kind: "some", included: ["g-list", "g-read", "g-send"] });
|
||||
});
|
||||
|
||||
it("full access selects all", () => {
|
||||
const g = gmail();
|
||||
expect(templateSelections("full_access", [g])[g.appKey]).toEqual({ kind: "all" });
|
||||
});
|
||||
|
||||
it("scratch selects none", () => {
|
||||
const g = gmail();
|
||||
expect(templateSelections("scratch", [g])[g.appKey]).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
import type {
|
||||
ToolCatalogEntry,
|
||||
ToolProfileDefaultAction,
|
||||
ToolProfileEntry,
|
||||
ToolRiskLevel,
|
||||
} from "@paperclipai/shared";
|
||||
import type { ToolProfileEntryInput } from "@/api/tools";
|
||||
|
||||
/**
|
||||
* Pure domain model for the prosumer access-profile wizard (PAP-10997).
|
||||
*
|
||||
* The wizard speaks a friendly per-app language ("All Gmail tools", "All Gmail
|
||||
* except 2", "3 Gmail tools") while the server stores the existing entry model
|
||||
* (`application` / `catalog_entry` selectors with include/exclude effects). This
|
||||
* module is the single source of truth for that translation and for the
|
||||
* "except N" math, so it can be unit-tested without React.
|
||||
*
|
||||
* Vocabulary gate: nothing here renders. Labels produced here are prosumer copy
|
||||
* ("tools", "All … except N"); selector/entry/effect/default-action vocabulary
|
||||
* never leaves this module.
|
||||
*/
|
||||
|
||||
// --- Capability ------------------------------------------------------------
|
||||
|
||||
export type ToolCapability = "read" | "write" | "destructive";
|
||||
|
||||
export function toolCapability(tool: ToolCatalogEntry): ToolCapability {
|
||||
if (tool.isDestructive) return "destructive";
|
||||
if (tool.isWrite) return "write";
|
||||
return "read";
|
||||
}
|
||||
|
||||
export const CAPABILITY_LABEL: Record<ToolCapability, string> = {
|
||||
read: "Read-only",
|
||||
write: "Makes changes",
|
||||
destructive: "Destructive",
|
||||
};
|
||||
|
||||
// --- App grouping ----------------------------------------------------------
|
||||
|
||||
/** A connected app and the concrete tools its catalog currently exposes. */
|
||||
export interface AppGroup {
|
||||
/** Stable grouping key — the application id, or the connection id when an
|
||||
* entry has no application (admin "run your own" connections). */
|
||||
appKey: string;
|
||||
applicationId: string | null;
|
||||
connectionId: string;
|
||||
name: string;
|
||||
tools: ToolCatalogEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group the company-wide catalog into apps, preferring the application name and
|
||||
* falling back to the connection name for application-less connections.
|
||||
*/
|
||||
export function groupCatalogByApp(
|
||||
catalog: ToolCatalogEntry[],
|
||||
applicationsById: Map<string, string>,
|
||||
connectionsById: Map<string, string>,
|
||||
): AppGroup[] {
|
||||
const groups = new Map<string, AppGroup>();
|
||||
for (const tool of catalog) {
|
||||
const appKey = tool.applicationId ?? tool.connectionId;
|
||||
let group = groups.get(appKey);
|
||||
if (!group) {
|
||||
const name =
|
||||
(tool.applicationId ? applicationsById.get(tool.applicationId) : null) ??
|
||||
connectionsById.get(tool.connectionId) ??
|
||||
"Tools";
|
||||
group = {
|
||||
appKey,
|
||||
applicationId: tool.applicationId,
|
||||
connectionId: tool.connectionId,
|
||||
name,
|
||||
tools: [],
|
||||
};
|
||||
groups.set(appKey, group);
|
||||
}
|
||||
group.tools.push(tool);
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
group.tools.sort((a, b) => a.toolName.localeCompare(b.toolName));
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// --- Per-app selection state ----------------------------------------------
|
||||
|
||||
export type AppSelection =
|
||||
| { kind: "all" }
|
||||
| { kind: "all_except"; excluded: string[] }
|
||||
| { kind: "some"; included: string[] }
|
||||
| { kind: "none" };
|
||||
|
||||
export type WizardSelections = Record<string, AppSelection>;
|
||||
|
||||
/** Set of catalog-entry ids currently allowed for one app, given the catalog. */
|
||||
export function selectedToolIds(group: AppGroup, selection: AppSelection | undefined): Set<string> {
|
||||
if (!selection || selection.kind === "none") return new Set();
|
||||
if (selection.kind === "all") return new Set(group.tools.map((t) => t.id));
|
||||
if (selection.kind === "all_except") {
|
||||
const excluded = new Set(selection.excluded);
|
||||
return new Set(group.tools.filter((t) => !excluded.has(t.id)).map((t) => t.id));
|
||||
}
|
||||
const included = new Set(selection.included);
|
||||
return new Set(group.tools.filter((t) => included.has(t.id)).map((t) => t.id));
|
||||
}
|
||||
|
||||
export function isToolSelected(
|
||||
group: AppGroup,
|
||||
selection: AppSelection | undefined,
|
||||
toolId: string,
|
||||
): boolean {
|
||||
return selectedToolIds(group, selection).has(toolId);
|
||||
}
|
||||
|
||||
export type AppCheckState = "checked" | "indeterminate" | "unchecked";
|
||||
|
||||
export function appCheckState(group: AppGroup, selection: AppSelection | undefined): AppCheckState {
|
||||
const n = selectedToolIds(group, selection).size;
|
||||
if (n === 0) return "unchecked";
|
||||
if (n === group.tools.length) return "checked";
|
||||
return "indeterminate";
|
||||
}
|
||||
|
||||
/**
|
||||
* "All Gmail tools (12)" / "All Gmail except 2" / "3 of 12 tools" — the prosumer
|
||||
* summary the tree row and the index render. `appName` is woven in by the caller
|
||||
* when it wants the app-scoped variant.
|
||||
*/
|
||||
export function appSelectionLabel(group: AppGroup, selection: AppSelection | undefined): string {
|
||||
const total = group.tools.length;
|
||||
const state = appCheckState(group, selection);
|
||||
if (state === "unchecked") return "None selected";
|
||||
if (selection?.kind === "all" || (selection?.kind === "all_except" && selection.excluded.length === 0)) {
|
||||
return `All ${group.name} tools (${total})`;
|
||||
}
|
||||
if (selection?.kind === "all_except") {
|
||||
const n = selection.excluded.length;
|
||||
return `All ${group.name} except ${n}`;
|
||||
}
|
||||
const n = selectedToolIds(group, selection).size;
|
||||
return `${n} of ${total} ${group.name} tools`;
|
||||
}
|
||||
|
||||
// --- Checkbox reducers -----------------------------------------------------
|
||||
|
||||
/** Toggle the app-level checkbox: off → all tools; on → none. */
|
||||
export function toggleApp(group: AppGroup, selection: AppSelection | undefined): AppSelection {
|
||||
const state = appCheckState(group, selection);
|
||||
return state === "unchecked" ? { kind: "all" } : { kind: "none" };
|
||||
}
|
||||
|
||||
/** Toggle one tool, preserving "include future tools" intent when the app box is on. */
|
||||
export function toggleTool(
|
||||
group: AppGroup,
|
||||
selection: AppSelection | undefined,
|
||||
toolId: string,
|
||||
): AppSelection {
|
||||
const current = selection ?? { kind: "none" as const };
|
||||
const selected = selectedToolIds(group, current);
|
||||
const willSelect = !selected.has(toolId);
|
||||
|
||||
// App box is on (all / all_except): keep app-include semantics, edit excludes.
|
||||
if (current.kind === "all" || current.kind === "all_except") {
|
||||
const excluded = new Set(current.kind === "all_except" ? current.excluded : []);
|
||||
if (willSelect) excluded.delete(toolId);
|
||||
else excluded.add(toolId);
|
||||
if (excluded.size === 0) return { kind: "all" };
|
||||
if (excluded.size >= group.tools.length) return { kind: "none" };
|
||||
return { kind: "all_except", excluded: group.tools.filter((t) => excluded.has(t.id)).map((t) => t.id) };
|
||||
}
|
||||
|
||||
// App box is off: explicit per-tool includes (future tools stay blocked).
|
||||
const included = new Set(current.kind === "some" ? current.included : []);
|
||||
if (willSelect) included.add(toolId);
|
||||
else included.delete(toolId);
|
||||
if (included.size === 0) return { kind: "none" };
|
||||
return { kind: "some", included: group.tools.filter((t) => included.has(t.id)).map((t) => t.id) };
|
||||
}
|
||||
|
||||
// --- Advanced rules (AP18) -------------------------------------------------
|
||||
|
||||
export type AdvancedRuleKind = "tool_name" | "risk_level" | "catalog_entry";
|
||||
|
||||
export interface AdvancedRule {
|
||||
id: string;
|
||||
kind: AdvancedRuleKind;
|
||||
/** wildcard pattern for tool_name, a risk level, or a catalog id. */
|
||||
value: string;
|
||||
riskLevel?: ToolRiskLevel;
|
||||
effect: "include" | "exclude";
|
||||
}
|
||||
|
||||
// --- Entry <-> selection translation --------------------------------------
|
||||
|
||||
/**
|
||||
* Flatten the wizard's per-app selections (+ advanced rules) into the server
|
||||
* entry model. App-on selections become an `application` include; per-tool
|
||||
* opt-outs become `catalog_entry` excludes; explicit picks become
|
||||
* `catalog_entry` includes.
|
||||
*/
|
||||
export function buildEntries(
|
||||
groups: AppGroup[],
|
||||
selections: WizardSelections,
|
||||
advancedRules: AdvancedRule[] = [],
|
||||
defaultAction: ToolProfileDefaultAction = "deny",
|
||||
): ToolProfileEntryInput[] {
|
||||
const entries: ToolProfileEntryInput[] = [];
|
||||
for (const group of groups) {
|
||||
const selection = selections[group.appKey];
|
||||
if (!selection) continue;
|
||||
if (defaultAction === "allow") {
|
||||
const selectedIds = selectedToolIds(group, selection);
|
||||
for (const tool of group.tools) {
|
||||
if (!selectedIds.has(tool.id)) {
|
||||
entries.push({ selectorType: "catalog_entry", effect: "exclude", catalogEntryId: tool.id });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (selection.kind === "none") continue;
|
||||
if (selection.kind === "all" || selection.kind === "all_except") {
|
||||
if (group.applicationId) {
|
||||
entries.push({ selectorType: "application", effect: "include", applicationId: group.applicationId });
|
||||
} else {
|
||||
entries.push({ selectorType: "connection", effect: "include", connectionId: group.connectionId });
|
||||
}
|
||||
if (selection.kind === "all_except") {
|
||||
for (const toolId of selection.excluded) {
|
||||
entries.push({ selectorType: "catalog_entry", effect: "exclude", catalogEntryId: toolId });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const toolId of selection.included) {
|
||||
entries.push({ selectorType: "catalog_entry", effect: "include", catalogEntryId: toolId });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const rule of advancedRules) {
|
||||
if (rule.kind === "tool_name") {
|
||||
entries.push({ selectorType: "tool_name", effect: rule.effect, toolName: rule.value });
|
||||
} else if (rule.kind === "risk_level") {
|
||||
entries.push({ selectorType: "risk_level", effect: rule.effect, riskLevel: rule.riskLevel ?? "destructive" });
|
||||
} else {
|
||||
entries.push({ selectorType: "catalog_entry", effect: rule.effect, catalogEntryId: rule.value });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover wizard state from stored entries (draft resume / copy a profile).
|
||||
* App-include + catalog excludes round-trip back to all / all_except; bare
|
||||
* catalog includes become explicit picks; everything else (wildcards, risk
|
||||
* levels, cross-app catalog ids) surfaces as an advanced rule.
|
||||
*/
|
||||
export function parseEntries(
|
||||
groups: AppGroup[],
|
||||
entries: ToolProfileEntry[],
|
||||
): { selections: WizardSelections; advancedRules: AdvancedRule[] } {
|
||||
const selections: WizardSelections = {};
|
||||
const advancedRules: AdvancedRule[] = [];
|
||||
const toolIdToApp = new Map<string, string>();
|
||||
for (const group of groups) {
|
||||
for (const tool of group.tools) toolIdToApp.set(tool.id, group.appKey);
|
||||
}
|
||||
const appByApplicationId = new Map<string, AppGroup>();
|
||||
const appByConnectionId = new Map<string, AppGroup>();
|
||||
for (const group of groups) {
|
||||
if (group.applicationId) appByApplicationId.set(group.applicationId, group);
|
||||
appByConnectionId.set(group.connectionId, group);
|
||||
}
|
||||
|
||||
const appOn = new Set<string>();
|
||||
const excludesByApp = new Map<string, Set<string>>();
|
||||
const includesByApp = new Map<string, Set<string>>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.effect === "include" && entry.selectorType === "application" && entry.applicationId) {
|
||||
const group = appByApplicationId.get(entry.applicationId);
|
||||
if (group) appOn.add(group.appKey);
|
||||
continue;
|
||||
}
|
||||
if (entry.effect === "include" && entry.selectorType === "connection" && entry.connectionId) {
|
||||
const group = appByConnectionId.get(entry.connectionId);
|
||||
if (group && !group.applicationId) appOn.add(group.appKey);
|
||||
else advancedRules.push({ id: entry.id, kind: "catalog_entry", value: entry.connectionId, effect: "include" });
|
||||
continue;
|
||||
}
|
||||
if (entry.selectorType === "catalog_entry" && entry.catalogEntryId) {
|
||||
const appKey = toolIdToApp.get(entry.catalogEntryId);
|
||||
if (!appKey) {
|
||||
advancedRules.push({ id: entry.id, kind: "catalog_entry", value: entry.catalogEntryId, effect: entry.effect });
|
||||
continue;
|
||||
}
|
||||
const bucket = entry.effect === "exclude" ? excludesByApp : includesByApp;
|
||||
const set = bucket.get(appKey) ?? new Set<string>();
|
||||
set.add(entry.catalogEntryId);
|
||||
bucket.set(appKey, set);
|
||||
continue;
|
||||
}
|
||||
if (entry.selectorType === "tool_name" && entry.toolName) {
|
||||
advancedRules.push({ id: entry.id, kind: "tool_name", value: entry.toolName, effect: entry.effect });
|
||||
continue;
|
||||
}
|
||||
if (entry.selectorType === "risk_level" && entry.riskLevel) {
|
||||
advancedRules.push({ id: entry.id, kind: "risk_level", value: entry.riskLevel, riskLevel: entry.riskLevel, effect: entry.effect });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
const on = appOn.has(group.appKey);
|
||||
const excluded = [...(excludesByApp.get(group.appKey) ?? [])].filter((id) =>
|
||||
group.tools.some((t) => t.id === id),
|
||||
);
|
||||
const included = [...(includesByApp.get(group.appKey) ?? [])].filter((id) =>
|
||||
group.tools.some((t) => t.id === id),
|
||||
);
|
||||
if (on) {
|
||||
selections[group.appKey] = excluded.length > 0 ? { kind: "all_except", excluded } : { kind: "all" };
|
||||
} else if (included.length > 0) {
|
||||
selections[group.appKey] = { kind: "some", included };
|
||||
} else {
|
||||
selections[group.appKey] = { kind: "none" };
|
||||
}
|
||||
}
|
||||
|
||||
return { selections, advancedRules };
|
||||
}
|
||||
|
||||
// --- Live count ------------------------------------------------------------
|
||||
|
||||
/** "Allows 14 of 63 tools" — resolves the assembled selection against the catalog. */
|
||||
export function countAllowedTools(
|
||||
groups: AppGroup[],
|
||||
selections: WizardSelections,
|
||||
defaultAction: ToolProfileDefaultAction,
|
||||
totalToolCount: number,
|
||||
): { allowed: number; total: number } {
|
||||
if (defaultAction === "allow") {
|
||||
// "Allowed automatically" — everything except the per-app opt-outs.
|
||||
let excluded = 0;
|
||||
for (const group of groups) {
|
||||
const selection = selections[group.appKey];
|
||||
if (selection?.kind === "all_except") excluded += selection.excluded.length;
|
||||
else if (selection?.kind === "some") excluded += group.tools.length - selection.included.length;
|
||||
else if (selection?.kind === "none") excluded += group.tools.length;
|
||||
}
|
||||
return { allowed: Math.max(0, totalToolCount - excluded), total: totalToolCount };
|
||||
}
|
||||
let allowed = 0;
|
||||
for (const group of groups) {
|
||||
allowed += selectedToolIds(group, selections[group.appKey]).size;
|
||||
}
|
||||
return { allowed, total: totalToolCount };
|
||||
}
|
||||
|
||||
// --- Step-1 templates ------------------------------------------------------
|
||||
|
||||
export type TemplateKey = "read_only" | "everyday" | "full_access" | "scratch" | "copy";
|
||||
|
||||
export interface TemplateDef {
|
||||
key: TemplateKey;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const TEMPLATES: TemplateDef[] = [
|
||||
{ key: "read_only", title: "Read-only", description: "See and fetch, but never change anything." },
|
||||
{ key: "everyday", title: "Everyday work", description: "Read and make routine changes — no destructive tools." },
|
||||
{ key: "full_access", title: "Full access", description: "Everything every connected app offers." },
|
||||
{ key: "scratch", title: "Start from scratch", description: "An empty profile you build up tool by tool." },
|
||||
{ key: "copy", title: "Copy an existing profile", description: "Start from a profile you already have." },
|
||||
];
|
||||
|
||||
function capabilityPredicate(key: TemplateKey): (tool: ToolCatalogEntry) => boolean {
|
||||
if (key === "read_only") return (t) => toolCapability(t) === "read";
|
||||
if (key === "everyday") return (t) => toolCapability(t) !== "destructive";
|
||||
return () => true; // full_access
|
||||
}
|
||||
|
||||
/** Build the initial per-app selection a template implies for the live catalog. */
|
||||
export function templateSelections(key: TemplateKey, groups: AppGroup[]): WizardSelections {
|
||||
const selections: WizardSelections = {};
|
||||
if (key === "scratch" || key === "copy") {
|
||||
for (const group of groups) selections[group.appKey] = { kind: "none" };
|
||||
return selections;
|
||||
}
|
||||
const matches = capabilityPredicate(key);
|
||||
for (const group of groups) {
|
||||
const matchingIds = group.tools.filter(matches).map((t) => t.id);
|
||||
if (matchingIds.length === 0) selections[group.appKey] = { kind: "none" };
|
||||
else if (matchingIds.length === group.tools.length) selections[group.appKey] = { kind: "all" };
|
||||
else selections[group.appKey] = { kind: "some", included: matchingIds };
|
||||
}
|
||||
return selections;
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolProfileSummary } from "@paperclipai/shared";
|
||||
import { allowsLabel, assignedLabel, STATUS_LABEL } from "./profile-summary";
|
||||
|
||||
function summary(partial: Partial<ToolProfileSummary>): ToolProfileSummary {
|
||||
return {
|
||||
accessMode: "selected",
|
||||
allowedToolCount: 0,
|
||||
allowedApplicationCount: 0,
|
||||
excludedToolCount: 0,
|
||||
totalToolCount: 0,
|
||||
assignmentCount: 0,
|
||||
appliesToAgentCount: 0,
|
||||
isCompanyDefault: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("allowsLabel", () => {
|
||||
it("renders tools and apps in selected mode", () => {
|
||||
expect(allowsLabel(summary({ allowedToolCount: 9, allowedApplicationCount: 3 }))).toBe("9 tools · 3 apps");
|
||||
});
|
||||
|
||||
it("drops the app clause when no whole app is allowed", () => {
|
||||
expect(allowsLabel(summary({ allowedToolCount: 1 }))).toBe("1 tool");
|
||||
});
|
||||
|
||||
it("renders 'All except N' in all_except mode", () => {
|
||||
expect(allowsLabel(summary({ accessMode: "all_except", excludedToolCount: 2 }))).toBe("All except 2 tools");
|
||||
});
|
||||
|
||||
it("renders 'All tools' when nothing is excluded", () => {
|
||||
expect(allowsLabel(summary({ accessMode: "all_except", excludedToolCount: 0 }))).toBe("All tools");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignedLabel", () => {
|
||||
it("prefers the company-default label", () => {
|
||||
expect(assignedLabel(summary({ isCompanyDefault: true, appliesToAgentCount: 4 }))).toEqual({
|
||||
text: "Company default",
|
||||
unassigned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts agents", () => {
|
||||
expect(assignedLabel(summary({ appliesToAgentCount: 2 }))).toEqual({ text: "2 agents", unassigned: false });
|
||||
});
|
||||
|
||||
it("flags an unassigned profile as having no effect", () => {
|
||||
expect(assignedLabel(summary({}))).toEqual({ text: "Not assigned yet", unassigned: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("STATUS_LABEL", () => {
|
||||
it("uses prosumer words", () => {
|
||||
expect(STATUS_LABEL.draft).toBe("Draft");
|
||||
expect(STATUS_LABEL.disabled).toBe("Off");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { ToolProfileStatus, ToolProfileSummary, ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Prosumer copy for the access-profile index (PAP-10997, AP1). Reads the
|
||||
* server-computed `summary` and renders the friendly "Allows" / "Assigned to"
|
||||
* lines the table shows. Vocabulary gate: nothing here says
|
||||
* binding/entry/selector/priority — only "tools", "apps", "agents".
|
||||
*/
|
||||
|
||||
function plural(n: number, one: string, many = `${one}s`): string {
|
||||
return `${n} ${n === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
/** "9 tools · 3 apps" / "All tools" / "All except 2 tools". */
|
||||
export function allowsLabel(summary: ToolProfileSummary): string {
|
||||
if (summary.accessMode === "all_except") {
|
||||
return summary.excludedToolCount === 0
|
||||
? "All tools"
|
||||
: `All except ${plural(summary.excludedToolCount, "tool")}`;
|
||||
}
|
||||
const parts = [plural(summary.allowedToolCount, "tool")];
|
||||
if (summary.allowedApplicationCount > 0) {
|
||||
parts.push(plural(summary.allowedApplicationCount, "app"));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export interface AssignedLabel {
|
||||
text: string;
|
||||
/** A profile with no assignment has no effect — the index shows a quiet hint. */
|
||||
unassigned: boolean;
|
||||
}
|
||||
|
||||
/** "Company default" / "2 agents" / "Not assigned yet". */
|
||||
export function assignedLabel(summary: ToolProfileSummary): AssignedLabel {
|
||||
if (summary.isCompanyDefault) return { text: "Company default", unassigned: false };
|
||||
if (summary.appliesToAgentCount > 0) {
|
||||
return { text: plural(summary.appliesToAgentCount, "agent"), unassigned: false };
|
||||
}
|
||||
if (summary.assignmentCount > 0) {
|
||||
return { text: plural(summary.assignmentCount, "assignment"), unassigned: false };
|
||||
}
|
||||
return { text: "Not assigned yet", unassigned: true };
|
||||
}
|
||||
|
||||
export const STATUS_LABEL: Record<ToolProfileStatus, string> = {
|
||||
draft: "Draft",
|
||||
active: "Active",
|
||||
disabled: "Off",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
export function isDraft(profile: Pick<ToolProfileWithDetails, "status">): boolean {
|
||||
return profile.status === "draft";
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { useMemo } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { routinesApi } from "@/api/routines";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { groupCatalogByApp } from "./profile-model";
|
||||
|
||||
/**
|
||||
* Shared data layer for the access-profiles index and wizard. Assembles the
|
||||
* company-wide catalog (there is no aggregate endpoint, so we fan out per
|
||||
* connection) and the lookup maps both surfaces need.
|
||||
*/
|
||||
export function useProfilesData(companyId: string) {
|
||||
const profiles = useQuery({
|
||||
queryKey: queryKeys.tools.profiles(companyId),
|
||||
queryFn: () => toolsApi.listProfiles(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 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 connectionList = connections.data?.connections ?? [];
|
||||
const catalogQueries = useQueries({
|
||||
queries: connectionList.map((c) => ({
|
||||
queryKey: queryKeys.tools.catalog(c.id),
|
||||
queryFn: () => toolsApi.listCatalog(c.id),
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
});
|
||||
const catalog = useMemo(
|
||||
() => catalogQueries.flatMap((q) => q.data?.catalog ?? []),
|
||||
[catalogQueries],
|
||||
);
|
||||
const catalogLoading = connections.isLoading || catalogQueries.some((q) => q.isLoading);
|
||||
|
||||
const maps = useMemo(
|
||||
() => ({
|
||||
applicationsById: new Map((applications.data?.applications ?? []).map((a) => [a.id, a.name])),
|
||||
connectionsById: new Map(connectionList.map((c) => [c.id, c.name])),
|
||||
agentsById: new Map((agents.data ?? []).map((a) => [a.id, a.name])),
|
||||
projectsById: new Map((projects.data ?? []).map((p) => [p.id, p.name])),
|
||||
routinesById: new Map((routines.data ?? []).map((r) => [r.id, r.title])),
|
||||
}),
|
||||
[applications.data, connectionList, agents.data, projects.data, routines.data],
|
||||
);
|
||||
|
||||
const appGroups = useMemo(
|
||||
() => groupCatalogByApp(catalog, maps.applicationsById, maps.connectionsById),
|
||||
[catalog, maps.applicationsById, maps.connectionsById],
|
||||
);
|
||||
|
||||
return {
|
||||
profiles,
|
||||
applications,
|
||||
connections,
|
||||
agents,
|
||||
projects,
|
||||
routines,
|
||||
catalog,
|
||||
catalogLoading,
|
||||
appGroups,
|
||||
maps,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { readWizardMeta, resumeStep, withWizardMeta } from "./wizard-draft";
|
||||
|
||||
describe("readWizardMeta", () => {
|
||||
it("reads a stored step and template", () => {
|
||||
expect(readWizardMeta({ metadata: { wizard: { lastCompletedStep: 2, template: "everyday" } } })).toEqual({
|
||||
lastCompletedStep: 2,
|
||||
template: "everyday",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for missing or malformed metadata", () => {
|
||||
expect(readWizardMeta(null)).toBeNull();
|
||||
expect(readWizardMeta({ metadata: null })).toBeNull();
|
||||
expect(readWizardMeta({ metadata: { wizard: { lastCompletedStep: 9 } } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withWizardMeta", () => {
|
||||
it("merges progress without dropping other metadata keys", () => {
|
||||
expect(withWizardMeta({ other: 1 }, { lastCompletedStep: 1, template: null })).toEqual({
|
||||
other: 1,
|
||||
wizard: { lastCompletedStep: 1, template: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeStep", () => {
|
||||
it("resumes at the first unfinished step after step 1", () => {
|
||||
expect(resumeStep({ lastCompletedStep: 1, template: null })).toBe(2);
|
||||
});
|
||||
|
||||
it("caps at the final step", () => {
|
||||
expect(resumeStep({ lastCompletedStep: 3, template: null })).toBe(3);
|
||||
});
|
||||
|
||||
it("starts at step 1 with no saved progress", () => {
|
||||
expect(resumeStep(null)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import type { TemplateKey } from "./profile-model";
|
||||
|
||||
/**
|
||||
* The wizard stashes its non-server-modelled progress (which step the user
|
||||
* reached, which template they started from) inside the profile's free-form
|
||||
* `metadata`. This is never rendered, so it sits outside the vocabulary gate.
|
||||
*/
|
||||
|
||||
export type WizardStep = 1 | 2 | 3;
|
||||
|
||||
export interface WizardMeta {
|
||||
lastCompletedStep: WizardStep;
|
||||
template: TemplateKey | null;
|
||||
}
|
||||
|
||||
const KEY = "wizard";
|
||||
|
||||
export function readWizardMeta(
|
||||
profile: Pick<ToolProfileWithDetails, "metadata"> | null | undefined,
|
||||
): WizardMeta | null {
|
||||
const raw = (profile?.metadata as Record<string, unknown> | null | undefined)?.[KEY];
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const step = obj.lastCompletedStep;
|
||||
if (step !== 1 && step !== 2 && step !== 3) return null;
|
||||
return {
|
||||
lastCompletedStep: step,
|
||||
template: (typeof obj.template === "string" ? obj.template : null) as TemplateKey | null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge wizard progress into a metadata object, preserving any other keys. */
|
||||
export function withWizardMeta(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
meta: WizardMeta,
|
||||
): Record<string, unknown> {
|
||||
return { ...(metadata ?? {}), [KEY]: meta };
|
||||
}
|
||||
|
||||
/** Where to resume a draft: the first step the user has not yet completed. */
|
||||
export function resumeStep(meta: WizardMeta | null): WizardStep {
|
||||
if (!meta) return 1;
|
||||
return Math.min(meta.lastCompletedStep + 1, 3) as WizardStep;
|
||||
}
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
ToolRiskLevel,
|
||||
ToolConnectionHealthStatus,
|
||||
ToolPolicyDecision,
|
||||
} from "@paperclipai/shared";
|
||||
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. */
|
||||
export function RiskBadge({ risk }: { risk: ToolRiskLevel | null | undefined }) {
|
||||
if (!risk) return <Badge variant="outline">unknown</Badge>;
|
||||
const variant =
|
||||
risk === "high" || risk === "critical"
|
||||
? "destructive"
|
||||
: risk === "medium"
|
||||
? "secondary"
|
||||
: "outline";
|
||||
return <Badge variant={variant}>{risk}</Badge>;
|
||||
}
|
||||
|
||||
/** Read/Write/Destructive capability chips. */
|
||||
export function CapabilityBadges({
|
||||
isReadOnly,
|
||||
isWrite,
|
||||
isDestructive,
|
||||
}: {
|
||||
isReadOnly?: boolean;
|
||||
isWrite?: boolean;
|
||||
isDestructive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex flex-wrap gap-1">
|
||||
{isReadOnly ? <Badge variant="outline">read-only</Badge> : null}
|
||||
{isWrite ? <Badge variant="secondary">write</Badge> : null}
|
||||
{isDestructive ? <Badge variant="destructive">destructive</Badge> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Catalog quarantine marker — canonical status key. */
|
||||
export function QuarantineBadge() {
|
||||
return <StatusBadge status="quarantined" />;
|
||||
}
|
||||
|
||||
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,
|
||||
}: {
|
||||
status: ToolConnectionHealthStatus | string | null | undefined;
|
||||
label?: string;
|
||||
}) {
|
||||
const raw = (status ?? "unknown").toString();
|
||||
return <StatusBadge status={healthToStatusKey(raw)} label={label ?? raw} />;
|
||||
}
|
||||
|
||||
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 — canonical status colors. */
|
||||
export function DecisionBadge({ decision }: { decision: ToolPolicyDecision | string | null | undefined }) {
|
||||
if (!decision) return <Badge variant="outline">—</Badge>;
|
||||
const { key, label } = decisionToStatusKey(decision.toString());
|
||||
return <StatusBadge status={key} label={label} />;
|
||||
}
|
||||
|
||||
/** Compact relative time, falling back to absolute. */
|
||||
export function RelativeTime({ value }: { value: Date | string | null | undefined }) {
|
||||
if (!value) return <span className="text-muted-foreground">never</span>;
|
||||
const date = typeof value === "string" ? new Date(value) : value;
|
||||
if (Number.isNaN(date.getTime())) return <span className="text-muted-foreground">—</span>;
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const abs = Math.abs(diffMs);
|
||||
const mins = Math.round(abs / 60000);
|
||||
const isFuture = diffMs < 0;
|
||||
let text: string;
|
||||
if (mins < 1) text = "just now";
|
||||
else {
|
||||
const value =
|
||||
mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.round(mins / 60)}h` : `${Math.round(mins / 1440)}d`;
|
||||
text = isFuture ? `in ${value}` : `${value} ago`;
|
||||
}
|
||||
return (
|
||||
<span title={date.toLocaleString()} className="text-muted-foreground">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolsPageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
{description ? <p className="max-w-2xl text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ label = "Loading…" }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Actionable error surface — surfaces the server message and HTTP status. */
|
||||
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
|
||||
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">
|
||||
<div className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Could not load this view</p>
|
||||
<p className="text-destructive/80">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="self-start rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
) : 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
|
||||
* follow-up issue that owns the missing contract.
|
||||
*/
|
||||
export function PendingBackendNotice({
|
||||
title,
|
||||
body,
|
||||
issue,
|
||||
}: {
|
||||
title: string;
|
||||
body: ReactNode;
|
||||
issue?: { identifier: string; href: string };
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col gap-2 py-8">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
{title}
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">{body}</p>
|
||||
{issue ? (
|
||||
<a href={issue.href} className="text-sm font-medium text-primary hover:underline">
|
||||
Tracked in {issue.identifier} →
|
||||
</a>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { SmokeRun, SmokeRunStep } from "@paperclipai/shared";
|
||||
import {
|
||||
buildSmokeMatrix,
|
||||
cellKey,
|
||||
failingPaths,
|
||||
matchLifecycleStage,
|
||||
runHealth,
|
||||
} from "./smoke-lab-matrix";
|
||||
|
||||
function step(overrides: Partial<SmokeRunStep> & Pick<SmokeRunStep, "path" | "scenarioStep" | "status">): SmokeRunStep {
|
||||
return {
|
||||
id: `${overrides.path}-${overrides.scenarioStep}`,
|
||||
companyId: "c1",
|
||||
runId: "r1",
|
||||
detail: null,
|
||||
screenshotArtifactRef: null,
|
||||
durationMs: null,
|
||||
createdAt: "2026-07-10T00:00:00Z",
|
||||
updatedAt: "2026-07-10T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function run(overrides: Partial<SmokeRun> = {}): SmokeRun {
|
||||
return {
|
||||
id: "r1",
|
||||
companyId: "c1",
|
||||
trigger: "manual",
|
||||
status: "passed",
|
||||
startedAt: "2026-07-10T00:00:00Z",
|
||||
finishedAt: "2026-07-10T00:05:00Z",
|
||||
summary: {},
|
||||
createdAt: "2026-07-10T00:00:00Z",
|
||||
updatedAt: "2026-07-10T00:05:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matchLifecycleStage", () => {
|
||||
it("folds catalog scenario wording onto canonical stages", () => {
|
||||
expect(matchLifecycleStage("oauth-login")).toBe("connect");
|
||||
expect(matchLifecycleStage("discover-catalog")).toBe("discover");
|
||||
expect(matchLifecycleStage("allowed-read")).toBe("read");
|
||||
expect(matchLifecycleStage("ask-first-write-approve")).toBe("write");
|
||||
expect(matchLifecycleStage("denied-call")).toBe("deny");
|
||||
expect(matchLifecycleStage("schema-change-quarantine")).toBe("quarantine");
|
||||
expect(matchLifecycleStage("revoke-token")).toBe("revoke");
|
||||
expect(matchLifecycleStage("audit-evidence")).toBe("audit");
|
||||
});
|
||||
|
||||
it("returns null for steps that don't map to a lifecycle stage", () => {
|
||||
expect(matchLifecycleStage("warm-up")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSmokeMatrix", () => {
|
||||
it("keeps the latest status per (path, stage) cell", () => {
|
||||
const steps = [
|
||||
step({ path: "P1", scenarioStep: "connect", status: "fail", updatedAt: "2026-07-10T00:00:01Z" }),
|
||||
step({ path: "P1", scenarioStep: "connect-retry", status: "pass", updatedAt: "2026-07-10T00:00:02Z" }),
|
||||
step({ path: "P2", scenarioStep: "revoke", status: "skipped", updatedAt: "2026-07-10T00:00:03Z" }),
|
||||
];
|
||||
const matrix = buildSmokeMatrix(steps);
|
||||
expect(matrix.get(cellKey("P1", "connect"))?.status).toBe("pass");
|
||||
expect(matrix.get(cellKey("P2", "revoke"))?.status).toBe("skipped");
|
||||
expect(matrix.get(cellKey("P3", "connect"))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runHealth + failingPaths", () => {
|
||||
it("is red when any step fails", () => {
|
||||
const steps = [step({ path: "P4", scenarioStep: "connect", status: "fail" })];
|
||||
expect(runHealth(run(), steps)).toBe("red");
|
||||
expect(failingPaths(steps)).toEqual(["P4"]);
|
||||
});
|
||||
|
||||
it("is green for a passed run with passing steps", () => {
|
||||
const steps = [step({ path: "P1", scenarioStep: "connect", status: "pass" })];
|
||||
expect(runHealth(run({ status: "passed" }), steps)).toBe("green");
|
||||
});
|
||||
|
||||
it("is amber for a running run or an empty run", () => {
|
||||
expect(runHealth(run({ status: "running" }), [])).toBe("amber");
|
||||
expect(runHealth(run({ status: "passed" }), [])).toBe("amber");
|
||||
});
|
||||
|
||||
it("is unknown when there is no run", () => {
|
||||
expect(runHealth(undefined, [])).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import {
|
||||
SMOKE_RUN_STEP_PATHS,
|
||||
type SmokeRun,
|
||||
type SmokeRunStep,
|
||||
type SmokeRunStepPath,
|
||||
type SmokeRunStepStatus,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Pure matrix/health helpers for the Smoke Lab tab (PAP-13347 / S2, plan §D3).
|
||||
* Kept free of React so the cell/health logic is unit-testable on its own.
|
||||
*
|
||||
* The integration matrix is the plan §3 table: rows are the seven paths
|
||||
* (P1–P7), columns are the PAP-12373 governed lifecycle. Each recorded step
|
||||
* carries a free-form `scenarioStep` string owned by the S4 catalog; we fold it
|
||||
* onto a canonical lifecycle stage by keyword so the matrix stays a stable
|
||||
* 7×8 grid no matter how S4 words its steps. Raw `scenarioStep` values are
|
||||
* always shown verbatim in the run drill-down, so nothing is hidden.
|
||||
*/
|
||||
|
||||
export const SMOKE_PATH_LABELS: Record<SmokeRunStepPath, { title: string; detail: string }> = {
|
||||
P1: { title: "Remote HTTP · OAuth", detail: "HTTP MCP fixture behind the fake OAuth provider" },
|
||||
P2: { title: "Remote HTTP · API key", detail: "HTTP MCP fixture with a static bearer key" },
|
||||
P3: { title: "Local stdio (template)", detail: "stdio fixture via the runtime supervisor" },
|
||||
P4: { title: "Plugin integration", detail: "plugin-provided catalog entry + install flow" },
|
||||
P5: { title: "Paste-a-config import", detail: "prosumer import via Advanced setup" },
|
||||
P6: { title: "Token broker / gateway", detail: "run-scoped connection token, TTL + scope checks" },
|
||||
P7: { title: "Governance surfaces", detail: "profiles, ask-first rules, quarantine" },
|
||||
};
|
||||
|
||||
export interface LifecycleStage {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Keywords (lowercased) that fold a `scenarioStep` onto this stage. */
|
||||
match: string[];
|
||||
}
|
||||
|
||||
/** The PAP-12373 governed lifecycle, in order (plan §3). */
|
||||
export const LIFECYCLE_STAGES: LifecycleStage[] = [
|
||||
{ key: "connect", label: "Connect", match: ["connect", "oauth", "login", "auth"] },
|
||||
{ key: "discover", label: "Discover catalog", match: ["discover", "catalog", "list-tools"] },
|
||||
{ key: "read", label: "Allowed read", match: ["read", "allowed"] },
|
||||
{ key: "write", label: "Ask-first write", match: ["write", "approve", "ask-first", "askfirst", "review"] },
|
||||
{ key: "deny", label: "Denied call", match: ["deny", "denied", "block", "forbidden"] },
|
||||
{ key: "quarantine", label: "Schema-change quarantine", match: ["quarantine", "schema"] },
|
||||
{ key: "revoke", label: "Revoke", match: ["revoke"] },
|
||||
{ key: "audit", label: "Audit evidence", match: ["audit", "activity", "evidence"] },
|
||||
];
|
||||
|
||||
/** Fold a free-form scenario step onto a canonical lifecycle stage, or null. */
|
||||
export function matchLifecycleStage(scenarioStep: string): string | null {
|
||||
const s = scenarioStep.toLowerCase();
|
||||
for (const stage of LIFECYCLE_STAGES) {
|
||||
if (stage.match.some((kw) => s.includes(kw))) return stage.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type CellStatus = SmokeRunStepStatus | "not-run";
|
||||
|
||||
function stepTime(step: SmokeRunStep): number {
|
||||
const raw = step.updatedAt ?? step.createdAt;
|
||||
const t = new Date(raw as string | Date).getTime();
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest status per (path, stage) cell across the given steps. Later steps win
|
||||
* so a matrix always reflects the most recent attempt at each cell.
|
||||
*/
|
||||
export function buildSmokeMatrix(steps: SmokeRunStep[]): Map<string, { status: CellStatus; step: SmokeRunStep }> {
|
||||
const cells = new Map<string, { status: CellStatus; step: SmokeRunStep }>();
|
||||
const ordered = [...steps].sort((a, b) => stepTime(a) - stepTime(b));
|
||||
for (const step of ordered) {
|
||||
const stage = matchLifecycleStage(step.scenarioStep);
|
||||
if (!stage) continue;
|
||||
cells.set(`${step.path}::${stage}`, { status: step.status, step });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function cellKey(path: SmokeRunStepPath, stageKey: string): string {
|
||||
return `${path}::${stageKey}`;
|
||||
}
|
||||
|
||||
export const SMOKE_PATHS = SMOKE_RUN_STEP_PATHS;
|
||||
|
||||
export type SmokeHealth = "green" | "amber" | "red" | "unknown";
|
||||
|
||||
/** Overall traffic-light for a run: red on any failure, amber if unfinished/empty. */
|
||||
export function runHealth(run: SmokeRun | undefined, steps: SmokeRunStep[]): SmokeHealth {
|
||||
if (!run) return "unknown";
|
||||
if (run.status === "failed") return "red";
|
||||
if (steps.some((s) => s.status === "fail")) return "red";
|
||||
if (run.status === "cancelled") return "amber";
|
||||
if (run.status === "running") return "amber";
|
||||
if (steps.length === 0) return "amber";
|
||||
return "green";
|
||||
}
|
||||
|
||||
/** Paths with at least one failing step in the given run. */
|
||||
export function failingPaths(steps: SmokeRunStep[]): SmokeRunStepPath[] {
|
||||
const failed = new Set<SmokeRunStepPath>();
|
||||
for (const step of steps) {
|
||||
if (step.status === "fail") failed.add(step.path);
|
||||
}
|
||||
return SMOKE_RUN_STEP_PATHS.filter((p) => failed.has(p));
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import {
|
||||
ClipboardPaste,
|
||||
FlaskConical,
|
||||
Layers,
|
||||
Network,
|
||||
ScrollText,
|
||||
Server,
|
||||
Shield,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* The Advanced door is mounted under `/apps/advanced` (PAP-10862, plan D8).
|
||||
* `/tools` and `/tools/:tab` redirect here; every in-surface link is built off
|
||||
* this base so the developer door has a single canonical home.
|
||||
*/
|
||||
export const ADVANCED_TOOLS_BASE = "/apps/advanced";
|
||||
|
||||
/** Build a tab href off the Advanced base. `run-your-own` is the bare base path (the door's default tab). */
|
||||
export function advancedTabHref(tab: ToolTabKey): string {
|
||||
return tab === "run-your-own" ? ADVANCED_TOOLS_BASE : `${ADVANCED_TOOLS_BASE}/${tab}`;
|
||||
}
|
||||
|
||||
// M8a/M8b — the prosumer-facing Advanced setup tabs (PAP-10839 wires). The only
|
||||
// screens where "MCP" vocabulary is permitted (PAP-10827).
|
||||
export const ADVANCED_TABS = [
|
||||
{ key: "run-your-own", label: "Run your own", icon: TerminalSquare },
|
||||
{ key: "paste-config", label: "Paste a config", icon: ClipboardPaste },
|
||||
] as const;
|
||||
|
||||
// The pre-Apps developer surface, kept reachable behind the Advanced door.
|
||||
// `smoke-lab` (PAP-13343 / S2) is experimental — hidden from the sidebar unless
|
||||
// `experimental.enableSmokeLab` is on (see `isExperimentalToolTab` +
|
||||
// `useSmokeLabEnabled`), and the route/tab itself gates on the same flag.
|
||||
export const DEVELOPER_TABS = [
|
||||
{ key: "gateways", label: "Gateways", icon: Network },
|
||||
{ key: "profiles", label: "Profiles", icon: Layers },
|
||||
{ key: "policies", label: "Rules", icon: Shield },
|
||||
{ key: "runtime", label: "Health", icon: Server },
|
||||
{ key: "audit", label: "Activity", icon: ScrollText },
|
||||
{ key: "smoke-lab", label: "Smoke Lab", icon: FlaskConical },
|
||||
] as const;
|
||||
|
||||
export const TOOL_TABS = [...ADVANCED_TABS, ...DEVELOPER_TABS] as const;
|
||||
|
||||
export type ToolTabKey = (typeof TOOL_TABS)[number]["key"];
|
||||
|
||||
export function isAdvancedSetupTab(tab: ToolTabKey): boolean {
|
||||
return ADVANCED_TABS.some((t) => t.key === tab);
|
||||
}
|
||||
|
||||
/** Developer tabs hidden behind an experimental flag (gated in the sidebar). */
|
||||
export function isExperimentalToolTab(tab: ToolTabKey): boolean {
|
||||
return tab === "smoke-lab";
|
||||
}
|
||||
Loading…
Reference in New Issue