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:

![Profiles
index](https://raw.githubusercontent.com/paperclipai/paperclip/ac72de3ec85feb14c5ec1ed0a3d39b29baa0abff/doc/screenshots/garden-mcp-split-stack/profiles-index.png)

![Profile
wizard](https://raw.githubusercontent.com/paperclipai/paperclip/ac72de3ec85feb14c5ec1ed0a3d39b29baa0abff/doc/screenshots/garden-mcp-split-stack/profile-wizard.png)


![Policies](https://raw.githubusercontent.com/paperclipai/paperclip/ac72de3ec85feb14c5ec1ed0a3d39b29baa0abff/doc/screenshots/garden-mcp-split-stack/policies.png)

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-14 15:31:12 -05:00 committed by GitHub
parent 9c8adee48b
commit a116713b9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
86 changed files with 16969 additions and 63 deletions

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

View File

@ -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();
});
});

View File

@ -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,
});

View File

@ -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;

View File

@ -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 }),
};

View File

@ -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,
});
});
});

View File

@ -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;

View File

@ -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.

68
ui/src/api/smokeLab.ts Normal file
View File

@ -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,
),
};

460
ui/src/api/tools.ts Normal file
View File

@ -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),
};

View File

@ -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)));
});
});

View File

@ -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>
);
}

View File

@ -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();

View File

@ -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" />}

View File

@ -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>
);
}

View File

@ -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");
});
});

View File

@ -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)}` : ""}

View File

@ -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 () => {

View File

@ -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" />
) : (

View File

@ -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;

View File

@ -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");
});
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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");
});
});

View File

@ -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 &amp; 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&apos;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 />;
}

View File

@ -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",

View File

@ -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} />}

View File

@ -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);
});

View File

@ -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(

View File

@ -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 3041) 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",

View File

@ -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,
};
}

View File

@ -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 };
}

View File

@ -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`;
}

View File

@ -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();

View File

@ -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",

View File

@ -19,6 +19,8 @@ export type {
RequestConfirmationPayload,
RequestConfirmationResult,
RequestConfirmationTarget,
RequestConfirmationToolActionPayload,
RequestConfirmationToolActionResult,
RequestItemVerdictsInteraction,
RequestItemVerdictsItem,
RequestItemVerdictsPayload,

View File

@ -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,

View File

@ -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&region=us"),
).toBe("https://mcp.zapier.com/api/v1/connect?token=REDACTED&region=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",
);
});
});

View File

@ -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);
}

View File

@ -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";

View File

@ -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.";

View File

@ -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 ? (

View File

@ -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 />;
}

View File

@ -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");
});
});

View File

@ -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>
);
}

View File

@ -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.");
});
});

View File

@ -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>
);
}

View File

@ -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.");
});
});

View File

@ -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>
);
}

View File

@ -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

View File

@ -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

View File

@ -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>
);
}

View File

@ -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");
});
});

View File

@ -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 23 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>
);
}

View File

@ -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 P1P7 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: {} });
});
});

View File

@ -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 (P1P7) 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>
);
}

View File

@ -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");
});
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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();
});
});

View File

@ -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";
}

View File

@ -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>
);
}

View File

@ -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();
});
});

View File

@ -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 &amp; 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>
);
}

View File

@ -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>
);
}

View File

@ -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" });
});
});

View File

@ -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>
);
}

View File

@ -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}</>;
}

View File

@ -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*");
});
});

View File

@ -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>
);
}

View File

@ -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" });
});
});

View File

@ -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;
}

View File

@ -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");
});
});

View File

@ -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";
}

View File

@ -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,
};
}

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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");
});
});

View File

@ -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
* (P1P7), 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));
}

View File

@ -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";
}