feat(apps): add connection grants and delegated identities (#12341)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - External tools need explicit identity and access boundaries. > - Shared connection credentials cannot represent every user-scoped use case. > - Grants must stay company-scoped and support safe delegation. > - This pull request adds connection grants, identity rules, and their database contract. > - The benefit is durable control over which identity an agent may use. ## Linked Issues or Issue Description Refs #11965 This is stack 3 of 11. It depends on stack 2 and replaces another reviewable part of #11965. ## What Changed - Add company and user connection grants. - Add delegated identity and membership rules. - Synchronize database, shared, server, and UI contracts. - Register the grant-member replacement route in the OpenAPI surface in the same layer that mounts it. - Add migration 0231 with replay-safe guards and coverage. ## Verification - `pnpm -r typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/tool-access-service.test.ts` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/openapi-routes.test.ts` (5 passed) - `pnpm --filter @paperclipai/db check:migrations` - `pnpm build` ## Risks - Incorrect grant selection could expose the wrong credential scope. - The service enforces company and subject boundaries before credential use. - Migration 0231 is generated, ordered after 0230, and safe to replay. > I checked `ROADMAP.md`. This stack continues the existing app connection work from #11965 and does not duplicate another planned item. ## Model Used OpenAI Codex, GPT-5. The runtime model ID and context window were not exposed. The model used reasoning, tool use, and code execution. ## 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 linked a public issue or pull request with `Refs #` - [x] I have not referenced internal or instance-local Paperclip issues or links - [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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b51112798f
commit
20ccf3f476
|
|
@ -735,6 +735,8 @@ For Tailscale HTTPS exposure, readiness includes stable listener-ownership check
|
|||
|
||||
In Vite middleware mode, Paperclip gives HMR a dedicated HTTP server bound to the managed runtime's loopback host. The browser still derives the HMR hostname from the public HTTPS page, so listener containment does not break remote hot reload.
|
||||
|
||||
When a workspace service runs Paperclip for browser OAuth QA, configure its `expose.urlTemplate` with the canonical URL the browser can reach. Paperclip preserves explicit `PAPERCLIP_PUBLIC_URL` or `BETTER_AUTH_URL` settings; otherwise it uses a valid exposed HTTPS origin (or loopback HTTP) as the managed runtime fallback for Better Auth and `/api/tools/oauth/callback`. Internal service names such as `http://paperclip-dev:<port>` are rejected unless that hostname is genuinely the browser route. Use a unique origin per isolated worktree. See [Execution Workspaces And Runtime Services](../docs/guides/board-operator/execution-workspaces-and-runtime-services.md#browser-reachable-origins-for-oauth-qa) for configuration and verification.
|
||||
|
||||
## App-Shipped Skills Catalog
|
||||
|
||||
The Paperclip app ships a curated catalog of company skills out of the box. The
|
||||
|
|
|
|||
|
|
@ -703,8 +703,10 @@ old creator-excluding behavior as canonical `not_creator`, legacy `board_only` r
|
|||
become `human_only`, and both are marked `legacy_inherited_restriction`. Resolved
|
||||
outcomes and resolver attribution are immutable.
|
||||
|
||||
An explicit named addressee and a company-configured cap may narrow the effective
|
||||
audience. A cap never widens the requested audience. Tool-action confirmations and
|
||||
An explicit named agent or user addressee and a company-configured cap may narrow
|
||||
the effective audience. Only the exact named addressee may resolve an addressed
|
||||
interaction; a human does not override a user addressee. A cap never widens the
|
||||
requested audience. Tool-action confirmations and
|
||||
other hard-governed action cards remain `human_only` (or move to the formal approval
|
||||
system) regardless of a requested open audience.
|
||||
|
||||
|
|
|
|||
|
|
@ -79,9 +79,9 @@ values for either.
|
|||
6. For OAuth, continue through browser consent. For API-key setup, create a
|
||||
personal API key using PostHog's **MCP Server** preset and paste it into
|
||||
Paperclip. Never put the key in connection configuration or a URL.
|
||||
7. Review discovered actions. Known writes ask first, destructive or nested
|
||||
execution tools remain quarantined, and unknown PostHog tools default to
|
||||
write risk until reviewed.
|
||||
7. Review discovered actions. Known writes and destructive actions default to
|
||||
**Ask first**, and unknown PostHog tools default to write risk so they inherit
|
||||
that approval gate unless the operator changes the selection.
|
||||
|
||||
Paperclip sends the project scope as the `x-posthog-project-id` managed header.
|
||||
It sends configured `readonly`, `features`, `tools`, and `mode` values as query
|
||||
|
|
|
|||
|
|
@ -48,7 +48,13 @@ context and server-side ownership checks.
|
|||
8. **External content is untrusted.** Provider responses, chat messages,
|
||||
documents, webhook payloads, and remote MCP outputs may contain prompt
|
||||
injection and must not widen grants or bypass approvals.
|
||||
|
||||
9. **Link-local egress is always denied.** Operator-configured remote MCP and
|
||||
OAuth URLs may reach intentional loopback, RFC 1918, or IPv6 ULA services in
|
||||
local/private deployments, but never IPv4 `169.254.0.0/16` or IPv6
|
||||
`fe80::/10`. Every hostname is resolved once and pinned; DNS answers, the
|
||||
connected socket peer, and every redirect are mediated before request bytes
|
||||
are written. Public deployments continue to deny the broader private and
|
||||
reserved address set.
|
||||
## Protected Assets
|
||||
|
||||
- OAuth tokens, refresh tokens, app-installation tokens, API keys, webhook
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ the URL bar, e.g. `PAP`). Replace it in the example paths.
|
|||
## 2. Open the Smoke Lab and start the services
|
||||
|
||||
1. In the left sidebar open **Apps**, then under the **Developer** section
|
||||
("Advanced setup for developers. Most teams never open this.") click
|
||||
("Advanced setup for developers.") click
|
||||
**Smoke Lab** (`/{PREFIX}/apps/advanced/smoke-lab`). The breadcrumb reads
|
||||
*Apps → Advanced setup → Smoke Lab*.
|
||||
2. **You should see:** a *Developer tools* page header, then the **Smoke Lab**
|
||||
|
|
|
|||
|
|
@ -65,6 +65,36 @@ Heartbeat resolves a workspace for the run (code location and session continuity
|
|||
4. Heartbeat passes the resolved code workspace to the agent run.
|
||||
5. Heartbeat calls `ensureRuntimeServicesForRun` to start the workspace's `running`-desired runtime services, running the lazy runtime provision command first if one is configured and has not yet run (see "Lazy runtime provisioning" below).
|
||||
|
||||
## Browser-reachable origins for OAuth QA
|
||||
|
||||
A managed service that runs Paperclip itself needs one canonical origin for Better Auth and tool OAuth callbacks. Paperclip resolves that origin in this order:
|
||||
|
||||
1. Explicit service/runtime configuration such as `PAPERCLIP_PUBLIC_URL` or `BETTER_AUTH_URL`.
|
||||
2. An explicit instance auth public base URL.
|
||||
3. The managed service's rendered `expose.urlTemplate`, injected as a low-priority runtime fallback.
|
||||
|
||||
The exposed URL must describe the route the operator's browser actually uses. Non-loopback callbacks require HTTPS. Loopback HTTP such as `http://127.0.0.1:45439` is supported for local browser QA. A non-loopback hostname rendered from workspace data must remain inside the stable domain suffix configured by `expose.urlTemplate`; branch names cannot replace that domain. Bind addresses, internal-only single-label names such as `paperclip-dev`, reserved/non-resolving names, and non-loopback HTTP origins fail service startup with configuration guidance instead of silently producing an unusable redirect URI.
|
||||
|
||||
Keep readiness and browser exposure separate when a proxy or tailnet route fronts the process:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "paperclip-dev",
|
||||
"command": "pnpm dev --bind lan",
|
||||
"port": { "type": "auto" },
|
||||
"readiness": {
|
||||
"type": "http",
|
||||
"urlTemplate": "http://127.0.0.1:{{port}}"
|
||||
},
|
||||
"expose": {
|
||||
"type": "url",
|
||||
"urlTemplate": "https://{{workspace.branchName}}.dev.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use a distinct reachable hostname (or other distinct origin) per isolated worktree. Do not point multiple worktree runtimes at the parent instance's origin. After startup, open the service URL in the same browser session used for QA and verify `GET /api/tools/oauth/client-metadata`; its `redirect_uris` entry should use that service origin and `/api/tools/oauth/callback`.
|
||||
|
||||
## Lazy runtime provisioning
|
||||
|
||||
Some workspaces need heavy one-time setup — seeding a database, warming caches — before their runtime services can start. That work can be deferred to the first runtime-service start instead of running eagerly during workspace preparation.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const sql = fs.readFileSync(path.join(import.meta.dirname, "migrations/0232_fixed_hannibal_king.sql"), "utf8");
|
||||
|
||||
describe("connection grants phase 2 migration", () => {
|
||||
it("renames workspace grants before restoring organization-only constraints", () => {
|
||||
const rename = sql.indexOf(`UPDATE "connection_grants" SET "kind" = 'organization' WHERE "kind" = 'workspace'`);
|
||||
const constraint = sql.indexOf(`"connection_grants"."kind" in ('organization', 'user')`);
|
||||
expect(rename).toBeGreaterThan(-1);
|
||||
expect(constraint).toBeGreaterThan(rename);
|
||||
expect(sql).not.toContain(`CHECK ("connection_grants"."kind" in ('workspace', 'user'))`);
|
||||
});
|
||||
|
||||
it("backfills a default organization grant only when a connection is missing one", () => {
|
||||
expect(sql).toContain(`c."id", 'organization', c."credential_secret_refs", 'active', true`);
|
||||
expect(sql).toContain(`WHERE NOT EXISTS`);
|
||||
expect(sql).toContain(`g."connection_id" = c."id" AND g."is_default" = true`);
|
||||
});
|
||||
|
||||
it("adds credential policy and the grant audience table", () => {
|
||||
expect(sql).toContain(`CREATE TABLE IF NOT EXISTS "connection_grant_members"`);
|
||||
expect(sql).toContain(`ADD COLUMN IF NOT EXISTS "credential_policy" text DEFAULT 'shared' NOT NULL`);
|
||||
expect(sql).toContain(`'shared', 'per_user', 'per_user_with_fallback'`);
|
||||
});
|
||||
|
||||
it("is safe to replay when schema state is ahead of the migration journal", () => {
|
||||
expect(sql).toContain(`DROP CONSTRAINT IF EXISTS "connection_grant_members_company_grant_fk"`);
|
||||
expect(sql).toContain(`DROP CONSTRAINT IF EXISTS "tool_connections_credential_policy_check"`);
|
||||
expect(sql).toContain(`CREATE UNIQUE INDEX IF NOT EXISTS "connection_grants_default_uq"`);
|
||||
expect(sql).toContain(`IF NOT EXISTS (\n\t\tSELECT 1\n\t\tFROM pg_constraint`);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import postgres from "postgres";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./test-embedded-postgres.js";
|
||||
|
||||
const MIGRATION_FILE = "0232_fixed_hannibal_king.sql";
|
||||
const migrationSql = fs.readFileSync(
|
||||
path.join(import.meta.dirname, "migrations", MIGRATION_FILE),
|
||||
"utf8",
|
||||
);
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function migrationHash() {
|
||||
return createHash("sha256").update(migrationSql).digest("hex");
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()?.();
|
||||
});
|
||||
|
||||
describe("connection grants phase 4 migration", () => {
|
||||
it("adds replay-safe named-agent standing delegations", () => {
|
||||
expect(migrationSql).toContain(`CREATE TABLE IF NOT EXISTS "connection_grant_delegations"`);
|
||||
expect(migrationSql).toContain(`"connection_grant_delegations_grant_agent_uq"`);
|
||||
expect(migrationSql).toContain(`FOREIGN KEY ("company_id","grant_id")`);
|
||||
expect(migrationSql).toContain(`DROP CONSTRAINT IF EXISTS "connection_grant_delegations_company_grant_fk"`);
|
||||
expect(migrationSql).toContain(`CREATE INDEX IF NOT EXISTS "connection_grant_delegations_company_agent_idx"`);
|
||||
expect(migrationSql).toContain(`ON CONFLICT DO NOTHING`);
|
||||
});
|
||||
|
||||
it("remediates ambiguous personal-secret ownership without assigning an arbitrary owner", () => {
|
||||
expect(migrationSql).toContain(`phase4_ambiguous_personal_secrets`);
|
||||
expect(migrationSql).toContain(`count(DISTINCT g."subject_user_id") AS "owner_count"`);
|
||||
expect(migrationSql).toContain(`organization_grant."kind" <> 'user'`);
|
||||
expect(migrationSql).toContain(`FROM "company_secret_bindings" binding`);
|
||||
expect(migrationSql).toContain(`FROM "routine_triggers" routine_trigger`);
|
||||
expect(migrationSql).toContain(`grant_row."kind" = 'user'`);
|
||||
expect(migrationSql).not.toContain(`UPDATE "tool_connections" connection_row`);
|
||||
expect(migrationSql).toContain(`"status" = 'needs_reauthorization'`);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("connection grants phase 4 executable migration", () => {
|
||||
it("converts one owner and rejects two-owner or mixed legacy references", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-connection-phase4-migration-");
|
||||
cleanups.push(database.cleanup);
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
|
||||
async function rewindMigration() {
|
||||
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${migrationHash()}`;
|
||||
expect(await inspectMigrations(database.connectionString)).toMatchObject({
|
||||
status: "needsMigrations",
|
||||
pendingMigrations: [MIGRATION_FILE],
|
||||
});
|
||||
}
|
||||
|
||||
async function seedLegacyCredential(input: {
|
||||
ownerUserIds: string[];
|
||||
includeOrganizationGrant?: boolean;
|
||||
includeConnectionReference?: boolean;
|
||||
includeCompanyBinding?: boolean;
|
||||
includeRoutineTrigger?: boolean;
|
||||
credentialPolicy?: "shared" | "per_user" | "per_user_with_fallback";
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const applicationId = randomUUID();
|
||||
const connectionId = randomUUID();
|
||||
const secretId = randomUUID();
|
||||
await sql`
|
||||
INSERT INTO "companies" ("id", "name", "issue_prefix")
|
||||
VALUES (${companyId}, ${`Phase 4 ${companyId}`}, ${`P${companyId.slice(0, 5)}`})
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO "tool_applications" ("id", "company_id", "name", "type")
|
||||
VALUES (${applicationId}, ${companyId}, ${`App ${applicationId}`}, 'mcp_http')
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO "tool_connections" (
|
||||
"id", "company_id", "application_id", "name", "uid", "transport",
|
||||
"status", "enabled", "health_status", "credential_policy", "credential_secret_refs"
|
||||
) VALUES (
|
||||
${connectionId}, ${companyId}, ${applicationId}, ${`Connection ${connectionId}`},
|
||||
${`connection-${connectionId}`}, 'mcp_remote', 'active', true, 'healthy',
|
||||
${input.credentialPolicy ?? "shared"},
|
||||
${sql.json(input.includeConnectionReference ? [{ secretId, version: "latest" }] : [])}
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO "company_secrets" ("id", "company_id", "key", "name", "scope")
|
||||
VALUES (${secretId}, ${companyId}, ${`secret-${secretId}`}, ${`Secret ${secretId}`}, 'company')
|
||||
`;
|
||||
for (const ownerUserId of input.ownerUserIds) {
|
||||
await sql`
|
||||
INSERT INTO "connection_grants" (
|
||||
"company_id", "connection_id", "kind", "subject_user_id", "credential_secret_refs"
|
||||
) VALUES (
|
||||
${companyId}, ${connectionId}, 'user', ${ownerUserId},
|
||||
${sql.json([{ secretId, version: "latest" }])}
|
||||
)
|
||||
`;
|
||||
}
|
||||
if (input.includeOrganizationGrant) {
|
||||
await sql`
|
||||
INSERT INTO "connection_grants" (
|
||||
"company_id", "connection_id", "kind", "credential_secret_refs"
|
||||
) VALUES (
|
||||
${companyId}, ${connectionId}, 'organization',
|
||||
${sql.json([{ secretId, version: "latest" }])}
|
||||
)
|
||||
`;
|
||||
}
|
||||
if (input.includeCompanyBinding) {
|
||||
await sql`
|
||||
INSERT INTO "company_secret_bindings" (
|
||||
"company_id", "secret_id", "target_type", "target_id", "config_path"
|
||||
) VALUES (
|
||||
${companyId}, ${secretId}, 'environment', ${randomUUID()}, 'env.SHARED_TOKEN'
|
||||
)
|
||||
`;
|
||||
}
|
||||
if (input.includeRoutineTrigger) {
|
||||
const routineId = randomUUID();
|
||||
await sql`
|
||||
INSERT INTO "routines" ("id", "company_id", "title")
|
||||
VALUES (${routineId}, ${companyId}, ${`Routine ${routineId}`})
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO "routine_triggers" ("company_id", "routine_id", "kind", "secret_id")
|
||||
VALUES (${companyId}, ${routineId}, 'webhook', ${secretId})
|
||||
`;
|
||||
}
|
||||
return { companyId, connectionId, secretId, ownerUserId: input.ownerUserIds[0]! };
|
||||
}
|
||||
|
||||
try {
|
||||
const valid = await seedLegacyCredential({ ownerUserIds: ["alice"] });
|
||||
await rewindMigration();
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
const converted = await sql<{
|
||||
scope: string;
|
||||
owner_user_id: string | null;
|
||||
user_secret_definition_id: string | null;
|
||||
}[]>`
|
||||
SELECT "scope", "owner_user_id", "user_secret_definition_id"
|
||||
FROM "company_secrets"
|
||||
WHERE "id" = ${valid.secretId}
|
||||
`;
|
||||
expect(converted).toEqual([{
|
||||
scope: "user",
|
||||
owner_user_id: valid.ownerUserId,
|
||||
user_secret_definition_id: expect.any(String),
|
||||
}]);
|
||||
|
||||
const bound = await seedLegacyCredential({
|
||||
ownerUserIds: ["binding-owner"],
|
||||
includeConnectionReference: true,
|
||||
includeCompanyBinding: true,
|
||||
credentialPolicy: "per_user",
|
||||
});
|
||||
await rewindMigration();
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
expect(await sql<{ scope: string; owner_user_id: string | null }[]>`
|
||||
SELECT "scope", "owner_user_id"
|
||||
FROM "company_secrets"
|
||||
WHERE "id" = ${bound.secretId}
|
||||
`).toEqual([{ scope: "company", owner_user_id: null }]);
|
||||
expect(await sql<{ status: string; enabled: boolean; credential_secret_refs: unknown[] }[]>`
|
||||
SELECT "status", "enabled", "credential_secret_refs"
|
||||
FROM "tool_connections"
|
||||
WHERE "id" = ${bound.connectionId}
|
||||
`).toEqual([{
|
||||
status: "active",
|
||||
enabled: true,
|
||||
credential_secret_refs: [{ secretId: bound.secretId, version: "latest" }],
|
||||
}]);
|
||||
|
||||
const routine = await seedLegacyCredential({
|
||||
ownerUserIds: ["routine-owner"],
|
||||
includeConnectionReference: true,
|
||||
includeRoutineTrigger: true,
|
||||
credentialPolicy: "per_user",
|
||||
});
|
||||
await rewindMigration();
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
expect(await sql<{ scope: string; owner_user_id: string | null }[]>`
|
||||
SELECT "scope", "owner_user_id"
|
||||
FROM "company_secrets"
|
||||
WHERE "id" = ${routine.secretId}
|
||||
`).toEqual([{ scope: "company", owner_user_id: null }]);
|
||||
expect(await sql<{ status: string; enabled: boolean; credential_secret_refs: unknown[] }[]>`
|
||||
SELECT "status", "enabled", "credential_secret_refs"
|
||||
FROM "tool_connections"
|
||||
WHERE "id" = ${routine.connectionId}
|
||||
`).toEqual([{
|
||||
status: "active",
|
||||
enabled: true,
|
||||
credential_secret_refs: [{ secretId: routine.secretId, version: "latest" }],
|
||||
}]);
|
||||
expect(await sql<{ status: string; credential_secret_refs: unknown[] }[]>`
|
||||
SELECT "status", "credential_secret_refs"
|
||||
FROM "connection_grants"
|
||||
WHERE "company_id" = ${routine.companyId} AND "kind" = 'user'
|
||||
`).toEqual([{ status: "needs_reauthorization", credential_secret_refs: [] }]);
|
||||
expect(await sql<{ secret_id: string }[]>`
|
||||
SELECT "secret_id"
|
||||
FROM "routine_triggers"
|
||||
WHERE "company_id" = ${routine.companyId}
|
||||
`).toEqual([{ secret_id: routine.secretId }]);
|
||||
expect(await sql<{ status: string; enabled: boolean; credential_secret_refs: unknown[] }[]>`
|
||||
SELECT "status", "enabled", "credential_secret_refs"
|
||||
FROM "tool_connections"
|
||||
WHERE "id" = ${routine.connectionId}
|
||||
`).toEqual([{
|
||||
status: "active",
|
||||
enabled: true,
|
||||
credential_secret_refs: [{ secretId: routine.secretId, version: "latest" }],
|
||||
}]);
|
||||
|
||||
// Replaying also preserves the direct routine-trigger reference.
|
||||
await rewindMigration();
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
expect(await sql<{ scope: string; owner_user_id: string | null }[]>`
|
||||
SELECT "scope", "owner_user_id"
|
||||
FROM "company_secrets"
|
||||
WHERE "id" = ${routine.secretId}
|
||||
`).toEqual([{ scope: "company", owner_user_id: null }]);
|
||||
expect(await sql<{ status: string; credential_secret_refs: unknown[] }[]>`
|
||||
SELECT "status", "credential_secret_refs"
|
||||
FROM "connection_grants"
|
||||
WHERE "company_id" = ${bound.companyId} AND "kind" = 'user'
|
||||
`).toEqual([{ status: "needs_reauthorization", credential_secret_refs: [] }]);
|
||||
expect(await sql<{ secret_id: string }[]>`
|
||||
SELECT "secret_id"
|
||||
FROM "company_secret_bindings"
|
||||
WHERE "company_id" = ${bound.companyId}
|
||||
`).toEqual([{ secret_id: bound.secretId }]);
|
||||
|
||||
// Replaying the migration preserves the same company-scoped binding.
|
||||
await rewindMigration();
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
expect(await sql<{ scope: string; owner_user_id: string | null }[]>`
|
||||
SELECT "scope", "owner_user_id"
|
||||
FROM "company_secrets"
|
||||
WHERE "id" = ${bound.secretId}
|
||||
`).toEqual([{ scope: "company", owner_user_id: null }]);
|
||||
expect(await sql<{ status: string; enabled: boolean; credential_secret_refs: unknown[] }[]>`
|
||||
SELECT "status", "enabled", "credential_secret_refs"
|
||||
FROM "tool_connections"
|
||||
WHERE "id" = ${bound.connectionId}
|
||||
`).toEqual([{
|
||||
status: "active",
|
||||
enabled: true,
|
||||
credential_secret_refs: [{ secretId: bound.secretId, version: "latest" }],
|
||||
}]);
|
||||
|
||||
const ambiguous = await seedLegacyCredential({ ownerUserIds: ["alice", "bob"] });
|
||||
await rewindMigration();
|
||||
await expect(applyPendingMigrations(database.connectionString)).resolves.toBeUndefined();
|
||||
expect(await sql<{
|
||||
status: string;
|
||||
is_default: boolean;
|
||||
credential_secret_refs: unknown[];
|
||||
}[]>`
|
||||
SELECT "status", "is_default", "credential_secret_refs"
|
||||
FROM "connection_grants"
|
||||
WHERE "company_id" = ${ambiguous.companyId}
|
||||
ORDER BY "is_default" DESC, "id"
|
||||
`).toEqual([
|
||||
{ status: "active", is_default: true, credential_secret_refs: [] },
|
||||
{ status: "needs_reauthorization", is_default: false, credential_secret_refs: [] },
|
||||
{ status: "needs_reauthorization", is_default: false, credential_secret_refs: [] },
|
||||
]);
|
||||
expect(await sql<{ scope: string; owner_user_id: string | null }[]>`
|
||||
SELECT "scope", "owner_user_id"
|
||||
FROM "company_secrets"
|
||||
WHERE "id" = ${ambiguous.secretId}
|
||||
`).toEqual([{ scope: "company", owner_user_id: null }]);
|
||||
|
||||
const mixed = await seedLegacyCredential({
|
||||
ownerUserIds: ["carol"],
|
||||
includeOrganizationGrant: true,
|
||||
includeConnectionReference: true,
|
||||
});
|
||||
await rewindMigration();
|
||||
await expect(applyPendingMigrations(database.connectionString)).resolves.toBeUndefined();
|
||||
expect(await sql<{
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
health_status: string;
|
||||
credential_secret_refs: unknown[];
|
||||
}[]>`
|
||||
SELECT "status", "enabled", "health_status", "credential_secret_refs"
|
||||
FROM "tool_connections"
|
||||
WHERE "id" = ${mixed.connectionId}
|
||||
`).toEqual([{
|
||||
status: "active",
|
||||
enabled: true,
|
||||
health_status: "healthy",
|
||||
credential_secret_refs: [{ secretId: mixed.secretId, version: "latest" }],
|
||||
}]);
|
||||
expect(await sql<{
|
||||
kind: string;
|
||||
status: string;
|
||||
is_default: boolean;
|
||||
credential_secret_refs: unknown[];
|
||||
}[]>`
|
||||
SELECT "kind", "status", "is_default", "credential_secret_refs"
|
||||
FROM "connection_grants"
|
||||
WHERE "company_id" = ${mixed.companyId}
|
||||
ORDER BY "kind", "is_default" DESC, "id"
|
||||
`).toEqual([
|
||||
{
|
||||
kind: "organization",
|
||||
status: "active",
|
||||
is_default: true,
|
||||
credential_secret_refs: [{ secretId: mixed.secretId, version: "latest" }],
|
||||
},
|
||||
{
|
||||
kind: "organization",
|
||||
status: "active",
|
||||
is_default: false,
|
||||
credential_secret_refs: [{ secretId: mixed.secretId, version: "latest" }],
|
||||
},
|
||||
{
|
||||
kind: "user",
|
||||
status: "needs_reauthorization",
|
||||
is_default: false,
|
||||
credential_secret_refs: [],
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}, 45_000);
|
||||
});
|
||||
|
|
@ -25,6 +25,8 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => {
|
|||
cleanups.push(async () => sql.end());
|
||||
|
||||
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
|
||||
await sql`DROP TABLE IF EXISTS "connection_grant_delegations"`;
|
||||
await sql`DROP TABLE IF EXISTS "connection_grant_members"`;
|
||||
await sql`DROP TABLE IF EXISTS "connection_grants"`;
|
||||
await sql`DROP INDEX IF EXISTS "tool_connections_company_uid_uq"`;
|
||||
await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_company_id_uq"`;
|
||||
|
|
@ -71,6 +73,7 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => {
|
|||
VALUES (${companyId}, ${connectionId}, 'user', 'user-1', true)
|
||||
`).rejects.toMatchObject({ code: "23514" });
|
||||
|
||||
await sql`DROP TABLE IF EXISTS "connection_grant_members"`;
|
||||
await sql`DROP TABLE "connection_grants"`;
|
||||
await sql`DROP INDEX "tool_connections_company_uid_uq"`;
|
||||
await sql`ALTER TABLE "tool_connections" DROP CONSTRAINT "tool_connections_company_id_uq"`;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
CREATE TABLE IF NOT EXISTS "connection_grant_members" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"grant_id" uuid NOT NULL,
|
||||
"subject_type" text NOT NULL,
|
||||
"subject_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "connection_grant_members_subject_type_check" CHECK ("connection_grant_members"."subject_type" in ('user'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "connection_grant_members" DROP CONSTRAINT IF EXISTS "connection_grant_members_company_grant_fk";--> statement-breakpoint
|
||||
ALTER TABLE "connection_grant_members" DROP CONSTRAINT IF EXISTS "connection_grant_members_grant_id_connection_grants_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "connection_grant_members" DROP CONSTRAINT IF EXISTS "connection_grant_members_company_id_companies_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "connection_grants" DROP CONSTRAINT IF EXISTS "connection_grants_kind_check";--> statement-breakpoint
|
||||
ALTER TABLE "connection_grants" DROP CONSTRAINT IF EXISTS "connection_grants_subject_check";--> statement-breakpoint
|
||||
ALTER TABLE "connection_grants" DROP CONSTRAINT IF EXISTS "connection_grants_default_check";--> statement-breakpoint
|
||||
ALTER TABLE "tool_connections" DROP CONSTRAINT IF EXISTS "tool_connections_credential_policy_check";--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "connection_grants_default_uq";--> statement-breakpoint
|
||||
UPDATE "connection_grants" SET "kind" = 'organization' WHERE "kind" = 'workspace';--> statement-breakpoint
|
||||
INSERT INTO "connection_grants" (
|
||||
"company_id", "connection_id", "kind", "credential_secret_refs", "status", "is_default",
|
||||
"created_by_agent_id", "created_by_user_id", "created_at", "updated_at"
|
||||
)
|
||||
SELECT
|
||||
c."company_id", c."id", 'organization', c."credential_secret_refs", 'active', true,
|
||||
c."created_by_agent_id", c."created_by_user_id", c."created_at", c."updated_at"
|
||||
FROM "tool_connections" c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "connection_grants" g
|
||||
WHERE g."connection_id" = c."id" AND g."is_default" = true
|
||||
);--> statement-breakpoint
|
||||
ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "credential_policy" text DEFAULT 'shared' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "connection_grant_members" ADD CONSTRAINT "connection_grant_members_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'connection_grants_company_id_uq'
|
||||
AND conrelid = 'connection_grants'::regclass
|
||||
) THEN
|
||||
ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_company_id_uq" UNIQUE("company_id","id");
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
ALTER TABLE "connection_grant_members" ADD CONSTRAINT "connection_grant_members_company_grant_fk" FOREIGN KEY ("company_id","grant_id") REFERENCES "public"."connection_grants"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "connection_grant_members_company_subject_idx" ON "connection_grant_members" USING btree ("company_id","subject_type","subject_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "connection_grant_members_grant_subject_uq" ON "connection_grant_members" USING btree ("grant_id","subject_type","subject_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "connection_grants_default_uq" ON "connection_grants" USING btree ("connection_id") WHERE "connection_grants"."is_default" = true and "connection_grants"."kind" = 'organization';--> statement-breakpoint
|
||||
ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_kind_check" CHECK ("connection_grants"."kind" in ('organization', 'user'));--> statement-breakpoint
|
||||
ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_subject_check" CHECK (("connection_grants"."kind" = 'user' and "connection_grants"."subject_user_id" is not null) or ("connection_grants"."kind" = 'organization' and "connection_grants"."subject_user_id" is null));--> statement-breakpoint
|
||||
ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_default_check" CHECK ("connection_grants"."is_default" = false or "connection_grants"."kind" = 'organization');--> statement-breakpoint
|
||||
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_credential_policy_check" CHECK ("tool_connections"."credential_policy" in ('shared', 'per_user', 'per_user_with_fallback'));
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "addressee_user_id" text;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "issue_thread_interactions_addressee_user_idx" ON "issue_thread_interactions" USING btree ("addressee_user_id");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "connection_grant_delegations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"grant_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"created_by_user_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "connection_grant_delegations" DROP CONSTRAINT IF EXISTS "connection_grant_delegations_company_id_companies_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "connection_grant_delegations" ADD CONSTRAINT "connection_grant_delegations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "connection_grant_delegations" DROP CONSTRAINT IF EXISTS "connection_grant_delegations_agent_id_agents_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "connection_grant_delegations" ADD CONSTRAINT "connection_grant_delegations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "connection_grant_delegations" DROP CONSTRAINT IF EXISTS "connection_grant_delegations_company_grant_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "connection_grant_delegations" ADD CONSTRAINT "connection_grant_delegations_company_grant_fk" FOREIGN KEY ("company_id","grant_id") REFERENCES "public"."connection_grants"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "connection_grant_delegations_company_agent_idx" ON "connection_grant_delegations" USING btree ("company_id","agent_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "connection_grant_delegations_grant_agent_uq" ON "connection_grant_delegations" USING btree ("grant_id","agent_id");
|
||||
--> statement-breakpoint
|
||||
-- A legacy company-scoped credential can only become user-scoped when exactly
|
||||
-- one personal owner references it and no organization grant, connection, or
|
||||
-- company binding also references it. Ambiguous rows fail closed in-place:
|
||||
-- remove only personal-grant references and require those users to
|
||||
-- reauthorize. Organization grants, shared/fallback connections, company
|
||||
-- bindings, and routine triggers keep the original company-scoped secret so
|
||||
-- their existing consumers continue to resolve it. Connection rows also keep
|
||||
-- direct references: policy-aware resolution ignores them for strict per-user
|
||||
-- access, while stripping them would break an independent shared consumer.
|
||||
DROP TABLE IF EXISTS "phase4_ambiguous_personal_secrets";
|
||||
--> statement-breakpoint
|
||||
CREATE TEMP TABLE "phase4_ambiguous_personal_secrets" ON COMMIT DROP AS
|
||||
WITH personal_secret_owners AS (
|
||||
SELECT
|
||||
s."id" AS "secret_id",
|
||||
s."company_id",
|
||||
count(DISTINCT g."subject_user_id") AS "owner_count"
|
||||
FROM "company_secrets" s
|
||||
JOIN "connection_grants" g
|
||||
ON g."company_id" = s."company_id"
|
||||
AND g."kind" = 'user'
|
||||
CROSS JOIN LATERAL jsonb_array_elements(g."credential_secret_refs") personal_ref
|
||||
WHERE s."scope" = 'company'
|
||||
AND s."id"::text = personal_ref ->> 'secretId'
|
||||
GROUP BY s."id", s."company_id"
|
||||
)
|
||||
SELECT owners."secret_id", owners."company_id"
|
||||
FROM personal_secret_owners owners
|
||||
WHERE owners."owner_count" <> 1
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM "connection_grants" organization_grant
|
||||
CROSS JOIN LATERAL jsonb_array_elements(organization_grant."credential_secret_refs") organization_ref
|
||||
WHERE organization_grant."company_id" = owners."company_id"
|
||||
AND organization_grant."kind" <> 'user'
|
||||
AND organization_ref ->> 'secretId' = owners."secret_id"::text
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM "tool_connections" connection
|
||||
CROSS JOIN LATERAL jsonb_array_elements(connection."credential_secret_refs") connection_ref
|
||||
WHERE connection."company_id" = owners."company_id"
|
||||
AND connection_ref ->> 'secretId' = owners."secret_id"::text
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM "company_secret_bindings" binding
|
||||
WHERE binding."company_id" = owners."company_id"
|
||||
AND binding."secret_id" = owners."secret_id"
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM "routine_triggers" routine_trigger
|
||||
WHERE routine_trigger."company_id" = owners."company_id"
|
||||
AND routine_trigger."secret_id" = owners."secret_id"
|
||||
);
|
||||
--> statement-breakpoint
|
||||
WITH ambiguous_secrets AS (
|
||||
SELECT "secret_id", "company_id" FROM "phase4_ambiguous_personal_secrets"
|
||||
)
|
||||
UPDATE "connection_grants" grant_row
|
||||
SET
|
||||
"credential_secret_refs" = COALESCE((
|
||||
SELECT jsonb_agg(ref)
|
||||
FROM jsonb_array_elements(grant_row."credential_secret_refs") ref
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ambiguous_secrets ambiguous
|
||||
WHERE ambiguous."company_id" = grant_row."company_id"
|
||||
AND ref ->> 'secretId' = ambiguous."secret_id"::text
|
||||
)
|
||||
), '[]'::jsonb),
|
||||
"status" = 'needs_reauthorization',
|
||||
"is_default" = false,
|
||||
"updated_at" = now()
|
||||
WHERE grant_row."kind" = 'user'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(grant_row."credential_secret_refs") ref
|
||||
JOIN ambiguous_secrets ambiguous
|
||||
ON ambiguous."company_id" = grant_row."company_id"
|
||||
AND ref ->> 'secretId' = ambiguous."secret_id"::text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS "phase4_ambiguous_personal_secrets";
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "user_secret_definitions" (
|
||||
"company_id", "key", "name", "description", "provider", "managed_mode",
|
||||
"provider_config_id", "provider_metadata", "created_by_agent_id", "created_by_user_id"
|
||||
)
|
||||
SELECT DISTINCT
|
||||
s."company_id",
|
||||
'tool_oauth.' || s."id"::text,
|
||||
s."name",
|
||||
'Personal connection credential migrated to user scope.',
|
||||
s."provider",
|
||||
s."managed_mode",
|
||||
s."provider_config_id",
|
||||
s."provider_metadata",
|
||||
s."created_by_agent_id",
|
||||
s."created_by_user_id"
|
||||
FROM "company_secrets" s
|
||||
JOIN "connection_grants" g ON g."company_id" = s."company_id" AND g."kind" = 'user'
|
||||
CROSS JOIN LATERAL jsonb_array_elements(g."credential_secret_refs") ref
|
||||
WHERE s."id"::text = ref ->> 'secretId'
|
||||
AND s."scope" = 'company'
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
UPDATE "company_secrets" s
|
||||
SET
|
||||
"scope" = 'user',
|
||||
"owner_user_id" = owner_map."owner_user_id",
|
||||
"user_secret_definition_id" = d."id",
|
||||
"updated_at" = now()
|
||||
FROM (
|
||||
SELECT s2."id" AS "secret_id", min(g."subject_user_id") AS "owner_user_id"
|
||||
FROM "company_secrets" s2
|
||||
JOIN "connection_grants" g ON g."company_id" = s2."company_id" AND g."kind" = 'user'
|
||||
CROSS JOIN LATERAL jsonb_array_elements(g."credential_secret_refs") ref
|
||||
WHERE s2."id"::text = ref ->> 'secretId' AND s2."scope" = 'company'
|
||||
GROUP BY s2."id"
|
||||
HAVING count(DISTINCT g."subject_user_id") = 1
|
||||
) owner_map
|
||||
JOIN "user_secret_definitions" d ON d."key" = 'tool_oauth.' || owner_map."secret_id"::text AND d."deleted_at" IS NULL
|
||||
WHERE s."id" = owner_map."secret_id" AND d."company_id" = s."company_id";
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1611,6 +1611,13 @@
|
|||
"when": 1787922567272,
|
||||
"tag": "0231_remove_app_connection_wide_includes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 232,
|
||||
"version": "7",
|
||||
"when": 1787922607497,
|
||||
"tag": "0232_fixed_hannibal_king",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -137,6 +137,8 @@ export {
|
|||
toolApplications,
|
||||
toolConnections,
|
||||
connectionGrants,
|
||||
connectionGrantMembers,
|
||||
connectionGrantDelegations,
|
||||
toolConnectionInstalls,
|
||||
toolOauthStates,
|
||||
toolCatalogEntries,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const issueThreadInteractions = pgTable(
|
|||
summary: text("summary"),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id),
|
||||
addresseeAgentId: uuid("addressee_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
addresseeUserId: text("addressee_user_id"),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id),
|
||||
resolvedByRunId: uuid("resolved_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
|
|
@ -72,5 +73,6 @@ export const issueThreadInteractions = pgTable(
|
|||
.where(sql`${table.idempotencyKey} IS NOT NULL`),
|
||||
sourceCommentIdx: index("issue_thread_interactions_source_comment_idx").on(table.sourceCommentId),
|
||||
addresseeAgentIdx: index("issue_thread_interactions_addressee_agent_idx").on(table.addresseeAgentId),
|
||||
addresseeUserIdx: index("issue_thread_interactions_addressee_user_idx").on(table.addresseeUserId),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,11 +29,13 @@ import type {
|
|||
ToolConnectionHealthStatus,
|
||||
ToolConnectionAuthKind,
|
||||
ToolConnectionKind,
|
||||
ToolConnectionCredentialPolicy,
|
||||
ToolConnectionOwnership,
|
||||
ToolConnectionInstallTargetType,
|
||||
ToolConnectionStatus,
|
||||
ToolConnectionTransport,
|
||||
ConnectionGrantKind,
|
||||
ConnectionGrantMemberSubjectType,
|
||||
ConnectionGrantStatus,
|
||||
ToolCredentialSecretRef,
|
||||
ToolInvocationApprovalState,
|
||||
|
|
@ -117,6 +119,7 @@ export const toolConnections = pgTable(
|
|||
ownership: text("ownership").$type<ToolConnectionOwnership>().notNull().default("customer"),
|
||||
transport: text("transport").$type<ToolConnectionTransport>().notNull(),
|
||||
authKind: text("auth_kind").$type<ToolConnectionAuthKind>().notNull().default("none"),
|
||||
credentialPolicy: text("credential_policy").$type<ToolConnectionCredentialPolicy>().notNull().default("shared"),
|
||||
status: text("status").$type<ToolConnectionStatus>().notNull().default("draft"),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
|
||||
|
|
@ -138,6 +141,7 @@ export const toolConnections = pgTable(
|
|||
check("tool_connections_ownership_check", sql`${table.ownership} in ('platform_shared', 'platform_provisioned', 'customer', 'dcr')`),
|
||||
check("tool_connections_transport_check", sql`${table.transport} in ('mcp_remote', 'rest_api', 'local_stdio')`),
|
||||
check("tool_connections_auth_kind_check", sql`${table.authKind} in ('oauth', 'api_key', 'none')`),
|
||||
check("tool_connections_credential_policy_check", sql`${table.credentialPolicy} in ('shared', 'per_user', 'per_user_with_fallback')`),
|
||||
index("tool_connections_company_idx").on(table.companyId),
|
||||
index("tool_connections_application_idx").on(table.applicationId),
|
||||
index("tool_connections_company_enabled_idx").on(table.companyId, table.enabled),
|
||||
|
|
@ -168,10 +172,10 @@ export const connectionGrants = pgTable(
|
|||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
check("connection_grants_kind_check", sql`${table.kind} in ('workspace', 'user')`),
|
||||
check("connection_grants_kind_check", sql`${table.kind} in ('organization', 'user')`),
|
||||
check("connection_grants_status_check", sql`${table.status} in ('active', 'revoked', 'expired', 'needs_reauthorization')`),
|
||||
check("connection_grants_subject_check", sql`(${table.kind} = 'user' and ${table.subjectUserId} is not null) or (${table.kind} = 'workspace' and ${table.subjectUserId} is null)`),
|
||||
check("connection_grants_default_check", sql`${table.isDefault} = false or ${table.kind} = 'workspace'`),
|
||||
check("connection_grants_subject_check", sql`(${table.kind} = 'user' and ${table.subjectUserId} is not null) or (${table.kind} = 'organization' and ${table.subjectUserId} is null)`),
|
||||
check("connection_grants_default_check", sql`${table.isDefault} = false or ${table.kind} = 'organization'`),
|
||||
foreignKey({
|
||||
columns: [table.companyId, table.connectionId],
|
||||
foreignColumns: [toolConnections.companyId, toolConnections.id],
|
||||
|
|
@ -179,8 +183,52 @@ export const connectionGrants = pgTable(
|
|||
}).onDelete("cascade"),
|
||||
index("connection_grants_company_connection_idx").on(table.companyId, table.connectionId),
|
||||
index("connection_grants_subject_user_idx").on(table.companyId, table.subjectUserId),
|
||||
unique("connection_grants_company_id_uq").on(table.companyId, table.id),
|
||||
uniqueIndex("connection_grants_user_uq").on(table.connectionId, table.subjectUserId),
|
||||
uniqueIndex("connection_grants_default_uq").on(table.connectionId).where(sql`${table.isDefault} = true and ${table.kind} = 'workspace'`),
|
||||
uniqueIndex("connection_grants_default_uq").on(table.connectionId).where(sql`${table.isDefault} = true and ${table.kind} = 'organization'`),
|
||||
],
|
||||
);
|
||||
|
||||
export const connectionGrantMembers = pgTable(
|
||||
"connection_grant_members",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
grantId: uuid("grant_id").notNull(),
|
||||
subjectType: text("subject_type").$type<ConnectionGrantMemberSubjectType>().notNull(),
|
||||
subjectId: text("subject_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
check("connection_grant_members_subject_type_check", sql`${table.subjectType} in ('user')`),
|
||||
foreignKey({
|
||||
columns: [table.companyId, table.grantId],
|
||||
foreignColumns: [connectionGrants.companyId, connectionGrants.id],
|
||||
name: "connection_grant_members_company_grant_fk",
|
||||
}).onDelete("cascade"),
|
||||
index("connection_grant_members_company_subject_idx").on(table.companyId, table.subjectType, table.subjectId),
|
||||
uniqueIndex("connection_grant_members_grant_subject_uq").on(table.grantId, table.subjectType, table.subjectId),
|
||||
],
|
||||
);
|
||||
|
||||
export const connectionGrantDelegations = pgTable(
|
||||
"connection_grant_delegations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
grantId: uuid("grant_id").notNull(),
|
||||
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
|
||||
createdByUserId: text("created_by_user_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.companyId, table.grantId],
|
||||
foreignColumns: [connectionGrants.companyId, connectionGrants.id],
|
||||
name: "connection_grant_delegations_company_grant_fk",
|
||||
}).onDelete("cascade"),
|
||||
index("connection_grant_delegations_company_agent_idx").on(table.companyId, table.agentId),
|
||||
uniqueIndex("connection_grant_delegations_grant_agent_uq").on(table.grantId, table.agentId),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1106,6 +1106,7 @@ export type {
|
|||
RequestConfirmationResult,
|
||||
RequestConfirmationToolActionPayload,
|
||||
RequestConfirmationToolActionResult,
|
||||
RequestConfirmationConnectionAuthorizationPayload,
|
||||
RequestConfirmationSecretProposalPayload,
|
||||
RequestConfirmationSecretProposalResult,
|
||||
RequestCheckboxConfirmationOption,
|
||||
|
|
@ -1344,6 +1345,7 @@ export type {
|
|||
ToolCatalogEntryKind,
|
||||
ToolConnectionHealthStatus,
|
||||
ToolConnectionAuthKind,
|
||||
ToolConnectionCredentialPolicy,
|
||||
ToolConnectionOwnership,
|
||||
ToolConnectionTransport,
|
||||
ToolConnectionStatus,
|
||||
|
|
@ -1370,9 +1372,17 @@ export type {
|
|||
ToolConnectionInstallTargetType,
|
||||
ToolConnectionRemovalResult,
|
||||
ToolConnectionRemovalSummary,
|
||||
ConnectionAudienceMember,
|
||||
ConnectionGrant,
|
||||
ConnectionGrantCapabilities,
|
||||
ConnectionGrantDelegation,
|
||||
ConnectionGrantKind,
|
||||
ConnectionGrantMember,
|
||||
ConnectionGrantMemberSubjectType,
|
||||
ConnectionGrantsResponse,
|
||||
ConnectionGrantStatus,
|
||||
ToolConnectionCapabilities,
|
||||
ToolConnectionCreateCapabilities,
|
||||
ConnectionTokenScope,
|
||||
ConnectionTokenRequest,
|
||||
ConnectionTokenAttribution,
|
||||
|
|
@ -2070,6 +2080,8 @@ export {
|
|||
connectionTokenRequestSchema,
|
||||
connectionTokenSubjectSchema,
|
||||
startConnectionAuthorizationSchema,
|
||||
createConnectionGrantDelegationSchema,
|
||||
replaceConnectionGrantMembersSchema,
|
||||
toolConnectionTestCallSchema,
|
||||
toolPolicyTestRequestSchema,
|
||||
importMcpJsonSchema,
|
||||
|
|
@ -2156,6 +2168,8 @@ export {
|
|||
type PutToolConnectionInstalls,
|
||||
type UpdateToolMcpGateway,
|
||||
type ConnectionTokenRequestInput,
|
||||
type CreateConnectionGrantDelegation,
|
||||
type ReplaceConnectionGrantMembersInput,
|
||||
type ImportMcpJson,
|
||||
type ToolPolicyTestRequestInput,
|
||||
type CreateToolInvocation,
|
||||
|
|
|
|||
|
|
@ -207,7 +207,9 @@ export interface AttentionResolverAudience {
|
|||
resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance;
|
||||
/** Agent the card is addressed to, when it names one. */
|
||||
addresseeAgentId: string | null;
|
||||
/** Display name of {@link addresseeAgentId}, resolved server-side. */
|
||||
/** User the card is addressed to, when it names one. */
|
||||
addresseeUserId?: string | null;
|
||||
/** Display name of the agent addressee, resolved server-side. */
|
||||
addresseeName: string | null;
|
||||
/** Agent that created the card, excluded when the policy is `not_creator`. */
|
||||
createdByAgentId: string | null;
|
||||
|
|
|
|||
|
|
@ -498,13 +498,22 @@ export type {
|
|||
ToolConnection,
|
||||
ToolConnectionHealthStatus,
|
||||
ToolConnectionAuthKind,
|
||||
ToolConnectionCredentialPolicy,
|
||||
ToolConnectionOwnership,
|
||||
ToolConnectionTransport,
|
||||
ToolConnectionStatus,
|
||||
ToolConnectionKind,
|
||||
ConnectionAudienceMember,
|
||||
ConnectionGrant,
|
||||
ConnectionGrantCapabilities,
|
||||
ConnectionGrantDelegation,
|
||||
ConnectionGrantKind,
|
||||
ConnectionGrantMember,
|
||||
ConnectionGrantMemberSubjectType,
|
||||
ConnectionGrantsResponse,
|
||||
ConnectionGrantStatus,
|
||||
ToolConnectionCapabilities,
|
||||
ToolConnectionCreateCapabilities,
|
||||
ToolCredentialSecretRef,
|
||||
ToolInvocation,
|
||||
ToolInvocationApprovalState,
|
||||
|
|
@ -711,6 +720,7 @@ export type {
|
|||
RequestConfirmationResult,
|
||||
RequestConfirmationToolActionPayload,
|
||||
RequestConfirmationToolActionResult,
|
||||
RequestConfirmationConnectionAuthorizationPayload,
|
||||
RequestConfirmationSecretProposalPayload,
|
||||
RequestConfirmationSecretProposalResult,
|
||||
RequestCheckboxConfirmationOption,
|
||||
|
|
|
|||
|
|
@ -1268,6 +1268,23 @@ export interface RequestConfirmationSecretProposalResult {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentation metadata for a connection-authorization confirmation
|
||||
* (PAP-17835). The interaction kind and the server-addressed audience are
|
||||
* unchanged; this block only lets the card render "Connect your Gmail to
|
||||
* continue" and name the agent that is waiting, instead of parsing a magic
|
||||
* title string to work out what the card is about.
|
||||
*/
|
||||
export interface RequestConfirmationConnectionAuthorizationPayload {
|
||||
version: 1;
|
||||
/** Provider label for the copy, e.g. "Gmail". Never a secret name or ref. */
|
||||
providerName: string;
|
||||
/** The connection's display name, when it differs from the provider. */
|
||||
connectionName?: string | null;
|
||||
/** The agent whose work is blocked, for "<Agent> needs your <Provider> identity". */
|
||||
requestingAgentName?: string | null;
|
||||
}
|
||||
|
||||
export interface RequestConfirmationPayload {
|
||||
version: 1;
|
||||
prompt: string;
|
||||
|
|
@ -1282,6 +1299,7 @@ export interface RequestConfirmationPayload {
|
|||
target?: RequestConfirmationTarget | null;
|
||||
toolAction?: RequestConfirmationToolActionPayload;
|
||||
secretProposal?: RequestConfirmationSecretProposalPayload;
|
||||
connectionAuthorization?: RequestConfirmationConnectionAuthorizationPayload;
|
||||
}
|
||||
|
||||
export interface RequestCheckboxConfirmationOption {
|
||||
|
|
@ -1395,6 +1413,7 @@ export interface IssueThreadInteractionBase extends IssueThreadInteractionActorF
|
|||
sourceCommentId?: string | null;
|
||||
sourceRunId?: string | null;
|
||||
addresseeAgentId?: string | null;
|
||||
addresseeUserId?: string | null;
|
||||
title?: string | null;
|
||||
summary?: string | null;
|
||||
status: IssueThreadInteractionStatus;
|
||||
|
|
|
|||
|
|
@ -72,8 +72,10 @@ export type ToolConnectionAuthKind = "oauth" | "api_key" | "none";
|
|||
export type ToolConnectionOwnership = "platform_shared" | "platform_provisioned" | "customer" | "dcr";
|
||||
export type ToolConnectionStatus = "draft" | "active" | "disabled" | "archived";
|
||||
export type ToolConnectionInstallTargetType = "company" | "agent";
|
||||
export type ConnectionGrantKind = "workspace" | "user";
|
||||
export type ConnectionGrantKind = "organization" | "user";
|
||||
export type ConnectionGrantStatus = "active" | "revoked" | "expired" | "needs_reauthorization";
|
||||
export type ToolConnectionCredentialPolicy = "shared" | "per_user" | "per_user_with_fallback";
|
||||
export type ConnectionGrantMemberSubjectType = "user";
|
||||
export type ToolCredentialPlacement = "header" | "env";
|
||||
|
||||
export interface McpConnectionCredentialRef {
|
||||
|
|
@ -132,6 +134,7 @@ export interface ToolConnection {
|
|||
ownership: ToolConnectionOwnership;
|
||||
transport: ToolConnectionTransport;
|
||||
authKind: ToolConnectionAuthKind;
|
||||
credentialPolicy: ToolConnectionCredentialPolicy;
|
||||
status?: ToolConnectionStatus;
|
||||
transportConfig: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
|
|
@ -172,6 +175,78 @@ export interface ConnectionGrant {
|
|||
lastUsedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
members?: ConnectionGrantMember[];
|
||||
delegations?: ConnectionGrantDelegation[];
|
||||
/**
|
||||
* Server-computed authorization for this grant (PAP-17835). The UI renders the
|
||||
* §3 permission matrix from these booleans; it must never rebuild policy from
|
||||
* `membershipRole` strings, because grant authorization also depends on
|
||||
* creator/subject identity that the client cannot evaluate.
|
||||
*/
|
||||
capabilities?: ConnectionGrantCapabilities;
|
||||
}
|
||||
|
||||
export interface ConnectionGrantCapabilities {
|
||||
canRevoke: boolean;
|
||||
canEditAudience: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection-level capabilities for the personal-connections UX. Policy-forbidden
|
||||
* actions are omitted from the UI entirely; `false` here means "do not render",
|
||||
* not "render disabled".
|
||||
*/
|
||||
export interface ToolConnectionCapabilities {
|
||||
canConfigure: boolean;
|
||||
canCreateOrganizationGrant: boolean;
|
||||
canSetCompanyInstall: boolean;
|
||||
canConnectAsCurrentUser: boolean;
|
||||
canManageAgentInstalls: boolean;
|
||||
canViewOtherPersonalIdentities: boolean;
|
||||
editableAgentIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Capabilities needed before a tool connection exists. These are returned by
|
||||
* the company-scoped app gallery read so create flows do not have to infer
|
||||
* authorization from membership roles or wait for a connection id.
|
||||
*/
|
||||
export interface ToolConnectionCreateCapabilities {
|
||||
canSetCompanyInstall: boolean;
|
||||
companyInstallReason: string | null;
|
||||
}
|
||||
|
||||
export interface ConnectionGrantsResponse {
|
||||
connection: { id: string; uid: string };
|
||||
grants: ConnectionGrant[];
|
||||
capabilities: ToolConnectionCapabilities;
|
||||
currentUserId: string | null;
|
||||
members: ConnectionAudienceMember[];
|
||||
}
|
||||
|
||||
/** A company member that can appear in an organization grant's audience. */
|
||||
export interface ConnectionAudienceMember {
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface ConnectionGrantDelegation {
|
||||
id: string;
|
||||
companyId: string;
|
||||
grantId: string;
|
||||
agentId: string;
|
||||
createdByUserId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ConnectionGrantMember {
|
||||
id: string;
|
||||
companyId: string;
|
||||
grantId: string;
|
||||
subjectType: ConnectionGrantMemberSubjectType;
|
||||
subjectId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ToolConnectionInstall {
|
||||
|
|
@ -240,11 +315,14 @@ export type ConnectionTokenSubject = { type: "app" } | { type: "user"; userId: s
|
|||
|
||||
export const CONNECTION_RECOVERABLE_ERROR_CODES = [
|
||||
"user_authorization_required",
|
||||
"organization_authorization_required",
|
||||
"grant_audience_denied",
|
||||
"grant_revoked",
|
||||
"needs_reauthorization",
|
||||
"installation_required",
|
||||
"connection_not_installed",
|
||||
"subject_not_permitted",
|
||||
"standing_delegation_required",
|
||||
] as const;
|
||||
|
||||
export type ConnectionRecoverableErrorCode = typeof CONNECTION_RECOVERABLE_ERROR_CODES[number];
|
||||
|
|
|
|||
|
|
@ -868,6 +868,8 @@ export {
|
|||
connectionTokenScopeSchema,
|
||||
connectionTokenSubjectSchema,
|
||||
startConnectionAuthorizationSchema,
|
||||
createConnectionGrantDelegationSchema,
|
||||
replaceConnectionGrantMembersSchema,
|
||||
createToolTrustRuleFromActionRequestSchema,
|
||||
revokeToolTrustRuleSchema,
|
||||
toolPolicyTestRequestSchema,
|
||||
|
|
@ -945,6 +947,8 @@ export {
|
|||
type UnbindToolProfileBinding,
|
||||
type UpsertToolCatalogEntry,
|
||||
type ConnectionTokenRequestInput,
|
||||
type CreateConnectionGrantDelegation,
|
||||
type ReplaceConnectionGrantMembersInput,
|
||||
type ToolPolicyTestRequestInput,
|
||||
type CreateToolTrustRuleFromActionRequest,
|
||||
type RevokeToolTrustRule,
|
||||
|
|
|
|||
|
|
@ -1334,6 +1334,7 @@ export const requestItemVerdictsResultSchema = z.object({
|
|||
const createIssueThreadInteractionCommon = {
|
||||
resolverPolicy: issueThreadInteractionResolverPolicySchema.optional(),
|
||||
addresseeAgentId: z.string().guid().nullable().optional(),
|
||||
addresseeUserId: z.string().trim().min(1).nullable().optional(),
|
||||
};
|
||||
|
||||
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
|
||||
|
|
|
|||
|
|
@ -44,8 +44,13 @@ export const toolApplicationStatusSchema = z.enum(TOOL_APPLICATION_STATUSES);
|
|||
export const toolConnectionTransportSchema = z.enum(["mcp_remote", "rest_api", "local_stdio"]);
|
||||
export const toolConnectionAuthKindSchema = z.enum(["oauth", "api_key", "none"]);
|
||||
export const toolConnectionOwnershipSchema = z.enum(["platform_shared", "platform_provisioned", "customer", "dcr"]);
|
||||
export const connectionGrantKindSchema = z.enum(["workspace", "user"]);
|
||||
export const connectionGrantKindSchema = z.enum(["organization", "user"]);
|
||||
export const connectionGrantStatusSchema = z.enum(["active", "revoked", "expired", "needs_reauthorization"]);
|
||||
export const createConnectionGrantDelegationSchema = z.object({
|
||||
agentId: z.string().uuid(),
|
||||
});
|
||||
export type CreateConnectionGrantDelegation = z.infer<typeof createConnectionGrantDelegationSchema>;
|
||||
export const toolConnectionCredentialPolicySchema = z.enum(["shared", "per_user", "per_user_with_fallback"]);
|
||||
export const toolConnectionStatusSchema = z.enum(["draft", "active", "disabled", "archived"]);
|
||||
export const toolConnectionInstallTargetTypeSchema = z.enum(["company", "agent"]);
|
||||
export const toolCredentialPlacementSchema = z.enum(["header", "env"]);
|
||||
|
|
@ -155,6 +160,7 @@ export const createToolConnectionSchema = z.object({
|
|||
name: z.string().trim().min(1).max(160),
|
||||
transport: toolConnectionTransportSchema.optional(),
|
||||
authKind: toolConnectionAuthKindSchema.default("none"),
|
||||
credentialPolicy: toolConnectionCredentialPolicySchema.optional(),
|
||||
ownership: toolConnectionOwnershipSchema.default("customer"),
|
||||
status: toolConnectionStatusSchema.optional(),
|
||||
connectionKind: toolConnectionKindSchema.default("managed"),
|
||||
|
|
@ -197,7 +203,7 @@ export const connectionGrantSchema = z.object({
|
|||
updatedAt: z.coerce.date(),
|
||||
}).superRefine((grant, ctx) => {
|
||||
if ((grant.kind === "user") !== Boolean(grant.subjectUserId)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["subjectUserId"], message: "User grants require a subject user; workspace grants must not have one" });
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["subjectUserId"], message: "User grants require a subject user; organization grants must not have one" });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -210,6 +216,18 @@ export const putToolConnectionInstallsSchema = z.object({
|
|||
|
||||
export type PutToolConnectionInstalls = z.infer<typeof putToolConnectionInstallsSchema>;
|
||||
|
||||
/**
|
||||
* Audience replacement for an organization grant (PAP-17835). An empty array is
|
||||
* the canonical encoding of "all organization members" — the UI never exposes
|
||||
* "empty list" as the mental model, so the wire format carries the emptiness and
|
||||
* the copy layer translates it.
|
||||
*/
|
||||
export const replaceConnectionGrantMembersSchema = z.object({
|
||||
memberUserIds: z.array(z.string().trim().min(1).max(500)).max(1000),
|
||||
}).strict();
|
||||
|
||||
export type ReplaceConnectionGrantMembersInput = z.infer<typeof replaceConnectionGrantMembersSchema>;
|
||||
|
||||
export const connectionTokenIssuancePathSchema = z.enum(CONNECTION_TOKEN_ISSUANCE_PATHS);
|
||||
|
||||
export const connectionTokenScopeSchema = z.union([
|
||||
|
|
@ -342,6 +360,13 @@ export const connectToolAppSchema = z.object({
|
|||
applicationId: z.string().guid().optional(),
|
||||
authMode: genericMcpAuthModeSchema.optional(),
|
||||
oauthClient: genericMcpOAuthClientSchema.optional(),
|
||||
/**
|
||||
* Which identity this credential becomes (PAP-17835). `user` means "Just me":
|
||||
* the credential is committed to the caller's own personal grant and never to
|
||||
* the connection row's shared secret refs or the default organization grant.
|
||||
* Omitted keeps the historical shared-credential behaviour.
|
||||
*/
|
||||
grantKind: connectionGrantKindSchema.optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.configValues) rejectSensitiveConfigKeys(value.configValues, ctx, ["configValues"]);
|
||||
if (value.credentialValues) rejectUnsafeHeaderCredentials(value.credentialValues, ctx, ["credentialValues"]);
|
||||
|
|
|
|||
|
|
@ -5,10 +5,15 @@ import { and, eq } from "drizzle-orm";
|
|||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
connectionGrantDelegations,
|
||||
connectionGrants,
|
||||
createDb,
|
||||
principalPermissionGrants,
|
||||
toolApplications,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -90,8 +95,13 @@ describeEmbeddedPostgres("access routes permissions upgrade compatibility", () =
|
|||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(connectionGrantDelegations);
|
||||
await db.delete(connectionGrants);
|
||||
await db.delete(toolConnections);
|
||||
await db.delete(toolApplications);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
|
|
@ -164,4 +174,85 @@ describeEmbeddedPostgres("access routes permissions upgrade compatibility", () =
|
|||
grantedByUserId: owner.principalId,
|
||||
});
|
||||
});
|
||||
|
||||
it("sweeps personal connection access when the member route suspends a user", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const member = await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `member-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const agent = await db.insert(agents).values({
|
||||
companyId: company.id,
|
||||
name: "Delegated route agent",
|
||||
role: "worker",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const application = await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
applicationKey: `route-app-${randomUUID()}`,
|
||||
name: "Route personal app",
|
||||
type: "mcp",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
companyId: company.id,
|
||||
applicationId: application.id,
|
||||
name: "Route personal connection",
|
||||
uid: `route-connection-${randomUUID()}`,
|
||||
connectionKind: "managed",
|
||||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const grant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: member.principalId,
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(connectionGrantDelegations).values({
|
||||
companyId: company.id,
|
||||
grantId: grant.id,
|
||||
agentId: agent.id,
|
||||
createdByUserId: member.principalId,
|
||||
});
|
||||
|
||||
const res = await request(await createApp(db, company.id, owner.principalId))
|
||||
.patch(`/api/companies/${company.id}/members/${member.id}`)
|
||||
.send({ status: "suspended" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.status).toBe("suspended");
|
||||
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "revoked" })]);
|
||||
|
||||
await request(await createApp(db, company.id, owner.principalId))
|
||||
.patch(`/api/companies/${company.id}/members/${member.id}/role-and-grants`)
|
||||
.send({ status: "active", grants: [] })
|
||||
.expect(200);
|
||||
await db.update(connectionGrants).set({ status: "active" }).where(eq(connectionGrants.id, grant.id));
|
||||
await db.insert(connectionGrantDelegations).values({
|
||||
companyId: company.id,
|
||||
grantId: grant.id,
|
||||
agentId: agent.id,
|
||||
createdByUserId: member.principalId,
|
||||
});
|
||||
|
||||
const permissionsRoute = await request(await createApp(db, company.id, owner.principalId))
|
||||
.patch(`/api/companies/${company.id}/members/${member.id}/role-and-grants`)
|
||||
.send({ status: "suspended", grants: [] });
|
||||
expect(permissionsRoute.status, JSON.stringify(permissionsRoute.body)).toBe(200);
|
||||
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "revoked" })]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companySecretBindings,
|
||||
companySecrets,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
connectionGrantDelegations,
|
||||
connectionGrantMembers,
|
||||
connectionGrants,
|
||||
instanceUserRoles,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
toolAccessAuditEvents,
|
||||
toolApplications,
|
||||
toolConnections,
|
||||
userSecretDeclarations,
|
||||
userSecretDefinitions,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -56,6 +66,16 @@ describeEmbeddedPostgres("access service", () => {
|
|||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(toolAccessAuditEvents);
|
||||
await db.delete(userSecretDeclarations);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(connectionGrantDelegations);
|
||||
await db.delete(connectionGrantMembers);
|
||||
await db.delete(connectionGrants);
|
||||
await db.delete(toolConnections);
|
||||
await db.delete(toolApplications);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(userSecretDefinitions);
|
||||
await db.delete(issues);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(instanceUserRoles);
|
||||
|
|
@ -226,6 +246,639 @@ describeEmbeddedPostgres("access service", () => {
|
|||
).rejects.toThrow("Instance admins cannot be removed from company access");
|
||||
});
|
||||
|
||||
it("sweeps personal grants when instance-level company access is removed", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const member = await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `member-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const agent = await db.insert(agents).values({
|
||||
companyId: company.id,
|
||||
name: "Instance access delegated agent",
|
||||
role: "worker",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const application = await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
applicationKey: `instance-access-${randomUUID()}`,
|
||||
name: "Instance access app",
|
||||
type: "mcp",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
companyId: company.id,
|
||||
applicationId: application.id,
|
||||
name: "Instance access connection",
|
||||
uid: `instance-access-${randomUUID()}`,
|
||||
connectionKind: "managed",
|
||||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const grant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: member.principalId,
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(connectionGrantDelegations).values({
|
||||
companyId: company.id,
|
||||
grantId: grant.id,
|
||||
agentId: agent.id,
|
||||
createdByUserId: member.principalId,
|
||||
});
|
||||
|
||||
await accessService(db).setUserCompanyAccess(member.principalId, [], {
|
||||
actorUserId: owner.principalId,
|
||||
});
|
||||
|
||||
expect(await db.select().from(companyMemberships).where(eq(companyMemberships.id, member.id)))
|
||||
.toEqual([expect.objectContaining({ status: "archived" })]);
|
||||
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "revoked" })]);
|
||||
});
|
||||
|
||||
it("revokes personal connection access and destroys user-scoped secrets when membership is archived", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const member = await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `member-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const agent = await db.insert(agents).values({
|
||||
companyId: company.id,
|
||||
name: "Delegated agent",
|
||||
role: "worker",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const application = await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
applicationKey: `app-${randomUUID()}`,
|
||||
name: "Personal mail",
|
||||
type: "mcp",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
companyId: company.id,
|
||||
applicationId: application.id,
|
||||
name: "Mail",
|
||||
uid: `mail-${randomUUID()}`,
|
||||
connectionKind: "managed",
|
||||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const definition = await db.insert(userSecretDefinitions).values({
|
||||
companyId: company.id,
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: "Personal OAuth token",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const secret = await db.insert(companySecrets).values({
|
||||
companyId: company.id,
|
||||
scope: "user",
|
||||
ownerUserId: member.principalId,
|
||||
userSecretDefinitionId: definition.id,
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: `OAuth ${randomUUID()}`,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.update(toolConnections).set({
|
||||
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token" }],
|
||||
}).where(eq(toolConnections.id, connection.id));
|
||||
const grant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: member.principalId,
|
||||
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token" }],
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(connectionGrantDelegations).values({
|
||||
companyId: company.id,
|
||||
grantId: grant.id,
|
||||
agentId: agent.id,
|
||||
createdByUserId: member.principalId,
|
||||
});
|
||||
await accessService(db).archiveMember(company.id, member.id, {
|
||||
reassignment: { assigneeUserId: owner.principalId },
|
||||
});
|
||||
|
||||
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
|
||||
expect(await db.select().from(connectionGrantMembers)).toHaveLength(0);
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id))).toHaveLength(0);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
||||
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))
|
||||
.toEqual([expect.objectContaining({
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
healthStatus: "missing_secret",
|
||||
lastError: "oauth_reauthorization_required",
|
||||
credentialSecretRefs: [],
|
||||
})]);
|
||||
expect(await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.reasonCode, "membership_removed")))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retains a personal credential while another member grant still uses it", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const [departing, surviving] = await db.insert(companyMemberships).values([
|
||||
{
|
||||
companyId: company.id,
|
||||
principalType: "user" as const,
|
||||
principalId: `departing-${randomUUID()}`,
|
||||
status: "active" as const,
|
||||
membershipRole: "member" as const,
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
principalType: "user" as const,
|
||||
principalId: `surviving-${randomUUID()}`,
|
||||
status: "active" as const,
|
||||
membershipRole: "member" as const,
|
||||
},
|
||||
]).returning();
|
||||
const application = await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
applicationKey: `shared-personal-${randomUUID()}`,
|
||||
name: "Shared personal app",
|
||||
type: "mcp",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
companyId: company.id,
|
||||
applicationId: application.id,
|
||||
name: "Shared personal connection",
|
||||
uid: `shared-personal-${randomUUID()}`,
|
||||
connectionKind: "managed",
|
||||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const definition = await db.insert(userSecretDefinitions).values({
|
||||
companyId: company.id,
|
||||
key: `shared-personal-${randomUUID()}`,
|
||||
name: "Shared personal token",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const secret = await db.insert(companySecrets).values({
|
||||
companyId: company.id,
|
||||
scope: "user",
|
||||
ownerUserId: departing!.principalId,
|
||||
userSecretDefinitionId: definition.id,
|
||||
key: `shared-personal-${randomUUID()}`,
|
||||
name: "Shared personal token",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const credentialSecretRefs = [{ secretId: secret.id, configPath: "oauth.access_token" }];
|
||||
await db.update(toolConnections).set({ credentialSecretRefs }).where(eq(toolConnections.id, connection.id));
|
||||
const [departingGrant, survivingGrant] = await db.insert(connectionGrants).values([
|
||||
{
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user" as const,
|
||||
subjectUserId: departing!.principalId,
|
||||
status: "active" as const,
|
||||
credentialSecretRefs,
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user" as const,
|
||||
subjectUserId: surviving!.principalId,
|
||||
status: "active" as const,
|
||||
credentialSecretRefs,
|
||||
},
|
||||
]).returning();
|
||||
|
||||
await accessService(db).archiveMember(company.id, departing!.id, {
|
||||
reassignment: { assigneeUserId: owner.principalId },
|
||||
});
|
||||
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id)))
|
||||
.toHaveLength(1);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, departingGrant!.id)))
|
||||
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, survivingGrant!.id)))
|
||||
.toEqual([expect.objectContaining({ status: "active", credentialSecretRefs })]);
|
||||
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))
|
||||
.toEqual([expect.objectContaining({ status: "active", enabled: true, credentialSecretRefs })]);
|
||||
});
|
||||
|
||||
it("keeps a mixed connection active when an unaffected organization credential survives", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const departing = await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `departing-mixed-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const application = await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
applicationKey: `mixed-${randomUUID()}`,
|
||||
name: "Mixed credential app",
|
||||
type: "mcp",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const definition = await db.insert(userSecretDefinitions).values({
|
||||
companyId: company.id,
|
||||
key: `mixed-personal-${randomUUID()}`,
|
||||
name: "Mixed personal token",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const personalSecret = await db.insert(companySecrets).values({
|
||||
companyId: company.id,
|
||||
scope: "user",
|
||||
ownerUserId: departing.principalId,
|
||||
userSecretDefinitionId: definition.id,
|
||||
key: `mixed-personal-${randomUUID()}`,
|
||||
name: "Mixed personal token",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const organizationSecret = await db.insert(companySecrets).values({
|
||||
companyId: company.id,
|
||||
scope: "company",
|
||||
key: `mixed-organization-${randomUUID()}`,
|
||||
name: "Mixed organization token",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const personalRef = { secretId: personalSecret.id, configPath: "credentials.personal" };
|
||||
const organizationRef = { secretId: organizationSecret.id, configPath: "credentials.organization" };
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
companyId: company.id,
|
||||
applicationId: application.id,
|
||||
name: "Mixed credential connection",
|
||||
uid: `mixed-${randomUUID()}`,
|
||||
connectionKind: "managed",
|
||||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "api_key",
|
||||
credentialPolicy: "per_user_with_fallback",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "healthy",
|
||||
credentialSecretRefs: [personalRef, organizationRef],
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const personalGrant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: departing.principalId,
|
||||
credentialSecretRefs: [personalRef],
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const organizationGrant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "organization",
|
||||
status: "active",
|
||||
isDefault: true,
|
||||
credentialSecretRefs: [organizationRef],
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
|
||||
await accessService(db).archiveMember(company.id, departing.id, {
|
||||
reassignment: { assigneeUserId: owner.principalId },
|
||||
});
|
||||
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, personalSecret.id)))
|
||||
.toHaveLength(0);
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, organizationSecret.id)))
|
||||
.toHaveLength(1);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, personalGrant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, organizationGrant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "active", credentialSecretRefs: [organizationRef] })]);
|
||||
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))
|
||||
.toEqual([expect.objectContaining({
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "healthy",
|
||||
lastError: null,
|
||||
credentialSecretRefs: [organizationRef],
|
||||
})]);
|
||||
});
|
||||
|
||||
it("keeps a sole organization audience dormant until its member is reactivated", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const departing = await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `departing-org-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const application = await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
applicationKey: `shared-org-${randomUUID()}`,
|
||||
name: "Shared organization app",
|
||||
type: "mcp",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
companyId: company.id,
|
||||
applicationId: application.id,
|
||||
name: "Shared organization connection",
|
||||
uid: `shared-org-${randomUUID()}`,
|
||||
connectionKind: "managed",
|
||||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "shared",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const definition = await db.insert(userSecretDefinitions).values({
|
||||
companyId: company.id,
|
||||
key: `shared-org-${randomUUID()}`,
|
||||
name: "Shared organization token",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const secret = await db.insert(companySecrets).values({
|
||||
companyId: company.id,
|
||||
scope: "user",
|
||||
ownerUserId: departing.principalId,
|
||||
userSecretDefinitionId: definition.id,
|
||||
key: `shared-org-${randomUUID()}`,
|
||||
name: "Shared organization token",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const credentialSecretRefs = [{ secretId: secret.id, configPath: "oauth.access_token" }];
|
||||
await db.update(toolConnections).set({ credentialSecretRefs }).where(eq(toolConnections.id, connection.id));
|
||||
await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: departing.principalId,
|
||||
status: "active",
|
||||
credentialSecretRefs,
|
||||
});
|
||||
const organizationGrant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "organization",
|
||||
status: "active",
|
||||
isDefault: true,
|
||||
credentialSecretRefs,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(connectionGrantMembers).values({
|
||||
companyId: company.id,
|
||||
grantId: organizationGrant.id,
|
||||
subjectType: "user",
|
||||
subjectId: departing.principalId,
|
||||
});
|
||||
|
||||
await accessService(db).archiveMember(company.id, departing.id, {
|
||||
reassignment: { assigneeUserId: owner.principalId },
|
||||
});
|
||||
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id)))
|
||||
.toHaveLength(1);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, organizationGrant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "active", isDefault: true, credentialSecretRefs })]);
|
||||
expect(await db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.grantId, organizationGrant.id)))
|
||||
.toEqual([expect.objectContaining({ subjectId: departing.principalId })]);
|
||||
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))
|
||||
.toEqual([expect.objectContaining({ status: "active", enabled: true, credentialSecretRefs })]);
|
||||
|
||||
await accessService(db).setUserCompanyAccess(departing.principalId, [company.id]);
|
||||
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id)))
|
||||
.toHaveLength(1);
|
||||
expect(await db.select().from(companyMemberships).where(eq(companyMemberships.id, departing.id)))
|
||||
.toEqual([expect.objectContaining({ status: "active" })]);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, organizationGrant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "active", credentialSecretRefs })]);
|
||||
});
|
||||
|
||||
it("revokes delegated personal connection access when membership is suspended", async () => {
|
||||
const { company } = await createCompanyWithOwner(db);
|
||||
const member = await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `member-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const agent = await db.insert(agents).values({
|
||||
companyId: company.id,
|
||||
name: "Delegated agent",
|
||||
role: "worker",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const application = await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
applicationKey: `app-${randomUUID()}`,
|
||||
name: "Personal mail",
|
||||
type: "mcp",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
companyId: company.id,
|
||||
applicationId: application.id,
|
||||
name: "Mail",
|
||||
uid: `mail-${randomUUID()}`,
|
||||
connectionKind: "managed",
|
||||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const definition = await db.insert(userSecretDefinitions).values({
|
||||
companyId: company.id,
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: "Personal OAuth token",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const secret = await db.insert(companySecrets).values({
|
||||
companyId: company.id,
|
||||
scope: "user",
|
||||
ownerUserId: member.principalId,
|
||||
userSecretDefinitionId: definition.id,
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: `OAuth ${randomUUID()}`,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const grant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: member.principalId,
|
||||
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token" }],
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(connectionGrantDelegations).values({
|
||||
companyId: company.id,
|
||||
grantId: grant.id,
|
||||
agentId: agent.id,
|
||||
createdByUserId: member.principalId,
|
||||
});
|
||||
|
||||
await accessService(db).updateMember(company.id, member.id, { status: "suspended" });
|
||||
|
||||
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id))).toHaveLength(0);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
||||
});
|
||||
|
||||
it("preserves personal grant secrets used by surviving declarations and bindings", async () => {
|
||||
const { company } = await createCompanyWithOwner(db);
|
||||
const member = await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `member-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const application = await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
applicationKey: `app-${randomUUID()}`,
|
||||
name: "Personal shared credentials",
|
||||
type: "mcp",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const definitions = await db.insert(userSecretDefinitions).values([
|
||||
{ companyId: company.id, key: `agent-declared-${randomUUID()}`, name: "Agent-declared credential" },
|
||||
{ companyId: company.id, key: `environment-declared-${randomUUID()}`, name: "Environment-declared credential" },
|
||||
{ companyId: company.id, key: `bound-${randomUUID()}`, name: "Bound credential" },
|
||||
]).returning();
|
||||
const secrets = await db.insert(companySecrets).values(definitions.map((definition, index) => ({
|
||||
companyId: company.id,
|
||||
scope: "user",
|
||||
ownerUserId: member.principalId,
|
||||
userSecretDefinitionId: definition.id,
|
||||
key: `shared-${index}-${randomUUID()}`,
|
||||
name: `Shared credential ${index}`,
|
||||
}))).returning();
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
companyId: company.id,
|
||||
applicationId: application.id,
|
||||
name: "Shared personal connection",
|
||||
uid: `shared-${randomUUID()}`,
|
||||
connectionKind: "managed",
|
||||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "api_key",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
credentialSecretRefs: secrets.map((secret, index) => ({
|
||||
secretId: secret.id,
|
||||
configPath: `credentials.shared_${index}`,
|
||||
})),
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const grant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: member.principalId,
|
||||
credentialSecretRefs: secrets.map((secret, index) => ({
|
||||
secretId: secret.id,
|
||||
configPath: `credentials.shared_${index}`,
|
||||
})),
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(userSecretDeclarations).values([
|
||||
{
|
||||
companyId: company.id,
|
||||
userSecretDefinitionId: definitions[0]!.id,
|
||||
targetType: "agent",
|
||||
targetId: `surviving-agent-${randomUUID()}`,
|
||||
configPath: "env.AGENT_TOKEN",
|
||||
envKey: "AGENT_TOKEN",
|
||||
},
|
||||
{
|
||||
companyId: company.id,
|
||||
userSecretDefinitionId: definitions[1]!.id,
|
||||
targetType: "environment",
|
||||
targetId: `surviving-environment-${randomUUID()}`,
|
||||
configPath: "env.ENVIRONMENT_TOKEN",
|
||||
envKey: "ENVIRONMENT_TOKEN",
|
||||
},
|
||||
]);
|
||||
await db.insert(companySecretBindings).values([
|
||||
...secrets.map((secret, index) => ({
|
||||
companyId: company.id,
|
||||
secretId: secret.id,
|
||||
targetType: "tool_connection",
|
||||
targetId: connection.id,
|
||||
configPath: `credentials.shared_${index}`,
|
||||
})),
|
||||
{
|
||||
companyId: company.id,
|
||||
secretId: secrets[2]!.id,
|
||||
targetType: "environment",
|
||||
targetId: `surviving-environment-${randomUUID()}`,
|
||||
configPath: "env.BOUND_TOKEN",
|
||||
},
|
||||
]);
|
||||
|
||||
await accessService(db).updateMember(company.id, member.id, { status: "suspended" });
|
||||
|
||||
expect(await db.select().from(companySecrets).where(inArray(companySecrets.id, secrets.map((secret) => secret.id))))
|
||||
.toHaveLength(3);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
||||
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))
|
||||
.toEqual([expect.objectContaining({
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
credentialSecretRefs: [],
|
||||
})]);
|
||||
expect(await db.select().from(companySecretBindings)).toEqual([
|
||||
expect.objectContaining({
|
||||
secretId: secrets[2]!.id,
|
||||
targetType: "environment",
|
||||
}),
|
||||
]);
|
||||
expect(await db.select().from(userSecretDeclarations)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves unrelated user-scoped secrets when membership is suspended", async () => {
|
||||
const { company } = await createCompanyWithOwner(db);
|
||||
const member = await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `member-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const definition = await db.insert(userSecretDefinitions).values({
|
||||
companyId: company.id,
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: "Personal OAuth token",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const unrelatedSecret = await db.insert(companySecrets).values({
|
||||
companyId: company.id,
|
||||
scope: "user",
|
||||
ownerUserId: member.principalId,
|
||||
userSecretDefinitionId: definition.id,
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: "Environment API key",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const otherUserSecret = await db.insert(companySecrets).values({
|
||||
companyId: company.id,
|
||||
scope: "user",
|
||||
ownerUserId: "another-user",
|
||||
userSecretDefinitionId: definition.id,
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: "Another user's credential",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
|
||||
await accessService(db).updateMember(company.id, member.id, { status: "suspended" });
|
||||
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, unrelatedSecret.id)))
|
||||
.toHaveLength(1);
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, otherUserSecret.id)))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows owner and admin role-default grants to manage environments", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const access = accessService(db);
|
||||
|
|
|
|||
|
|
@ -860,9 +860,23 @@ describeEmbeddedPostgres("attention service", () => {
|
|||
addresseeAgentId: reviewerId,
|
||||
payload: { version: 1, questions: [] },
|
||||
});
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
title: "User-addressed question",
|
||||
createdByAgentId: workerId,
|
||||
addresseeUserId: "board-user",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
payload: { version: 1, questions: [] },
|
||||
});
|
||||
await agentService(db).pause(reviewerId);
|
||||
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
const otherUserFeed = await attentionService(db).list(companyId, { userId: "other-user" });
|
||||
const audienceByTitle = new Map(feed.items
|
||||
.filter((item) => item.sourceKind === "issue_thread_interaction")
|
||||
.map((item) => [item.subject.title, item.resolverAudience]));
|
||||
|
|
@ -888,6 +902,11 @@ describeEmbeddedPostgres("attention service", () => {
|
|||
addresseeAgentId: reviewerId,
|
||||
addresseeName: "Reviewer",
|
||||
});
|
||||
expect(audienceByTitle.get("User-addressed question")).toMatchObject({
|
||||
addresseeUserId: "board-user",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
});
|
||||
expect(otherUserFeed.items.some((item) => item.subject.title === "User-addressed question")).toBe(false);
|
||||
// Non-interaction rows carry no resolver policy at all.
|
||||
expect(feed.items.find((item) => item.sourceKind !== "issue_thread_interaction")?.resolverAudience)
|
||||
.toBeNull();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isConnectionGrantAudienceAllowed,
|
||||
resolveCredentialGrantKind,
|
||||
} from "../services/tool-gateway.js";
|
||||
|
||||
describe("connection grant resolution", () => {
|
||||
it("resolves every credential policy deterministically", () => {
|
||||
expect(resolveCredentialGrantKind("shared", "alice", true)).toBe("organization");
|
||||
expect(resolveCredentialGrantKind("per_user", "alice", true)).toBe("user");
|
||||
expect(resolveCredentialGrantKind("per_user", "alice", false)).toBe("user_authorization_required");
|
||||
expect(resolveCredentialGrantKind("per_user", null, false)).toBe("user_authorization_required");
|
||||
expect(resolveCredentialGrantKind("per_user_with_fallback", "alice", true)).toBe("user");
|
||||
expect(resolveCredentialGrantKind("per_user_with_fallback", "alice", false)).toBe("organization");
|
||||
expect(resolveCredentialGrantKind("per_user_with_fallback", null, false)).toBe("organization");
|
||||
});
|
||||
|
||||
it("allows an empty audience and rejects users outside a restricted audience", () => {
|
||||
expect(isConnectionGrantAudienceAllowed([], "alice", true)).toBe(true);
|
||||
expect(isConnectionGrantAudienceAllowed(["alice", "bob"], "alice", true)).toBe(true);
|
||||
expect(isConnectionGrantAudienceAllowed(["alice", "bob"], "carol", true)).toBe(false);
|
||||
expect(isConnectionGrantAudienceAllowed(["alice"], null, false)).toBe(false);
|
||||
expect(isConnectionGrantAudienceAllowed([], "alice", false)).toBe(false);
|
||||
expect(isConnectionGrantAudienceAllowed(["alice"], "alice", false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -366,6 +366,9 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
|||
// this connection cannot be depending on gallery metadata for anything.
|
||||
expect(connection!.config).not.toHaveProperty("sourceTemplateKey");
|
||||
expect(connection!.config).not.toHaveProperty("connectionMethodKey");
|
||||
await expect(service.listConnectionGrants(result.connectionId, company.id)).resolves.toMatchObject({
|
||||
grants: [expect.objectContaining({ kind: "organization", isDefault: true, credentialSecretRefs: [] })],
|
||||
});
|
||||
const profiles = await db.select().from(toolProfiles).where(eq(
|
||||
toolProfiles.profileKey,
|
||||
`app:${result.connectionId}`,
|
||||
|
|
@ -902,6 +905,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
|||
});
|
||||
|
||||
it("redacts a hostile denial from the callback route and consumes the state", async () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", PUBLIC_BASE_URL);
|
||||
installMcpOAuthFixture({ auth: "oauth" });
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
|
|
@ -966,6 +970,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => {
|
|||
});
|
||||
|
||||
it("returns browser denials to setup without reflecting provider-authored details", async () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", PUBLIC_BASE_URL);
|
||||
installMcpOAuthFixture({ auth: "oauth" });
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
|
|
|
|||
|
|
@ -1218,7 +1218,7 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("routes delayed input to the worker and back to the listener", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
connectionGrants,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
|
|
@ -354,10 +355,13 @@ describeEmbeddedPostgres("smoke lab service pack and results API", () => {
|
|||
|
||||
const applications = await db.select().from(toolApplications).where(eq(toolApplications.companyId, company.id));
|
||||
const connections = await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id));
|
||||
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.companyId, company.id));
|
||||
const catalog = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.companyId, company.id));
|
||||
const profiles = await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, company.id));
|
||||
expect(applications).toHaveLength(2);
|
||||
expect(connections).toHaveLength(2);
|
||||
expect(grants).toHaveLength(2);
|
||||
expect(grants.every((grant) => grant.kind === "organization" && grant.isDefault)).toBe(true);
|
||||
expect(catalog.some((entry) => entry.toolName === "todo.add" && entry.riskLevel === "write")).toBe(true);
|
||||
expect(catalog.some((entry) => entry.toolName === "time.now" && entry.riskLevel === "read")).toBe(true);
|
||||
expect(profiles).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -1660,7 +1660,7 @@ describeEmbeddedPostgres("tool access policy service", () => {
|
|||
await expect(svc.createConnection(company.id, {
|
||||
name: "Wrong secret",
|
||||
transport: "mcp_remote",
|
||||
transportConfig: { url: "https://example.invalid/mcp" },
|
||||
transportConfig: { url: "https://8.8.8.8/mcp" },
|
||||
credentialSecretRefs: [{
|
||||
secretId: otherSecret.id,
|
||||
configPath: "headers.Authorization",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -380,7 +380,8 @@ describeEmbeddedPostgres("tool connection removal", () => {
|
|||
expect(removed.removal).toMatchObject({
|
||||
secretsRevoked: headerSecretIds.length + 3,
|
||||
secretsRetainedShared: 0,
|
||||
grantsRevoked: 1,
|
||||
// The default organization grant and the explicit user grant are both revoked.
|
||||
grantsRevoked: 2,
|
||||
oauthStatesDiscarded: 1,
|
||||
tokenIssuanceHashesCleared: 1,
|
||||
});
|
||||
|
|
@ -665,8 +666,9 @@ describeEmbeddedPostgres("tool connection removal", () => {
|
|||
const reconnectedSecretIds = after!.credentialSecretRefs.map((ref) => ref.secretId);
|
||||
// Not one of the revoked secrets came back.
|
||||
for (const secretId of originalSecretIds) expect(reconnectedSecretIds).not.toContain(secretId);
|
||||
// And the operator has to choose access again — the app profile is not back.
|
||||
expect(await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connectionId}`))).toEqual([]);
|
||||
// A fresh connect recreates the deny-by-default profile, but does not restore installs.
|
||||
await expect(db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connectionId}`)))
|
||||
.resolves.toHaveLength(1);
|
||||
expect(await service.listConnectionInstalls(connectionId, company.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { filterVisibleToolConnections } from "../routes/tool-access.js";
|
||||
|
||||
const connections = [
|
||||
{ id: "other-draft", status: "draft", createdByUserId: "other-user" },
|
||||
{ id: "own-draft", status: "draft", createdByUserId: "member-user" },
|
||||
{ id: "active", status: "active", createdByUserId: "other-user" },
|
||||
];
|
||||
|
||||
describe("tool connection visibility", () => {
|
||||
it("keeps foreign drafts out of a regular member's reusable connection candidates", () => {
|
||||
expect(filterVisibleToolConnections(connections, {
|
||||
userId: "member-user",
|
||||
canManageConnections: false,
|
||||
})).toEqual([
|
||||
connections[1],
|
||||
connections[2],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps every draft visible to a connection manager", () => {
|
||||
expect(filterVisibleToolConnections(connections, {
|
||||
userId: "owner-user",
|
||||
canManageConnections: true,
|
||||
})).toEqual(connections);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
agents,
|
||||
approvals,
|
||||
companies,
|
||||
connectionGrants,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
|
|
@ -41,6 +42,7 @@ function createTestToolGatewayService(db: ReturnType<typeof createDb>, options:
|
|||
return createToolGatewayService(db, {
|
||||
...options,
|
||||
toolActionSigningSecret: options.toolActionSigningSecret ?? testToolActionSigningSecret,
|
||||
remoteHttpRequest: options.remoteHttpRequest ?? (async (url, init) => fetch(url, init)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -93,8 +95,17 @@ async function createRemoteMcpToolFixture(db: ReturnType<typeof createDb>, compa
|
|||
healthStatus: "ok",
|
||||
// Use a public IP literal so protocol tests remain independent of DNS while
|
||||
// still exercising the production egress guard and their global fetch stub.
|
||||
credentialPolicy: "shared",
|
||||
config: { url: "https://8.8.8.8/mcp" },
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(connectionGrants).values({
|
||||
companyId,
|
||||
connectionId: connection.id,
|
||||
kind: "organization",
|
||||
credentialSecretRefs: [],
|
||||
status: "active",
|
||||
isDefault: true,
|
||||
});
|
||||
const catalogEntry = await db.insert(toolCatalogEntries).values({
|
||||
companyId,
|
||||
applicationId: application.id,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import {
|
|||
companySecretVersions,
|
||||
companyMemberships,
|
||||
companies,
|
||||
connectionGrantMembers,
|
||||
connectionGrantDelegations,
|
||||
connectionGrants,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueThreadInteractions,
|
||||
|
|
@ -113,6 +116,16 @@ async function createIssueAndRun(db: Db, companyId: string, agentId: string) {
|
|||
return { project, issue, run };
|
||||
}
|
||||
|
||||
async function createActiveMember(db: Db, companyId: string, userId: string) {
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
}
|
||||
|
||||
async function allowToolsForAgent(db: Db, companyId: string, agentId: string, toolNames: string[]) {
|
||||
const profile = await db
|
||||
.insert(toolProfiles)
|
||||
|
|
@ -214,6 +227,14 @@ async function createRemoteMcpTool(
|
|||
credentialRefs: input.credentialRefs ?? [],
|
||||
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
||||
}).returning();
|
||||
await db.insert(connectionGrants).values({
|
||||
companyId,
|
||||
connectionId: connection.id,
|
||||
kind: "organization",
|
||||
credentialSecretRefs: connection.credentialSecretRefs,
|
||||
status: "active",
|
||||
isDefault: true,
|
||||
});
|
||||
if (input.credentialRefs?.length || input.credentialSecretRefs?.length) {
|
||||
await db.insert(companySecretBindings).values([
|
||||
...(input.credentialRefs ?? []).map((ref) => ({
|
||||
|
|
@ -276,6 +297,11 @@ async function createLocalStdioMcpTool(
|
|||
healthStatus?: "unknown" | "healthy" | "degraded" | "failed" | "unchecked" | "ok" | "error" | "missing_secret";
|
||||
catalogStatus?: "active" | "disabled" | "quarantined" | "removed";
|
||||
riskLevel?: "read" | "write" | "destructive";
|
||||
credentialPolicy?: "shared" | "per_user" | "per_user_with_fallback";
|
||||
credentialSecretRefs?: typeof toolConnections.$inferInsert["credentialSecretRefs"];
|
||||
stdioScript?: string;
|
||||
envKeys?: string[];
|
||||
connectionConfig?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const applicationKey = input.applicationKey ?? `local-app-${randomUUID().slice(0, 8)}`;
|
||||
|
|
@ -333,9 +359,31 @@ rl.on("line", (line) => {
|
|||
status: input.connectionStatus ?? "active",
|
||||
enabled: input.connectionEnabled ?? true,
|
||||
healthStatus: input.healthStatus ?? "ok",
|
||||
credentialPolicy: input.credentialPolicy ?? "shared",
|
||||
config: { templateId: templateKey, ...(input.connectionConfig ?? {}) },
|
||||
transportConfig: { templateId: templateKey, ...(input.connectionConfig ?? {}) },
|
||||
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
||||
}).returning();
|
||||
await db.insert(connectionGrants).values({
|
||||
companyId,
|
||||
connectionId: connection.id,
|
||||
kind: "organization",
|
||||
credentialSecretRefs: connection.credentialSecretRefs,
|
||||
status: "active",
|
||||
isDefault: true,
|
||||
});
|
||||
if (input.credentialSecretRefs?.length) {
|
||||
await db.insert(companySecretBindings).values(input.credentialSecretRefs.map((ref) => ({
|
||||
companyId,
|
||||
secretId: ref.secretId,
|
||||
targetType: "tool_connection" as const,
|
||||
targetId: connection.id,
|
||||
configPath: ref.configPath,
|
||||
versionSelector: String(ref.versionSelector ?? "latest"),
|
||||
required: ref.required ?? true,
|
||||
label: ref.label ?? null,
|
||||
}))).onConflictDoNothing();
|
||||
}
|
||||
const [catalogEntry] = await db.insert(toolCatalogEntries).values({
|
||||
companyId,
|
||||
applicationId: application!.id,
|
||||
|
|
@ -1276,13 +1324,26 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
|
|||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
const allowedToken = await secretService(db).create(company.id, {
|
||||
name: `Local stdio token ${randomUUID()}`,
|
||||
key: `local_stdio_token_${randomUUID().replace(/-/g, "")}`,
|
||||
provider: "local_encrypted",
|
||||
value: "allowed-token",
|
||||
});
|
||||
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
||||
applicationKey: "local-env-demo",
|
||||
connectionName: "Local Env Demo",
|
||||
toolName: "inspect_env",
|
||||
title: "Inspect env",
|
||||
envKeys: ["ALLOWED_TOKEN"],
|
||||
connectionConfig: { env: { ALLOWED_TOKEN: "allowed-token", EXTRA_CONFIG: "extra-value", NODE_OPTIONS: "--trace-warnings" } },
|
||||
credentialSecretRefs: [{
|
||||
secretId: allowedToken.id,
|
||||
versionSelector: "latest",
|
||||
configPath: "env.ALLOWED_TOKEN",
|
||||
required: true,
|
||||
label: "Allowed token",
|
||||
}],
|
||||
connectionConfig: { env: { ALLOWED_TOKEN: "connection-level-token", EXTRA_CONFIG: "extra-value", NODE_OPTIONS: "--trace-warnings" } },
|
||||
stdioScript: `
|
||||
const readline = require("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
|
|
@ -1352,6 +1413,149 @@ rl.on("line", (line) => {
|
|||
}
|
||||
});
|
||||
|
||||
it("passes only the selected grant identity to local stdio MCP processes", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
await createActiveMember(db, company.id, "alice");
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id));
|
||||
const values = {
|
||||
organization: `organization-${randomUUID()}`,
|
||||
alice: `alice-${randomUUID()}`,
|
||||
bob: `bob-${randomUUID()}`,
|
||||
};
|
||||
const organizationSecret = await secretService(db).create(company.id, {
|
||||
name: `Organization stdio token ${randomUUID()}`,
|
||||
key: `organization_stdio_${randomUUID().replace(/-/g, "")}`,
|
||||
provider: "local_encrypted",
|
||||
value: values.organization,
|
||||
});
|
||||
const aliceSecret = await secretService(db).create(company.id, {
|
||||
name: `Alice stdio token ${randomUUID()}`,
|
||||
key: `alice_stdio_${randomUUID().replace(/-/g, "")}`,
|
||||
provider: "local_encrypted",
|
||||
value: values.alice,
|
||||
});
|
||||
const bobSecret = await secretService(db).create(company.id, {
|
||||
name: `Bob stdio token ${randomUUID()}`,
|
||||
key: `bob_stdio_${randomUUID().replace(/-/g, "")}`,
|
||||
provider: "local_encrypted",
|
||||
value: values.bob,
|
||||
});
|
||||
const localTool = await createLocalStdioMcpTool(db, company.id, {
|
||||
applicationKey: "local-grant-identity",
|
||||
toolName: "identity",
|
||||
title: "Grant identity",
|
||||
envKeys: ["IDENTITY_TOKEN"],
|
||||
credentialSecretRefs: [{
|
||||
secretId: organizationSecret.id,
|
||||
versionSelector: "latest",
|
||||
configPath: "env.IDENTITY_TOKEN",
|
||||
required: true,
|
||||
label: "Organization identity",
|
||||
}],
|
||||
connectionConfig: { env: { IDENTITY_TOKEN: "legacy-connection-identity" } },
|
||||
stdioScript: `
|
||||
const readline = require("node:readline");
|
||||
const identities = ${JSON.stringify(values)};
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "identity-stdio", version: "0.0.0" } } }) + "\\n");
|
||||
return;
|
||||
}
|
||||
if (message.method === "tools/call") {
|
||||
const identity = Object.entries(identities).find(([, value]) => value === process.env.IDENTITY_TOKEN)?.[0] ?? "unknown";
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: identity }], structuredContent: { identity } } }) + "\\n");
|
||||
}
|
||||
});
|
||||
`,
|
||||
});
|
||||
const grantRef = (secretId: string, label: string) => ({
|
||||
secretId,
|
||||
versionSelector: "latest" as const,
|
||||
configPath: "env.IDENTITY_TOKEN",
|
||||
required: true,
|
||||
label,
|
||||
});
|
||||
const [aliceGrant] = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: localTool.connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: "alice",
|
||||
credentialSecretRefs: [grantRef(aliceSecret.id, "Alice identity")],
|
||||
status: "active",
|
||||
isDefault: false,
|
||||
}).returning();
|
||||
await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: localTool.connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: "bob",
|
||||
credentialSecretRefs: [grantRef(bobSecret.id, "Bob identity")],
|
||||
status: "active",
|
||||
isDefault: false,
|
||||
});
|
||||
await allowAllToolsForAgent(db, company.id, agent.id);
|
||||
const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } });
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.connectionId === localTool.connection.id)!;
|
||||
const executeIdentity = async () => {
|
||||
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} });
|
||||
return (result.result as { data?: { structuredContent?: { identity?: string } } }).data?.structuredContent?.identity;
|
||||
};
|
||||
|
||||
expect(await executeIdentity()).toBe("organization");
|
||||
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id));
|
||||
expect(await executeIdentity()).toBe("alice");
|
||||
await db.update(toolConnections).set({ credentialPolicy: "per_user_with_fallback" }).where(eq(toolConnections.id, localTool.connection.id));
|
||||
expect(await executeIdentity()).toBe("alice");
|
||||
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, aliceGrant.id));
|
||||
expect(await executeIdentity()).toBe("organization");
|
||||
|
||||
await db.delete(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id));
|
||||
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id));
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
||||
.rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" });
|
||||
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
|
||||
|
||||
const [organizationGrant] = await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.connectionId, localTool.connection.id),
|
||||
eq(connectionGrants.kind, "organization"),
|
||||
));
|
||||
await db.insert(connectionGrantMembers).values({
|
||||
companyId: company.id,
|
||||
grantId: organizationGrant.id,
|
||||
subjectType: "user",
|
||||
subjectId: "sales-user",
|
||||
});
|
||||
await db.update(toolConnections).set({ credentialPolicy: "shared" }).where(eq(toolConnections.id, localTool.connection.id));
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
||||
.rejects.toMatchObject({ status: 403, reasonCode: "grant_audience_denied" });
|
||||
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
|
||||
|
||||
await db.insert(connectionGrantMembers).values({
|
||||
companyId: company.id,
|
||||
grantId: organizationGrant.id,
|
||||
subjectType: "user",
|
||||
subjectId: "alice",
|
||||
});
|
||||
expect(await executeIdentity()).toBe("organization");
|
||||
|
||||
await db.delete(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id));
|
||||
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, organizationGrant.id));
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
||||
.rejects.toMatchObject({ status: 409, reasonCode: "organization_authorization_required" });
|
||||
|
||||
await db.delete(connectionGrantMembers).where(eq(connectionGrantMembers.grantId, organizationGrant.id));
|
||||
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id));
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, run.id));
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
||||
.rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" });
|
||||
expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps connected remote MCP gateway names collision-safe and excludes inactive catalog sources", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
|
|
@ -1629,6 +1833,152 @@ rl.on("line", (line) => {
|
|||
}
|
||||
});
|
||||
|
||||
it("creates a personal authorization card and resumes after the user grant exists", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
await createActiveMember(db, company.id, "carol");
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: "carol" }).where(eq(heartbeatRuns.id, run.id));
|
||||
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: fakeRequest.body?.id,
|
||||
result: { content: [{ type: "text", text: "connected" }] },
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const { connection } = await createRemoteMcpTool(db, company.id, {
|
||||
url: fake.url,
|
||||
toolName: "whoami",
|
||||
riskLevel: "read",
|
||||
});
|
||||
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, connection.id));
|
||||
await allowAllToolsForAgent(db, company.id, agent.id);
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.providerType === "mcp_remote_http")!;
|
||||
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
companyId: company.id,
|
||||
issueId: issue.id,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "none",
|
||||
requestedResolverPolicy: "anyone",
|
||||
effectiveResolverPolicy: "anyone",
|
||||
idempotencyKey: `connection-authorization:${connection.id}:carol`,
|
||||
title: "Connect your account",
|
||||
summary: `Connect ${connection.name} to continue`,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: `Connect your account to ${connection.name}`,
|
||||
acceptLabel: "Open authorization",
|
||||
rejectLabel: "Not now",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
||||
.rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" });
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id));
|
||||
expect(interaction).toMatchObject({
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
addresseeUserId: "carol",
|
||||
});
|
||||
expect(interaction!.payload).toMatchObject({
|
||||
prompt: `Connect your ${connection.name} account to continue`,
|
||||
target: { key: `connection:${connection.uid}:user:carol` },
|
||||
});
|
||||
|
||||
await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: "carol",
|
||||
credentialSecretRefs: [],
|
||||
status: "active",
|
||||
isDefault: false,
|
||||
});
|
||||
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} });
|
||||
expect(result).toMatchObject({ status: "completed", result: { content: "connected" } });
|
||||
expect(fake.requests).toHaveLength(1);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires an explicit named-agent delegation for autonomous personal-identity runs", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
await createActiveMember(db, company.id, "alice");
|
||||
await db.update(heartbeatRuns).set({
|
||||
responsibleUserId: "alice",
|
||||
invocationSource: "automation",
|
||||
}).where(eq(heartbeatRuns.id, run.id));
|
||||
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: fakeRequest.body?.id,
|
||||
result: { content: [{ type: "text", text: "delegated" }] },
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const { connection } = await createRemoteMcpTool(db, company.id, {
|
||||
url: fake.url,
|
||||
toolName: "whoami",
|
||||
riskLevel: "read",
|
||||
});
|
||||
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, connection.id));
|
||||
const grant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: "alice",
|
||||
credentialSecretRefs: [],
|
||||
status: "active",
|
||||
isDefault: false,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await allowAllToolsForAgent(db, company.id, agent.id);
|
||||
const gateway = createTestToolGatewayService(db);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.providerType === "mcp_remote_http")!;
|
||||
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
||||
.rejects.toMatchObject({ status: 409, reasonCode: "standing_delegation_required" });
|
||||
expect(fake.requests).toHaveLength(0);
|
||||
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id)))
|
||||
.toEqual([expect.objectContaining({
|
||||
status: "pending",
|
||||
addresseeUserId: "alice",
|
||||
idempotencyKey: `connection-delegation:${connection.id}:alice:${agent.id}`,
|
||||
})]);
|
||||
|
||||
await db.insert(connectionGrantDelegations).values({
|
||||
companyId: company.id,
|
||||
grantId: grant.id,
|
||||
agentId: agent.id,
|
||||
createdByUserId: "alice",
|
||||
});
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
||||
.resolves.toMatchObject({ status: "completed", result: { content: "delegated" } });
|
||||
expect(fake.requests).toHaveLength(1);
|
||||
|
||||
await db.update(companyMemberships).set({ status: "suspended" }).where(and(
|
||||
eq(companyMemberships.companyId, company.id),
|
||||
eq(companyMemberships.principalId, "alice"),
|
||||
));
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
||||
.rejects.toMatchObject({ status: 403, reasonCode: "grant_owner_membership_inactive" });
|
||||
expect(fake.requests).toHaveLength(1);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps managed credentials authoritative even when legacy override flags are set", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
|
|
|
|||
|
|
@ -6806,6 +6806,7 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () =>
|
|||
services: [{
|
||||
name: "paperclip-dev",
|
||||
command,
|
||||
env: { PAPERCLIP_PUBLIC_URL: "http://127.0.0.1:3100" },
|
||||
port: { type: "fixed", value: 45_439, envKey: "PORT" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
|
|
@ -7717,6 +7718,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
{
|
||||
name: "paperclip-dev",
|
||||
command,
|
||||
env: { PAPERCLIP_PUBLIC_URL: "http://127.0.0.1:3100" },
|
||||
port: legacyPort,
|
||||
// The pre-feature block: backend URL only, no exposure declaration.
|
||||
expose: { type: "url", urlTemplate: "http://127.0.0.1:{{port}}" },
|
||||
|
|
|
|||
|
|
@ -101,11 +101,15 @@ export function errorHandler(
|
|||
const workspaceRepairPreconditionFailure = details?.code === "workspace_repair_precondition_failed";
|
||||
const structuredConnectionError = new Set([
|
||||
"user_authorization_required",
|
||||
"organization_authorization_required",
|
||||
"grant_audience_denied",
|
||||
"grant_revoked",
|
||||
"needs_reauthorization",
|
||||
"installation_required",
|
||||
"connection_not_installed",
|
||||
"subject_not_permitted",
|
||||
"standing_delegation_required",
|
||||
"grant_owner_membership_inactive",
|
||||
]).has(typeof details?.code === "string" ? details.code : "");
|
||||
recordResponsibleUserDenialFromHttpError(req, details);
|
||||
if (err.status >= 500) {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import path from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { Router } from "express";
|
||||
import type { Request } from "express";
|
||||
import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, ne, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, ne } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
assets,
|
||||
|
|
@ -47,7 +47,7 @@ import {
|
|||
PERMISSION_KEYS,
|
||||
isUuidLike,
|
||||
} from "@paperclipai/shared";
|
||||
import type { DeploymentExposure, DeploymentMode, HumanCompanyMembershipRole, PermissionKey } from "@paperclipai/shared";
|
||||
import type { DeploymentExposure, DeploymentMode, HumanCompanyMembershipRole } from "@paperclipai/shared";
|
||||
import {
|
||||
forbidden,
|
||||
conflict,
|
||||
|
|
@ -121,11 +121,6 @@ const INVITE_TOKEN_MAX_RETRIES = 5;
|
|||
const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000;
|
||||
const INVITE_RESOLUTION_DNS_TIMEOUT_MS = 3_000;
|
||||
|
||||
type MemberGrantPayload = {
|
||||
permissionKey: PermissionKey;
|
||||
scope?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function createInviteToken() {
|
||||
const suffix = randomBytes(INVITE_TOKEN_ENTROPY_BYTES).toString("base64url");
|
||||
return `${INVITE_TOKEN_PREFIX}${suffix}`;
|
||||
|
|
@ -4491,69 +4486,7 @@ export function accessRoutes(
|
|||
if (!memberToUpdate) throw notFound("Member not found");
|
||||
await assertCanManageCompanyMember(req, access, companyId, memberToUpdate);
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select ${companyMemberships.id}
|
||||
from ${companyMemberships}
|
||||
where ${companyMemberships.companyId} = ${companyId}
|
||||
and ${companyMemberships.principalType} = 'user'
|
||||
and ${companyMemberships.status} = 'active'
|
||||
and ${companyMemberships.membershipRole} = 'owner'
|
||||
for update
|
||||
`);
|
||||
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.id, memberId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const nextMembershipRole =
|
||||
req.body.membershipRole !== undefined
|
||||
? req.body.membershipRole
|
||||
: existing.membershipRole;
|
||||
const nextStatus = req.body.status ?? existing.status;
|
||||
|
||||
if (
|
||||
existing.principalType === "user" &&
|
||||
existing.status === "active" &&
|
||||
existing.membershipRole === "owner" &&
|
||||
(nextStatus !== "active" || nextMembershipRole !== "owner")
|
||||
) {
|
||||
const activeOwnerCount = await tx
|
||||
.select({ id: companyMemberships.id })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.status, "active"),
|
||||
eq(companyMemberships.membershipRole, "owner"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.length);
|
||||
if (activeOwnerCount <= 1) {
|
||||
throw conflict("Cannot remove the last active owner");
|
||||
}
|
||||
}
|
||||
|
||||
return tx
|
||||
.update(companyMemberships)
|
||||
.set({
|
||||
membershipRole: nextMembershipRole,
|
||||
status: nextStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companyMemberships.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? existing);
|
||||
});
|
||||
const updated = await access.updateMember(companyId, memberId, req.body);
|
||||
if (!updated) throw notFound("Member not found");
|
||||
|
||||
await logActivity(db, {
|
||||
|
|
@ -4588,98 +4521,16 @@ export function accessRoutes(
|
|||
if (!memberToUpdate) throw notFound("Member not found");
|
||||
await assertCanManageCompanyMember(req, access, companyId, memberToUpdate);
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select ${companyMemberships.id}
|
||||
from ${companyMemberships}
|
||||
where ${companyMemberships.companyId} = ${companyId}
|
||||
and ${companyMemberships.principalType} = 'user'
|
||||
and ${companyMemberships.status} = 'active'
|
||||
and ${companyMemberships.membershipRole} = 'owner'
|
||||
for update
|
||||
`);
|
||||
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.id, memberId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const nextMembershipRole =
|
||||
req.body.membershipRole !== undefined
|
||||
? req.body.membershipRole
|
||||
: existing.membershipRole;
|
||||
const nextStatus = req.body.status ?? existing.status;
|
||||
|
||||
if (
|
||||
existing.principalType === "user" &&
|
||||
existing.status === "active" &&
|
||||
existing.membershipRole === "owner" &&
|
||||
(nextStatus !== "active" || nextMembershipRole !== "owner")
|
||||
) {
|
||||
const activeOwnerCount = await tx
|
||||
.select({ id: companyMemberships.id })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.status, "active"),
|
||||
eq(companyMemberships.membershipRole, "owner"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.length);
|
||||
if (activeOwnerCount <= 1) {
|
||||
throw conflict("Cannot remove the last active owner");
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updatedMember = await tx
|
||||
.update(companyMemberships)
|
||||
.set({
|
||||
membershipRole: nextMembershipRole,
|
||||
status: nextStatus,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companyMemberships.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? existing);
|
||||
|
||||
await tx
|
||||
.delete(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, existing.principalType),
|
||||
eq(principalPermissionGrants.principalId, existing.principalId),
|
||||
),
|
||||
);
|
||||
|
||||
const grants = (req.body.grants ?? []) as MemberGrantPayload[];
|
||||
if (grants.length > 0) {
|
||||
await tx.insert(principalPermissionGrants).values(
|
||||
grants.map((grant) => ({
|
||||
companyId,
|
||||
principalType: existing.principalType,
|
||||
principalId: existing.principalId,
|
||||
permissionKey: grant.permissionKey,
|
||||
scope: grant.scope ?? null,
|
||||
grantedByUserId: req.actor.userId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return updatedMember;
|
||||
});
|
||||
const updated = await access.updateMemberAndPermissions(
|
||||
companyId,
|
||||
memberId,
|
||||
{
|
||||
membershipRole: req.body.membershipRole,
|
||||
status: req.body.status,
|
||||
grants: req.body.grants ?? [],
|
||||
},
|
||||
req.actor.userId ?? null,
|
||||
);
|
||||
if (!updated) throw notFound("Member not found");
|
||||
|
||||
await logActivity(db, {
|
||||
|
|
|
|||
|
|
@ -4333,6 +4333,7 @@ export function issueRoutes(
|
|||
effectiveResolverPolicy: string;
|
||||
resolverPolicyProvenance?: string | null;
|
||||
addresseeAgentId?: string | null;
|
||||
addresseeUserId?: string | null;
|
||||
kind: string;
|
||||
status: string;
|
||||
payload?: unknown;
|
||||
|
|
@ -11274,6 +11275,7 @@ export function issueRoutes(
|
|||
interactionStatus: interaction.status,
|
||||
continuationPolicy: interaction.continuationPolicy,
|
||||
addresseeAgentId: interaction.addresseeAgentId ?? null,
|
||||
addresseeUserId: interaction.addresseeUserId ?? null,
|
||||
requestedResolverPolicy: interaction.requestedResolverPolicy,
|
||||
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
|
||||
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ import {
|
|||
createToolApplicationSchema,
|
||||
updateToolApplicationSchema,
|
||||
createToolConnectionSchema,
|
||||
createConnectionGrantDelegationSchema,
|
||||
connectionTokenRequestSchema,
|
||||
startConnectionAuthorizationSchema,
|
||||
createToolStdioCommandTemplateSchema,
|
||||
|
|
@ -915,6 +916,8 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
|||
"GET /api/tool-connections/{connectionId}",
|
||||
"GET /api/tool-connections/{connectionId}/grants",
|
||||
"POST /api/tool-connections/{connectionId}/grants/installations",
|
||||
"POST /api/tool-connections/{connectionId}/grants/{grantId}/delegations",
|
||||
"DELETE /api/tool-connections/{connectionId}/grants/{grantId}/delegations/{delegationId}",
|
||||
"DELETE /api/tool-connections/{connectionId}/grants/{grantId}",
|
||||
"GET /api/tool-connections/{connectionId}/usage",
|
||||
"PATCH /api/tool-connections/{connectionId}",
|
||||
|
|
@ -7289,6 +7292,22 @@ registerCurrentRoute({
|
|||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/tool-connections/{connectionId}/grants/{grantId}/delegations",
|
||||
tags: ["tool-access"],
|
||||
summary: "Delegate a personal tool connection grant to an agent",
|
||||
body: createConnectionGrantDelegationSchema,
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "delete",
|
||||
path: "/api/tool-connections/{connectionId}/grants/{grantId}/delegations/{delegationId}",
|
||||
tags: ["tool-access"],
|
||||
summary: "Revoke a personal tool connection grant delegation",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "delete",
|
||||
path: "/api/tool-connections/{connectionId}/grants/{grantId}",
|
||||
|
|
@ -7303,6 +7322,13 @@ registerCurrentRoute({
|
|||
summary: "Get tool connection usage",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "put",
|
||||
path: "/api/tool-connections/{connectionId}/grants/{grantId}/members",
|
||||
tags: ["tool-access"],
|
||||
summary: "Replace the member audience of a tool connection grant",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/tool-connections/{connectionId}/installs",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import {
|
|||
type DeploymentExposure,
|
||||
type DeploymentMode,
|
||||
type PermissionKey,
|
||||
type ToolConnectionCreateCapabilities,
|
||||
connectToolAppSchema,
|
||||
createConnectionGrantDelegationSchema,
|
||||
createToolStdioCommandTemplateSchema,
|
||||
createToolApplicationSchema,
|
||||
createToolConnectionSchema,
|
||||
|
|
@ -23,6 +25,7 @@ import {
|
|||
duplicateToolProfileSchema,
|
||||
finishToolAppSchema,
|
||||
reconnectToolAppSchema,
|
||||
replaceConnectionGrantMembersSchema,
|
||||
reviewToolProfileNewToolsSchema,
|
||||
createToolTrustRuleFromActionRequestSchema,
|
||||
importMcpJsonSchema,
|
||||
|
|
@ -50,6 +53,9 @@ import {
|
|||
oauthClientIdMetadataDocument,
|
||||
} from "../services/tool-access.js";
|
||||
|
||||
const COMPANY_INSTALL_DENIAL_REASON =
|
||||
"Only someone who can configure this connection can choose this.";
|
||||
|
||||
/** Allowlist (e.g. Google Sheets allowed spreadsheet ids) lives in connection config. */
|
||||
function allowlistIds(config: Record<string, unknown> | null | undefined): string[] {
|
||||
const raw = config?.allowedSpreadsheetIds;
|
||||
|
|
@ -104,6 +110,19 @@ function classifyConnectionUpdate(
|
|||
return events;
|
||||
}
|
||||
|
||||
export function filterVisibleToolConnections<T extends {
|
||||
status?: string;
|
||||
createdByUserId?: string | null;
|
||||
}>(
|
||||
connections: T[],
|
||||
actor: { userId?: string | null; canManageConnections: boolean },
|
||||
): T[] {
|
||||
return connections.filter((connection) =>
|
||||
connection.status !== "draft"
|
||||
|| actor.canManageConnections
|
||||
|| Boolean(actor.userId && connection.createdByUserId === actor.userId));
|
||||
}
|
||||
|
||||
export function toolAccessRoutes(
|
||||
db: Db,
|
||||
options: {
|
||||
|
|
@ -227,10 +246,10 @@ export function toolAccessRoutes(
|
|||
eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.status, "active"),
|
||||
or(
|
||||
eq(connectionGrants.kind, "workspace"),
|
||||
eq(connectionGrants.kind, "organization"),
|
||||
req.actor.userId
|
||||
? and(eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, req.actor.userId))
|
||||
: eq(connectionGrants.kind, "workspace"),
|
||||
: eq(connectionGrants.kind, "organization"),
|
||||
),
|
||||
))
|
||||
.limit(1);
|
||||
|
|
@ -238,6 +257,118 @@ export function toolAccessRoutes(
|
|||
throw forbidden("You need access to this connection before you can install it on an agent");
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-throwing membership probe for capability reporting (PAP-17835).
|
||||
*
|
||||
* `activeToolMembership` throws for viewers because it guards mutations. The
|
||||
* personal-connections UI still has to *render* for a viewer, so capability
|
||||
* computation needs the role without the 403. Returns `null` for a principal
|
||||
* with no scoped membership (local implicit / instance admin), matching
|
||||
* `activeToolMembership`'s "unrestricted" sentinel.
|
||||
*/
|
||||
function toolMembershipRole(req: Request, companyId: string): {
|
||||
unrestricted: boolean;
|
||||
role: string | null;
|
||||
isViewer: boolean;
|
||||
isActive: boolean;
|
||||
} {
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) {
|
||||
return { unrestricted: true, role: null, isViewer: false, isActive: true };
|
||||
}
|
||||
const membership = Array.isArray(req.actor.memberships)
|
||||
? req.actor.memberships.find((item) => item.companyId === companyId)
|
||||
: null;
|
||||
const isActive = Boolean(membership && membership.status === "active");
|
||||
const role = membership?.membershipRole ?? null;
|
||||
return { unrestricted: false, role, isViewer: role === "viewer", isActive };
|
||||
}
|
||||
|
||||
async function isToolConnectionManagerQuiet(req: Request, companyId: string) {
|
||||
const membership = toolMembershipRole(req, companyId);
|
||||
if (membership.unrestricted) return true;
|
||||
if (!membership.isActive || membership.isViewer) return false;
|
||||
if (membership.role === "owner" || membership.role === "admin") return true;
|
||||
return Boolean(req.actor.userId && await access.hasPermission(
|
||||
companyId,
|
||||
"user",
|
||||
req.actor.userId,
|
||||
"tools:manage_connections",
|
||||
));
|
||||
}
|
||||
|
||||
async function describeConnectionCreateCapabilities(
|
||||
req: Request,
|
||||
companyId: string,
|
||||
): Promise<ToolConnectionCreateCapabilities> {
|
||||
const canSetCompanyInstall = await isToolConnectionManagerQuiet(req, companyId);
|
||||
return {
|
||||
canSetCompanyInstall,
|
||||
companyInstallReason: canSetCompanyInstall ? null : COMPANY_INSTALL_DENIAL_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-computed capabilities for the connection identity/install surfaces.
|
||||
* The UI renders the §3 matrix from these booleans instead of reconstructing
|
||||
* policy from role strings, because creator identity and per-agent edit rights
|
||||
* are not derivable client-side.
|
||||
*/
|
||||
async function describeConnectionCapabilities(
|
||||
req: Request,
|
||||
connection: { id: string; companyId: string; createdByUserId?: string | null },
|
||||
) {
|
||||
const membership = toolMembershipRole(req, connection.companyId);
|
||||
const isManager = await isToolConnectionManagerQuiet(req, connection.companyId);
|
||||
const mutationCapable = membership.unrestricted || (membership.isActive && !membership.isViewer);
|
||||
const isCreator = Boolean(req.actor.userId && connection.createdByUserId === req.actor.userId);
|
||||
const canConfigure = isManager || (mutationCapable && isCreator);
|
||||
const editableAgentIds: string[] = [];
|
||||
if (mutationCapable) {
|
||||
const companyAgents = await db
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, connection.companyId));
|
||||
for (const agent of companyAgents) {
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: connection.companyId, agentId: agent.id },
|
||||
});
|
||||
if (decision.allowed) editableAgentIds.push(agent.id);
|
||||
}
|
||||
}
|
||||
return {
|
||||
canConfigure,
|
||||
canCreateOrganizationGrant: canConfigure,
|
||||
canSetCompanyInstall: canConfigure,
|
||||
// Personal consent belongs to the person: it needs a named board user, and
|
||||
// it is never available to a viewer or to an unauthenticated principal.
|
||||
canConnectAsCurrentUser: Boolean(req.actor.userId) && mutationCapable,
|
||||
canManageAgentInstalls: mutationCapable && editableAgentIds.length > 0,
|
||||
canViewOtherPersonalIdentities: isManager,
|
||||
editableAgentIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-grant authorization. Revoking your own identity is always allowed (the
|
||||
* consent is yours to withdraw); revoking anyone else's is manager-only — that
|
||||
* is the kill switch. Audience editing is creator-or-manager, and only for an
|
||||
* organization grant.
|
||||
*/
|
||||
function describeGrantCapabilities(
|
||||
grant: { kind: string; subjectUserId: string | null; createdByUserId: string | null },
|
||||
context: { userId: string | null; isManager: boolean; mutationCapable: boolean },
|
||||
) {
|
||||
if (!context.mutationCapable) return { canRevoke: false, canEditAudience: false };
|
||||
const isOwnGrant = Boolean(context.userId && grant.subjectUserId === context.userId);
|
||||
const isGrantCreator = Boolean(context.userId && grant.createdByUserId === context.userId);
|
||||
return {
|
||||
canRevoke: isOwnGrant || isGrantCreator || context.isManager,
|
||||
canEditAudience: grant.kind === "organization" && (isGrantCreator || context.isManager),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertBoardAnyToolPermission(req: Request, companyId: string, permissionKeys: PermissionKey[]) {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
|
@ -341,6 +472,7 @@ export function toolAccessRoutes(
|
|||
assertCompanyAccess(req, companyId);
|
||||
const googleSheetsAvailability = googleSheetsRobotEmailFromEnv();
|
||||
res.json({
|
||||
capabilities: await describeConnectionCreateCapabilities(req, companyId),
|
||||
apps: CONNECTABLE_APP_DEFINITIONS.map((app) =>
|
||||
app.slug === "google-sheets"
|
||||
? {
|
||||
|
|
@ -378,9 +510,16 @@ export function toolAccessRoutes(
|
|||
const result = await svc.connectGalleryApp(companyId, req.body, getActorInfo(req));
|
||||
if (result.auth?.kind === "oauth") {
|
||||
try {
|
||||
// "Just me" must consent as the caller, not as the workspace: passing
|
||||
// `subjectUserId` is what makes the callback land the tokens on the
|
||||
// caller's personal grant instead of an organization grant
|
||||
// (PAP-17835). The service refuses any subject other than the actor,
|
||||
// so this cannot start consent on someone else's behalf.
|
||||
const personalSubjectUserId = req.body.grantKind === "user" ? req.actor.userId ?? null : null;
|
||||
const start = await svc.startOAuth(companyId, result.connectionId, {
|
||||
redirectUri: oauthRedirectUri(),
|
||||
actor: getActorInfo(req),
|
||||
...(personalSubjectUserId ? { subjectUserId: personalSubjectUserId } : {}),
|
||||
});
|
||||
result.auth.startUrl = start.authorizationUrl;
|
||||
result.auth.issuer = start.issuer ?? result.auth.issuer ?? null;
|
||||
|
|
@ -692,7 +831,14 @@ export function toolAccessRoutes(
|
|||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json({ connections: await svc.listConnections(companyId) });
|
||||
const connections = await svc.listConnections(companyId);
|
||||
const canManageConnections = await isToolConnectionManagerQuiet(req, companyId);
|
||||
res.json({
|
||||
connections: filterVisibleToolConnections(connections, {
|
||||
userId: req.actor.userId,
|
||||
canManageConnections,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/tools/connections", validate(createToolConnectionSchema), async (req, res) => {
|
||||
|
|
@ -731,9 +877,70 @@ export function toolAccessRoutes(
|
|||
assertBoard(req);
|
||||
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!connection) return;
|
||||
res.json(await svc.listConnectionGrants(connection.id, connection.companyId));
|
||||
const listed = await svc.listConnectionGrants(connection.id, connection.companyId);
|
||||
const capabilities = await describeConnectionCapabilities(req, connection);
|
||||
const membership = toolMembershipRole(req, connection.companyId);
|
||||
const grantContext = {
|
||||
userId: req.actor.userId ?? null,
|
||||
isManager: await isToolConnectionManagerQuiet(req, connection.companyId),
|
||||
mutationCapable: membership.unrestricted || (membership.isActive && !membership.isViewer),
|
||||
};
|
||||
// A regular member has no reason to browse coworkers' personal identities;
|
||||
// the manager kill switch does. Filtering here rather than in the UI keeps
|
||||
// the list itself — not just its controls — under server policy.
|
||||
const visibleGrants = listed.grants.filter((grant) =>
|
||||
grant.kind === "organization"
|
||||
|| capabilities.canViewOtherPersonalIdentities
|
||||
|| (grantContext.userId !== null && grant.subjectUserId === grantContext.userId));
|
||||
res.json({
|
||||
...listed,
|
||||
grants: visibleGrants.map((grant) => ({
|
||||
...grant,
|
||||
capabilities: describeGrantCapabilities(grant, grantContext),
|
||||
})),
|
||||
capabilities,
|
||||
currentUserId: grantContext.userId,
|
||||
members: capabilities.canConfigure
|
||||
? await svc.listConnectionAudienceMembers(connection.companyId)
|
||||
: [],
|
||||
});
|
||||
});
|
||||
|
||||
router.put(
|
||||
"/tool-connections/:connectionId/grants/:grantId/members",
|
||||
validate(replaceConnectionGrantMembersSchema),
|
||||
async (req, res) => {
|
||||
assertBoard(req);
|
||||
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!connection) return;
|
||||
// Viewer/inactive principals are rejected before anything else, so a
|
||||
// read-only member can never reach the audience writer.
|
||||
activeToolMembership(req, connection.companyId);
|
||||
const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId);
|
||||
const target = grants.find((grant) => grant.id === req.params.grantId);
|
||||
if (!target) throw notFound("Connection grant not found");
|
||||
const isGrantCreator = Boolean(req.actor.userId && target.createdByUserId === req.actor.userId);
|
||||
if (!isGrantCreator) await assertToolConnectionConfigureAccess(req, connection);
|
||||
const memberUserIds = req.body.memberUserIds as string[];
|
||||
const grant = await svc.replaceConnectionGrantMembers(
|
||||
connection.id,
|
||||
target.id,
|
||||
memberUserIds,
|
||||
getActorInfo(req),
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId: connection.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "tool_connection.grant_audience_replaced",
|
||||
entityType: "connection_grant",
|
||||
entityId: grant.id,
|
||||
details: { connectionId: connection.id, memberCount: memberUserIds.length },
|
||||
});
|
||||
res.json(grant);
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/tool-connections/:connectionId/grants/installations", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
|
|
@ -786,6 +993,60 @@ export function toolAccessRoutes(
|
|||
res.json(grant);
|
||||
});
|
||||
|
||||
router.post("/tool-connections/:connectionId/grants/:grantId/delegations", validate(createConnectionGrantDelegationSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!connection) return;
|
||||
const ownerUserId = req.actor.userId;
|
||||
if (!ownerUserId) throw forbidden("A named user is required to delegate a personal grant");
|
||||
const agentId = req.body.agentId;
|
||||
const delegation = await svc.createConnectionGrantDelegation(
|
||||
connection.id,
|
||||
req.params.grantId as string,
|
||||
agentId,
|
||||
ownerUserId,
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId: connection.companyId,
|
||||
actorType: "user",
|
||||
actorId: ownerUserId,
|
||||
action: "tool_connection.grant_delegated",
|
||||
entityType: "connection_grant",
|
||||
entityId: req.params.grantId as string,
|
||||
details: { connectionId: connection.id, delegationId: delegation.id, agentId },
|
||||
});
|
||||
res.status(201).json(delegation);
|
||||
});
|
||||
|
||||
router.delete("/tool-connections/:connectionId/grants/:grantId/delegations/:delegationId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!connection) return;
|
||||
const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId);
|
||||
const grant = grants.find((candidate) => candidate.id === req.params.grantId);
|
||||
if (!grant) throw notFound("Connection grant not found");
|
||||
const canRevokeOwnDelegation = Boolean(req.actor.userId && grant.subjectUserId === req.actor.userId);
|
||||
if (!canRevokeOwnDelegation && !await isToolConnectionManager(req, connection.companyId)) {
|
||||
throw forbidden("Only the personal grant owner or a connection manager can revoke a delegation");
|
||||
}
|
||||
const delegation = await svc.revokeConnectionGrantDelegation(
|
||||
connection.id,
|
||||
grant.id,
|
||||
req.params.delegationId as string,
|
||||
getActorInfo(req),
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId: connection.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "tool_connection.grant_delegation_revoked",
|
||||
entityType: "connection_grant",
|
||||
entityId: grant.id,
|
||||
details: { connectionId: connection.id, delegationId: delegation.id, agentId: delegation.agentId },
|
||||
});
|
||||
res.json(delegation);
|
||||
});
|
||||
|
||||
router.get("/tool-connections/:connectionId/usage", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import { and, eq, inArray, ne, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, ne, notInArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
companyMemberships,
|
||||
companySecretBindings,
|
||||
companySecrets,
|
||||
connectionGrantDelegations,
|
||||
connectionGrantMembers,
|
||||
connectionGrants,
|
||||
instanceUserRoles,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
toolAccessAuditEvents,
|
||||
toolConnections,
|
||||
userSecretDeclarations,
|
||||
} from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import { conflict } from "../errors.js";
|
||||
|
|
@ -28,6 +36,318 @@ type MemberArchiveInput = {
|
|||
export function accessService(db: Db) {
|
||||
const authorization = authorizationService(db);
|
||||
|
||||
async function sweepMemberConnectionAccess(
|
||||
tx: Parameters<Parameters<Db["transaction"]>[0]>[0],
|
||||
companyId: string,
|
||||
userId: string,
|
||||
now: Date,
|
||||
) {
|
||||
const departingAudienceRows = await tx.select({
|
||||
grantId: connectionGrantMembers.grantId,
|
||||
}).from(connectionGrantMembers).where(and(
|
||||
eq(connectionGrantMembers.companyId, companyId),
|
||||
eq(connectionGrantMembers.subjectType, "user"),
|
||||
eq(connectionGrantMembers.subjectId, userId),
|
||||
));
|
||||
const departingAudienceGrantIds = [...new Set(departingAudienceRows.map((row) => row.grantId))];
|
||||
const affectedAudienceRows = departingAudienceGrantIds.length === 0 ? [] : await tx.select({
|
||||
grantId: connectionGrantMembers.grantId,
|
||||
subjectId: connectionGrantMembers.subjectId,
|
||||
}).from(connectionGrantMembers).where(and(
|
||||
eq(connectionGrantMembers.companyId, companyId),
|
||||
eq(connectionGrantMembers.subjectType, "user"),
|
||||
inArray(connectionGrantMembers.grantId, departingAudienceGrantIds),
|
||||
));
|
||||
const activeOrganizationAudienceGrants = departingAudienceGrantIds.length === 0 ? [] : await tx.select({
|
||||
id: connectionGrants.id,
|
||||
}).from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, companyId),
|
||||
eq(connectionGrants.kind, "organization"),
|
||||
eq(connectionGrants.status, "active"),
|
||||
inArray(connectionGrants.id, departingAudienceGrantIds),
|
||||
));
|
||||
const activeOrganizationAudienceGrantIds = new Set(activeOrganizationAudienceGrants.map((grant) => grant.id));
|
||||
const soleAudienceGrantIds = new Set(departingAudienceGrantIds.filter((grantId) =>
|
||||
activeOrganizationAudienceGrantIds.has(grantId)
|
||||
&& affectedAudienceRows.filter((row) => row.grantId === grantId).length === 1,
|
||||
));
|
||||
|
||||
const ownedGrants = await tx.select({
|
||||
id: connectionGrants.id,
|
||||
connectionId: connectionGrants.connectionId,
|
||||
credentialSecretRefs: connectionGrants.credentialSecretRefs,
|
||||
}).from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, companyId),
|
||||
eq(connectionGrants.kind, "user"),
|
||||
eq(connectionGrants.subjectUserId, userId),
|
||||
));
|
||||
const grantIds = ownedGrants.map((grant) => grant.id);
|
||||
const ownedGrantIds = new Set(grantIds);
|
||||
const affectedConnectionIds = [...new Set(ownedGrants.map((grant) => grant.connectionId))];
|
||||
const affectedConnections = new Set(affectedConnectionIds);
|
||||
// Membership removal revokes personal connection identities. It must not
|
||||
// erase unrelated user-scoped values used by agent or environment secret
|
||||
// declarations or bindings. Start with only secrets the departing user's
|
||||
// grants explicitly reference, then fail toward retention whenever another
|
||||
// consumer still names the secret or its user-secret definition.
|
||||
const referencedSecretIds = [...new Set(ownedGrants.flatMap((grant) =>
|
||||
grant.credentialSecretRefs.map((ref) => ref.secretId),
|
||||
))];
|
||||
const ownedSecrets = referencedSecretIds.length === 0 ? [] : await tx.select({
|
||||
id: companySecrets.id,
|
||||
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
||||
}).from(companySecrets).where(and(
|
||||
eq(companySecrets.companyId, companyId),
|
||||
eq(companySecrets.scope, "user"),
|
||||
eq(companySecrets.ownerUserId, userId),
|
||||
inArray(companySecrets.id, referencedSecretIds),
|
||||
));
|
||||
const ownedSecretIds = ownedSecrets.map((secret) => secret.id);
|
||||
const ownedSecretSet = new Set(ownedSecretIds);
|
||||
const retainedSecretIds = new Set<string>();
|
||||
const sharedConnectionSecretIds = new Set<string>();
|
||||
let grantMemberRefs: Array<{ grantId: string; subjectId: string }> = [];
|
||||
let existingMemberUserIds = new Set<string>();
|
||||
let grantRefs: Array<{
|
||||
id: string;
|
||||
connectionId: string;
|
||||
kind: typeof connectionGrants.$inferSelect.kind;
|
||||
status: typeof connectionGrants.$inferSelect.status;
|
||||
credentialSecretRefs: typeof connectionGrants.$inferSelect.credentialSecretRefs;
|
||||
}> = [];
|
||||
let connectionRefs: Array<{
|
||||
id: string;
|
||||
credentialRefs: typeof toolConnections.$inferSelect.credentialRefs;
|
||||
credentialSecretRefs: typeof toolConnections.$inferSelect.credentialSecretRefs;
|
||||
}> = [];
|
||||
if (ownedSecretIds.length > 0) {
|
||||
const definitionIds = ownedSecrets.flatMap((secret) =>
|
||||
secret.userSecretDefinitionId ? [secret.userSecretDefinitionId] : [],
|
||||
);
|
||||
const [
|
||||
bindingRefs,
|
||||
declarationRefs,
|
||||
allGrantRefs,
|
||||
allConnectionRefs,
|
||||
allGrantMemberRefs,
|
||||
membershipRefs,
|
||||
] = await Promise.all([
|
||||
tx.select({
|
||||
secretId: companySecretBindings.secretId,
|
||||
targetType: companySecretBindings.targetType,
|
||||
targetId: companySecretBindings.targetId,
|
||||
}).from(companySecretBindings).where(and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
inArray(companySecretBindings.secretId, ownedSecretIds),
|
||||
)),
|
||||
definitionIds.length === 0 ? Promise.resolve([]) : tx.select({
|
||||
userSecretDefinitionId: userSecretDeclarations.userSecretDefinitionId,
|
||||
}).from(userSecretDeclarations).where(and(
|
||||
eq(userSecretDeclarations.companyId, companyId),
|
||||
inArray(userSecretDeclarations.userSecretDefinitionId, definitionIds),
|
||||
)),
|
||||
tx.select({
|
||||
id: connectionGrants.id,
|
||||
connectionId: connectionGrants.connectionId,
|
||||
kind: connectionGrants.kind,
|
||||
status: connectionGrants.status,
|
||||
credentialSecretRefs: connectionGrants.credentialSecretRefs,
|
||||
}).from(connectionGrants).where(eq(connectionGrants.companyId, companyId)),
|
||||
tx.select({
|
||||
id: toolConnections.id,
|
||||
credentialRefs: toolConnections.credentialRefs,
|
||||
credentialSecretRefs: toolConnections.credentialSecretRefs,
|
||||
}).from(toolConnections).where(eq(toolConnections.companyId, companyId)),
|
||||
tx.select({
|
||||
grantId: connectionGrantMembers.grantId,
|
||||
subjectId: connectionGrantMembers.subjectId,
|
||||
}).from(connectionGrantMembers).where(and(
|
||||
eq(connectionGrantMembers.companyId, companyId),
|
||||
eq(connectionGrantMembers.subjectType, "user"),
|
||||
)),
|
||||
tx.select({ userId: companyMemberships.principalId }).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
ne(companyMemberships.principalId, userId),
|
||||
)),
|
||||
]);
|
||||
grantRefs = allGrantRefs;
|
||||
connectionRefs = allConnectionRefs;
|
||||
grantMemberRefs = allGrantMemberRefs;
|
||||
existingMemberUserIds = new Set(membershipRefs.map((row) => row.userId));
|
||||
|
||||
for (const binding of bindingRefs) {
|
||||
if (binding.targetType !== "tool_connection" || !affectedConnections.has(binding.targetId)) {
|
||||
retainedSecretIds.add(binding.secretId);
|
||||
}
|
||||
}
|
||||
const declaredDefinitions = new Set(declarationRefs.map((row) => row.userSecretDefinitionId));
|
||||
for (const secret of ownedSecrets) {
|
||||
if (secret.userSecretDefinitionId && declaredDefinitions.has(secret.userSecretDefinitionId)) {
|
||||
retainedSecretIds.add(secret.id);
|
||||
}
|
||||
}
|
||||
for (const grant of grantRefs) {
|
||||
if (ownedGrantIds.has(grant.id)) continue;
|
||||
const grantAudience = grantMemberRefs.filter((member) => member.grantId === grant.id);
|
||||
const remainingGrantAudience = grantAudience.filter((member) => member.subjectId !== userId);
|
||||
const hasSurvivingOrganizationAudience = grant.kind === "organization"
|
||||
&& grant.status === "active"
|
||||
&& (
|
||||
soleAudienceGrantIds.has(grant.id)
|
||||
? true
|
||||
: remainingGrantAudience.length === 0
|
||||
? existingMemberUserIds.size > 0
|
||||
: remainingGrantAudience.some((member) => existingMemberUserIds.has(member.subjectId))
|
||||
);
|
||||
for (const ref of grant.credentialSecretRefs) {
|
||||
if (!ownedSecretSet.has(ref.secretId)) continue;
|
||||
if (!affectedConnections.has(grant.connectionId)) {
|
||||
retainedSecretIds.add(ref.secretId);
|
||||
} else if (grant.kind === "user" || hasSurvivingOrganizationAudience) {
|
||||
// A connection may temporarily carry separate user grants that
|
||||
// reference the same credential, or an organization grant may
|
||||
// still have another persisted audience member. A sole named
|
||||
// audience row stays persisted instead of being widened to company
|
||||
// scope; both resolvers require current active membership, so the
|
||||
// row remains dormant until company access is restored. Pending,
|
||||
// suspended, and archived memberships are intentionally included
|
||||
// because company access can reactivate each of them later.
|
||||
retainedSecretIds.add(ref.secretId);
|
||||
sharedConnectionSecretIds.add(ref.secretId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const connection of connectionRefs) {
|
||||
if (affectedConnections.has(connection.id)) continue;
|
||||
for (const ref of [...connection.credentialRefs, ...connection.credentialSecretRefs]) {
|
||||
if (ownedSecretSet.has(ref.secretId)) retainedSecretIds.add(ref.secretId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const secretIdsToDelete = ownedSecretIds.filter((secretId) => !retainedSecretIds.has(secretId));
|
||||
const connectionSecretIdsToRemove = new Set(
|
||||
ownedSecretIds.filter((secretId) => !sharedConnectionSecretIds.has(secretId)),
|
||||
);
|
||||
const removedDelegations = grantIds.length === 0 ? [] : await tx
|
||||
.delete(connectionGrantDelegations)
|
||||
.where(and(
|
||||
eq(connectionGrantDelegations.companyId, companyId),
|
||||
inArray(connectionGrantDelegations.grantId, grantIds),
|
||||
))
|
||||
.returning();
|
||||
if (grantIds.length > 0) {
|
||||
await tx.update(connectionGrants).set({
|
||||
status: "revoked",
|
||||
isDefault: false,
|
||||
revokedAt: now,
|
||||
revokedByUserId: null,
|
||||
revokedByAgentId: null,
|
||||
updatedAt: now,
|
||||
}).where(and(
|
||||
eq(connectionGrants.companyId, companyId),
|
||||
inArray(connectionGrants.id, grantIds),
|
||||
));
|
||||
}
|
||||
if (ownedSecretIds.length > 0) {
|
||||
for (const grant of grantRefs) {
|
||||
if (!ownedGrantIds.has(grant.id) && !affectedConnections.has(grant.connectionId)) continue;
|
||||
const refsToRemove = ownedGrantIds.has(grant.id) ? ownedSecretSet : connectionSecretIdsToRemove;
|
||||
const credentialSecretRefs = grant.credentialSecretRefs.filter(
|
||||
(ref) => !refsToRemove.has(ref.secretId),
|
||||
);
|
||||
if (credentialSecretRefs.length !== grant.credentialSecretRefs.length) {
|
||||
await tx.update(connectionGrants).set({
|
||||
credentialSecretRefs,
|
||||
...(ownedGrantIds.has(grant.id) || grant.status === "revoked" ? {
|
||||
status: "revoked" as const,
|
||||
isDefault: false,
|
||||
} : {
|
||||
status: "needs_reauthorization" as const,
|
||||
isDefault: false,
|
||||
}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(connectionGrants.id, grant.id));
|
||||
}
|
||||
}
|
||||
for (const connection of connectionRefs) {
|
||||
if (!affectedConnections.has(connection.id)) continue;
|
||||
const credentialRefs = connection.credentialRefs.filter(
|
||||
(ref) => !connectionSecretIdsToRemove.has(ref.secretId),
|
||||
);
|
||||
const credentialSecretRefs = connection.credentialSecretRefs.filter(
|
||||
(ref) => !connectionSecretIdsToRemove.has(ref.secretId),
|
||||
);
|
||||
if (
|
||||
credentialRefs.length !== connection.credentialRefs.length ||
|
||||
credentialSecretRefs.length !== connection.credentialSecretRefs.length
|
||||
) {
|
||||
const hasUnaffectedActiveGrant = grantRefs.some((grant) =>
|
||||
grant.connectionId === connection.id
|
||||
&& !ownedGrantIds.has(grant.id)
|
||||
&& grant.status === "active"
|
||||
&& grant.credentialSecretRefs.length > 0
|
||||
&& grant.credentialSecretRefs.every((ref) => !connectionSecretIdsToRemove.has(ref.secretId)),
|
||||
);
|
||||
await tx.update(toolConnections).set({
|
||||
credentialRefs,
|
||||
credentialSecretRefs,
|
||||
...(!hasUnaffectedActiveGrant ? {
|
||||
status: "draft" as const,
|
||||
enabled: false,
|
||||
healthStatus: "missing_secret" as const,
|
||||
healthMessage: "Personal credential owner no longer has company access. Reauthorize this connection.",
|
||||
lastError: "oauth_reauthorization_required",
|
||||
} : {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, connection.id));
|
||||
}
|
||||
}
|
||||
if (affectedConnectionIds.length > 0 && connectionSecretIdsToRemove.size > 0) {
|
||||
await tx.delete(companySecretBindings).where(and(
|
||||
eq(companySecretBindings.companyId, companyId),
|
||||
eq(companySecretBindings.targetType, "tool_connection"),
|
||||
inArray(companySecretBindings.targetId, affectedConnectionIds),
|
||||
inArray(companySecretBindings.secretId, [...connectionSecretIdsToRemove]),
|
||||
));
|
||||
}
|
||||
if (secretIdsToDelete.length > 0) {
|
||||
await tx.delete(companySecrets).where(and(
|
||||
eq(companySecrets.companyId, companyId),
|
||||
inArray(companySecrets.id, secretIdsToDelete),
|
||||
));
|
||||
}
|
||||
}
|
||||
await tx.delete(connectionGrantMembers).where(and(
|
||||
eq(connectionGrantMembers.companyId, companyId),
|
||||
eq(connectionGrantMembers.subjectType, "user"),
|
||||
eq(connectionGrantMembers.subjectId, userId),
|
||||
soleAudienceGrantIds.size > 0
|
||||
? notInArray(connectionGrantMembers.grantId, [...soleAudienceGrantIds])
|
||||
: undefined,
|
||||
));
|
||||
if (removedDelegations.length > 0) {
|
||||
const connectionByGrant = new Map(ownedGrants.map((grant) => [grant.id, grant.connectionId]));
|
||||
await tx.insert(toolAccessAuditEvents).values(removedDelegations.map((delegation) => ({
|
||||
companyId,
|
||||
connectionId: connectionByGrant.get(delegation.grantId) ?? null,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "connection_grant.delegation_revoked",
|
||||
outcome: "success",
|
||||
reasonCode: "membership_removed",
|
||||
details: {
|
||||
grantId: delegation.grantId,
|
||||
delegationId: delegation.id,
|
||||
agentId: delegation.agentId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const row = await db
|
||||
|
|
@ -185,6 +505,7 @@ export function accessService(db: Db) {
|
|||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
|
|
@ -216,6 +537,9 @@ export function accessService(db: Db) {
|
|||
}
|
||||
|
||||
const now = new Date();
|
||||
if (existing.status === "active" && nextStatus !== "active" && existing.principalType === "user") {
|
||||
await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now);
|
||||
}
|
||||
const updated = await tx
|
||||
.update(companyMemberships)
|
||||
.set({
|
||||
|
|
@ -334,6 +658,7 @@ export function accessService(db: Db) {
|
|||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
if (existing.principalType !== "user") {
|
||||
|
|
@ -356,6 +681,7 @@ export function accessService(db: Db) {
|
|||
await assertAssignableArchiveTarget(companyId, input.reassignment, tx);
|
||||
|
||||
const now = new Date();
|
||||
await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now);
|
||||
const assignmentPatch = {
|
||||
assigneeAgentId: input.reassignment?.assigneeAgentId ?? null,
|
||||
assigneeUserId: input.reassignment?.assigneeUserId ?? null,
|
||||
|
|
@ -449,11 +775,17 @@ export function accessService(db: Db) {
|
|||
companyIds: string[],
|
||||
options: { actorUserId?: string | null } = {},
|
||||
) {
|
||||
const existing = await listUserCompanyAccess(userId);
|
||||
const existingByCompany = new Map(existing.map((row) => [row.companyId, row]));
|
||||
const target = new Set(companyIds);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Serialize every company-access removal/reactivation with personal OAuth
|
||||
// completion, which locks the same membership row before writing secrets.
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId)))
|
||||
.for("update");
|
||||
const existingByCompany = new Map(existing.map((row) => [row.companyId, row]));
|
||||
const toArchive = existing.filter((row) => !target.has(row.companyId) && row.status !== "archived");
|
||||
if (toArchive.length > 0 && options.actorUserId && options.actorUserId === userId) {
|
||||
throw conflict("You cannot remove yourself");
|
||||
|
|
@ -489,9 +821,13 @@ export function accessService(db: Db) {
|
|||
}
|
||||
}
|
||||
if (toArchive.length > 0) {
|
||||
const now = new Date();
|
||||
for (const membership of toArchive) {
|
||||
await sweepMemberConnectionAccess(tx, membership.companyId, membership.principalId, now);
|
||||
}
|
||||
await tx
|
||||
.update(companyMemberships)
|
||||
.set({ status: "archived", updatedAt: new Date() })
|
||||
.set({ status: "archived", updatedAt: now })
|
||||
.where(inArray(companyMemberships.id, toArchive.map((row) => row.id)));
|
||||
await tx
|
||||
.delete(principalPermissionGrants)
|
||||
|
|
@ -736,6 +1072,7 @@ export function accessService(db: Db) {
|
|||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
|
|
@ -766,12 +1103,21 @@ export function accessService(db: Db) {
|
|||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (
|
||||
existing.principalType === "user" &&
|
||||
existing.status !== "suspended" &&
|
||||
nextStatus === "suspended"
|
||||
) {
|
||||
await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now);
|
||||
}
|
||||
|
||||
return tx
|
||||
.update(companyMemberships)
|
||||
.set({
|
||||
membershipRole: nextMembershipRole,
|
||||
status: nextStatus,
|
||||
updatedAt: new Date(),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companyMemberships.id, existing.id))
|
||||
.returning()
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ describe("attention feed resolver audience", () => {
|
|||
effectiveResolverPolicySource: "requested",
|
||||
resolverPolicyProvenance: "inherited",
|
||||
addresseeAgentId: null,
|
||||
addresseeUserId: null,
|
||||
addresseeName: null,
|
||||
createdByAgentId: "agent-watchdog",
|
||||
createdByAgentName: "Watchdog",
|
||||
|
|
|
|||
|
|
@ -733,6 +733,7 @@ function interactionVerbs(kind: string, payload: Record<string, unknown>) {
|
|||
export function interactionResolverAudience(
|
||||
row: {
|
||||
addresseeAgentId: string | null;
|
||||
addresseeUserId?: string | null;
|
||||
createdByAgentId: string | null;
|
||||
requestedResolverPolicy: string;
|
||||
effectiveResolverPolicy: string;
|
||||
|
|
@ -752,6 +753,7 @@ export function interactionResolverAudience(
|
|||
(row.effectiveResolverPolicySource ?? "requested") as IssueThreadInteractionEffectiveResolverPolicySource,
|
||||
resolverPolicyProvenance: provenance,
|
||||
addresseeAgentId: row.addresseeAgentId,
|
||||
addresseeUserId: row.addresseeUserId ?? null,
|
||||
addresseeName: row.addresseeAgentId ? agentName(row.addresseeAgentId) : null,
|
||||
createdByAgentId: row.createdByAgentId,
|
||||
createdByAgentName: row.createdByAgentId ? agentName(row.createdByAgentId) : null,
|
||||
|
|
@ -1172,6 +1174,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
|||
summary: issueThreadInteractions.summary,
|
||||
payload: issueThreadInteractions.payload,
|
||||
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
|
||||
addresseeUserId: issueThreadInteractions.addresseeUserId,
|
||||
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
||||
requestedResolverPolicy: issueThreadInteractions.requestedResolverPolicy,
|
||||
effectiveResolverPolicy: issueThreadInteractions.effectiveResolverPolicy,
|
||||
|
|
@ -1207,8 +1210,9 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
|||
: [];
|
||||
const companyAgentMap = new Map(companyAgentRows.map((agent) => [agent.id, agent]));
|
||||
const boardInteractionRows = interactionRows.filter((row) =>
|
||||
row.addresseeAgentId === null ||
|
||||
!evaluateAgentInvokability(companyAgentMap.get(row.addresseeAgentId), companyAgentRows).invokable
|
||||
(row.addresseeAgentId === null ||
|
||||
!evaluateAgentInvokability(companyAgentMap.get(row.addresseeAgentId), companyAgentRows).invokable)
|
||||
&& (row.addresseeUserId === null || row.addresseeUserId === options.userId)
|
||||
);
|
||||
const visibleInteractionRows = collapsePendingConfirmationsToNewest(boardInteractionRows);
|
||||
const [interactionIssueMap, interactionImageMap, interactionPlanDocumentMap] = await Promise.all([
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ function interaction(overrides: Record<string, unknown> = {}) {
|
|||
createdByUserId: null,
|
||||
sourceRunId: "run-1",
|
||||
addresseeAgentId: null,
|
||||
addresseeUserId: null,
|
||||
effectiveResolverPolicy: "anyone",
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -50,6 +51,18 @@ describe("issue-thread interaction resolver audience", () => {
|
|||
expect(decision).toMatchObject({ allowed: false, code: "interaction_addressee_mismatch" });
|
||||
});
|
||||
|
||||
it("allows only the addressed user to resolve a user-addressed interaction", () => {
|
||||
expect(evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: { type: "user", userId: "alice" },
|
||||
interaction: interaction({ addresseeUserId: "alice", effectiveResolverPolicy: "human_only" }),
|
||||
})).toMatchObject({ allowed: true, reason: "allow_addressee" });
|
||||
|
||||
expect(evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: { type: "user", userId: "bob" },
|
||||
interaction: interaction({ addresseeUserId: "alice", effectiveResolverPolicy: "human_only" }),
|
||||
})).toMatchObject({ allowed: false, code: "interaction_addressee_mismatch" });
|
||||
});
|
||||
|
||||
it("requires run attribution for agents", () => {
|
||||
const decision = evaluateIssueThreadInteractionResolverAudience({
|
||||
actor: { type: "agent", agentId: "agent-1", runId: null },
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export type IssueThreadInteractionResolverAudienceInput = {
|
|||
createdByUserId?: string | null;
|
||||
sourceRunId?: string | null;
|
||||
addresseeAgentId?: string | null;
|
||||
addresseeUserId?: string | null;
|
||||
effectiveResolverPolicy: IssueThreadInteractionResolverPolicy | string;
|
||||
resolverPolicyProvenance?: string | null;
|
||||
};
|
||||
|
|
@ -180,6 +181,18 @@ export function evaluateIssueThreadInteractionResolverAudience(
|
|||
}
|
||||
|
||||
if (input.actor.type === "user") {
|
||||
if (
|
||||
input.interaction.addresseeUserId
|
||||
&& input.interaction.addresseeUserId !== input.actor.userId
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
effectiveResolverPolicy,
|
||||
status: 403,
|
||||
code: "interaction_addressee_mismatch",
|
||||
message: "Only the addressed user may resolve this issue-thread interaction",
|
||||
};
|
||||
}
|
||||
if (
|
||||
creatorExcluded
|
||||
&& input.interaction.createdByUserId === input.actor.userId
|
||||
|
|
@ -204,7 +217,11 @@ export function evaluateIssueThreadInteractionResolverAudience(
|
|||
return {
|
||||
allowed: true,
|
||||
effectiveResolverPolicy,
|
||||
reason: input.interaction.addresseeAgentId ? "allow_human_override" : "allow_human",
|
||||
reason: input.interaction.addresseeUserId
|
||||
? "allow_addressee"
|
||||
: input.interaction.addresseeAgentId
|
||||
? "allow_human_override"
|
||||
: "allow_human",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -421,6 +421,7 @@ function isEquivalentCreateRequest(
|
|||
row.kind === input.kind
|
||||
&& row.requestedResolverPolicy === input.resolverPolicy
|
||||
&& (row.addresseeAgentId ?? null) === (input.addresseeAgentId ?? null)
|
||||
&& (row.addresseeUserId ?? null) === (input.addresseeUserId ?? null)
|
||||
&& row.continuationPolicy === input.continuationPolicy
|
||||
&& (row.idempotencyKey ?? null) === (input.idempotencyKey ?? null)
|
||||
&& (row.sourceCommentId ?? null) === (input.sourceCommentId ?? null)
|
||||
|
|
@ -478,6 +479,7 @@ function hydrateInteraction(
|
|||
...row,
|
||||
idempotencyKey: row.idempotencyKey ?? null,
|
||||
addresseeAgentId: row.addresseeAgentId ?? null,
|
||||
addresseeUserId: row.addresseeUserId ?? null,
|
||||
status: row.status as IssueThreadInteraction["status"],
|
||||
continuationPolicy: row.continuationPolicy as IssueThreadInteraction["continuationPolicy"],
|
||||
resolverPolicy: requestedResolverPolicy,
|
||||
|
|
@ -2279,6 +2281,10 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
});
|
||||
const normalizedData = { ...data, resolverPolicy: policy.requestedResolverPolicy };
|
||||
|
||||
if (normalizedData.addresseeAgentId && normalizedData.addresseeUserId) {
|
||||
throw unprocessable("An issue-thread interaction cannot address both an agent and a user");
|
||||
}
|
||||
|
||||
if (normalizedData.addresseeAgentId) {
|
||||
if (normalizedData.addresseeAgentId === actor.agentId) {
|
||||
throw unprocessable("Agents cannot address issue-thread interactions to themselves");
|
||||
|
|
@ -2409,6 +2415,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
|||
summary: data.summary ?? null,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
addresseeAgentId: data.addresseeAgentId ?? null,
|
||||
addresseeUserId: data.addresseeUserId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
payload: data.payload,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
connectionGrants,
|
||||
smokeRuns,
|
||||
smokeRunSteps,
|
||||
toolApplications,
|
||||
|
|
@ -741,8 +742,28 @@ export function smokeLabService(db: Db, options: {
|
|||
lastHealthAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const ensureDefaultOrganizationGrant = async (connectionId: string) => {
|
||||
const [existingGrant] = await db.select({ id: connectionGrants.id }).from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, input.companyId),
|
||||
eq(connectionGrants.connectionId, connectionId),
|
||||
eq(connectionGrants.kind, "organization"),
|
||||
eq(connectionGrants.isDefault, true),
|
||||
));
|
||||
if (existingGrant) return;
|
||||
await db.insert(connectionGrants).values({
|
||||
companyId: input.companyId,
|
||||
connectionId,
|
||||
kind: "organization",
|
||||
status: "active",
|
||||
isDefault: true,
|
||||
credentialSecretRefs: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
};
|
||||
if (existing) {
|
||||
const [updated] = await db.update(toolConnections).set(values).where(eq(toolConnections.id, existing.id)).returning();
|
||||
await ensureDefaultOrganizationGrant(existing.id);
|
||||
return { row: updated ?? existing, created: false };
|
||||
}
|
||||
const [created] = await db.insert(toolConnections).values({
|
||||
|
|
@ -754,6 +775,7 @@ export function smokeLabService(db: Db, options: {
|
|||
createdByUserId: input.actor?.actorType === "user" ? input.actor.actorId : null,
|
||||
createdAt: now,
|
||||
}).returning();
|
||||
await ensureDefaultOrganizationGrant(created.id);
|
||||
return { row: created, created: true };
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,6 +5,11 @@ import type { Db } from "@paperclipai/db";
|
|||
import {
|
||||
agents,
|
||||
approvals,
|
||||
companies,
|
||||
companyMemberships,
|
||||
connectionGrantMembers,
|
||||
connectionGrantDelegations,
|
||||
connectionGrants,
|
||||
documents,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
|
|
@ -80,6 +85,25 @@ import {
|
|||
const DEFAULT_SESSION_TTL_MS = 15 * 60 * 1000;
|
||||
const MAX_SESSION_TTL_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_TOOL_TIMEOUT_MS = 10_000;
|
||||
|
||||
export function resolveCredentialGrantKind(
|
||||
policy: "shared" | "per_user" | "per_user_with_fallback",
|
||||
actingUserId: string | null,
|
||||
hasUserGrant: boolean,
|
||||
): "organization" | "user" | "user_authorization_required" {
|
||||
if (policy === "shared") return "organization";
|
||||
if (actingUserId && hasUserGrant) return "user";
|
||||
return policy === "per_user" ? "user_authorization_required" : "organization";
|
||||
}
|
||||
|
||||
export function isConnectionGrantAudienceAllowed(
|
||||
memberUserIds: string[],
|
||||
actingUserId: string | null,
|
||||
actingUserIsActiveMember: boolean,
|
||||
): boolean {
|
||||
if (actingUserId !== null && !actingUserIsActiveMember) return false;
|
||||
return memberUserIds.length === 0 || (actingUserId !== null && memberUserIds.includes(actingUserId));
|
||||
}
|
||||
// When a human approves a parked write, the server carries it out on their
|
||||
// behalf with no interactive caller left to raise `timeoutMs`. Remote write
|
||||
// providers (e.g. Zapier Google Sheets `add_row`) routinely take longer than
|
||||
|
|
@ -2389,16 +2413,36 @@ export function createToolGatewayService(
|
|||
.where(eq(toolConnections.id, connection.id));
|
||||
}
|
||||
|
||||
async function resolveCredentialHeaders(connection: typeof toolConnections.$inferSelect): Promise<Record<string, string>> {
|
||||
function grantRefForHeader(
|
||||
grant: typeof connectionGrants.$inferSelect,
|
||||
ref: McpConnectionCredentialRef,
|
||||
): ToolCredentialSecretRef | undefined {
|
||||
return grant.credentialSecretRefs.find((candidate) =>
|
||||
candidate.configPath === ref.name || candidate.configPath === `credentials.${ref.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveCredentialHeaders(
|
||||
session: ToolGatewaySession,
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
grant: typeof connectionGrants.$inferSelect,
|
||||
): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const ref of connection.credentialRefs ?? []) {
|
||||
if (ref.placement !== "header") continue;
|
||||
const grantRef = grantRefForHeader(grant, ref);
|
||||
if (!grantRef) continue;
|
||||
try {
|
||||
const value = await secrets.resolveSecretValue(connection.companyId, ref.secretId, ref.version ?? "latest", {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connection.id,
|
||||
configPath: `credentials.${ref.name}`,
|
||||
actorType: "system",
|
||||
const value = await secrets.resolveSecretValue(connection.companyId, grantRef.secretId, grantRef.versionSelector ?? "latest", {
|
||||
accessContext: {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connection.id,
|
||||
configPath: `credentials.${ref.name}`,
|
||||
actorType: "system",
|
||||
actorId: session.agentId,
|
||||
issueId: session.issueId,
|
||||
heartbeatRunId: session.runId,
|
||||
},
|
||||
});
|
||||
headers[ref.key] = `${ref.prefix ?? ""}${value}`;
|
||||
} catch {
|
||||
|
|
@ -2411,6 +2455,34 @@ export function createToolGatewayService(
|
|||
);
|
||||
}
|
||||
}
|
||||
const oauthAccessRef = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token");
|
||||
if (oauthAccessRef && headers.Authorization === undefined) {
|
||||
try {
|
||||
const value = await secrets.resolveSecretValue(
|
||||
connection.companyId,
|
||||
oauthAccessRef.secretId,
|
||||
oauthAccessRef.versionSelector ?? "latest",
|
||||
{
|
||||
accessContext: {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connection.id,
|
||||
configPath: oauthAccessRef.configPath,
|
||||
actorType: "system",
|
||||
actorId: session.agentId,
|
||||
issueId: session.issueId,
|
||||
heartbeatRunId: session.runId,
|
||||
},
|
||||
},
|
||||
);
|
||||
headers.Authorization = `Bearer ${value}`;
|
||||
} catch {
|
||||
await markRemoteConnectionHealth(connection, "missing_secret", "A configured credential secret could not be resolved.");
|
||||
throw new ToolGatewayHttpError(422, "A configured credential secret could not be resolved.", "mcp_remote_missing_secret", {
|
||||
connectionId: connection.id,
|
||||
credential: oauthAccessRef.configPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
|
|
@ -2430,12 +2502,7 @@ export function createToolGatewayService(
|
|||
): Promise<ConnectedCredentialVersionSnapshot> {
|
||||
const versionSelector = input.versionSelector ?? "latest";
|
||||
try {
|
||||
const resolvedVersion = await secrets.resolveSecretVersion(connection.companyId, input.secretId, versionSelector, {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connection.id,
|
||||
configPath: input.configPath,
|
||||
actorType: "system",
|
||||
});
|
||||
const resolvedVersion = await secrets.resolveSecretVersion(connection.companyId, input.secretId, versionSelector);
|
||||
return {
|
||||
refHash: input.refHash,
|
||||
versionSelector: String(versionSelector),
|
||||
|
|
@ -2461,6 +2528,7 @@ export function createToolGatewayService(
|
|||
|
||||
async function connectedCredentialVersionSnapshots(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
grant: typeof connectionGrants.$inferSelect,
|
||||
options: { requireResolved: boolean },
|
||||
): Promise<{
|
||||
headerCredentialVersions: ConnectedCredentialVersionSnapshot[];
|
||||
|
|
@ -2472,15 +2540,17 @@ export function createToolGatewayService(
|
|||
for (const ref of connection.credentialRefs ?? []) {
|
||||
if (ref.placement !== "header") continue;
|
||||
const typedRef = ref as McpConnectionCredentialRef;
|
||||
const grantRef = grantRefForHeader(grant, typedRef);
|
||||
if (!grantRef) continue;
|
||||
const configPath = `credentials.${typedRef.name}`;
|
||||
headerCredentialVersions.push(await resolveConnectedCredentialVersion(connection, {
|
||||
secretId: typedRef.secretId,
|
||||
versionSelector: typedRef.version,
|
||||
secretId: grantRef.secretId,
|
||||
versionSelector: grantRef.versionSelector,
|
||||
configPath,
|
||||
refHash: credentialVersionRefHash({
|
||||
kind: "header",
|
||||
name: typedRef.name,
|
||||
secretId: typedRef.secretId,
|
||||
secretId: grantRef.secretId,
|
||||
placement: typedRef.placement,
|
||||
key: typedRef.key,
|
||||
prefix: typedRef.prefix ?? null,
|
||||
|
|
@ -2490,7 +2560,7 @@ export function createToolGatewayService(
|
|||
}));
|
||||
}
|
||||
|
||||
for (const ref of connection.credentialSecretRefs ?? []) {
|
||||
for (const ref of grant.credentialSecretRefs ?? []) {
|
||||
const typedRef = ref as ToolCredentialSecretRef;
|
||||
credentialSecretVersions.push(await resolveConnectedCredentialVersion(connection, {
|
||||
secretId: typedRef.secretId,
|
||||
|
|
@ -2510,6 +2580,252 @@ export function createToolGatewayService(
|
|||
return { headerCredentialVersions, credentialSecretVersions };
|
||||
}
|
||||
|
||||
async function createUserAuthorizationInteraction(
|
||||
session: ToolGatewaySession,
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
userId: string,
|
||||
) {
|
||||
if (!session.issueId || !session.agentId || !session.runId) return;
|
||||
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies)
|
||||
.where(eq(companies.id, session.companyId)).limit(1);
|
||||
const href = `/${company?.issuePrefix ?? ""}/apps/${connection.id}/setup`;
|
||||
const idempotencyKey = `connection-authorization:${connection.id}:${userId}`;
|
||||
const payload = {
|
||||
version: 1 as const,
|
||||
prompt: `Connect your ${connection.name} account to continue`,
|
||||
acceptLabel: "Connect account",
|
||||
rejectLabel: "Not now",
|
||||
detailsMarkdown: "This run needs your personal authorization. Paperclip will not use another user's identity.",
|
||||
target: {
|
||||
type: "custom" as const,
|
||||
key: `connection:${connection.uid}:user:${userId}`,
|
||||
revisionId: connection.updatedAt.toISOString(),
|
||||
label: `Connect ${connection.name}`,
|
||||
href,
|
||||
},
|
||||
};
|
||||
const [existing] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
|
||||
eq(issueThreadInteractions.companyId, session.companyId),
|
||||
eq(issueThreadInteractions.issueId, session.issueId),
|
||||
eq(issueThreadInteractions.idempotencyKey, idempotencyKey),
|
||||
)).limit(1);
|
||||
if (existing) {
|
||||
await db.update(issueThreadInteractions).set({
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
addresseeUserId: userId,
|
||||
payload,
|
||||
result: null,
|
||||
resolvedAt: null,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(issueThreadInteractions.id, existing.id));
|
||||
return;
|
||||
}
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
companyId: session.companyId,
|
||||
issueId: session.issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
idempotencyKey,
|
||||
sourceRunId: session.runId,
|
||||
title: `Connect your ${connection.name}`,
|
||||
summary: "Personal authorization is required before this run can continue.",
|
||||
createdByAgentId: session.agentId,
|
||||
addresseeUserId: userId,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
async function createStandingDelegationInteraction(
|
||||
session: ToolGatewaySession,
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
userId: string,
|
||||
) {
|
||||
if (!session.issueId || !session.agentId || !session.runId) return;
|
||||
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies)
|
||||
.where(eq(companies.id, session.companyId)).limit(1);
|
||||
const href = `/${company?.issuePrefix ?? ""}/apps/${connection.id}/setup`;
|
||||
const idempotencyKey = `connection-delegation:${connection.id}:${userId}:${session.agentId}`;
|
||||
const payload = {
|
||||
version: 1 as const,
|
||||
prompt: `Allow this agent to use your ${connection.name} account for autonomous runs`,
|
||||
acceptLabel: "Review delegation",
|
||||
rejectLabel: "Not now",
|
||||
detailsMarkdown: "This autonomous run is paused. Paperclip will not use your personal identity until you explicitly delegate it to this named agent.",
|
||||
target: {
|
||||
type: "custom" as const,
|
||||
key: `connection:${connection.uid}:delegation:${userId}:${session.agentId}`,
|
||||
revisionId: connection.updatedAt.toISOString(),
|
||||
label: `Delegate ${connection.name}`,
|
||||
href,
|
||||
},
|
||||
};
|
||||
const [existing] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
|
||||
eq(issueThreadInteractions.companyId, session.companyId),
|
||||
eq(issueThreadInteractions.issueId, session.issueId),
|
||||
eq(issueThreadInteractions.idempotencyKey, idempotencyKey),
|
||||
)).limit(1);
|
||||
if (existing) {
|
||||
await db.update(issueThreadInteractions).set({
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
addresseeUserId: userId,
|
||||
payload,
|
||||
result: null,
|
||||
resolvedAt: null,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(issueThreadInteractions.id, existing.id));
|
||||
return;
|
||||
}
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
companyId: session.companyId,
|
||||
issueId: session.issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
effectiveResolverPolicySource: "requested",
|
||||
idempotencyKey,
|
||||
sourceRunId: session.runId,
|
||||
title: `Delegate your ${connection.name}`,
|
||||
summary: "An explicit standing delegation is required for this autonomous run.",
|
||||
createdByAgentId: session.agentId,
|
||||
addresseeUserId: userId,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveConnectionGrant(
|
||||
session: ToolGatewaySession,
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
): Promise<typeof connectionGrants.$inferSelect> {
|
||||
const [run] = session.runId
|
||||
? await db.select({
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
}).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, session.runId),
|
||||
eq(heartbeatRuns.companyId, session.companyId),
|
||||
)).limit(1)
|
||||
: [];
|
||||
const actingUserId = run?.responsibleUserId ?? null;
|
||||
const autonomous = run?.invocationSource === "automation" || run?.invocationSource === "timer";
|
||||
const findUserGrant = async () => {
|
||||
if (!actingUserId) return undefined;
|
||||
const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, connection.companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, actingUserId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
)).limit(1);
|
||||
if (!membership) {
|
||||
throw new ToolGatewayHttpError(403, "The personal grant owner is not an active company member", "grant_owner_membership_inactive", {
|
||||
connectionId: connection.id,
|
||||
actingUserId,
|
||||
remediation: { action: "restore_membership_or_reconnect" },
|
||||
});
|
||||
}
|
||||
const [grant] = await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.kind, "user"),
|
||||
eq(connectionGrants.subjectUserId, actingUserId),
|
||||
eq(connectionGrants.status, "active"),
|
||||
)).limit(1);
|
||||
return grant;
|
||||
};
|
||||
const findOrganizationGrant = async () => {
|
||||
const [grant] = await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.kind, "organization"),
|
||||
eq(connectionGrants.isDefault, true),
|
||||
eq(connectionGrants.status, "active"),
|
||||
)).limit(1);
|
||||
if (!grant) {
|
||||
throw new ToolGatewayHttpError(409, "Organization authorization is required", "organization_authorization_required", {
|
||||
connectionId: connection.id,
|
||||
});
|
||||
}
|
||||
const members = await db.select({ subjectId: connectionGrantMembers.subjectId }).from(connectionGrantMembers).where(and(
|
||||
eq(connectionGrantMembers.companyId, connection.companyId),
|
||||
eq(connectionGrantMembers.grantId, grant.id),
|
||||
eq(connectionGrantMembers.subjectType, "user"),
|
||||
));
|
||||
const activeAudienceMember = actingUserId ? await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, connection.companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, actingUserId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
)).limit(1).then((rows) => rows[0] ?? null) : null;
|
||||
if (!isConnectionGrantAudienceAllowed(
|
||||
members.map((member) => member.subjectId),
|
||||
actingUserId,
|
||||
Boolean(activeAudienceMember),
|
||||
)) {
|
||||
throw new ToolGatewayHttpError(403, "The acting user is not in this grant's audience", "grant_audience_denied", {
|
||||
connectionId: connection.id,
|
||||
grantId: grant.id,
|
||||
actingUserId,
|
||||
});
|
||||
}
|
||||
return grant;
|
||||
};
|
||||
|
||||
const userGrant = connection.credentialPolicy === "shared" ? undefined : await findUserGrant();
|
||||
const resolution = resolveCredentialGrantKind(connection.credentialPolicy, actingUserId, Boolean(userGrant));
|
||||
if (resolution === "user" && userGrant) {
|
||||
if (autonomous) {
|
||||
if (!session.agentId) {
|
||||
throw new ToolGatewayHttpError(409, "Standing delegation requires a named agent", "standing_delegation_required", {
|
||||
connectionId: connection.id,
|
||||
grantId: userGrant.id,
|
||||
actingUserId,
|
||||
});
|
||||
}
|
||||
const [delegation] = await db.select({ id: connectionGrantDelegations.id }).from(connectionGrantDelegations).where(and(
|
||||
eq(connectionGrantDelegations.companyId, connection.companyId),
|
||||
eq(connectionGrantDelegations.grantId, userGrant.id),
|
||||
eq(connectionGrantDelegations.agentId, session.agentId),
|
||||
)).limit(1);
|
||||
if (!delegation) {
|
||||
await createStandingDelegationInteraction(session, connection, actingUserId!);
|
||||
throw new ToolGatewayHttpError(409, "Standing delegation is required for this autonomous run", "standing_delegation_required", {
|
||||
connectionId: connection.id,
|
||||
grantId: userGrant.id,
|
||||
actingUserId,
|
||||
agentId: session.agentId,
|
||||
remediation: { action: "delegate_personal_grant", grantId: userGrant.id, agentId: session.agentId },
|
||||
});
|
||||
}
|
||||
}
|
||||
return userGrant;
|
||||
}
|
||||
if (resolution === "user_authorization_required") {
|
||||
if (actingUserId) await createUserAuthorizationInteraction(session, connection, actingUserId);
|
||||
throw new ToolGatewayHttpError(409, "User authorization is required", "user_authorization_required", {
|
||||
connectionId: connection.id,
|
||||
actingUserId,
|
||||
});
|
||||
}
|
||||
return findOrganizationGrant();
|
||||
}
|
||||
|
||||
async function resolveConnectedRemoteTool(session: ToolGatewaySession, tool: ToolGatewayDescriptor) {
|
||||
if (tool.providerType !== "mcp_remote_http" || !tool.connectionId || !tool.catalogEntryId) {
|
||||
throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found");
|
||||
|
|
@ -2615,9 +2931,12 @@ export function createToolGatewayService(
|
|||
};
|
||||
}
|
||||
|
||||
function localStdioEnvironment(connection: typeof toolConnections.$inferSelect, template: LocalStdioRuntimeTemplate): NodeJS.ProcessEnv {
|
||||
const config = asRecord(connection.config) ?? {};
|
||||
const configEnv = asRecord(config.env) ?? {};
|
||||
async function localStdioEnvironment(
|
||||
session: ToolGatewaySession,
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
template: LocalStdioRuntimeTemplate,
|
||||
grant: typeof connectionGrants.$inferSelect,
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const key of ["PATH", "Path", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"]) {
|
||||
const value = process.env[key];
|
||||
|
|
@ -2626,9 +2945,33 @@ export function createToolGatewayService(
|
|||
}
|
||||
}
|
||||
for (const key of template.envKeys) {
|
||||
const configured = configEnv[key];
|
||||
if (typeof configured === "string") {
|
||||
env[key] = configured;
|
||||
const grantRef = grant.credentialSecretRefs.find((ref) => ref.configPath === `env.${key}`);
|
||||
if (!grantRef) continue;
|
||||
try {
|
||||
env[key] = await secrets.resolveSecretValue(
|
||||
connection.companyId,
|
||||
grantRef.secretId,
|
||||
grantRef.versionSelector ?? "latest",
|
||||
{
|
||||
accessContext: {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connection.id,
|
||||
configPath: grantRef.configPath,
|
||||
actorType: "system",
|
||||
actorId: session.agentId,
|
||||
issueId: session.issueId,
|
||||
heartbeatRunId: session.runId,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
await markRemoteConnectionHealth(connection, "missing_secret", "A configured local stdio credential could not be resolved.");
|
||||
throw new ToolGatewayHttpError(
|
||||
422,
|
||||
"A configured local stdio credential could not be resolved.",
|
||||
"local_stdio_missing_secret",
|
||||
{ connectionId: connection.id, credential: grantRef.configPath },
|
||||
);
|
||||
}
|
||||
}
|
||||
return env;
|
||||
|
|
@ -2642,6 +2985,7 @@ export function createToolGatewayService(
|
|||
connection: typeof toolConnections.$inferSelect;
|
||||
entry: typeof toolCatalogEntries.$inferSelect;
|
||||
template: LocalStdioRuntimeTemplate;
|
||||
env: NodeJS.ProcessEnv;
|
||||
parameters: unknown;
|
||||
timeoutMs: number;
|
||||
}): Promise<unknown> {
|
||||
|
|
@ -2654,7 +2998,7 @@ export function createToolGatewayService(
|
|||
);
|
||||
}
|
||||
const child = spawn(input.template.command, input.template.args, {
|
||||
env: localStdioEnvironment(input.connection, input.template),
|
||||
env: input.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
|
|
@ -2801,7 +3145,8 @@ export function createToolGatewayService(
|
|||
))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const credentialVersions = await connectedCredentialVersionSnapshots(row.connection, {
|
||||
const grant = await resolveConnectionGrant(session, row.connection);
|
||||
const credentialVersions = await connectedCredentialVersionSnapshots(row.connection, grant, {
|
||||
requireResolved: options.requireResolvedCredentials === true,
|
||||
});
|
||||
return {
|
||||
|
|
@ -2817,6 +3162,8 @@ export function createToolGatewayService(
|
|||
connectionTransportConfigHash: stableHash(row.connection.transportConfig ?? {}),
|
||||
credentialRefsHash: stableHash(row.connection.credentialRefs ?? []),
|
||||
credentialSecretRefsHash: stableHash(row.connection.credentialSecretRefs ?? []),
|
||||
credentialGrantId: grant.id,
|
||||
credentialGrantRefsHash: stableHash(grant.credentialSecretRefs ?? []),
|
||||
headerCredentialVersions: credentialVersions.headerCredentialVersions,
|
||||
credentialSecretVersions: credentialVersions.credentialSecretVersions,
|
||||
catalogEntryId: row.entry.id,
|
||||
|
|
@ -3058,13 +3405,14 @@ export function createToolGatewayService(
|
|||
callerHeaders?: ExecuteGatewayToolInput["callerHeaders"],
|
||||
): Promise<RemoteHttpExecutionResult> {
|
||||
const { entry, connection } = await resolveConnectedRemoteTool(session, tool);
|
||||
const grant = await resolveConnectionGrant(session, connection);
|
||||
const endpoint = remoteEndpoint(connection.config ?? {});
|
||||
// Method-defined headers are trusted catalog configuration. Treat them as
|
||||
// managed headers so callers cannot override the scope that was reviewed
|
||||
// during tools/list. Credentials remain authoritative on collisions.
|
||||
const credentialHeaders = {
|
||||
...projectedConnectionHeaders(connection),
|
||||
...await resolveCredentialHeaders(connection),
|
||||
...await resolveCredentialHeaders(session, connection, grant),
|
||||
};
|
||||
const { headers, summary: headerSummary } = buildRemoteHeaders({
|
||||
session,
|
||||
|
|
@ -3208,7 +3556,9 @@ export function createToolGatewayService(
|
|||
ms: number,
|
||||
): Promise<RemoteHttpExecutionResult> {
|
||||
const { entry, connection } = await resolveConnectedLocalStdioTool(session, tool);
|
||||
const grant = await resolveConnectionGrant(session, connection);
|
||||
const template = await resolveLocalStdioRuntimeTemplate(connection);
|
||||
const env = await localStdioEnvironment(session, connection, template, grant);
|
||||
const result = await runtimeSupervisor.useConnectionSlot(
|
||||
{
|
||||
companyId: session.companyId,
|
||||
|
|
@ -3232,6 +3582,7 @@ export function createToolGatewayService(
|
|||
connection,
|
||||
entry,
|
||||
template,
|
||||
env,
|
||||
parameters,
|
||||
timeoutMs: ms,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -123,10 +123,6 @@ test.describe.serial("not-connected app page", () => {
|
|||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-02-reconnect-prefilled.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: "Check link" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Who can use Bla?" })).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: "Continue to install" }).click();
|
||||
await expect(page.getByRole("heading", { name: /Install .* tools\?/i })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByRole("button", { name: "Finish setup" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Bla is ready." })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const apps = await request.get(`/api/companies/${seed.companyId}/tools/applications`);
|
||||
|
|
@ -147,37 +143,25 @@ test.describe.serial("not-connected app page", () => {
|
|||
expect(appConns[0].status).not.toBe("archived");
|
||||
});
|
||||
|
||||
test("draft app connection stays on provider setup until setup finishes", async ({ page, request }) => {
|
||||
const draftMock = await startMockMcp();
|
||||
try {
|
||||
const draft = await request.post(`/api/companies/${seed.companyId}/tools/apps/connect`, {
|
||||
data: {
|
||||
link: draftMock.url,
|
||||
name: "Draft app",
|
||||
credentialValues: { "credentials.authorization": "qa-token" },
|
||||
},
|
||||
});
|
||||
expect(draft.ok(), `draft connect failed ${draft.status()}: ${await draft.text()}`).toBe(true);
|
||||
const draftBody = await draft.json();
|
||||
const draftApplicationId = draftBody.application.id as string;
|
||||
const archive = await request.delete(`/api/tool-connections/${draftBody.connectionId}`);
|
||||
expect(archive.ok(), `draft archive failed ${archive.status()}: ${await archive.text()}`).toBe(true);
|
||||
const revive = await request.patch(`/api/tool-applications/${draftApplicationId}`, { data: { status: "active" } });
|
||||
expect(revive.ok(), `draft revive failed ${revive.status()}: ${await revive.text()}`).toBe(true);
|
||||
test("archived app connection returns to provider setup", async ({ page, request }) => {
|
||||
const archive = await request.delete(`/api/tool-connections/${connectionId}`);
|
||||
expect(archive.ok(), `archive failed ${archive.status()}: ${await archive.text()}`).toBe(true);
|
||||
const revive = await request.patch(`/api/tool-applications/${applicationId}`, { data: { status: "active" } });
|
||||
expect(revive.ok(), `revive failed ${revive.status()}: ${await revive.text()}`).toBe(true);
|
||||
|
||||
await page.goto(`/${seed.prefix}/apps/app/${draftApplicationId}`);
|
||||
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/app/${draftApplicationId}/setup$`), { timeout: 20_000 });
|
||||
await expect(page.getByText("Not connected", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Connect this app" })).toBeVisible();
|
||||
await page.goto(`/${seed.prefix}/apps/app/${applicationId}`);
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/${seed.prefix}/apps/app/${applicationId}/setup$`),
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await expect(page.getByText("Not connected", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Connect this app" })).toBeVisible();
|
||||
|
||||
await page.goto(`/${seed.prefix}/apps/connections`);
|
||||
const row = page.locator("tbody tr", { hasText: "Draft app" });
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByRole("button", { name: "Connect" })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-03-reconnected-row.png`, fullPage: true });
|
||||
} finally {
|
||||
await draftMock.close();
|
||||
}
|
||||
await page.goto(`/${seed.prefix}/apps/connections`);
|
||||
const row = page.locator("tbody tr", { hasText: "Bla" });
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByRole("button", { name: "Connect" })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-03-reconnected-row.png`, fullPage: true });
|
||||
});
|
||||
|
||||
test("danger zone on the app page removes the app", async ({ page, request }) => {
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ test.describe.serial("applications lifecycle", () => {
|
|||
|
||||
test("Connections list surfaces connected and not-connected apps", async ({ page, request }) => {
|
||||
const connectedName = `${APP_PREFIX}-connected`;
|
||||
const notConnectedName = `${APP_PREFIX}-not-connected`;
|
||||
const notConnectedName = `${APP_PREFIX}-offline`;
|
||||
const connected = await createConnection(request, seed.companyId, {
|
||||
applicationName: connectedName,
|
||||
name: connectedName,
|
||||
|
|
|
|||
|
|
@ -161,6 +161,8 @@ test.describe.serial("dark-mode Apps surfaces", () => {
|
|||
await forceDark(page);
|
||||
await page.goto(`/${seed.prefix}/apps/advanced/profiles`);
|
||||
await expect(page.getByRole("heading", { name: "Access profiles" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('a[href$="/apps/advanced/gateways"]', { hasText: "Gateways" })).toBeVisible();
|
||||
await expect(page.locator('a[href$="/apps/advanced/profiles"]', { hasText: "Profiles" })).toBeVisible();
|
||||
await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0);
|
||||
// Apps section lives in the same sidebar now.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { listenOnFetchAllowedPort } from "./fetch-allowed-port";
|
|||
|
||||
// prosumer MCP flow — QA harness for the prosumer Connect-an-app flow on top of the
|
||||
// tool-access foundation. Covers the M-series happy path (gallery + key paste
|
||||
// → choose actions → who-can-use → success), the expired-key reconnect path,
|
||||
// → choose access → install → success), the expired-key reconnect path,
|
||||
// the Needs-attention surface, and a regression check that /apps/advanced
|
||||
// still mounts.
|
||||
//
|
||||
|
|
@ -32,8 +32,7 @@ async function newCompany(request: APIRequestContext, label: string): Promise<Se
|
|||
// ---- Mock MCP HTTP fixture --------------------------------------------------
|
||||
// Minimal MCP JSON-RPC server. /catalog refresh hits this with method
|
||||
// `tools/list`; the gateway calls it with `tools/call`. We expose one
|
||||
// read-only and one write tool so the wizard can show the Ask-first toggle
|
||||
// for write actions.
|
||||
// read-only and one write tool so setup can apply risk-based ask-first defaults.
|
||||
|
||||
type MockMcpServer = { url: string; close: () => Promise<void>; captures: Array<{ method: string; params: unknown }> };
|
||||
|
||||
|
|
@ -145,7 +144,7 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
|||
await mock?.close();
|
||||
});
|
||||
|
||||
test("Connect wizard happy path: link mode → access → install → success", async ({ page, request }) => {
|
||||
test("Connect wizard happy path: link mode → success", async ({ page, request }) => {
|
||||
const seed = await newCompany(request, "connect");
|
||||
|
||||
await gotoConnect(page, seed.prefix);
|
||||
|
|
@ -159,7 +158,7 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
|||
await linkInput.fill(mock.url);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// LinkKey step shows the guided MCP connection heading. Mock doesn't
|
||||
// LinkKey step keeps the BYO connection heading. Mock doesn't
|
||||
// require a key — leave the default "No" answer.
|
||||
await expect(page.getByRole("heading", { name: "Connect your own MCP server" })).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-02-key-step.png`, fullPage: true });
|
||||
|
|
@ -167,19 +166,10 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
|||
// Submit (button label is "Check link").
|
||||
await page.getByRole("button", { name: /Check link/i }).click();
|
||||
|
||||
// Who-can-use step — defaults to All agents.
|
||||
await expect(page.getByRole("heading", { name: /Who can use/i })).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-03-who-step.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: /Continue to install/i }).click();
|
||||
await expect(page.getByRole("heading", { name: /Install .* tools\?/i })).toBeVisible({ timeout: 15_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-04-install-step.png`, fullPage: true });
|
||||
|
||||
// Finish.
|
||||
await page.getByRole("button", { name: /Finish setup/i }).click();
|
||||
|
||||
// Success step.
|
||||
await expect(page.getByText(/ready|all set|done/i).first()).toBeVisible({ timeout: 20_000 });
|
||||
// Link-mode setup uses the safe organization/any-agent defaults, enables
|
||||
// discovered actions, and applies risk-based ask-first defaults in one
|
||||
// commit. Classification remains covered by the server suite.
|
||||
await expect(page.getByRole("heading", { name: /is ready\.$/i })).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-05-success.png`, fullPage: true });
|
||||
|
||||
// Verify the mock saw a tools/list call from the catalog refresh.
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ async function setScoutScript(
|
|||
) {
|
||||
await json(await request.patch(`/api/agents/${scout.id}`, {
|
||||
data: {
|
||||
adapterType: "process",
|
||||
adapterConfig: {
|
||||
command: process.execPath,
|
||||
args: ["--input-type=module", "-e", script],
|
||||
|
|
@ -287,17 +288,22 @@ test.describe.serial("MCP prod Phase 5a user-story harness", () => {
|
|||
test(`${storyById("US-1").id} ${storyById("US-1").title} @mcp-runnable @mcp-us1`, async ({ page, request }) => {
|
||||
const { seed, scout, mock, connectionId } = await seedConnectedFixture(request, "us1");
|
||||
try {
|
||||
await page.goto(`/${seed.prefix}/apps/${connectionId}`);
|
||||
await expect(page.getByRole("heading", { name: /Sheets Fixture us1/i })).toBeVisible({ timeout: 30_000 });
|
||||
await screenshot(page, "US-1", "01-connected-app");
|
||||
|
||||
await setScoutScript(request, scout, buildGatewayCallScript(connectionId, "sheets:list_rows"));
|
||||
const invoked = await invokeHeartbeat(request, scout.id);
|
||||
const run = await waitForRun(request, invoked.id);
|
||||
expect(run.status, run.error ?? `heartbeat run ${run.id} did not succeed`).toBe("succeeded");
|
||||
const failedLog = run.status === "succeeded"
|
||||
? null
|
||||
: await json<{ content?: string }>(await request.get(`/api/heartbeat-runs/${run.id}/log?offset=0&limitBytes=65536`));
|
||||
expect(
|
||||
run.status,
|
||||
[run.error ?? `heartbeat run ${run.id} did not succeed`, failedLog?.content].filter(Boolean).join("\n"),
|
||||
).toBe("succeeded");
|
||||
expect(mock.captures.some((capture) => capture.method === "tools/call" && capture.toolName === "sheets:list_rows")).toBe(true);
|
||||
await expectAuditEvent(request, seed.companyId, { connectionId, agentId: scout.id, search: "sheets:list_rows" });
|
||||
|
||||
await page.goto(`/${seed.prefix}/apps/${connectionId}`);
|
||||
await expect(page.getByRole("heading", { name: /Sheets Fixture us1/i })).toBeVisible({ timeout: 30_000 });
|
||||
await screenshot(page, "US-1", "01-connected-app");
|
||||
await page.goto(`/${seed.prefix}/apps/${connectionId}/activity`);
|
||||
await screenshot(page, "US-1", "02-activity");
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export default defineConfig({
|
|||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
PORT: String(PORT),
|
||||
PAPERCLIP_API_URL: BASE_URL,
|
||||
PAPERCLIP_HOME,
|
||||
PAPERCLIP_INSTANCE_ID,
|
||||
PAPERCLIP_CONFIG,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ import type {
|
|||
UpdateToolMcpGateway,
|
||||
CreateToolTrustRuleFromActionRequest,
|
||||
ToolRedactedValueSummary,
|
||||
ConnectionGrant,
|
||||
ConnectionGrantDelegation,
|
||||
ConnectionGrantsResponse,
|
||||
ToolConnectionCreateCapabilities,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
|
|
@ -73,7 +77,10 @@ export type ToolRuntimeHealthResponse = ToolRuntimeHealthSummary;
|
|||
export type ToolTrustRulesResponse = { trustRules: ToolPolicy[] };
|
||||
export type ToolPoliciesResponse = { policies: ToolPolicy[] };
|
||||
export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] };
|
||||
export type ToolGalleryResponse = { apps: AppDefinition[] };
|
||||
export type ToolGalleryResponse = {
|
||||
apps: AppDefinition[];
|
||||
capabilities: ToolConnectionCreateCapabilities;
|
||||
};
|
||||
export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] };
|
||||
export type CreateGatewayTokenInput = Omit<CreateToolMcpGatewayToken, "expiresAt"> & {
|
||||
expiresAt?: string | Date | null;
|
||||
|
|
@ -306,6 +313,46 @@ export const toolsApi = {
|
|||
`/tool-connections/${connectionId}/installs`,
|
||||
{ installs },
|
||||
),
|
||||
// --- Identity grants (PAP-17835): who a connection acts as. The response
|
||||
// carries server-computed capabilities and the audience member directory, so
|
||||
// the UI never rebuilds the permission matrix from `membershipRole`.
|
||||
listConnectionGrants: (connectionId: string) =>
|
||||
api.get<ConnectionGrantsResponse>(`/tool-connections/${connectionId}/grants`),
|
||||
createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) =>
|
||||
api.post<ConnectionGrantDelegation>(
|
||||
`/tool-connections/${connectionId}/grants/${grantId}/delegations`,
|
||||
{ agentId },
|
||||
),
|
||||
revokeConnectionGrantDelegation: (
|
||||
connectionId: string,
|
||||
grantId: string,
|
||||
delegationId: string,
|
||||
) => api.delete<ConnectionGrantDelegation>(
|
||||
`/tool-connections/${connectionId}/grants/${grantId}/delegations/${delegationId}`,
|
||||
),
|
||||
revokeConnectionGrant: (connectionId: string, grantId: string) =>
|
||||
api.delete<ConnectionGrant>(`/tool-connections/${connectionId}/grants/${grantId}`),
|
||||
// An empty `memberUserIds` is the canonical "all organization members".
|
||||
// Replacement is atomic server-side, so this never partially widens access.
|
||||
replaceConnectionGrantMembers: (connectionId: string, grantId: string, memberUserIds: string[]) =>
|
||||
api.put<ConnectionGrant>(
|
||||
`/tool-connections/${connectionId}/grants/${grantId}/members`,
|
||||
{ memberUserIds },
|
||||
),
|
||||
/**
|
||||
* Start the signed-in user's own personal authorization. `subjectUserId` must
|
||||
* be the caller: the server refuses any other subject, so there is no way to
|
||||
* initiate consent on someone else's behalf.
|
||||
*/
|
||||
startPersonalAuthorization: (
|
||||
companyId: string,
|
||||
connectionId: string,
|
||||
input: { subjectUserId: string; scopes?: string[]; returnTo?: string },
|
||||
) =>
|
||||
api.post<{ url: string }>(
|
||||
`/companies/${companyId}/tools/connections/${connectionId}/start-authorization`,
|
||||
input,
|
||||
),
|
||||
createConnection: (companyId: string, input: CreateToolConnectionInput) =>
|
||||
api.post<ToolConnection>(`/companies/${companyId}/tools/connections`, input),
|
||||
updateConnection: (connectionId: string, input: UpdateToolConnectionInput) =>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ import {
|
|||
pendingRequestConfirmationInteraction,
|
||||
pendingToolActionDestructiveInteraction,
|
||||
pendingToolActionWriteInteraction,
|
||||
issueThreadInteractionFixtureMeta,
|
||||
pendingSecretProposalInteraction,
|
||||
pendingConnectionAuthorizationInteraction,
|
||||
resolvedConnectionAuthorizationInteraction,
|
||||
executedSecretProposalInteraction,
|
||||
failedSecretProposalInteraction,
|
||||
rejectedSecretProposalInteraction,
|
||||
|
|
@ -1217,9 +1220,132 @@ describe("IssueThreadInteractionCard secret-proposal card", () => {
|
|||
});
|
||||
|
||||
/**
|
||||
* The effective audience is shown *before* anyone responds, so a reader never
|
||||
* has to guess whether an open card is waiting on them (PAP-17280).
|
||||
* Connection authorization has its own card composition (PAP-17796 Surface F,
|
||||
* corrected in PAP-17859). It must not fall through to the generic
|
||||
* Approve / Revise… / Reject layout: there is nothing to revise, and only the
|
||||
* addressed person can answer at all.
|
||||
*/
|
||||
describe("IssueThreadInteractionCard connection-authorization card", () => {
|
||||
const CAROL_LABELS = new Map([
|
||||
[issueThreadInteractionFixtureMeta.currentUserId, "Carol"],
|
||||
]);
|
||||
|
||||
function buttonLabels(host: HTMLElement) {
|
||||
return Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim());
|
||||
}
|
||||
|
||||
it("offers the addressed person Connect plus Not now, and nothing from the generic grammar", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingConnectionAuthorizationInteraction,
|
||||
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
onAcceptInteraction: async () => {},
|
||||
onRejectInteraction: async () => {},
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("Connect your Gmail to continue");
|
||||
|
||||
// "Action required" rather than the generic kind label: the mechanism is
|
||||
// not the point, the blocked work is.
|
||||
const statusBadge = host.querySelector('[data-testid="interaction-status-badge"]');
|
||||
expect(statusBadge?.textContent).toContain("Action required");
|
||||
expect(statusBadge?.textContent).not.toContain("Confirmation");
|
||||
|
||||
// One title, one body. The generic layout rendered `payload.prompt` too,
|
||||
// which is the title verbatim.
|
||||
const titles = (host.textContent ?? "").split("Connect your Gmail to continue").length - 1;
|
||||
expect(titles).toBe(1);
|
||||
const body = host.querySelector('[data-testid="connection-authorization-body"]');
|
||||
expect(host.querySelectorAll('[data-testid="connection-authorization-body"]').length).toBe(1);
|
||||
expect(body?.textContent).toContain(
|
||||
"Outreach Agent needs your Gmail identity for work running as you.",
|
||||
);
|
||||
expect(body?.textContent).toContain("No one else can complete this step.");
|
||||
|
||||
// The primary action is a link to the server-minted target, not an accept
|
||||
// call: the OAuth callback is what resolves this card.
|
||||
const connect = Array.from(host.querySelectorAll("a")).find((anchor) =>
|
||||
anchor.textContent?.includes("Connect Gmail"),
|
||||
);
|
||||
expect(connect?.getAttribute("href")).toBe(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?client_id=paperclip",
|
||||
);
|
||||
expect(connect?.getAttribute("target")).toBe("_blank");
|
||||
|
||||
const labels = buttonLabels(host);
|
||||
expect(labels).toContain("Not now");
|
||||
for (const generic of ["Approve", "Revise…", "Reject", "Send revision"]) {
|
||||
expect(labels).not.toContain(generic);
|
||||
}
|
||||
|
||||
// Consent is the addressed person's alone. The card must never imply a
|
||||
// teammate can give it for them.
|
||||
expect(host.textContent?.toLowerCase()).not.toContain("on behalf of");
|
||||
expect(host.textContent?.toLowerCase()).not.toContain("anyone can");
|
||||
});
|
||||
|
||||
it("gives another reader Waiting for Carol and no action controls at all", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingConnectionAuthorizationInteraction,
|
||||
currentUserId: "user-someone-else",
|
||||
userLabelMap: CAROL_LABELS,
|
||||
onAcceptInteraction: async () => {},
|
||||
onRejectInteraction: async () => {},
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="connection-authorization-waiting"]')?.textContent)
|
||||
.toContain("Waiting for Carol");
|
||||
expect(host.querySelector('[data-testid="interaction-status-badge"]')?.textContent)
|
||||
.toContain("Waiting for Carol");
|
||||
// The server's summary is second-person ("your Gmail identity", "as you")
|
||||
// because it addressed one person. A teammate must not be told the agent
|
||||
// wants *their* account.
|
||||
const body = host.querySelector('[data-testid="connection-authorization-body"]')?.textContent;
|
||||
expect(body).toBe(
|
||||
"Outreach Agent needs Carol's Gmail identity for work running as them.",
|
||||
);
|
||||
expect(host.querySelector('[data-testid="connection-authorization-waiting"]')?.textContent)
|
||||
.toContain("Only Carol can connect their own Gmail account.");
|
||||
|
||||
// Omitted, not disabled — and the authorization URL is not even in the DOM
|
||||
// for someone who may not use it.
|
||||
const labels = buttonLabels(host);
|
||||
for (const forbidden of ["Connect Gmail", "Not now", "Approve", "Revise…", "Reject"]) {
|
||||
expect(labels).not.toContain(forbidden);
|
||||
}
|
||||
expect(
|
||||
Array.from(host.querySelectorAll("a")).some((a) => a.textContent?.includes("Connect Gmail")),
|
||||
).toBe(false);
|
||||
expect(host.innerHTML).not.toContain("accounts.google.com");
|
||||
});
|
||||
|
||||
it("resolves to Gmail connected with the resolver and a timestamp", () => {
|
||||
const host = renderCard({
|
||||
interaction: resolvedConnectionAuthorizationInteraction,
|
||||
currentUserId: "user-someone-else",
|
||||
userLabelMap: CAROL_LABELS,
|
||||
onAcceptInteraction: async () => {},
|
||||
onRejectInteraction: async () => {},
|
||||
});
|
||||
|
||||
const statusBadge = host.querySelector('[data-testid="interaction-status-badge"]');
|
||||
expect(statusBadge?.textContent).toContain("Gmail connected");
|
||||
expect(statusBadge?.textContent).not.toContain("Action required");
|
||||
|
||||
const connected = host.querySelector('[data-testid="connection-authorization-connected"]');
|
||||
expect(connected?.textContent).toContain("Gmail connected");
|
||||
expect(connected?.textContent).toContain("Connected by Carol");
|
||||
expect(connected?.textContent).toMatch(/on .*2026/);
|
||||
expect(host.querySelector('[data-testid="connection-authorization-body"]')?.textContent)
|
||||
.toBe("Outreach Agent needed Carol's Gmail identity for work running as them.");
|
||||
|
||||
// A resolved card never re-offers the spent authorization target.
|
||||
expect(host.innerHTML).not.toContain("accounts.google.com");
|
||||
expect(buttonLabels(host)).not.toContain("Connect Gmail");
|
||||
// The card states its own resolver, so the shared footer must not repeat it.
|
||||
expect(host.querySelector('[data-testid="interaction-resolved-footer"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("IssueThreadInteractionCard resolver audience", () => {
|
||||
it("shows an open audience on a pending card created without a restriction", () => {
|
||||
const host = renderCard({ interaction: pendingRequestConfirmationInteraction });
|
||||
|
|
|
|||
|
|
@ -342,6 +342,86 @@ function isSecretProposalConfirmation(interaction: IssueThreadInteraction): bool
|
|||
return secretProposalPayload(interaction) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `request_confirmation` carrying `payload.connectionAuthorization` is asking
|
||||
* one person to connect their own account so an agent can act as them
|
||||
* (PAP-17835). It keeps the interaction kind and the server-addressed audience;
|
||||
* only the presentation differs, and it reads that presentation from the payload
|
||||
* rather than parsing the title string.
|
||||
*/
|
||||
function connectionAuthorizationPayload(
|
||||
interaction: IssueThreadInteraction,
|
||||
): NonNullable<RequestConfirmationInteraction["payload"]["connectionAuthorization"]> | null {
|
||||
if (interaction.kind !== "request_confirmation") return null;
|
||||
return interaction.payload.connectionAuthorization ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five states the connection-authorization card can be read in (PAP-17859).
|
||||
*
|
||||
* `actionable` and `waiting` are the *same* pending row seen by two different
|
||||
* readers: consent belongs to the addressed person alone, so who is looking
|
||||
* changes what may be offered — not merely whether a button is greyed out.
|
||||
*/
|
||||
type ConnectionAuthorizationCardState =
|
||||
| "actionable"
|
||||
| "waiting"
|
||||
| "connected"
|
||||
| "declined"
|
||||
| "expired";
|
||||
|
||||
function connectionAuthorizationCardState({
|
||||
interaction,
|
||||
isAddressee,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
isAddressee: boolean;
|
||||
}): ConnectionAuthorizationCardState {
|
||||
if (interaction.status === "accepted") return "connected";
|
||||
if (interaction.status === "rejected") return "declined";
|
||||
if (interaction.status === "pending") return isAddressee ? "actionable" : "waiting";
|
||||
// expired / cancelled / failed all mean the same thing to a reader here: the
|
||||
// authorization run this card carried is over.
|
||||
return "expired";
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when the signed-in reader *is* the person the server addressed.
|
||||
*
|
||||
* Deliberately strict: an unknown viewer (`currentUserId` not loaded yet) is
|
||||
* never treated as the addressee, so the Connect action cannot flash into view
|
||||
* for someone who may not consent. The server re-authorizes the callback
|
||||
* regardless; this only decides what the card offers.
|
||||
*/
|
||||
function isConnectionAuthorizationAddressee({
|
||||
interaction,
|
||||
currentUserId,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
currentUserId?: string | null;
|
||||
}): boolean {
|
||||
const addressee = interaction.addresseeUserId;
|
||||
if (!addressee || !currentUserId) return false;
|
||||
return addressee === currentUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The authorization URL this card may open, or `null`.
|
||||
*
|
||||
* A target is offered **only while the interaction is pending**. The server
|
||||
* re-upserts this row with a freshly minted `state` every time it starts a new
|
||||
* authorization run, so a pending card always carries a live target and a
|
||||
* resolved/declined/expired one always carries a spent one. Gating on the
|
||||
* status is therefore how "never reuse an expired OAuth URL" is enforced —
|
||||
* there is no client-visible expiry on the payload to check instead.
|
||||
*/
|
||||
function connectionAuthorizationHref(interaction: RequestConfirmationInteraction): string | null {
|
||||
if (interaction.status !== "pending") return null;
|
||||
const href = interaction.payload.target?.href;
|
||||
if (!href) return null;
|
||||
return normalizeRequestConfirmationTargetHref(href);
|
||||
}
|
||||
|
||||
type ToolActionCardState =
|
||||
| "pending"
|
||||
| "running"
|
||||
|
|
@ -2482,6 +2562,262 @@ function ConfirmationActionRow({
|
|||
);
|
||||
}
|
||||
|
||||
function connectionAuthorizationStatusClasses(
|
||||
state: ConnectionAuthorizationCardState,
|
||||
copy: { providerName: string; addresseeLabel: string },
|
||||
): {
|
||||
shell: string;
|
||||
badge: string;
|
||||
label: string;
|
||||
Icon: typeof CheckCircle2;
|
||||
} {
|
||||
switch (state) {
|
||||
case "actionable":
|
||||
return {
|
||||
shell: "border-2 border-sky-500/70 bg-transparent",
|
||||
badge: "border-sky-500/60 bg-sky-500/10 text-sky-900 dark:bg-sky-500/15 dark:text-sky-100",
|
||||
label: "Action required",
|
||||
Icon: KeyRound,
|
||||
};
|
||||
case "waiting":
|
||||
// Not a warning and not a failure: someone else's decision is simply
|
||||
// outstanding. The calm inert lane keeps it out of the reader's queue.
|
||||
return {
|
||||
shell: "border-border bg-transparent",
|
||||
badge: "border-border bg-muted/60 text-muted-foreground",
|
||||
label: `Waiting for ${copy.addresseeLabel}`,
|
||||
Icon: Clock,
|
||||
};
|
||||
case "connected":
|
||||
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: `${copy.providerName} connected`,
|
||||
Icon: CheckCircle2,
|
||||
};
|
||||
case "declined":
|
||||
return {
|
||||
shell: "border-border bg-transparent",
|
||||
badge: "border-border bg-muted/60 text-muted-foreground",
|
||||
label: "Not connected",
|
||||
Icon: MinusCircle,
|
||||
};
|
||||
case "expired":
|
||||
default:
|
||||
return {
|
||||
shell: "border-border bg-transparent",
|
||||
badge: "border-border bg-muted/60 text-muted-foreground",
|
||||
label: "Authorization expired",
|
||||
Icon: CircleDashed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection authorization — "Connect your Gmail to continue" (PAP-17796
|
||||
* Surface F, corrected in PAP-17859).
|
||||
*
|
||||
* This is deliberately *not* the generic Approve / Revise… / Reject grammar it
|
||||
* used to fall through to. Authorization is not a review: there is nothing to
|
||||
* revise, "Reject" is the wrong word for declining to link your own account,
|
||||
* and only one person in the company can answer at all. So the card composes
|
||||
* one title, one body, and exactly the affordances the reader legitimately has:
|
||||
*
|
||||
* - the addressed person gets the single primary **Connect <Provider>** target
|
||||
* plus a plain **Not now**;
|
||||
* - anybody else gets **Waiting for <Person>** and no action at all — a
|
||||
* policy-forbidden action is omitted, never rendered disabled, and the card
|
||||
* must not imply a teammate can consent on their behalf;
|
||||
* - once resolved it states the outcome, who resolved it, and when.
|
||||
*
|
||||
* The primary action is a link to the server-minted authorization URL, not an
|
||||
* accept call: the OAuth callback is what resolves this interaction, so the
|
||||
* card never claims success the provider has not granted.
|
||||
*/
|
||||
function RequestConnectionAuthorizationCard({
|
||||
interaction,
|
||||
state,
|
||||
isAddressee,
|
||||
providerName,
|
||||
addresseeLabel,
|
||||
requestingAgentLabel,
|
||||
resolvedByLabel,
|
||||
resolvedByAgent,
|
||||
onRejectInteraction,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
state: ConnectionAuthorizationCardState;
|
||||
/** Is the signed-in reader the person the server addressed? */
|
||||
isAddressee: boolean;
|
||||
providerName: string;
|
||||
addresseeLabel: string;
|
||||
requestingAgentLabel: string | null;
|
||||
resolvedByLabel: string | null;
|
||||
resolvedByAgent: boolean;
|
||||
onRejectInteraction?: (
|
||||
interaction: RequestConfirmationInteraction,
|
||||
reason?: string,
|
||||
) => Promise<void> | void;
|
||||
}) {
|
||||
const [working, setWorking] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const resolutionErrorMessage = useResolutionErrorMessage();
|
||||
const href = connectionAuthorizationHref(interaction);
|
||||
const declineReason = getAdministrativeReason(interaction);
|
||||
|
||||
useEffect(() => {
|
||||
setActionError(null);
|
||||
setWorking(false);
|
||||
}, [interaction.id, interaction.status]);
|
||||
|
||||
async function handleNotNow() {
|
||||
if (!onRejectInteraction) return;
|
||||
setWorking(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await onRejectInteraction(interaction);
|
||||
} catch (error) {
|
||||
setActionError(resolutionErrorMessage(error));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
// One body, composed here rather than split between the header summary and a
|
||||
// payload prompt that repeats the title.
|
||||
//
|
||||
// The server's summary is written in the second person ("needs *your* Gmail
|
||||
// identity for work running as *you*") because the server addressed one
|
||||
// person. Shown to a teammate it names the wrong account, so a reader who is
|
||||
// not the addressee gets the same fact stated about them. Caught by rendering
|
||||
// the card, not by reading it.
|
||||
const agentLabel = requestingAgentLabel ?? "An agent";
|
||||
const lead = isAddressee
|
||||
? interaction.summary?.trim()
|
||||
|| `${agentLabel} needs your ${providerName} identity for work running as you.`
|
||||
: `${agentLabel} ${state === "connected" ? "needed" : "needs"} ${addresseeLabel}'s ${providerName} identity for work running as them.`;
|
||||
// Only the actionable state needs the consent boundary spelled out; the other
|
||||
// states carry it in their own status line.
|
||||
const consentSentence = state === "actionable" ? "No one else can complete this step." : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p
|
||||
className="max-w-3xl text-sm leading-6 text-muted-foreground"
|
||||
data-testid="connection-authorization-body"
|
||||
>
|
||||
{consentSentence ? `${lead} ${consentSentence}` : lead}
|
||||
</p>
|
||||
|
||||
{state === "actionable" ? (
|
||||
<div className="space-y-3">
|
||||
{href ? (
|
||||
<div
|
||||
data-testid="connection-authorization-actions"
|
||||
className="grid grid-cols-1 items-stretch gap-2 sm:flex sm:flex-wrap sm:items-center sm:justify-end"
|
||||
>
|
||||
<Button asChild size="sm" variant="cta" className="w-full sm:w-auto">
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
Connect {providerName}
|
||||
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={!onRejectInteraction || working}
|
||||
onClick={() => void handleNotNow()}
|
||||
>
|
||||
{working ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Saving…
|
||||
</>
|
||||
) : (
|
||||
"Not now"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// A pending card with no usable target is a server-side gap, not an
|
||||
// invitation to reuse an old URL.
|
||||
<ConnectionAuthorizationStatusLine
|
||||
Icon={TriangleAlert}
|
||||
testId="connection-authorization-no-target"
|
||||
headline="This authorization link is unavailable"
|
||||
detail={`Ask ${requestingAgentLabel ?? "the agent"} to send a fresh ${providerName} authorization link.`}
|
||||
/>
|
||||
)}
|
||||
<InteractionActionError message={actionError} />
|
||||
</div>
|
||||
) : state === "waiting" ? (
|
||||
<ConnectionAuthorizationStatusLine
|
||||
Icon={Clock}
|
||||
testId="connection-authorization-waiting"
|
||||
headline={`Waiting for ${addresseeLabel}`}
|
||||
detail={`Only ${addresseeLabel} can connect their own ${providerName} account.`}
|
||||
/>
|
||||
) : state === "connected" ? (
|
||||
<ConnectionAuthorizationStatusLine
|
||||
Icon={CheckCircle2}
|
||||
testId="connection-authorization-connected"
|
||||
headline={`${providerName} connected`}
|
||||
detail={
|
||||
<>
|
||||
Connected by{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{/* "You" is display-cased for a badge; this is mid-sentence. */}
|
||||
{(resolvedByLabel ?? addresseeLabel) === "You" ? "you" : resolvedByLabel ?? addresseeLabel}
|
||||
</span>
|
||||
{resolvedByAgent ? <ResolvedByAgentChip /> : null}
|
||||
{interaction.resolvedAt ? ` on ${formatDateTime(interaction.resolvedAt)}` : ""}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
// Declined or expired. No Connect action: the authorization target this
|
||||
// card carried is spent, and the agent asks again with a fresh one
|
||||
// rather than the board replaying a dead URL.
|
||||
<ConnectionAuthorizationStatusLine
|
||||
Icon={state === "declined" ? MinusCircle : CircleDashed}
|
||||
testId={state === "declined" ? "connection-authorization-declined" : "connection-authorization-expired"}
|
||||
headline={state === "declined" ? `${providerName} was not connected` : "This authorization request expired"}
|
||||
detail={
|
||||
declineReason
|
||||
?? `${requestingAgentLabel ?? "The agent"} can ask again with a new ${providerName} authorization link.`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionAuthorizationStatusLine({
|
||||
Icon,
|
||||
testId,
|
||||
headline,
|
||||
detail,
|
||||
}: {
|
||||
Icon: typeof CheckCircle2;
|
||||
testId: string;
|
||||
headline: string;
|
||||
detail: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="flex items-start gap-2 rounded-sm border border-border/70 bg-muted/30 p-3"
|
||||
>
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 space-y-0.5 text-sm">
|
||||
<div className="font-medium text-foreground">{headline}</div>
|
||||
<div className="leading-6 text-muted-foreground">{detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestConfirmationCard({
|
||||
interaction,
|
||||
isPlan = false,
|
||||
|
|
@ -2954,7 +3290,11 @@ function RequestCheckboxConfirmationCard({
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-4">
|
||||
<div className="text-sm leading-6 text-foreground">{interaction.payload.prompt}</div>
|
||||
{/* Show each piece of state once: a connection-authorization prompt is
|
||||
the same sentence as the card title, so repeating it here is noise. */}
|
||||
{interaction.payload.prompt === interaction.title ? null : (
|
||||
<div className="text-sm leading-6 text-foreground">{interaction.payload.prompt}</div>
|
||||
)}
|
||||
{interaction.payload.detailsMarkdown ? (
|
||||
<div className="border-t border-border/60 pt-3 text-sm">
|
||||
<MarkdownBody externalReferences={externalReferences}>{interaction.payload.detailsMarkdown}</MarkdownBody>
|
||||
|
|
@ -3020,7 +3360,7 @@ function RequestCheckboxConfirmationCard({
|
|||
<ConfirmationActionRow
|
||||
resetKey={`${interaction.id}:${interaction.status}`}
|
||||
approveLabel={interaction.payload.acceptLabel ?? CONFIRMATION_APPROVE_LABEL}
|
||||
rejectLabel={CONFIRMATION_REJECT_LABEL}
|
||||
rejectLabel={interaction.payload.rejectLabel ?? CONFIRMATION_REJECT_LABEL}
|
||||
primaryActionOnRight={primaryActionOnRight}
|
||||
allowRevise={allowRevise}
|
||||
rejectRequiresReason={rejectRequiresReason}
|
||||
|
|
@ -3552,6 +3892,15 @@ export function IssueThreadInteractionCard({
|
|||
interaction.kind === "request_confirmation" && isToolActionConfirmation(interaction);
|
||||
const isSecretProposal =
|
||||
interaction.kind === "request_confirmation" && isSecretProposalConfirmation(interaction);
|
||||
const connectionAuthorization = connectionAuthorizationPayload(interaction);
|
||||
const isConnectionAddressee =
|
||||
connectionAuthorization && interaction.kind === "request_confirmation"
|
||||
? isConnectionAuthorizationAddressee({ interaction, currentUserId })
|
||||
: false;
|
||||
const connectionAuthorizationState =
|
||||
connectionAuthorization && interaction.kind === "request_confirmation"
|
||||
? connectionAuthorizationCardState({ interaction, isAddressee: isConnectionAddressee })
|
||||
: null;
|
||||
const toolActionState =
|
||||
isToolAction && interaction.kind === "request_confirmation"
|
||||
? toolActionCardState(interaction)
|
||||
|
|
@ -3572,7 +3921,26 @@ export function IssueThreadInteractionCard({
|
|||
interaction.result && "outcome" in interaction.result ? interaction.result.outcome : null,
|
||||
)
|
||||
: null;
|
||||
const activeStyles = secretProposalStyles ?? toolActionStyles ?? planStyles;
|
||||
// Interactions can be directed at a specific agent or board user. Resolved
|
||||
// before the status styling because the connection-authorization badge names
|
||||
// the addressee ("Waiting for Carol").
|
||||
const addresseeLabel = interaction.addresseeAgentId || interaction.addresseeUserId
|
||||
? resolveActorLabel({
|
||||
agentId: interaction.addresseeAgentId,
|
||||
userId: interaction.addresseeUserId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
})
|
||||
: null;
|
||||
const connectionAuthorizationStyles = connectionAuthorization && connectionAuthorizationState
|
||||
? connectionAuthorizationStatusClasses(connectionAuthorizationState, {
|
||||
providerName: connectionAuthorization.providerName,
|
||||
addresseeLabel: addresseeLabel ?? "the addressed person",
|
||||
})
|
||||
: null;
|
||||
const activeStyles =
|
||||
connectionAuthorizationStyles ?? secretProposalStyles ?? toolActionStyles ?? planStyles;
|
||||
const adminOutcome = getAdministrativeOutcome(interaction);
|
||||
const adminReason = adminOutcome ? getAdministrativeReason(interaction) : null;
|
||||
// P4 (design review R2): a withdrawal is a neutral administrative retraction by
|
||||
|
|
@ -3612,15 +3980,6 @@ export function IssueThreadInteractionCard({
|
|||
: null;
|
||||
// P4: audit-visible distinction between agent and human resolution.
|
||||
const resolvedByAgent = Boolean(interaction.resolvedByAgentId);
|
||||
// P3: interactions directed at a specific agent addressee.
|
||||
const addresseeLabel = interaction.addresseeAgentId
|
||||
? resolveActorLabel({
|
||||
agentId: interaction.addresseeAgentId,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
})
|
||||
: null;
|
||||
// PAP-17280: the effective audience, shown *before* anyone responds so a
|
||||
// reader never has to guess whether an open card is waiting on them. Derived
|
||||
// from the same server snapshot the resolver routes enforce, so the copy
|
||||
|
|
@ -3661,6 +4020,11 @@ export function IssueThreadInteractionCard({
|
|||
<span className="hidden text-current/60 sm:inline">/</span>
|
||||
<span>{statusText}</span>
|
||||
</span>
|
||||
) : connectionAuthorization ? (
|
||||
// One state, in the reader's own terms: "Action required",
|
||||
// "Waiting for Carol", "Gmail connected". The interaction kind
|
||||
// is machinery the person being asked does not need.
|
||||
<span>{statusText}</span>
|
||||
) : (
|
||||
<>
|
||||
{isPlan ? "Plan" : interactionKindLabel(interaction.kind)}
|
||||
|
|
@ -3703,6 +4067,8 @@ export function IssueThreadInteractionCard({
|
|||
? "Checkbox confirmation requested"
|
||||
: isSecretProposal
|
||||
? "Secret binding requested"
|
||||
: connectionAuthorization
|
||||
? `Connect your ${connectionAuthorization.providerName} to continue`
|
||||
: isToolAction
|
||||
? "Tool approval requested"
|
||||
: interaction.kind === "request_item_verdicts"
|
||||
|
|
@ -3711,7 +4077,11 @@ export function IssueThreadInteractionCard({
|
|||
? "Plan review"
|
||||
: "Confirmation requested")}
|
||||
</div>
|
||||
{interaction.summary ? (
|
||||
{/* A connection-authorization card composes its own single body
|
||||
below, because the closing sentence depends on whether the
|
||||
reader is the person who may consent. Rendering the summary here
|
||||
as well would be the second body PAP-17859 removed. */}
|
||||
{interaction.summary && !connectionAuthorization ? (
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
{interaction.summary}
|
||||
</p>
|
||||
|
|
@ -3759,6 +4129,20 @@ export function IssueThreadInteractionCard({
|
|||
onRejectInteraction={onRejectInteraction}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
) : connectionAuthorization
|
||||
&& interaction.kind === "request_confirmation"
|
||||
&& connectionAuthorizationState ? (
|
||||
<RequestConnectionAuthorizationCard
|
||||
interaction={interaction}
|
||||
state={connectionAuthorizationState}
|
||||
isAddressee={isConnectionAddressee}
|
||||
providerName={connectionAuthorization.providerName}
|
||||
addresseeLabel={addresseeLabel ?? "the addressed person"}
|
||||
requestingAgentLabel={connectionAuthorization.requestingAgentName ?? null}
|
||||
resolvedByLabel={resolvedByLabel}
|
||||
resolvedByAgent={resolvedByAgent}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
/>
|
||||
) : isSecretProposal && interaction.kind === "request_confirmation" && secretProposalState ? (
|
||||
<RequestSecretProposalCard
|
||||
interaction={interaction}
|
||||
|
|
@ -3820,7 +4204,10 @@ export function IssueThreadInteractionCard({
|
|||
>
|
||||
{formatShortDate(interaction.resolvedAt)}
|
||||
</div>
|
||||
) : resolvedByLabel && !isToolAction ? (
|
||||
) : resolvedByLabel && !isToolAction && !connectionAuthorization ? (
|
||||
// The connection-authorization card states its own resolver and
|
||||
// timestamp inside the "Gmail connected" block, so the shared footer
|
||||
// would repeat it.
|
||||
<div
|
||||
className="mt-4 flex flex-wrap items-center gap-x-1 gap-y-0.5 border-t border-border/60 pt-3 text-xs text-muted-foreground"
|
||||
data-testid="interaction-resolved-footer"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
import { useEffect, useMemo, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Identity } from "@/components/Identity";
|
||||
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 MemberMultiSelectOption {
|
||||
userId: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
}
|
||||
|
||||
export function memberOptionLabel(member: MemberMultiSelectOption): string {
|
||||
return member.name?.trim() || member.email?.trim() || member.userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* People counterpart to `AgentMultiSelect` (PAP-17835).
|
||||
*
|
||||
* The organization-grant audience editor needs a searchable, keyboard-operable,
|
||||
* selected-first people picker, and the design calls for one system component
|
||||
* rather than a feature-local audience picker — a future Teams primitive can
|
||||
* replace what feeds it without changing this API. Behaviour deliberately
|
||||
* matches `AgentMultiSelect`: pass `onSave` for a staged draft (Save/Cancel), or
|
||||
* `onChange` for live selection.
|
||||
*/
|
||||
export function MemberMultiSelect({
|
||||
members,
|
||||
selectedUserIds,
|
||||
onChange,
|
||||
onSave,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
pending = false,
|
||||
getDescription,
|
||||
triggerLabel,
|
||||
triggerVariant = "outline",
|
||||
triggerSize = "default",
|
||||
triggerFullWidth = true,
|
||||
triggerClassName,
|
||||
contentAlign = "start",
|
||||
emptyMessage = "No members yet.",
|
||||
showSelectionPreview = true,
|
||||
filterPlaceholder = "Filter people",
|
||||
onOpenChange,
|
||||
}: {
|
||||
members: MemberMultiSelectOption[];
|
||||
selectedUserIds: Set<string>;
|
||||
onChange?: (next: Set<string>) => void;
|
||||
onSave?: (next: Set<string>) => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
pending?: boolean;
|
||||
getDescription?: (member: MemberMultiSelectOption) => string | null | undefined;
|
||||
triggerLabel?: string;
|
||||
triggerVariant?: ComponentProps<typeof Button>["variant"];
|
||||
triggerSize?: ComponentProps<typeof Button>["size"];
|
||||
triggerFullWidth?: boolean;
|
||||
triggerClassName?: string;
|
||||
contentAlign?: ComponentProps<typeof PopoverContent>["align"];
|
||||
emptyMessage?: string;
|
||||
showSelectionPreview?: boolean;
|
||||
filterPlaceholder?: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}): ReactNode {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [draftUserIds, setDraftUserIds] = useState<Set<string>>(new Set(selectedUserIds));
|
||||
const staged = Boolean(onSave);
|
||||
const workingUserIds = staged ? draftUserIds : selectedUserIds;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && staged) setDraftUserIds(new Set(selectedUserIds));
|
||||
}, [open, selectedUserIds, staged]);
|
||||
|
||||
const normalizedFilter = filter.trim().toLowerCase();
|
||||
const filteredMembers = useMemo(
|
||||
() =>
|
||||
members
|
||||
.filter((member) => {
|
||||
const description = getDescription?.(member) ?? member.email ?? "";
|
||||
return `${memberOptionLabel(member)} ${description}`.toLowerCase().includes(normalizedFilter);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aSelected = workingUserIds.has(a.userId);
|
||||
const bSelected = workingUserIds.has(b.userId);
|
||||
if (aSelected !== bSelected) return aSelected ? -1 : 1;
|
||||
return memberOptionLabel(a).localeCompare(memberOptionLabel(b));
|
||||
}),
|
||||
[members, getDescription, normalizedFilter, workingUserIds],
|
||||
);
|
||||
const selectedCount = selectedUserIds.size;
|
||||
const selectedMembers = members.filter((member) => selectedUserIds.has(member.userId));
|
||||
|
||||
function setSelection(next: Set<string>) {
|
||||
if (staged) setDraftUserIds(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">
|
||||
<span className="truncate">
|
||||
{triggerLabel ?? (selectedCount === 0
|
||||
? "Select people"
|
||||
: `${selectedCount} ${selectedCount === 1 ? "person" : "people"} 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={filterPlaceholder}
|
||||
className="h-8"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-2 p-3">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</div>
|
||||
) : members.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">
|
||||
{filteredMembers.map((member) => {
|
||||
const label = memberOptionLabel(member);
|
||||
const description = getDescription?.(member)
|
||||
?? (member.email && member.email !== label ? member.email : null);
|
||||
return (
|
||||
<label
|
||||
key={member.userId}
|
||||
className="flex cursor-pointer items-start gap-2 px-3 py-2 hover:bg-accent/30"
|
||||
>
|
||||
<Checkbox
|
||||
checked={workingUserIds.has(member.userId)}
|
||||
aria-label={`Allow ${label}`}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = new Set(workingUserIds);
|
||||
if (checked) next.add(member.userId);
|
||||
else next.delete(member.userId);
|
||||
setSelection(next);
|
||||
}}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<Identity name={label} avatarUrl={member.image ?? null} size="sm" />
|
||||
{description ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{filteredMembers.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" aria-live="polite">
|
||||
{workingUserIds.size === 0 ? "No people selected" : `${workingUserIds.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?.(draftUserIds);
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
{staged ? (pending ? "Saving…" : "Save") : "Done"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{showSelectionPreview && selectedMembers.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{selectedMembers.slice(0, 3).map((member) => (
|
||||
<div key={member.userId} className="flex items-center gap-2 px-1.5 py-1 text-sm">
|
||||
<Identity name={memberOptionLabel(member)} avatarUrl={member.image ?? null} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
{selectedMembers.length > 3 ? (
|
||||
<p className="px-1.5 pt-0.5 text-xs text-muted-foreground">
|
||||
and {selectedMembers.length - 3} more
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,12 @@ export type RadioCardOption = {
|
|||
value: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/**
|
||||
* Disable this one option while its siblings stay live. For a choice the
|
||||
* viewer's capabilities forbid: the option stays legible, with its reason in
|
||||
* `description`, instead of vanishing and making the scope unexplained.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -72,15 +78,23 @@ export function RadioCardGroup({
|
|||
if (disabled) return;
|
||||
const idx = options.findIndex((option) => option.value === value);
|
||||
if (idx === -1) return;
|
||||
let nextIdx: number | null = null;
|
||||
let step: number | null = null;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
nextIdx = (idx + 1) % options.length;
|
||||
step = 1;
|
||||
} else if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
nextIdx = (idx - 1 + options.length) % options.length;
|
||||
step = -1;
|
||||
}
|
||||
if (nextIdx !== null) {
|
||||
event.preventDefault();
|
||||
onValueChange(options[nextIdx].value);
|
||||
if (step === null) return;
|
||||
event.preventDefault();
|
||||
// Step over disabled options rather than landing on one: arrowing onto a
|
||||
// choice the viewer cannot make would select it.
|
||||
const len = options.length;
|
||||
for (let hop = 1; hop <= len; hop++) {
|
||||
const candidate = options[(((idx + step * hop) % len) + len) % len];
|
||||
if (!candidate.disabled) {
|
||||
onValueChange(candidate.value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -97,7 +111,7 @@ export function RadioCardGroup({
|
|||
selected={option.value === value}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
disabled={disabled}
|
||||
disabled={disabled || option.disabled}
|
||||
tabIndex={option.value === value ? 0 : -1}
|
||||
onClick={() => onValueChange(option.value)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -814,6 +814,62 @@ export const pendingSecretProposalInteraction = createSecretProposalConfirmation
|
|||
id: "interaction-secret-proposal-pending",
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection-authorization fixtures (PAP-17835). Same interaction kind and the
|
||||
// same server-addressed audience as any other confirmation; only the
|
||||
// presentation payload is added, so the card never has to parse a title string
|
||||
// to know what it is looking at.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createConnectionAuthorizationInteraction(
|
||||
overrides: Partial<RequestConfirmationInteraction> = {},
|
||||
): RequestConfirmationInteraction {
|
||||
const { payload, ...rest } = overrides;
|
||||
return createRequestConfirmationInteraction({
|
||||
id: "interaction-connection-authorization-default",
|
||||
title: "Connect your Gmail to continue",
|
||||
summary: "Outreach Agent needs your Gmail identity for work running as you.",
|
||||
createdByAgentId: "agent-codex",
|
||||
addresseeUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolverPolicy: "human_only",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Connect your Gmail to continue",
|
||||
acceptLabel: "Connect Gmail",
|
||||
rejectLabel: "Not now",
|
||||
...payload,
|
||||
connectionAuthorization: {
|
||||
version: 1,
|
||||
providerName: "Gmail",
|
||||
connectionName: null,
|
||||
requestingAgentName: "Outreach Agent",
|
||||
},
|
||||
target: {
|
||||
type: "custom",
|
||||
key: "connection:gmail-abc:user:user-dotta",
|
||||
label: "Connect Gmail",
|
||||
href: "https://accounts.google.com/o/oauth2/v2/auth?client_id=paperclip",
|
||||
},
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
}
|
||||
|
||||
export const pendingConnectionAuthorizationInteraction = createConnectionAuthorizationInteraction({
|
||||
id: "interaction-connection-authorization-pending",
|
||||
});
|
||||
|
||||
export const resolvedConnectionAuthorizationInteraction = createConnectionAuthorizationInteraction({
|
||||
id: "interaction-connection-authorization-resolved",
|
||||
status: "accepted",
|
||||
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||||
resolvedAt: new Date("2026-04-20T15:02:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T15:02:03.000Z"),
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
});
|
||||
|
||||
export const executedSecretProposalInteraction = createSecretProposalConfirmationInteraction({
|
||||
id: "interaction-secret-proposal-executed",
|
||||
status: "accepted",
|
||||
|
|
|
|||
|
|
@ -107,6 +107,30 @@ describe("describeInteractionAudience", () => {
|
|||
expect(audience.narrowedNote).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* PAP-17859, caught by rendering the card rather than reading it:
|
||||
* `formatAssigneeUserLabel` returns the display-cased "You" for the signed-in
|
||||
* reader, which is correct in a badge and wrong inside a sentence.
|
||||
*/
|
||||
it("lowercases a self-referring label inside the summary sentence", () => {
|
||||
const addressed = describeInteractionAudience({
|
||||
interaction: confirmation({ addresseeUserId: "user-me" }),
|
||||
addresseeLabel: "You",
|
||||
});
|
||||
expect(addressed.summary).toBe("Only you can respond.");
|
||||
expect(addressed.shortSummary).toBe("Only you can respond");
|
||||
|
||||
const excluded = describeInteractionAudience({
|
||||
interaction: confirmation({
|
||||
requestedResolverPolicy: "not_creator",
|
||||
effectiveResolverPolicy: "not_creator",
|
||||
resolverPolicyProvenance: "explicit",
|
||||
}),
|
||||
creatorLabel: "You",
|
||||
});
|
||||
expect(excluded.summary).toBe("Anyone in the organization except you can respond.");
|
||||
});
|
||||
|
||||
it("falls back to a generic creator phrase when the creator label is unknown", () => {
|
||||
const audience = describeInteractionAudience({
|
||||
interaction: confirmation({
|
||||
|
|
@ -163,6 +187,20 @@ describe("describeInteractionAudience", () => {
|
|||
expect(audience.label).toBe("Human only");
|
||||
});
|
||||
|
||||
it("names one addressed user instead of the whole board", () => {
|
||||
const audience = describeInteractionAudience({
|
||||
interaction: confirmation({
|
||||
addresseeUserId: "user-alice",
|
||||
requestedResolverPolicy: "human_only",
|
||||
effectiveResolverPolicy: "human_only",
|
||||
}),
|
||||
addresseeLabel: "Alice",
|
||||
});
|
||||
expect(audience.summary).toBe("Only Alice can respond.");
|
||||
expect(audience.shortSummary).toBe("Only Alice can respond");
|
||||
expect(audience.label).toBe("Addressed");
|
||||
});
|
||||
|
||||
it("explains a governed-action clamp", () => {
|
||||
const audience = describeInteractionAudience({
|
||||
interaction: confirmation({
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export interface InteractionAudienceFacts {
|
|||
effectiveResolverPolicySource: IssueThreadInteractionEffectiveResolverPolicySource;
|
||||
resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance;
|
||||
hasAddressee: boolean;
|
||||
isUserAddressee?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -143,7 +144,7 @@ export function describeInteractionAudience({
|
|||
interaction: IssueThreadInteraction;
|
||||
/** Display label of the creating actor, when known. */
|
||||
creatorLabel?: string | null;
|
||||
/** Display label of the named addressee agent, when the card has one. */
|
||||
/** Display label of the named addressee, when the card has one. */
|
||||
addresseeLabel?: string | null;
|
||||
}): InteractionAudienceDescription {
|
||||
return describeResolverAudience({
|
||||
|
|
@ -152,7 +153,8 @@ export function describeInteractionAudience({
|
|||
requestedResolverPolicy: interaction.requestedResolverPolicy,
|
||||
effectiveResolverPolicySource: interaction.effectiveResolverPolicySource,
|
||||
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
|
||||
hasAddressee: Boolean(interaction.addresseeAgentId),
|
||||
hasAddressee: Boolean(interaction.addresseeAgentId || interaction.addresseeUserId),
|
||||
isUserAddressee: Boolean(interaction.addresseeUserId),
|
||||
},
|
||||
creatorLabel,
|
||||
addresseeLabel,
|
||||
|
|
@ -176,10 +178,19 @@ export function describeResolverAudience({
|
|||
const policy = facts.effectiveResolverPolicy;
|
||||
const requestedPolicy = facts.requestedResolverPolicy;
|
||||
const hasAddressee = facts.hasAddressee;
|
||||
const addressee = addresseeLabel?.trim() || "the addressed agent";
|
||||
const creator = creatorLabel?.trim() || "the agent that created it";
|
||||
const isUserAddressee = facts.isUserAddressee === true;
|
||||
// `formatAssigneeUserLabel` returns the display-cased "You" for the signed-in
|
||||
// reader, which is right for a badge and wrong mid-sentence ("Only You can
|
||||
// respond."). Every use below is inside a sentence.
|
||||
const midSentence = (label: string) => (label === "You" ? "you" : label);
|
||||
const addressee = midSentence(
|
||||
addresseeLabel?.trim() || (isUserAddressee ? "the addressed user" : "the addressed agent"),
|
||||
);
|
||||
const creator = midSentence(creatorLabel?.trim() || "the agent that created it");
|
||||
|
||||
const summary = policy === "human_only"
|
||||
const summary = isUserAddressee
|
||||
? `Only ${addressee} can respond.`
|
||||
: policy === "human_only"
|
||||
? "Only a person on the board can respond — agents cannot resolve this card."
|
||||
: hasAddressee
|
||||
? `Only ${addressee} or a person on the board can respond.`
|
||||
|
|
@ -189,7 +200,9 @@ export function describeResolverAudience({
|
|||
|
||||
// Same fact, fewer words: a collapsed row has to answer "is this mine to
|
||||
// decide?" in one glance, next to the buttons that act on the answer.
|
||||
const shortSummary = policy === "human_only"
|
||||
const shortSummary = isUserAddressee
|
||||
? `Only ${addressee} can respond`
|
||||
: policy === "human_only"
|
||||
? "Only the board can respond"
|
||||
: hasAddressee
|
||||
? `Only ${addressee} or the board can respond`
|
||||
|
|
@ -228,9 +241,9 @@ export function describeResolverAudience({
|
|||
policy,
|
||||
requestedPolicy,
|
||||
// A named addressee owns the response, so the label must not read "Anyone"
|
||||
// while the sentence next to it names one agent. `human_only` still wins,
|
||||
// because an addressed agent cannot resolve a human-only card.
|
||||
label: policy !== "human_only" && hasAddressee
|
||||
// while the sentence next to it names one actor. `human_only` wins for an
|
||||
// agent addressee, while a user addressee is the narrower human audience.
|
||||
label: (policy !== "human_only" || isUserAddressee) && hasAddressee
|
||||
? "Addressed"
|
||||
: RESOLVER_POLICY_LABELS[policy],
|
||||
summary,
|
||||
|
|
@ -259,7 +272,8 @@ export function describeAttentionResolverAudience(
|
|||
requestedResolverPolicy: audience.requestedResolverPolicy,
|
||||
effectiveResolverPolicySource: audience.effectiveResolverPolicySource,
|
||||
resolverPolicyProvenance: audience.resolverPolicyProvenance,
|
||||
hasAddressee: Boolean(audience.addresseeAgentId),
|
||||
hasAddressee: Boolean(audience.addresseeAgentId || audience.addresseeUserId),
|
||||
isUserAddressee: Boolean(audience.addresseeUserId),
|
||||
},
|
||||
creatorLabel: audience.createdByAgentName,
|
||||
addresseeLabel: audience.addresseeName,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export const queryKeys = {
|
|||
connection: (connectionId: string) => ["tools", "connection", connectionId] as const,
|
||||
connectionInstalls: (connectionId: string) =>
|
||||
["tools", "connection", connectionId, "installs"] as const,
|
||||
connectionGrants: (connectionId: string) =>
|
||||
["tools", "connection", connectionId, "grants"] as const,
|
||||
catalog: (connectionId: string) => ["tools", "connection", connectionId, "catalog"] as const,
|
||||
connectionActivity: (connectionId: string) =>
|
||||
["tools", "connection", connectionId, "activity"] as const,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ const finishAppMock = vi.hoisted(() => vi.fn());
|
|||
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
const refreshCatalogMock = vi.hoisted(() => vi.fn());
|
||||
const startOAuthMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionGrantsMock = vi.hoisted(() => vi.fn());
|
||||
const revokeConnectionGrantMock = vi.hoisted(() => vi.fn());
|
||||
const createConnectionGrantDelegationMock = vi.hoisted(() => vi.fn());
|
||||
const revokeConnectionGrantDelegationMock = vi.hoisted(() => vi.fn());
|
||||
const replaceConnectionGrantMembersMock = vi.hoisted(() => vi.fn());
|
||||
const startPersonalAuthorizationMock = vi.hoisted(() => vi.fn());
|
||||
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
|
||||
const getSessionMock = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -50,6 +56,20 @@ vi.mock("@/api/tools", () => ({
|
|||
archiveConnection: vi.fn(),
|
||||
refreshCatalog: (connectionId: string) => refreshCatalogMock(connectionId),
|
||||
startOAuth: (connectionId: string) => startOAuthMock(connectionId),
|
||||
listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId),
|
||||
revokeConnectionGrant: (connectionId: string, grantId: string) =>
|
||||
revokeConnectionGrantMock(connectionId, grantId),
|
||||
createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) =>
|
||||
createConnectionGrantDelegationMock(connectionId, grantId, agentId),
|
||||
revokeConnectionGrantDelegation: (
|
||||
connectionId: string,
|
||||
grantId: string,
|
||||
delegationId: string,
|
||||
) => revokeConnectionGrantDelegationMock(connectionId, grantId, delegationId),
|
||||
replaceConnectionGrantMembers: (connectionId: string, grantId: string, memberUserIds: string[]) =>
|
||||
replaceConnectionGrantMembersMock(connectionId, grantId, memberUserIds),
|
||||
startPersonalAuthorization: (companyId: string, connectionId: string, input: unknown) =>
|
||||
startPersonalAuthorizationMock(companyId, connectionId, input),
|
||||
reconnectConnection: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
|
@ -155,6 +175,58 @@ function connection(overrides: Record<string, unknown> = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
/** A member who may configure this connection and edit every agent. */
|
||||
function fullCapabilities(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
canConfigure: true,
|
||||
canCreateOrganizationGrant: true,
|
||||
canSetCompanyInstall: true,
|
||||
canConnectAsCurrentUser: true,
|
||||
canManageAgentInstalls: true,
|
||||
canViewOtherPersonalIdentities: false,
|
||||
editableAgentIds: ["agent-1", "agent-2"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function organizationGrant(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "grant-org",
|
||||
companyId: "company-1",
|
||||
connectionId: "conn-1",
|
||||
kind: "organization",
|
||||
subjectUserId: null,
|
||||
providerTenant: { name: "Notion workspace" },
|
||||
credentialSecretRefs: [],
|
||||
status: "active",
|
||||
isDefault: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
revokedAt: null,
|
||||
revokedByAgentId: null,
|
||||
revokedByUserId: null,
|
||||
lastUsedAt: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
members: [],
|
||||
capabilities: { canRevoke: true, canEditAudience: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function personalGrant(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...organizationGrant(),
|
||||
id: "grant-user",
|
||||
kind: "user",
|
||||
subjectUserId: "user-1",
|
||||
providerTenant: null,
|
||||
isDefault: false,
|
||||
capabilities: { canRevoke: true, canEditAudience: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function catalogEntry(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "catalog-read",
|
||||
|
|
@ -193,6 +265,13 @@ describe("AppDetail", () => {
|
|||
mockSearchParams.value = new URLSearchParams();
|
||||
getConnectionMock.mockResolvedValue(connection());
|
||||
getConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [],
|
||||
capabilities: fullCapabilities(),
|
||||
currentUserId: "user-1",
|
||||
members: [],
|
||||
});
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [
|
||||
{
|
||||
|
|
@ -265,6 +344,12 @@ describe("AppDetail", () => {
|
|||
authorizationUrl: "https://example.test/oauth",
|
||||
expiresAt: "2026-07-10T00:00:00.000Z",
|
||||
});
|
||||
createConnectionGrantDelegationMock.mockResolvedValue({
|
||||
id: "delegation-1",
|
||||
grantId: "grant-user",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
revokeConnectionGrantDelegationMock.mockResolvedValue({});
|
||||
listUserDirectoryMock.mockResolvedValue({ users: [] });
|
||||
getSessionMock.mockResolvedValue({
|
||||
user: { id: "user-1", name: "Dotta", image: null },
|
||||
|
|
@ -529,10 +614,15 @@ describe("AppDetail", () => {
|
|||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Connect with Smoke OAuth");
|
||||
// The old generic "Connect with <provider>" block is gone: identity is now
|
||||
// expressed per-identity, and a connection with no organization grant offers
|
||||
// an explicit connect action instead of a single ambiguous button.
|
||||
expect(container.textContent).toContain("Identities");
|
||||
expect(container.textContent).toContain("Organization identity");
|
||||
expect(container.textContent).toContain("Not connected");
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("button")).some(
|
||||
(button) => button.textContent?.trim() === "Connect with Smoke OAuth",
|
||||
(button) => button.textContent?.trim() === "Connect organization identity",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
|
@ -575,16 +665,31 @@ describe("AppDetail", () => {
|
|||
},
|
||||
}],
|
||||
});
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [organizationGrant()],
|
||||
capabilities: fullCapabilities(),
|
||||
currentUserId: "user-1",
|
||||
members: [],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Dotta’s Notion");
|
||||
expect(container.textContent).toContain("Connected by");
|
||||
expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain(
|
||||
"Your workspace authorization is active. Reconnect any time to replace it.",
|
||||
);
|
||||
expect(container.textContent).not.toContain("Sign in again any time");
|
||||
// Identity is legible per identity, and the shared one names its audience.
|
||||
// "workspace authorization" is deliberately gone: it could only ever
|
||||
// describe one shared identity (PAP-17835).
|
||||
expect(container.textContent).toContain("Organization identity");
|
||||
expect(container.textContent).toContain("Notion workspace");
|
||||
expect(container.textContent).toContain("All organization members");
|
||||
expect(container.textContent).not.toContain("workspace authorization");
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("button")).some(
|
||||
(button) => button.textContent?.trim() === "Reconnect",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("lets Google Sheets connections add spreadsheet links from setup", async () => {
|
||||
|
|
@ -706,10 +811,10 @@ describe("AppDetail", () => {
|
|||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Installed on agents");
|
||||
expect(container.textContent).toContain("Available to agents");
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Choose agents to install on"))
|
||||
.find((button) => button.textContent?.includes("Choose agents"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
|
@ -726,7 +831,13 @@ describe("AppDetail", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("removes an existing agent grant directly from Permissions", async () => {
|
||||
/**
|
||||
* PAP-17859: agent availability is stated once. The tab used to stack a
|
||||
* legacy "Who can use it" editor — its own Change button, per-agent remove
|
||||
* buttons and Save — on top of "Available to agents", so the same fact had
|
||||
* two visible editors and the reader had to guess which one won.
|
||||
*/
|
||||
it("shows one agent-availability model on Permissions, not the legacy access editor", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
listProfilesMock.mockResolvedValue({
|
||||
profiles: [{
|
||||
|
|
@ -741,18 +852,54 @@ describe("AppDetail", () => {
|
|||
|
||||
await renderAppDetail();
|
||||
|
||||
const remove = container.querySelector<HTMLButtonElement>('button[aria-label="Remove Coder access"]');
|
||||
expect(remove).toBeTruthy();
|
||||
await act(async () => {
|
||||
remove!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Available to agents");
|
||||
expect(container.textContent).not.toContain("Who can use it");
|
||||
expect(container.textContent).not.toContain("Only specific agents");
|
||||
expect(container.querySelector('button[aria-label="Remove Coder access"]')).toBeNull();
|
||||
// Exactly one radiogroup on the tab: the install model.
|
||||
expect(container.querySelectorAll('[role="radiogroup"]').length).toBe(1);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("button")).filter(
|
||||
(button) => button.textContent?.trim() === "Change",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
|
||||
enabledCatalogEntryIds: ["catalog-read", "catalog-write"],
|
||||
askFirstCatalogEntryIds: ["catalog-write"],
|
||||
access: { agentIds: [] },
|
||||
/**
|
||||
* Viewer rule D4: a policy-forbidden action is omitted, not disabled. The
|
||||
* viewer still sees the whole state — which agents have it, what each action
|
||||
* is allowed to do — with nothing to press.
|
||||
*/
|
||||
it("gives a viewer a read-only Permissions tab with no mutation affordances", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-uid-1" },
|
||||
grants: [],
|
||||
currentUserId: "user-1",
|
||||
members: [],
|
||||
capabilities: fullCapabilities({
|
||||
canConfigure: false,
|
||||
canManageAgentInstalls: false,
|
||||
canSetCompanyInstall: false,
|
||||
canConnectAsCurrentUser: false,
|
||||
editableAgentIds: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
// State is still legible.
|
||||
expect(container.textContent).toContain("Available to agents");
|
||||
expect(container.textContent).toContain("Action permissions");
|
||||
expect(container.textContent).toContain("Read repo");
|
||||
|
||||
// Nothing to mutate: no radios, no permission selects, no refresh, no save.
|
||||
expect(container.querySelector('[role="radiogroup"]')).toBeNull();
|
||||
expect(container.querySelectorAll("select").length).toBe(0);
|
||||
const labels = Array.from(container.querySelectorAll("button")).map((b) => b.textContent?.trim());
|
||||
for (const forbidden of ["Change", "Save", "Refresh actions", "Choose agents"]) {
|
||||
expect(labels).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders activity attribution with issue context and human resolver names", async () => {
|
||||
|
|
@ -925,7 +1072,7 @@ describe("AppDetail", () => {
|
|||
expect(container.textContent).toContain("Needs attention");
|
||||
expect(container.textContent).toContain("This app needs reconnecting");
|
||||
expect(container.textContent).toContain("Token expired.");
|
||||
expect(container.textContent).toContain("Who can use it");
|
||||
expect(container.textContent).toContain("Available to agents");
|
||||
});
|
||||
|
||||
it("shows terminal OAuth failures as reconnect-required sign-in", async () => {
|
||||
|
|
@ -993,4 +1140,288 @@ describe("AppDetail", () => {
|
|||
expect(body.length).toBeGreaterThan(0);
|
||||
expect(body).not.toContain(authorizationUrl);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Personal connection identity (PAP-17835). These cover the permission
|
||||
// matrix in the accepted design: a member self-serving, manager oversight,
|
||||
// a read-only viewer, and the audience editor's two scopes.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function perUserConnection(overrides: Record<string, unknown> = {}) {
|
||||
return connection({ credentialPolicy: "per_user", authKind: "oauth", ...overrides });
|
||||
}
|
||||
|
||||
function findButton(label: string) {
|
||||
return Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === label);
|
||||
}
|
||||
|
||||
it("answers 'who does this act as' in the header on every tab", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
getConnectionMock.mockResolvedValue(perUserConnection());
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Acts as each person");
|
||||
expect(container.textContent).toContain("Each person connects their own account.");
|
||||
});
|
||||
|
||||
it("lets a regular member connect their own identity and never someone else's", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(perUserConnection());
|
||||
startPersonalAuthorizationMock.mockResolvedValue({ url: "https://accounts.example.test/authorize" });
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
// Missing personal identity is explicit, never a silent fallback.
|
||||
expect(container.textContent).toContain("Your identity");
|
||||
expect(container.textContent).toContain("You have not connected your account.");
|
||||
|
||||
await act(async () => {
|
||||
findButton("Connect as me")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// The subject is the signed-in user, so there is no path here to start
|
||||
// consent on a coworker's behalf.
|
||||
expect(startPersonalAuthorizationMock).toHaveBeenCalledWith("company-1", "conn-1", {
|
||||
subjectUserId: "user-1",
|
||||
returnTo: "/apps/conn-1/setup",
|
||||
});
|
||||
expect(navigateTopLevelMock).toHaveBeenCalledWith("https://accounts.example.test/authorize");
|
||||
});
|
||||
|
||||
it("lets the personal identity owner grant a named agent autonomous access", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(perUserConnection());
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [personalGrant({ delegations: [] })],
|
||||
capabilities: fullCapabilities(),
|
||||
currentUserId: "user-1",
|
||||
members: [{ userId: "user-1", name: "Dotta", email: "dotta@example.com" }],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(findButton("Allow autonomous access")).toBeTruthy();
|
||||
await act(async () => {
|
||||
findButton("Allow autonomous access")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const agentCheckbox = document.querySelector<HTMLInputElement>('button[role="checkbox"][aria-label="Allow Coder"]');
|
||||
expect(agentCheckbox).toBeTruthy();
|
||||
await act(async () => {
|
||||
agentCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
Array.from(document.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Save")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(createConnectionGrantDelegationMock).toHaveBeenCalledWith(
|
||||
"conn-1",
|
||||
"grant-user",
|
||||
"agent-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a viewer read-only across identities and installs", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(perUserConnection());
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [organizationGrant({ capabilities: { canRevoke: false, canEditAudience: false } })],
|
||||
capabilities: {
|
||||
canConfigure: false,
|
||||
canCreateOrganizationGrant: false,
|
||||
canSetCompanyInstall: false,
|
||||
canConnectAsCurrentUser: false,
|
||||
canManageAgentInstalls: false,
|
||||
canViewOtherPersonalIdentities: false,
|
||||
editableAgentIds: [],
|
||||
},
|
||||
currentUserId: "viewer-1",
|
||||
members: [],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
// State stays legible...
|
||||
expect(container.textContent).toContain("Organization identity");
|
||||
expect(container.textContent).toContain("Connected");
|
||||
expect(container.textContent).toContain("All organization members");
|
||||
// ...and every mutation control is absent rather than disabled.
|
||||
expect(findButton("Connect as me")).toBeUndefined();
|
||||
expect(findButton("Manage audience")).toBeUndefined();
|
||||
expect(findButton("Revoke")).toBeUndefined();
|
||||
expect(findButton("Connect organization identity")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives a manager oversight of other people's identities", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(perUserConnection());
|
||||
revokeConnectionGrantMock.mockResolvedValue({ id: "grant-other", kind: "user" });
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [
|
||||
organizationGrant(),
|
||||
personalGrant({ id: "grant-other", subjectUserId: "user-2" }),
|
||||
],
|
||||
capabilities: fullCapabilities({ canViewOtherPersonalIdentities: true }),
|
||||
currentUserId: "user-1",
|
||||
members: [
|
||||
{ userId: "user-1", name: "Dotta", email: "dotta@example.com" },
|
||||
{ userId: "user-2", name: "Carol", email: "carol@example.com" },
|
||||
],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Other personal identities · 1");
|
||||
|
||||
await act(async () => {
|
||||
Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Other personal identities"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Carol");
|
||||
|
||||
// Scope to Carol's own row: the organization identity also offers Revoke,
|
||||
// and picking the first match would silently test the wrong grant.
|
||||
const carolRow = Array.from(container.querySelectorAll("div"))
|
||||
.filter((row) => row.textContent?.includes("Carol")
|
||||
&& Array.from(row.querySelectorAll("button")).some((b) => b.textContent?.trim() === "Revoke"))
|
||||
.at(-1);
|
||||
expect(carolRow).toBeTruthy();
|
||||
await act(async () => {
|
||||
Array.from(carolRow!.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Revoke")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// Revoke is a confirmation, not a one-click action, and it never offers to
|
||||
// reconnect on the other person's behalf.
|
||||
const dialogText = document.body.textContent ?? "";
|
||||
expect(dialogText).toContain("Revoke this");
|
||||
expect(dialogText).toContain("They can connect again themselves");
|
||||
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Revoke identity")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(revokeConnectionGrantMock).toHaveBeenCalledWith("conn-1", "grant-other");
|
||||
});
|
||||
|
||||
it("persists an empty audience as all organization members", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(connection({ createdByUserId: "user-1" }));
|
||||
replaceConnectionGrantMembersMock.mockResolvedValue(organizationGrant({ members: [] }));
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [organizationGrant({
|
||||
members: [{ id: "m-1", companyId: "company-1", grantId: "grant-org", subjectType: "user", subjectId: "user-2", createdAt: new Date() }],
|
||||
})],
|
||||
capabilities: fullCapabilities(),
|
||||
currentUserId: "user-1",
|
||||
members: [
|
||||
{ userId: "user-1", name: "Dotta", email: "dotta@example.com" },
|
||||
{ userId: "user-2", name: "Carol", email: "carol@example.com" },
|
||||
],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("1 selected member");
|
||||
|
||||
await act(async () => {
|
||||
findButton("Manage audience")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll('[role="radio"]'))
|
||||
.find((option) => option.textContent?.includes("All organization members"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Save audience")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// "All members" is the empty set on the wire; the UI never says "empty".
|
||||
expect(replaceConnectionGrantMembersMock).toHaveBeenCalledWith("conn-1", "grant-org", []);
|
||||
});
|
||||
|
||||
it("persists a selected audience and keeps the dialog open when the server refuses", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(connection({ createdByUserId: "user-1" }));
|
||||
replaceConnectionGrantMembersMock.mockRejectedValue(
|
||||
new Error("Every audience member must be an active company member"),
|
||||
);
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [organizationGrant({ members: [] })],
|
||||
capabilities: fullCapabilities(),
|
||||
currentUserId: "user-1",
|
||||
members: [
|
||||
{ userId: "user-1", name: "Dotta", email: "dotta@example.com" },
|
||||
{ userId: "user-2", name: "Carol", email: "carol@example.com" },
|
||||
],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
await act(async () => {
|
||||
findButton("Manage audience")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll('[role="radio"]'))
|
||||
.find((option) => option.textContent?.includes("Selected members"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Choose people"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Allow Carol"]')
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Save audience")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(replaceConnectionGrantMembersMock).toHaveBeenCalledWith("conn-1", "grant-org", ["user-2"]);
|
||||
// A denial keeps the dialog open with the selection intact and explains
|
||||
// itself inline, rather than dropping the work into a toast.
|
||||
const dialogText = document.body.textContent ?? "";
|
||||
expect(dialogText).toContain("Who can use this identity");
|
||||
expect(dialogText).toContain("Every audience member must be an active company member");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ import {
|
|||
type AppGalleryDisplayEntry,
|
||||
} from "./app-definition-display";
|
||||
import { appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
|
||||
import { SetupPanel } from "./app-detail/SetupPanel";
|
||||
import { SetupPanel, connectionProviderName } from "./app-detail/SetupPanel";
|
||||
import { IdentitiesSection } from "./app-detail/IdentitiesSection";
|
||||
import { actsAsSummary } from "./connection-identity";
|
||||
import { PermissionsPanel } from "./app-detail/PermissionsPanel";
|
||||
import { TestPanel } from "./app-detail/TestPanel";
|
||||
import { ReviewPanel } from "./app-detail/ReviewPanel";
|
||||
|
|
@ -104,7 +106,9 @@ export function AppDetail() {
|
|||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && (activeTab === "permissions" || activeTab === "activity"),
|
||||
enabled: !!selectedCompanyId && (
|
||||
activeTab === "setup" || activeTab === "permissions" || activeTab === "activity"
|
||||
),
|
||||
});
|
||||
const activityQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionActivity(connectionId),
|
||||
|
|
@ -122,6 +126,14 @@ export function AppDetail() {
|
|||
queryFn: () => authApi.getSession(),
|
||||
enabled: activeTab === "activity",
|
||||
});
|
||||
// Identity grants drive the Setup tab's Identities section and the header's
|
||||
// "acts as" sentence, and Permissions reads `capabilities` from the same
|
||||
// response so install controls follow one server verdict (PAP-17835).
|
||||
const grantsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionGrants(connectionId),
|
||||
queryFn: () => toolsApi.listConnectionGrants(connectionId),
|
||||
enabled: !!connectionId && (activeTab === "setup" || activeTab === "permissions"),
|
||||
});
|
||||
|
||||
const connection = connectionQuery.data;
|
||||
const logoEntry = useMemo(
|
||||
|
|
@ -178,11 +190,11 @@ export function AppDetail() {
|
|||
() => askFirstCatalogIds(policiesQuery.data?.policies ?? [], connectionId),
|
||||
[policiesQuery.data, connectionId],
|
||||
);
|
||||
const access = useMemo(() => accessFrom(profile), [profile]);
|
||||
const install = useMemo(
|
||||
() => installStateFrom(installsQuery.data?.installs ?? connection?.installs),
|
||||
[connection?.installs, installsQuery.data?.installs],
|
||||
);
|
||||
const access = useMemo(() => accessFrom(profile, install), [profile, install]);
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const userLabelById = useMemo(() => {
|
||||
const labels = buildCompanyUserLabelMap(userDirectoryQuery.data?.users);
|
||||
|
|
@ -299,6 +311,127 @@ export function AppDetail() {
|
|||
}),
|
||||
});
|
||||
|
||||
const invalidateGrants = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connectionGrants(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
};
|
||||
|
||||
/**
|
||||
* "Connect as me" and "Reconnect" for the signed-in user's own identity. The
|
||||
* subject is always the caller — the server refuses any other subject — so
|
||||
* there is no path here to start consent on a coworker's behalf.
|
||||
*/
|
||||
const startPersonalAuth = useMutation({
|
||||
mutationFn: () => {
|
||||
const subjectUserId = grantsQuery.data?.currentUserId;
|
||||
if (!subjectUserId) throw new Error("Sign in again to connect your own account.");
|
||||
return toolsApi.startPersonalAuthorization(selectedCompanyId!, connectionId, {
|
||||
subjectUserId,
|
||||
returnTo: appTabHref(connectionId, "setup"),
|
||||
});
|
||||
},
|
||||
onSuccess: ({ url }) => {
|
||||
const target = resolveAuthorizationTarget(url);
|
||||
if (!target.ok) {
|
||||
pushToast({ title: "Couldn't start sign-in", body: target.message, tone: "error" });
|
||||
return;
|
||||
}
|
||||
navigateTopLevel(target.url);
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't start sign-in",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const revokeGrant = useMutation({
|
||||
mutationFn: (grantId: string) => toolsApi.revokeConnectionGrant(connectionId, grantId),
|
||||
onSuccess: (grant) => {
|
||||
invalidateGrants();
|
||||
pushToast({
|
||||
title: grant.kind === "user" ? "Identity revoked" : "Organization identity revoked",
|
||||
body: grant.kind === "user"
|
||||
? "Agents will stop acting as this person."
|
||||
: "Installed agents no longer have the shared identity.",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Couldn't revoke that identity",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const replaceDelegations = useMutation({
|
||||
mutationFn: async ({
|
||||
grantId,
|
||||
currentDelegations,
|
||||
agentIds,
|
||||
}: {
|
||||
grantId: string;
|
||||
currentDelegations: Array<{ id: string; agentId: string }>;
|
||||
agentIds: string[];
|
||||
}) => {
|
||||
const desired = new Set(agentIds);
|
||||
const existing = new Map(currentDelegations.map((delegation) => [delegation.agentId, delegation]));
|
||||
await Promise.all([
|
||||
...currentDelegations
|
||||
.filter((delegation) => !desired.has(delegation.agentId))
|
||||
.map((delegation) => toolsApi.revokeConnectionGrantDelegation(
|
||||
connectionId,
|
||||
grantId,
|
||||
delegation.id,
|
||||
)),
|
||||
...agentIds
|
||||
.filter((agentId) => !existing.has(agentId))
|
||||
.map((agentId) => toolsApi.createConnectionGrantDelegation(connectionId, grantId, agentId)),
|
||||
]);
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateGrants();
|
||||
pushToast({
|
||||
title: "Autonomous access saved",
|
||||
body: "Only the agents you selected can use your identity in autonomous runs.",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
invalidateGrants();
|
||||
pushToast({
|
||||
title: "Couldn't save autonomous access",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// A denied or conflicting audience save keeps the dialog open with the
|
||||
// selection intact, so the error is surfaced inline rather than as a toast.
|
||||
const [audienceError, setAudienceError] = useState<string | null>(null);
|
||||
const [audienceOpenGrantId, setAudienceOpenGrantId] = useState<string | null>(null);
|
||||
const replaceAudience = useMutation({
|
||||
mutationFn: ({ grantId, memberUserIds }: { grantId: string; memberUserIds: string[] }) =>
|
||||
toolsApi.replaceConnectionGrantMembers(connectionId, grantId, memberUserIds),
|
||||
onMutate: () => setAudienceError(null),
|
||||
onSuccess: (grant) => {
|
||||
invalidateGrants();
|
||||
setAudienceOpenGrantId(null);
|
||||
pushToast({
|
||||
title: "Audience saved",
|
||||
body: (grant.members?.length ?? 0) === 0
|
||||
? "Every organization member can use this identity."
|
||||
: `${grant.members?.length} ${grant.members?.length === 1 ? "member" : "members"} can use this identity.`,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) =>
|
||||
setAudienceError(error instanceof Error ? error.message : "We couldn't save that audience."),
|
||||
});
|
||||
|
||||
const removeApp = useMutation({
|
||||
mutationFn: () => toolsApi.archiveConnection(connectionId),
|
||||
onSuccess: () => {
|
||||
|
|
@ -414,6 +547,7 @@ export function AppDetail() {
|
|||
}
|
||||
|
||||
const status = statusFor(connection);
|
||||
const providerName = connectionProviderName(logoEntry, baseAppName);
|
||||
const needsReconnect = status.tone === "attention" && connection.healthStatus !== "unknown";
|
||||
const quarantined = catalog.filter((e) => e.status === "quarantined");
|
||||
const active = catalog.filter((e) => e.status !== "quarantined" && e.status !== "removed");
|
||||
|
|
@ -470,8 +604,46 @@ export function AppDetail() {
|
|||
onToggleApp={() => toggleEnabled.mutate()}
|
||||
configUpdateDisabled={updateConfig.isPending}
|
||||
onUpdateConfig={(config) => updateConfig.mutate(config)}
|
||||
oauthStartDisabled={startOAuth.isPending}
|
||||
onStartOAuth={() => startOAuth.mutate()}
|
||||
identities={
|
||||
<IdentitiesSection
|
||||
appName={appName}
|
||||
providerName={providerName}
|
||||
credentialPolicy={connection.credentialPolicy}
|
||||
grantsQuery={grantsQuery.data}
|
||||
agents={agents}
|
||||
agentsLoading={agentsQuery.isLoading}
|
||||
agentsError={agentsQuery.isError}
|
||||
loading={grantsQuery.isLoading}
|
||||
error={grantsQuery.isError}
|
||||
connectPending={startPersonalAuth.isPending || startOAuth.isPending}
|
||||
revokePending={revokeGrant.isPending}
|
||||
delegationPending={replaceDelegations.isPending}
|
||||
audiencePending={replaceAudience.isPending}
|
||||
audienceError={audienceError}
|
||||
audienceGrantId={audienceOpenGrantId}
|
||||
onOpenAudience={(grantId) => {
|
||||
setAudienceError(null);
|
||||
setAudienceOpenGrantId(grantId);
|
||||
}}
|
||||
onCloseAudience={() => {
|
||||
setAudienceOpenGrantId(null);
|
||||
setAudienceError(null);
|
||||
}}
|
||||
onConnectAsMe={() => startPersonalAuth.mutate()}
|
||||
// The organization identity is a shared credential, so it goes
|
||||
// through the connection-level OAuth start, not a personal one.
|
||||
onConnectOrganization={() => startOAuth.mutate()}
|
||||
onReconnectOrganization={() => startOAuth.mutate()}
|
||||
onRevokeGrant={(grant) => revokeGrant.mutate(grant.id)}
|
||||
onReplaceDelegations={(grant, agentIds) => replaceDelegations.mutate({
|
||||
grantId: grant.id,
|
||||
currentDelegations: grant.delegations ?? [],
|
||||
agentIds,
|
||||
})}
|
||||
onReplaceAudience={(grant, memberUserIds) =>
|
||||
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<AdvancedPanel
|
||||
connection={connection}
|
||||
|
|
@ -515,8 +687,7 @@ export function AppDetail() {
|
|||
: permissionsLoading
|
||||
? <ToolsLoading />
|
||||
: <PermissionsPanel
|
||||
appName={appName}
|
||||
access={access}
|
||||
capabilities={grantsQuery.data?.capabilities}
|
||||
agents={agents}
|
||||
install={install}
|
||||
readOnly={readOnly}
|
||||
|
|
@ -527,7 +698,6 @@ export function AppDetail() {
|
|||
pending={pending}
|
||||
installPending={persistInstall.isPending}
|
||||
refreshPending={refreshTools.isPending}
|
||||
onSaveAccess={(next) => apply({ access: next })}
|
||||
onSaveInstall={(next) => persistInstall.mutate(next)}
|
||||
onRefreshActions={() => refreshTools.mutate()}
|
||||
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
|
||||
|
|
@ -588,6 +758,7 @@ function AppDetailHeader({
|
|||
onRenameSubmit: (value: string) => void;
|
||||
}) {
|
||||
const unverifiedHost = unverifiedRemoteHost(connection);
|
||||
const actsAs = actsAsSummary(connection.credentialPolicy);
|
||||
|
||||
return (
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
|
|
@ -633,6 +804,14 @@ function AppDetailHeader({
|
|||
{connectionDisplaySecondaryHint(connection) && (
|
||||
<p className="text-xs text-muted-foreground">{connectionDisplaySecondaryHint(connection)}</p>
|
||||
)}
|
||||
{/* One sentence, not a cluster of badges: whether an agent acts as you
|
||||
or as the organization is the first thing every tab has to answer
|
||||
(PAP-17835). It lives in the header so it carries across tabs. */}
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{actsAs.title}</span>
|
||||
{" · "}
|
||||
{actsAs.detail}
|
||||
</p>
|
||||
{owner && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Connected by</span>
|
||||
|
|
@ -745,14 +924,35 @@ function askFirstCatalogIds(policies: ToolPolicy[], connectionId: string): Set<s
|
|||
return ids;
|
||||
}
|
||||
|
||||
function accessFrom(profile: ToolProfileWithDetails | undefined): AccessDraft {
|
||||
/**
|
||||
* Who may use this connection, read back from the app profile's bindings.
|
||||
*
|
||||
* `finishApp` replaces a profile's whole binding set, so every save from this
|
||||
* page — including an action-permission toggle — has to restate this. Since
|
||||
* PAP-17859 there is no user-facing editor for it: the Permissions tab shows
|
||||
* agent availability once, as installs. This function therefore only ever
|
||||
* *preserves* what the server already has, and falls back to the install state
|
||||
* when the profile has no bindings yet.
|
||||
*
|
||||
* That fallback is the important part. It used to return "all agents" for an
|
||||
* unbound profile, which turned any unrelated save into a silent company-wide
|
||||
* grant from a control the reader could not see. Installs authorize their
|
||||
* targets, so mirroring the install state is both the truthful reading and the
|
||||
* one that agrees with what the tab displays.
|
||||
*/
|
||||
function accessFrom(
|
||||
profile: ToolProfileWithDetails | undefined,
|
||||
install: InstallState,
|
||||
): AccessDraft {
|
||||
const bindings = profile?.bindings ?? [];
|
||||
if (bindings.some((b) => b.targetType === "company")) {
|
||||
return { mode: "all", agentIds: new Set() };
|
||||
}
|
||||
const agentIds = new Set(bindings.filter((b) => b.targetType === "agent").map((b) => b.targetId));
|
||||
if (agentIds.size === 0) return { mode: "all", agentIds: new Set() };
|
||||
return { mode: "specific", agentIds };
|
||||
if (agentIds.size > 0) return { mode: "specific", agentIds };
|
||||
return install.onAll
|
||||
? { mode: "all", agentIds: new Set() }
|
||||
: { mode: "specific", agentIds: new Set(install.agentIds) };
|
||||
}
|
||||
|
||||
function galleryEntryFor(
|
||||
|
|
|
|||
|
|
@ -118,6 +118,29 @@ function buttonContaining(text: string): HTMLButtonElement | undefined {
|
|||
) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance past the Access step (PAP-17835), which now sits between picking a
|
||||
* curated app and entering its credential. Picks "Any agent" so Continue is
|
||||
* enabled without depending on the agent list.
|
||||
*/
|
||||
async function passAccessStep() {
|
||||
const anyAgent = Array.from(document.body.querySelectorAll('[role="radio"]'))
|
||||
.find((option) => option.textContent?.includes("Any agent"));
|
||||
if (!anyAgent) return;
|
||||
await act(async () => {
|
||||
anyAgent.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
const submit = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Save and continue"
|
||||
|| b.textContent?.trim().startsWith("Continue to"),
|
||||
);
|
||||
await act(async () => {
|
||||
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function gotoLinkFrame(container: HTMLDivElement, url: string) {
|
||||
const linkInput = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>("input"),
|
||||
|
|
@ -143,6 +166,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
apps: [
|
||||
ZAPIER,
|
||||
],
|
||||
capabilities: {
|
||||
canSetCompanyInstall: true,
|
||||
companyInstallReason: null,
|
||||
},
|
||||
});
|
||||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||||
listConnectionsMock.mockResolvedValue({ connections: [] });
|
||||
|
|
@ -225,10 +252,226 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(nameInput?.value).toBe("example.com/actions");
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Access step (PAP-17835). Identity and agent reach are chosen before any
|
||||
// credential is entered.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("asks both access questions before the credential and defaults per auth kind", async () => {
|
||||
mockParams.appKey = "zapier";
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).toContain("Who is this credential for?");
|
||||
expect(container.textContent).toContain("Which agents can use this connection?");
|
||||
// Nothing about the credential is on screen yet.
|
||||
expect(container.textContent).not.toContain("Connect Zapier");
|
||||
|
||||
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
|
||||
const justMe = radios.find((r) => r.textContent?.includes("Just me"));
|
||||
const wholeOrg = radios.find((r) => r.textContent?.includes("The whole organization"));
|
||||
const agentsIPick = radios.find((r) => r.textContent?.includes("Agents I pick"));
|
||||
expect(justMe).toBeTruthy();
|
||||
expect(wholeOrg).toBeTruthy();
|
||||
// A key-based app is a shared service credential by default...
|
||||
expect(wholeOrg?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(justMe?.getAttribute("aria-checked")).toBe("false");
|
||||
// ...and the safer agent default is a picked set, not every agent.
|
||||
expect(agentsIPick?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("blocks Continue until Agents I pick has at least one agent", async () => {
|
||||
mockParams.appKey = "zapier";
|
||||
await render();
|
||||
|
||||
// "Agents I pick" with nothing picked is not a usable connection, so the
|
||||
// primary action stays disabled rather than failing at submit.
|
||||
expect(buttonByText("Save and continue")?.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll('[role="radio"]'))
|
||||
.find((r) => r.textContent?.includes("Any agent"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(buttonByText("Save and continue")?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the access selections when the wizard moves backward", async () => {
|
||||
mockParams.appKey = "zapier";
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll('[role="radio"]'))
|
||||
.find((r) => r.textContent?.includes("Just me"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll('[role="radio"]'))
|
||||
.find((r) => r.textContent?.includes("Any agent"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Save and continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// Moving backward must not silently reset the identity the operator chose.
|
||||
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
|
||||
expect(radios.find((r) => r.textContent?.includes("Just me"))?.getAttribute("aria-checked"))
|
||||
.toBe("true");
|
||||
expect(radios.find((r) => r.textContent?.includes("Any agent"))?.getAttribute("aria-checked"))
|
||||
.toBe("true");
|
||||
});
|
||||
|
||||
/**
|
||||
* The identity question is answered per connection method, not per auth kind.
|
||||
*
|
||||
* A homogeneous fixture cannot tell those two apart: if every app on screen
|
||||
* is API-key-only, a blanket rule and a per-method predicate produce exactly
|
||||
* the same DOM. So both live in one gallery here — Zapier, whose only method
|
||||
* is an API key, and PostHog, whose methods are identity-bearing sign-in plus
|
||||
* a key its own label calls *personal*. The two mounts must disagree about
|
||||
* the default, which no blanket rule can do.
|
||||
*
|
||||
* "Just me" also has to stay live for the API-key-only app, not disabled with
|
||||
* a reason: the personal key path completes and lands on the caller's own
|
||||
* grant, so disabling it would describe the product wrongly.
|
||||
*/
|
||||
it("decides the identity default per method, and keeps a personal key submittable", async () => {
|
||||
listGalleryMock.mockResolvedValue({ apps: [ZAPIER, POSTHOG] });
|
||||
|
||||
const identityChoices = () => {
|
||||
const radios = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
);
|
||||
return {
|
||||
justMe: radios.find((r) => r.textContent?.includes("Just me")),
|
||||
wholeOrg: radios.find((r) => r.textContent?.includes("The whole organization")),
|
||||
};
|
||||
};
|
||||
|
||||
// --- API-key-only method: shared by default, personal still offered ------
|
||||
let root = await render();
|
||||
await act(async () => {
|
||||
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const zapier = identityChoices();
|
||||
expect(zapier.wholeOrg?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(zapier.justMe?.getAttribute("aria-checked")).toBe("false");
|
||||
// Present, and genuinely selectable — not the disabled-with-reason state.
|
||||
expect(zapier.justMe).toBeTruthy();
|
||||
expect(zapier.justMe?.disabled).toBe(false);
|
||||
expect(document.body.textContent).not.toContain(
|
||||
"This connection method supports a shared organization credential only.",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
zapier.justMe?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll('[role="radio"]'))
|
||||
.find((r) => r.textContent?.includes("Any agent"))
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Save and continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
|
||||
await act(async () => setInputValue(keyField!, "zapier-personal-token"));
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// The load-bearing assertion: an API-key-only method reaches the server as
|
||||
// a personal grant. A disabled "Just me" would make this unreachable.
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
galleryKey: "zapier",
|
||||
grantKind: "user",
|
||||
});
|
||||
|
||||
// --- Same gallery, identity-bearing method: personal by default ----------
|
||||
await act(async () => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockParams.appKey = "posthog";
|
||||
root = await render();
|
||||
|
||||
const posthog = identityChoices();
|
||||
expect(posthog.justMe?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(posthog.wholeOrg?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(posthog.justMe?.disabled).toBe(false);
|
||||
expect(posthog.wholeOrg?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Design §"Question 2": a member who may create a personal grant but cannot
|
||||
* configure a company-wide install sees **Any agent** disabled with the
|
||||
* reason — not hidden. Disabling it is only half the job; Continue has to
|
||||
* refuse it too, or the forbidden choice is still submittable by keyboard.
|
||||
*
|
||||
* Driven through AppsConnect so this proves the pre-connection capability
|
||||
* returned with the gallery reaches the real create flow.
|
||||
*/
|
||||
it("disables Any agent and blocks Continue when the member cannot install company-wide", async () => {
|
||||
mockParams.appKey = "zapier";
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [ZAPIER],
|
||||
capabilities: {
|
||||
canSetCompanyInstall: false,
|
||||
companyInstallReason: "Your company policy limits this choice to connection managers.",
|
||||
},
|
||||
});
|
||||
await render();
|
||||
|
||||
const anyAgent = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
).find((r) => r.textContent?.includes("Any agent"));
|
||||
|
||||
// Visible, with the reason, and not selectable.
|
||||
expect(anyAgent).toBeTruthy();
|
||||
expect(anyAgent?.disabled).toBe(true);
|
||||
expect(anyAgent?.textContent).toContain(
|
||||
"Your company policy limits this choice to connection managers.",
|
||||
);
|
||||
// "Agents I pick" is the live alternative, so the step is not a dead end.
|
||||
const pick = Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
).find((r) => r.textContent?.includes("Agents I pick"));
|
||||
expect(pick?.disabled).toBe(false);
|
||||
// Continue refuses the forbidden choice even though it is the current one.
|
||||
expect(buttonByText("Save and continue")?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("opens the selected app directly on its setup route", async () => {
|
||||
mockParams.appKey = "zapier";
|
||||
await render();
|
||||
|
||||
// A deep-linked app lands on Access first: identity and reach are chosen
|
||||
// before the credential (PAP-17835).
|
||||
expect(container.textContent).toContain("Who is this credential for?");
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
|
||||
});
|
||||
|
|
@ -237,6 +480,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
mockParams.appKey = "posthog";
|
||||
listGalleryMock.mockResolvedValueOnce({ apps: [POSTHOG] });
|
||||
await render();
|
||||
// Access comes first for a curated app; the method chooser shares a screen
|
||||
// with the credential fields, so it sits behind it (PAP-17835).
|
||||
expect(container.textContent).toContain("Who is this credential for?");
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("How do you want to connect?");
|
||||
expect(buttonByText("Sign in with PostHog")?.getAttribute("aria-pressed")).toBe("false");
|
||||
|
|
@ -290,6 +537,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
mode: "tools",
|
||||
},
|
||||
applicationId: undefined,
|
||||
// PostHog's Access step defaults to "Just me" because its primary method
|
||||
// is identity-bearing sign-in, and a personal API key is personal too.
|
||||
// The choice was on screen and accepted, so it reaches the server.
|
||||
grantKind: "user",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -719,7 +970,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(connectAppMock.mock.calls[0]?.[1].credentialValues).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps Zapier visible and uses the compact agent multi-selector throughout its wizard", async () => {
|
||||
it("keeps Zapier visible and finishes without a separate access or install step", async () => {
|
||||
mockSearch.value = "byo=1&source=zapier";
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
|
|
@ -746,12 +997,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
});
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Step 1 of 3");
|
||||
// The pasted-URL path enters its server address in this step, so an identity
|
||||
// question cannot precede it; it keeps today's every-agent default rather
|
||||
// than asking (PAP-17835 leaves the Access step to the curated app path).
|
||||
expect(container.textContent).toContain("Step 1 of 1");
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
expect(container.textContent).toContain("Add MCP URL");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("Pick the app you want your agents to use.");
|
||||
expect(container.textContent).not.toContain("More ways to connect");
|
||||
|
||||
const linkInput = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder^="https://mcp.zapier.com"]',
|
||||
|
|
@ -767,66 +1020,17 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, name: "Zapier" });
|
||||
expect(container.textContent).toContain("Step 2 of 3");
|
||||
expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Only specific agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Select agents");
|
||||
expect(container.textContent).not.toContain("Ada");
|
||||
expect(container.textContent).not.toContain("Grace");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Select agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Ada");
|
||||
expect(document.body.textContent).toContain("Grace");
|
||||
|
||||
const adaCheckbox = document.body.querySelector<HTMLElement>('[aria-label="Allow Ada"]');
|
||||
await act(async () => {
|
||||
adaCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonByText("Done")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("1 agent selected");
|
||||
expect(container.textContent).not.toContain("Grace");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Continue to install")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Step 3 of 3");
|
||||
expect(container.textContent).toContain("Install Zapier tools?");
|
||||
expect(container.textContent).toContain("Not yet");
|
||||
|
||||
await act(async () => {
|
||||
buttonContaining("Specific agents")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("1 agent selected");
|
||||
await act(async () => {
|
||||
buttonByText("Finish setup")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
// No grantKind is sent: the pasted-URL path never offered the choice, and
|
||||
// sending "user" without asking would mis-scope the credential.
|
||||
expect(connectAppMock.mock.calls[0]?.[1]).not.toHaveProperty("grantKind");
|
||||
|
||||
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
|
||||
enabledCatalogEntryIds: ["action-1"],
|
||||
askFirstCatalogEntryIds: [],
|
||||
access: { agentIds: ["agent-1"] },
|
||||
access: "all_agents",
|
||||
});
|
||||
expect(putConnectionInstallsMock).toHaveBeenCalledWith("conn-1", [
|
||||
{ targetType: "agent", targetId: "agent-1" },
|
||||
{ targetType: "company", targetId: "company-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -911,6 +1115,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Share each sheet with this email");
|
||||
expect(container.textContent).toContain("robot@paperclip.iam.gserviceaccount.com");
|
||||
|
|
@ -932,6 +1137,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
await act(async () => setTextareaValue(textarea!, "https://example.com/not-a-sheet"));
|
||||
await flushReact();
|
||||
|
|
@ -959,16 +1165,27 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
expect(nameInputFrom(container)?.value).toBe("Zapier");
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1&appKey=zapier&stage=setup");
|
||||
});
|
||||
|
||||
it("returns from an app key step to the BYO gallery", async () => {
|
||||
it("steps back from the key step to Access, and from Access to the BYO gallery", async () => {
|
||||
mockSearch.value = "byo=1";
|
||||
mockParams.appKey = "zapier";
|
||||
await render();
|
||||
await passAccessStep();
|
||||
expect(container.textContent).toContain("Connect Zapier");
|
||||
|
||||
// Back from the credential goes to Access, not all the way out: the
|
||||
// selections made there have to survive (PAP-17835).
|
||||
await act(async () => {
|
||||
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Who is this credential for?");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
|
@ -984,6 +1201,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
|
||||
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
|
||||
await act(async () => setInputValue(keyField!, "secret-key"));
|
||||
|
|
@ -1006,6 +1224,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
buttonContaining("Zapier")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
|
||||
await act(async () => setInputValue(nameInputFrom(container)!, "Zapier (stdio smoke)"));
|
||||
const keyField = container.querySelector<HTMLInputElement>("input[type=password]");
|
||||
|
|
@ -1033,6 +1252,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
|
||||
// Default is the app name.
|
||||
expect(nameInputFrom(container)?.value).toBe("Google Sheets");
|
||||
|
|
@ -1068,6 +1288,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
buttonContaining("Google Sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
await act(async () =>
|
||||
setTextareaValue(
|
||||
|
|
@ -1220,9 +1441,12 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Who can use mcp.example.test?");
|
||||
expect(container.textContent).toContain("Unverified server");
|
||||
expect(container.textContent).toContain("mcp.example.test");
|
||||
// The completion summary states identity and reach once each, as three
|
||||
// lines rather than badges, and still never lists the actions.
|
||||
expect(container.textContent).toContain("mcp.example.test is ready.");
|
||||
expect(container.textContent).toContain("Organization identity");
|
||||
expect(container.textContent).toContain("Any agent");
|
||||
expect(container.textContent).toContain("3 actions on");
|
||||
expect(container.textContent).not.toContain("List things");
|
||||
expect(container.querySelectorAll('[role="switch"]')).toHaveLength(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@ import type { LucideIcon } from "lucide-react";
|
|||
import type {
|
||||
Agent,
|
||||
AppDefinition,
|
||||
ConnectionGrantKind,
|
||||
ConnectionMethodDef,
|
||||
ConnectToolAppResult,
|
||||
FieldDef,
|
||||
ToolApplication,
|
||||
ToolConnection,
|
||||
ToolConnectionAuthKind,
|
||||
ToolConnectionCreateCapabilities,
|
||||
} from "@paperclipai/shared";
|
||||
import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod, getAvailableConnectionMethods } from "@paperclipai/shared";
|
||||
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||||
|
|
@ -29,6 +32,7 @@ import { useCompany } from "@/context/CompanyContext";
|
|||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { RadioCardGroup } from "@/components/ui/radio-card";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
|
|
@ -66,13 +70,12 @@ import {
|
|||
} from "./generic-mcp-connect";
|
||||
import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
|
||||
|
||||
type Step = "gallery" | "key" | "who" | "install" | "success";
|
||||
type Step = "gallery" | "access" | "key" | "success";
|
||||
export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error";
|
||||
|
||||
const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
|
||||
access: "access",
|
||||
key: "setup",
|
||||
who: "access",
|
||||
install: "install",
|
||||
success: "complete",
|
||||
};
|
||||
|
||||
|
|
@ -82,21 +85,36 @@ function appConnectHref(appKey: string, step: Step): string {
|
|||
return `/apps/connect?${params.toString()}`;
|
||||
}
|
||||
type AppAccessSelection = "all_agents" | { agentIds: string[] };
|
||||
type InstallMode = "none" | "specific" | "all";
|
||||
|
||||
const STEP_LABELS = ["Pick app", "Add your key", "Choose access", "Install tools"];
|
||||
// Access comes before credentials so the reader knows what identity and reach
|
||||
// the secret is about to get before they share it (PAP-17835).
|
||||
const STEP_LABELS = ["Pick app", "Access", "Add your key"];
|
||||
const STEP_INDEX: Record<Exclude<Step, "success">, number> = {
|
||||
gallery: 0,
|
||||
key: 1,
|
||||
who: 2,
|
||||
install: 3,
|
||||
access: 1,
|
||||
key: 2,
|
||||
};
|
||||
const ZAPIER_STEP_INDEX: Record<Exclude<Step, "gallery" | "success">, number> = {
|
||||
key: 0,
|
||||
who: 1,
|
||||
install: 2,
|
||||
access: 1,
|
||||
};
|
||||
const ZAPIER_STEP_LABELS = ["Add MCP URL", "Choose access", "Install tools"];
|
||||
const ZAPIER_STEP_LABELS = ["Add MCP URL"];
|
||||
|
||||
/**
|
||||
* Which identity a fresh connection should default to (PAP-17835).
|
||||
*
|
||||
* An identity-bearing OAuth app (Gmail, Notion) is almost always personal — the
|
||||
* whole point is that the agent acts as you. A service credential like an API
|
||||
* key is almost always shared. Defaulting per auth kind keeps the common path a
|
||||
* single Continue click without ever guessing silently: the choice is on screen.
|
||||
*/
|
||||
function defaultGrantKindFor(
|
||||
entry: AppDefinition | null,
|
||||
method: ConnectionMethodDef | null,
|
||||
): ConnectionGrantKind {
|
||||
const auth = method?.auth ?? (entry ? getAvailableConnectionMethod(entry)?.auth : null);
|
||||
return auth === "oauth" ? "user" : "organization";
|
||||
}
|
||||
|
||||
function isGoogleSheetsEntry(entry: AppDefinition | null): boolean {
|
||||
return entry?.slug === "google-sheets";
|
||||
|
|
@ -201,8 +219,14 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
|
||||
const [access, setAccess] = useState<"all" | "specific">("all");
|
||||
const [agentIds, setAgentIds] = useState<Set<string>>(new Set());
|
||||
const [installMode, setInstallMode] = useState<InstallMode>("none");
|
||||
const [installAgentIds, setInstallAgentIds] = useState<Set<string>>(new Set());
|
||||
/**
|
||||
* Access-step selections (PAP-17835). These are chosen before the credential
|
||||
* and committed with it, so they must survive a failed submit and a trip
|
||||
* backwards through the wizard.
|
||||
*/
|
||||
const [grantKind, setGrantKind] = useState<ConnectionGrantKind>("organization");
|
||||
const [installChoice, setInstallChoice] = useState<"specific" | "all">("all");
|
||||
const [oauthPhase, setOAuthPhase] = useState<OAuthConnectPhase>("entry");
|
||||
const [oauthError, setOAuthError] = useState<string | null>(null);
|
||||
/** Host of the page the operator is about to be sent to, shown while redirecting. */
|
||||
|
|
@ -248,10 +272,11 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
setGoogleSheetsLinks("");
|
||||
setGoogleSheetsError(null);
|
||||
setConnectResult(null);
|
||||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setStep("key");
|
||||
navigate(appConnectHref(picked.slug, "key"));
|
||||
setInstallChoice("specific");
|
||||
setGrantKind(defaultGrantKindFor(picked, initialMethod));
|
||||
setStep("access");
|
||||
navigate(appConnectHref(picked.slug, "access"));
|
||||
};
|
||||
|
||||
const openGallery = () => {
|
||||
|
|
@ -268,8 +293,9 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
setGoogleSheetsLinks("");
|
||||
setGoogleSheetsError(null);
|
||||
setConnectResult(null);
|
||||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setInstallChoice("all");
|
||||
setGrantKind("organization");
|
||||
setStep("gallery");
|
||||
navigate(byoOnly ? "/apps/byo" : "/apps/connect?byo=1");
|
||||
};
|
||||
|
|
@ -361,6 +387,18 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
});
|
||||
const startOAuth = oauthStartMutation.mutate;
|
||||
|
||||
/**
|
||||
* Commit the Access step's agent reach for a connection. Shared by the
|
||||
* key-path finish and the OAuth redirect, so both routes through the wizard
|
||||
* apply the same selection.
|
||||
*/
|
||||
const applyAccessInstalls = async (connectionId: string) => {
|
||||
const installState = installChoice === "all"
|
||||
? { onAll: true, agentIds: new Set<string>() }
|
||||
: { onAll: false, agentIds: installAgentIds };
|
||||
await toolsApi.putConnectionInstalls(connectionId, installPayload(selectedCompanyId!, installState));
|
||||
};
|
||||
|
||||
const connectMutation = useMutation({
|
||||
mutationFn: (entryOverride?: AppDefinition) => {
|
||||
const connectEntry = entryOverride ?? entry;
|
||||
|
|
@ -378,6 +416,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
? configValues
|
||||
: undefined,
|
||||
applicationId: prefill.applicationId,
|
||||
...(grantKind === "user" ? { grantKind } : {}),
|
||||
});
|
||||
}
|
||||
return toolsApi.connectApp(selectedCompanyId!, {
|
||||
|
|
@ -392,11 +431,17 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
oauthClientSecret: linkOAuthClientSecret,
|
||||
}),
|
||||
applicationId: prefill.applicationId,
|
||||
...(grantKind === "user" ? { grantKind } : {}),
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.auth?.kind === "oauth") {
|
||||
setConnectResult(result);
|
||||
// The redirect takes the operator out of the wizard, so the Access
|
||||
// step's agent reach is committed now rather than after the callback.
|
||||
// Best-effort: a failure here must not block the sign-in they asked for,
|
||||
// and Permissions still shows the real state when they land.
|
||||
void applyAccessInstalls(result.connectionId);
|
||||
// Discovery worked but this authorization server insists on a client the
|
||||
// operator registers themselves. Keep the draft and ask for it in place
|
||||
// rather than sending them back to the start.
|
||||
|
|
@ -433,9 +478,7 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
for (const a of result.actions.readOnly) defaults[a.catalogEntryId] = true;
|
||||
for (const a of result.actions.canMakeChanges) defaults[a.catalogEntryId] = true;
|
||||
setEnabled(defaults);
|
||||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setAppStep("who");
|
||||
finishMutation.mutate({ result, enabled: defaults });
|
||||
},
|
||||
onError: (error) => {
|
||||
const details = error instanceof ApiError && error.body && typeof error.body === "object"
|
||||
|
|
@ -502,10 +545,12 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
setGoogleSheetsLinks("");
|
||||
setGoogleSheetsError(null);
|
||||
setConnectResult(null);
|
||||
setGrantKind(defaultGrantKindFor(requestedEntry, initialMethod));
|
||||
if (!directOAuth) setInstallChoice("specific");
|
||||
}
|
||||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setStep("key");
|
||||
// A direct-OAuth deep link is an express reconnect: it redirects on arrival,
|
||||
// so there is no credential entry for an Access step to precede.
|
||||
setStep(directOAuth ? "key" : "access");
|
||||
|
||||
if (directOAuth && (
|
||||
!applicationsQuery.isFetchedAfterMount ||
|
||||
|
|
@ -543,29 +588,50 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
startOAuth,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Commit the connection: action defaults, agent reach, and installs.
|
||||
*
|
||||
* Takes the connect result and the enabled set as arguments rather than
|
||||
* reading them from state. The Access step removed the separate who/install
|
||||
* screens, so this now runs in the same tick as the `setConnectResult` /
|
||||
* `setEnabled` that precede it, where that state has not been applied yet.
|
||||
*/
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const enabledIds = Object.entries(enabled)
|
||||
mutationFn: async (input: { result: ConnectToolAppResult; enabled: Record<string, boolean> }) => {
|
||||
const { result: connected, enabled: enabledMap } = input;
|
||||
const enabledIds = Object.entries(enabledMap)
|
||||
.filter(([, on]) => on)
|
||||
.map(([id]) => id);
|
||||
const selection: AppAccessSelection =
|
||||
access === "all" ? "all_agents" : { agentIds: Array.from(agentIds) };
|
||||
const result = await toolsApi.finishApp(selectedCompanyId!, connectResult!.connectionId, {
|
||||
const askFirstRiskLevels = new Set(
|
||||
Array.isArray(connected.suggestedDefaults.askFirstRiskLevels)
|
||||
? connected.suggestedDefaults.askFirstRiskLevels.filter(
|
||||
(riskLevel): riskLevel is string => typeof riskLevel === "string",
|
||||
)
|
||||
: [],
|
||||
);
|
||||
const askFirstIds = connected.actions.canMakeChanges
|
||||
.filter((action) => enabledMap[action.catalogEntryId] && askFirstRiskLevels.has(action.riskLevel))
|
||||
.map((action) => action.catalogEntryId);
|
||||
// The Access step asks one question about agent reach, so profile access
|
||||
// and installs are committed to the same target set instead of drifting
|
||||
// apart behind two separate wizard screens.
|
||||
const selection: AppAccessSelection = installChoice === "all"
|
||||
? "all_agents"
|
||||
: { agentIds: Array.from(installAgentIds) };
|
||||
const finished = await toolsApi.finishApp(selectedCompanyId!, connected.connectionId, {
|
||||
enabledCatalogEntryIds: enabledIds,
|
||||
askFirstCatalogEntryIds: [],
|
||||
askFirstCatalogEntryIds: askFirstIds,
|
||||
access: selection,
|
||||
});
|
||||
const installState = installMode === "all"
|
||||
? { onAll: true, agentIds: new Set<string>() }
|
||||
: { onAll: false, agentIds: installMode === "specific" ? installAgentIds : new Set<string>() };
|
||||
await toolsApi.putConnectionInstalls(
|
||||
connectResult!.connectionId,
|
||||
installPayload(selectedCompanyId!, installState),
|
||||
);
|
||||
return result;
|
||||
await applyAccessInstalls(connected.connectionId);
|
||||
return finished;
|
||||
},
|
||||
onSuccess: () => setAppStep("success"),
|
||||
onError: (error) => {
|
||||
// Creation must feel transactional: a failed commit returns the operator
|
||||
// to Access with their identity and agent selections intact rather than
|
||||
// stranding them on a half-made connection.
|
||||
setAppStep("access");
|
||||
pushToast({
|
||||
title: "Couldn’t finish setup",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
|
|
@ -675,10 +741,33 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
const stepLabels = zapierSource
|
||||
? ZAPIER_STEP_LABELS
|
||||
: entry && getAvailableConnectionMethods(entry).length > 1
|
||||
? ["Pick app", "Choose connection", "Choose access", "Install tools"]
|
||||
? ["Pick app", "Access", "Choose connection"]
|
||||
: isGoogleSheetsEntry(entry)
|
||||
? ["Pick app", "Share sheet", "Choose access", "Install tools"]
|
||||
? ["Pick app", "Access", "Share sheet"]
|
||||
: STEP_LABELS;
|
||||
// The Access step's identity question only makes sense when there *is* a
|
||||
// credential, so it reads the selected method's auth kind.
|
||||
const accessStepMethod = entry
|
||||
? (connectionMethodKey
|
||||
? getAvailableConnectionMethods(entry).find((m) => m.key === connectionMethodKey) ?? null
|
||||
: getAvailableConnectionMethod(entry))
|
||||
: null;
|
||||
const accessStepAuthKind: ToolConnectionAuthKind = entry
|
||||
? accessStepMethod?.auth ?? "none"
|
||||
: linkAuthMode === "none"
|
||||
? "none"
|
||||
: linkAuthMode === "oauth"
|
||||
? "oauth"
|
||||
: "api_key";
|
||||
// The primary label names the next effect, so an OAuth handoff never arrives
|
||||
// unannounced.
|
||||
const accessMethodIsKnown = !entry
|
||||
|| Boolean(connectionMethodKey)
|
||||
|| getAvailableConnectionMethods(entry).length === 1;
|
||||
const accessSubmitLabel = accessStepAuthKind === "oauth" && accessMethodIsKnown
|
||||
? `Continue to ${entry?.name ?? "sign-in"}`
|
||||
: "Save and continue";
|
||||
|
||||
const stepIndex = zapierSource && step !== "gallery" && step !== "success"
|
||||
? ZAPIER_STEP_INDEX[step]
|
||||
: step === "success"
|
||||
|
|
@ -728,8 +817,9 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
setCredentials({});
|
||||
setGoogleSheetsLinks("");
|
||||
setGoogleSheetsError(null);
|
||||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setInstallChoice("all");
|
||||
setGrantKind("organization");
|
||||
setStep("key");
|
||||
}}
|
||||
onRunYourOwn={() => navigate(advancedTabHref("run-your-own"))}
|
||||
|
|
@ -759,7 +849,10 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
setGoogleSheetsError(null);
|
||||
}}
|
||||
submitting={connectMutation.isPending}
|
||||
onBack={openGallery}
|
||||
// Back returns to Access, not to the gallery: the design requires the
|
||||
// identity and agent selections to survive moving backward, and
|
||||
// `openGallery` resets them.
|
||||
onBack={() => setAppStep("access")}
|
||||
onConnect={() => {
|
||||
if (isGoogleSheetsEntry(entry)) {
|
||||
const parsed = parseGoogleSheetIds(googleSheetsLinks);
|
||||
|
|
@ -831,32 +924,24 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{step === "who" && connectResult && (
|
||||
<WhoStep
|
||||
{step === "access" && (
|
||||
<AccessStep
|
||||
appName={appName}
|
||||
providerName={entry?.name ?? appName}
|
||||
companyId={selectedCompanyId}
|
||||
access={access}
|
||||
setAccess={setAccess}
|
||||
agentIds={agentIds}
|
||||
setAgentIds={setAgentIds}
|
||||
onBack={() => setAppStep("key")}
|
||||
onContinue={() => setAppStep("install")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "install" && connectResult && (
|
||||
<InstallStep
|
||||
appName={appName}
|
||||
companyId={selectedCompanyId}
|
||||
access={access}
|
||||
accessAgentIds={agentIds}
|
||||
installMode={installMode}
|
||||
setInstallMode={setInstallMode}
|
||||
authKind={accessStepAuthKind}
|
||||
grantKind={grantKind}
|
||||
setGrantKind={setGrantKind}
|
||||
installChoice={installChoice}
|
||||
setInstallChoice={setInstallChoice}
|
||||
installAgentIds={installAgentIds}
|
||||
setInstallAgentIds={setInstallAgentIds}
|
||||
submitting={finishMutation.isPending}
|
||||
onBack={() => setAppStep("who")}
|
||||
onFinish={() => finishMutation.mutate()}
|
||||
capabilities={galleryQuery.data?.capabilities}
|
||||
submitLabel={accessSubmitLabel}
|
||||
// Leaving Access abandons the app choice entirely, so this resets the
|
||||
// draft and returns to the gallery the operator came from.
|
||||
onBack={() => (entry || linkUrl ? openGallery() : navigate("/apps"))}
|
||||
onContinue={() => (entry ? setAppStep("key") : setStep("key"))}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -864,10 +949,13 @@ export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
|||
<SuccessStep
|
||||
appName={appName}
|
||||
logoUrl={entry?.branding.logoUrl}
|
||||
enabledCount={Object.values(enabled).filter(Boolean).length}
|
||||
access={access}
|
||||
installMode={installMode}
|
||||
installCount={installAgentIds.size}
|
||||
summary={accessSummaryLines({
|
||||
grantKind,
|
||||
authKind: accessStepAuthKind,
|
||||
installChoice,
|
||||
installCount: installAgentIds.size,
|
||||
enabledCount: Object.values(enabled).filter(Boolean).length,
|
||||
})}
|
||||
onDone={() => navigate("/apps/connections")}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -2083,238 +2171,188 @@ function MethodConfigField({
|
|||
);
|
||||
}
|
||||
|
||||
function WhoStep({
|
||||
/**
|
||||
* Access step (PAP-17835 Surface A).
|
||||
*
|
||||
* Two binary questions, asked together and *before* any credential is entered,
|
||||
* so the reader understands the identity and the reach the secret is about to
|
||||
* get. Hick's Law: two choices, not a matrix. Both use full-row radio targets.
|
||||
*/
|
||||
export function AccessStep({
|
||||
appName,
|
||||
providerName,
|
||||
companyId,
|
||||
access,
|
||||
setAccess,
|
||||
agentIds,
|
||||
setAgentIds,
|
||||
authKind,
|
||||
grantKind,
|
||||
setGrantKind,
|
||||
installChoice,
|
||||
setInstallChoice,
|
||||
installAgentIds,
|
||||
setInstallAgentIds,
|
||||
capabilities,
|
||||
submitLabel,
|
||||
onBack,
|
||||
onContinue,
|
||||
}: {
|
||||
appName: string;
|
||||
providerName: string;
|
||||
companyId: string;
|
||||
access: "all" | "specific";
|
||||
setAccess: (a: "all" | "specific") => void;
|
||||
agentIds: Set<string>;
|
||||
setAgentIds: (s: Set<string>) => void;
|
||||
authKind: ToolConnectionAuthKind;
|
||||
grantKind: ConnectionGrantKind;
|
||||
setGrantKind: (kind: ConnectionGrantKind) => void;
|
||||
installChoice: "specific" | "all";
|
||||
setInstallChoice: (choice: "specific" | "all") => void;
|
||||
installAgentIds: Set<string>;
|
||||
setInstallAgentIds: (ids: Set<string>) => void;
|
||||
capabilities?: Pick<ToolConnectionCreateCapabilities, "canSetCompanyInstall"> & {
|
||||
companyInstallReason?: string | null;
|
||||
editableAgentIds?: string[];
|
||||
} | null;
|
||||
submitLabel: string;
|
||||
onBack: () => void;
|
||||
onContinue: () => void;
|
||||
}) {
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
enabled: access === "specific",
|
||||
});
|
||||
const agents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
|
||||
const canFinish = access === "all" || agentIds.size > 0;
|
||||
const allAgents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
|
||||
// "Agents I pick" means agents this person may actually edit. When the server
|
||||
// has not told us, fall back to every live agent rather than an empty list —
|
||||
// an empty picker would read as "you have no agents".
|
||||
const editableAgentIds = capabilities?.editableAgentIds;
|
||||
const agents = editableAgentIds
|
||||
? allAgents.filter((agent) => editableAgentIds.includes(agent.id))
|
||||
: allAgents;
|
||||
// Company-wide install is the connection creator's to give. When it is not
|
||||
// available the option stays visible and disabled with the reason, so the
|
||||
// scope stays legible instead of quietly disappearing.
|
||||
const canSetCompanyInstall = capabilities?.canSetCompanyInstall ?? true;
|
||||
const needsIdentityChoice = authKind !== "none";
|
||||
const canContinue = installChoice === "all"
|
||||
? canSetCompanyInstall
|
||||
: installAgentIds.size > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<div className="rounded-2xl border border-border bg-card p-8">
|
||||
<h2 className="text-xl font-bold tracking-tight">Who can use {appName}?</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">You can change this later from the app’s page.</p>
|
||||
<h2 className="text-xl font-bold tracking-tight">Access</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Choose who this credential represents and where agents can use it.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccess("all")}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-xl border-2 p-4 text-left transition-colors",
|
||||
access === "all" ? "border-foreground bg-muted/40" : "border-border hover:border-foreground/30",
|
||||
<div className="mt-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Who is this credential for?</h3>
|
||||
{needsIdentityChoice ? (
|
||||
<RadioCardGroup
|
||||
ariaLabel="Who is this credential for?"
|
||||
className="mt-2 sm:grid-cols-2"
|
||||
value={grantKind}
|
||||
onValueChange={(next) => setGrantKind(next as ConnectionGrantKind)}
|
||||
options={[
|
||||
{
|
||||
value: "user",
|
||||
title: "Just me",
|
||||
description: "Agents use this identity only when work runs for you.",
|
||||
},
|
||||
{
|
||||
value: "organization",
|
||||
title: "The whole organization",
|
||||
description: "Agents use one shared identity for eligible organization members.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
// A connection with no credential has no identity to choose, so
|
||||
// asking would be a meaningless decision.
|
||||
<p className="mt-2 text-sm text-muted-foreground">No identity required</p>
|
||||
)}
|
||||
>
|
||||
<Radio selected={access === "all"} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-foreground">All agents</span>
|
||||
<span className="rounded-full bg-foreground px-2 py-0.5 text-(length:--text-nano) font-bold text-background">
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Anyone you’ve added to Paperclip can use {appName} in their tasks. This is what most teams want.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccess("specific")}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-xl border-2 p-4 text-left transition-colors",
|
||||
access === "specific" ? "border-foreground bg-muted/40" : "border-border hover:border-foreground/30",
|
||||
)}
|
||||
>
|
||||
<Radio selected={access === "specific"} />
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold text-foreground">Only specific agents</span>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Tick the agents who can use {appName}.</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{access === "specific" && (
|
||||
<AgentMultiSelect
|
||||
agents={agents}
|
||||
selectedAgentIds={agentIds}
|
||||
onChange={setAgentIds}
|
||||
loading={agentsQuery.isLoading}
|
||||
showSelectionPreview={false}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Which agents can use this connection?
|
||||
</h3>
|
||||
<RadioCardGroup
|
||||
ariaLabel="Which agents can use this connection?"
|
||||
className="mt-2 sm:grid-cols-2"
|
||||
value={installChoice}
|
||||
onValueChange={(next) => setInstallChoice(next as "specific" | "all")}
|
||||
options={[
|
||||
{
|
||||
value: "specific",
|
||||
title: "Agents I pick",
|
||||
description: "Choose one or more agents you can edit.",
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
title: "Any agent",
|
||||
description: canSetCompanyInstall
|
||||
? "Make this connection available to every agent."
|
||||
: capabilities?.companyInstallReason ??
|
||||
"Only someone who can configure this connection can choose this.",
|
||||
disabled: !canSetCompanyInstall,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{installChoice === "specific" ? (
|
||||
<div className="mt-2">
|
||||
<AgentMultiSelect
|
||||
agents={agents}
|
||||
selectedAgentIds={installAgentIds}
|
||||
onChange={setInstallAgentIds}
|
||||
loading={agentsQuery.isLoading}
|
||||
emptyMessage="You cannot edit any agents yet."
|
||||
showSelectionPreview={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
{/* Mobile stacks actions full-width with the primary action first in
|
||||
reading order; desktop keeps Back on the left. */}
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button variant="ghost" className="w-full sm:w-auto" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onContinue} disabled={!canFinish}>
|
||||
Continue to install
|
||||
<Button className="w-full sm:w-auto" onClick={onContinue} disabled={!canContinue}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InstallStep({
|
||||
appName,
|
||||
companyId,
|
||||
access,
|
||||
accessAgentIds,
|
||||
installMode,
|
||||
setInstallMode,
|
||||
installAgentIds,
|
||||
setInstallAgentIds,
|
||||
submitting,
|
||||
onBack,
|
||||
onFinish,
|
||||
}: {
|
||||
appName: string;
|
||||
companyId: string;
|
||||
access: "all" | "specific";
|
||||
accessAgentIds: Set<string>;
|
||||
installMode: InstallMode;
|
||||
setInstallMode: (mode: InstallMode) => void;
|
||||
installAgentIds: Set<string>;
|
||||
setInstallAgentIds: (ids: Set<string>) => void;
|
||||
submitting: boolean;
|
||||
onBack: () => void;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
const agents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
|
||||
const installSpecific = () => {
|
||||
setInstallMode("specific");
|
||||
if (installAgentIds.size === 0 && access === "specific") setInstallAgentIds(new Set(accessAgentIds));
|
||||
};
|
||||
const extendingAgentIds = access === "all"
|
||||
? []
|
||||
: installMode === "all"
|
||||
? agents.filter((agent) => !accessAgentIds.has(agent.id)).map((agent) => agent.id)
|
||||
: [...installAgentIds].filter((id) => !accessAgentIds.has(id));
|
||||
const canFinish = installMode !== "specific" || installAgentIds.size > 0;
|
||||
const extendingLabel = extendingAgentIds.length === 1
|
||||
? agents.find((agent) => agent.id === extendingAgentIds[0])?.name ?? "1 agent"
|
||||
: `${extendingAgentIds.length} agents`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl">
|
||||
<div className="rounded-2xl border border-border bg-card p-8">
|
||||
<h2 className="text-xl font-bold tracking-tight">Install {appName} tools?</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Access is permission. Install decides whose runs actually carry these tools.
|
||||
</p>
|
||||
|
||||
<div className="mt-5">
|
||||
<InlineBanner tone="info" compact>
|
||||
{installInfoNotice(appName)}
|
||||
</InlineBanner>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInstallMode("none")}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-xl border-2 p-4 text-left transition-colors",
|
||||
installMode === "none" ? "border-foreground bg-muted/40" : "border-border hover:border-foreground/30",
|
||||
)}
|
||||
>
|
||||
<Radio selected={installMode === "none"} />
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">Not yet</span>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Keep {appName} permitted only. You can install it later from the app or agent page.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={installSpecific}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-xl border-2 p-4 text-left transition-colors",
|
||||
installMode === "specific" ? "border-foreground bg-muted/40" : "border-border hover:border-foreground/30",
|
||||
)}
|
||||
>
|
||||
<Radio selected={installMode === "specific"} />
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold text-foreground">Specific agents</span>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Tick the agents that should load {appName} every run.</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{installMode === "specific" ? (
|
||||
<div className="ml-7 border-l border-border pl-4">
|
||||
<AgentMultiSelect
|
||||
agents={agents}
|
||||
selectedAgentIds={installAgentIds}
|
||||
onChange={setInstallAgentIds}
|
||||
loading={agentsQuery.isLoading}
|
||||
showSelectionPreview={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInstallMode("all")}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-xl border-2 p-4 text-left transition-colors",
|
||||
installMode === "all" ? "border-foreground bg-muted/40" : "border-border hover:border-foreground/30",
|
||||
)}
|
||||
>
|
||||
<Radio selected={installMode === "all"} />
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">All agents</span>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{INSTALL_ALL_WARNING}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{extendingAgentIds.length > 0 ? (
|
||||
<InlineBanner tone="warning" compact>
|
||||
{autoExtendNotice(extendingLabel)}
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={onBack} disabled={submitting}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onFinish} disabled={submitting || !canFinish}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{submitting ? "Finishing..." : "Finish setup"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
/**
|
||||
* Summary of what the Access step committed. Three lines, not badges: identity,
|
||||
* reach, and the existing action summary each said once.
|
||||
*/
|
||||
export function accessSummaryLines(input: {
|
||||
grantKind: ConnectionGrantKind;
|
||||
authKind: ToolConnectionAuthKind;
|
||||
installChoice: "specific" | "all";
|
||||
installCount: number;
|
||||
enabledCount: number;
|
||||
}): Array<{ label: string; value: string }> {
|
||||
const identity = input.authKind === "none"
|
||||
? "No identity required"
|
||||
: input.grantKind === "user"
|
||||
? "Your identity"
|
||||
: "Organization identity";
|
||||
const availableTo = input.installChoice === "all"
|
||||
? "Any agent"
|
||||
: `${input.installCount} selected ${input.installCount === 1 ? "agent" : "agents"}`;
|
||||
return [
|
||||
{ label: "Identity", value: identity },
|
||||
{ label: "Available to", value: availableTo },
|
||||
{
|
||||
label: "Actions",
|
||||
value: `${input.enabledCount} ${input.enabledCount === 1 ? "action" : "actions"} on`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function Radio({ selected }: { selected: boolean }) {
|
||||
|
|
@ -2333,25 +2371,15 @@ function Radio({ selected }: { selected: boolean }) {
|
|||
function SuccessStep({
|
||||
appName,
|
||||
logoUrl,
|
||||
enabledCount,
|
||||
access,
|
||||
installMode,
|
||||
installCount,
|
||||
summary,
|
||||
onDone,
|
||||
}: {
|
||||
appName: string;
|
||||
logoUrl?: string | null;
|
||||
enabledCount: number;
|
||||
access: "all" | "specific";
|
||||
installMode: InstallMode;
|
||||
installCount: number;
|
||||
/** Identity / Available to / Actions, as three lines rather than badges. */
|
||||
summary: Array<{ label: string; value: string }>;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const installSummary = installMode === "all"
|
||||
? "Installed on all agents"
|
||||
: installMode === "specific"
|
||||
? `${installCount} ${installCount === 1 ? "agent" : "agents"} installed`
|
||||
: "Permitted only";
|
||||
return (
|
||||
<div className="mx-auto max-w-md py-10 text-center">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full border-2 border-emerald-500 bg-emerald-500/10">
|
||||
|
|
@ -2361,18 +2389,17 @@ function SuccessStep({
|
|||
<AppLogo name={appName} logoUrl={logoUrl} size={28} />
|
||||
<h2 className="text-2xl font-bold tracking-tight">{appName} is ready.</h2>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{installMode === "none"
|
||||
? "Agents can use it after you install it on their Tools tab."
|
||||
: "Installed agents will load it on their next run."}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{enabledCount} {enabledCount === 1 ? "action" : "actions"} on ·{" "}
|
||||
{access === "all" ? "All agents can use it" : "Specific agents can use it"} · {installSummary}
|
||||
</p>
|
||||
<dl className="mx-auto mt-6 max-w-xs space-y-1 text-left">
|
||||
{summary.map((line) => (
|
||||
<div key={line.label} className="flex items-baseline justify-between gap-4">
|
||||
<dt className="text-xs font-medium text-muted-foreground">{line.label}</dt>
|
||||
<dd className="text-sm text-foreground">{line.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="mt-8">
|
||||
<Button size="lg" className="px-10" onClick={onDone}>
|
||||
Done
|
||||
View connection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
await act(async () => {
|
||||
byoCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/byo");
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1");
|
||||
});
|
||||
|
||||
it("filters the gallery by the search query", async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,571 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
import type {
|
||||
ConnectionAudienceMember,
|
||||
ConnectionGrant,
|
||||
ConnectionGrantsResponse,
|
||||
ToolConnectionCredentialPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { MemberMultiSelect } from "@/components/MemberMultiSelect";
|
||||
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
|
||||
import { RadioCardGroup } from "@/components/ui/radio-card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { brandChipBadge } from "@/lib/status-colors";
|
||||
import {
|
||||
audienceSummary,
|
||||
audienceUserIds,
|
||||
grantAccountLabel,
|
||||
grantStatusLabel,
|
||||
grantStatusTone,
|
||||
memberLabel,
|
||||
organizationGrant,
|
||||
otherPersonalGrants,
|
||||
personalGrantFor,
|
||||
type GrantStatusTone,
|
||||
} from "../connection-identity";
|
||||
|
||||
const STATUS_CHIP: Record<GrantStatusTone, string> = {
|
||||
connected: brandChipBadge.green,
|
||||
attention: brandChipBadge.amber,
|
||||
inactive: brandChipBadge.gray,
|
||||
missing: brandChipBadge.gray,
|
||||
};
|
||||
|
||||
function StatusText({ status }: { status: ConnectionGrant["status"] | null }) {
|
||||
const tone = grantStatusTone(status);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
STATUS_CHIP[tone],
|
||||
)}
|
||||
>
|
||||
{grantStatusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLastUsed(value: ConnectionGrant["lastUsedAt"]): string | null {
|
||||
if (!value) return null;
|
||||
const parsed = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
return `Last used ${parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity rows for the connection Setup tab (PAP-17835 Surface B).
|
||||
*
|
||||
* Rows are separated by space and a rule, not wrapped in one card each: "space
|
||||
* separates; lines contain". Every action here is rendered from a server
|
||||
* capability — a policy-forbidden action is absent rather than disabled, so a
|
||||
* viewer sees the same legible state with no controls at all.
|
||||
*/
|
||||
export function IdentitiesSection({
|
||||
appName,
|
||||
providerName,
|
||||
credentialPolicy,
|
||||
grantsQuery,
|
||||
agents,
|
||||
agentsLoading,
|
||||
agentsError,
|
||||
loading,
|
||||
error,
|
||||
onConnectAsMe,
|
||||
onConnectOrganization,
|
||||
onReconnectOrganization,
|
||||
onRevokeGrant,
|
||||
onReplaceAudience,
|
||||
connectPending,
|
||||
revokePending,
|
||||
delegationPending,
|
||||
audiencePending,
|
||||
audienceError,
|
||||
audienceGrantId,
|
||||
onOpenAudience,
|
||||
onCloseAudience,
|
||||
onReplaceDelegations,
|
||||
}: {
|
||||
appName: string;
|
||||
providerName: string;
|
||||
credentialPolicy: ToolConnectionCredentialPolicy;
|
||||
grantsQuery: ConnectionGrantsResponse | undefined;
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
icon?: string | null;
|
||||
status: string;
|
||||
}>;
|
||||
agentsLoading: boolean;
|
||||
agentsError: boolean;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
onConnectAsMe: () => void;
|
||||
onConnectOrganization: () => void;
|
||||
onReconnectOrganization: () => void;
|
||||
onRevokeGrant: (grant: ConnectionGrant) => void;
|
||||
onReplaceAudience: (grant: ConnectionGrant, memberUserIds: string[]) => void;
|
||||
connectPending: boolean;
|
||||
revokePending: boolean;
|
||||
delegationPending: boolean;
|
||||
audiencePending: boolean;
|
||||
audienceError: string | null;
|
||||
/**
|
||||
* The audience dialog is controlled by the page, not this section: a save that
|
||||
* the server rejects has to keep the dialog open with the selection intact,
|
||||
* which only the mutation's outcome knows.
|
||||
*/
|
||||
audienceGrantId: string | null;
|
||||
onOpenAudience: (grantId: string) => void;
|
||||
onCloseAudience: () => void;
|
||||
onReplaceDelegations: (grant: ConnectionGrant, agentIds: string[]) => void;
|
||||
}) {
|
||||
const [revokeTarget, setRevokeTarget] = useState<ConnectionGrant | null>(null);
|
||||
const [othersExpanded, setOthersExpanded] = useState(false);
|
||||
|
||||
const grants = grantsQuery?.grants ?? [];
|
||||
const capabilities = grantsQuery?.capabilities;
|
||||
const currentUserId = grantsQuery?.currentUserId ?? null;
|
||||
const members = grantsQuery?.members ?? [];
|
||||
const orgGrant = useMemo(() => organizationGrant(grants), [grants]);
|
||||
const myGrant = useMemo(() => personalGrantFor(grants, currentUserId), [grants, currentUserId]);
|
||||
const others = useMemo(() => otherPersonalGrants(grants, currentUserId), [grants, currentUserId]);
|
||||
const myLabel = memberLabel(members, currentUserId);
|
||||
const audienceGrant = audienceGrantId
|
||||
? grants.find((grant) => grant.id === audienceGrantId) ?? null
|
||||
: null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="space-y-3" aria-busy="true">
|
||||
<IdentitiesHeading />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<IdentitiesHeading />
|
||||
<InlineBanner tone="warning" compact>
|
||||
We couldn't load who this connection acts as. Reload the page to try again.
|
||||
</InlineBanner>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<IdentitiesHeading />
|
||||
|
||||
{agentsError ? (
|
||||
<div className="mt-3">
|
||||
<InlineBanner tone="warning" compact>
|
||||
We couldn't load agents for autonomous access. Reload the page to try again.
|
||||
</InlineBanner>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{/* Organization identity — always visible, including when missing, so the
|
||||
shared-vs-personal distinction never has to be inferred from absence. */}
|
||||
<IdentityRow
|
||||
title="Organization identity"
|
||||
secondary={grantAccountLabel(orgGrant)}
|
||||
status={orgGrant?.status ?? null}
|
||||
detail={orgGrant
|
||||
? audienceSummary(orgGrant)
|
||||
: "Used when the connection policy allows a shared identity."}
|
||||
actions={
|
||||
<>
|
||||
{orgGrant?.capabilities?.canEditAudience ? (
|
||||
<Button size="sm" variant="outline" onClick={() => onOpenAudience(orgGrant.id)}>
|
||||
Manage audience
|
||||
</Button>
|
||||
) : null}
|
||||
{orgGrant && capabilities?.canConfigure ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={connectPending}
|
||||
onClick={onReconnectOrganization}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
) : null}
|
||||
{orgGrant?.capabilities?.canRevoke && orgGrant.status !== "revoked" ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setRevokeTarget(orgGrant)}>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
{!orgGrant && capabilities?.canCreateOrganizationGrant ? (
|
||||
<Button size="sm" disabled={connectPending} onClick={onConnectOrganization}>
|
||||
Connect organization identity
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Your identity — the signed-in user only. Audience is never shown here:
|
||||
a personal identity is consent-bound to the person who granted it. */}
|
||||
{capabilities?.canConnectAsCurrentUser || myGrant ? (
|
||||
<IdentityRow
|
||||
id="personal-identity"
|
||||
title="Your identity"
|
||||
secondary={myGrant ? grantAccountLabel(myGrant, { subjectLabel: myLabel }) : null}
|
||||
status={myGrant?.status ?? null}
|
||||
detail={myGrant
|
||||
? formatLastUsed(myGrant.lastUsedAt) ?? "Only work running for you can use this identity."
|
||||
: "You have not connected your account."}
|
||||
actions={
|
||||
<>
|
||||
{myGrant && myGrant.status !== "revoked" ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" disabled={connectPending} onClick={onConnectAsMe}>
|
||||
Reconnect
|
||||
</Button>
|
||||
{myGrant.capabilities?.canRevoke ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setRevokeTarget(myGrant)}>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
{myGrant.status === "active" ? (
|
||||
<AgentMultiSelect
|
||||
agents={agents.filter((agent) => agent.status !== "terminated")}
|
||||
loading={agentsLoading}
|
||||
selectedAgentIds={new Set(
|
||||
(myGrant.delegations ?? []).map((delegation) => delegation.agentId),
|
||||
)}
|
||||
pending={delegationPending}
|
||||
triggerLabel={(myGrant.delegations?.length ?? 0) === 0
|
||||
? "Allow autonomous access"
|
||||
: `${myGrant.delegations?.length ?? 0} ${myGrant.delegations?.length === 1 ? "agent" : "agents"} allowed for autonomous runs`}
|
||||
triggerSize="sm"
|
||||
triggerFullWidth={false}
|
||||
showSelectionPreview={false}
|
||||
headerContent={(
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Select the named agents that may use your identity in autonomous runs.
|
||||
</p>
|
||||
)}
|
||||
onSave={(agentIds) => onReplaceDelegations(myGrant, [...agentIds])}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : capabilities?.canConnectAsCurrentUser ? (
|
||||
<Button size="sm" disabled={connectPending} onClick={onConnectAsMe}>
|
||||
{connectPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Connect as me
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Manager oversight. A regular member never sees this list — the server
|
||||
omits the grants entirely, so there is nothing to hide client-side. */}
|
||||
{capabilities?.canViewOtherPersonalIdentities && others.length > 0 ? (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 text-left text-sm text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={othersExpanded}
|
||||
onClick={() => setOthersExpanded((open) => !open)}
|
||||
>
|
||||
<span className="flex-1">Other personal identities · {others.length}</span>
|
||||
<ChevronRight
|
||||
className={cn("h-4 w-4 transition-transform", othersExpanded && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
{othersExpanded ? (
|
||||
<div className="mt-2 divide-y divide-border">
|
||||
{others.map((grant) => (
|
||||
<div key={grant.id} className="flex flex-wrap items-center gap-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-foreground">
|
||||
{grantAccountLabel(grant, {
|
||||
subjectLabel: memberLabel(members, grant.subjectUserId),
|
||||
})}
|
||||
</div>
|
||||
{formatLastUsed(grant.lastUsedAt) ? (
|
||||
<div className="text-xs text-muted-foreground">{formatLastUsed(grant.lastUsedAt)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusText status={grant.status} />
|
||||
{grant.capabilities?.canRevoke && grant.status !== "revoked" ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setRevokeTarget(grant)}>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{audienceGrant ? (
|
||||
<AudienceDialog
|
||||
appName={appName}
|
||||
grant={audienceGrant}
|
||||
members={members}
|
||||
pending={audiencePending}
|
||||
error={audienceError}
|
||||
onCancel={onCloseAudience}
|
||||
onSave={(memberUserIds) => onReplaceAudience(audienceGrant, memberUserIds)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{revokeTarget ? (
|
||||
<RevokeGrantDialog
|
||||
grant={revokeTarget}
|
||||
providerName={providerName}
|
||||
pending={revokePending}
|
||||
credentialPolicy={credentialPolicy}
|
||||
isOwnIdentity={revokeTarget.kind === "user" && revokeTarget.subjectUserId === currentUserId}
|
||||
onCancel={() => setRevokeTarget(null)}
|
||||
onConfirm={() => {
|
||||
onRevokeGrant(revokeTarget);
|
||||
setRevokeTarget(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentitiesHeading() {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Identities</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Who agents act as when they use this connection.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityRow({
|
||||
id,
|
||||
title,
|
||||
secondary,
|
||||
status,
|
||||
detail,
|
||||
actions,
|
||||
}: {
|
||||
id?: string;
|
||||
title: string;
|
||||
secondary: string | null;
|
||||
status: ConnectionGrant["status"] | null;
|
||||
detail: string | null;
|
||||
actions: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div id={id} className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">{title}</span>
|
||||
<StatusText status={status} />
|
||||
</div>
|
||||
{secondary ? <div className="mt-0.5 truncate text-sm text-foreground">{secondary}</div> : null}
|
||||
{detail ? <div className="mt-0.5 text-xs text-muted-foreground">{detail}</div> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">{actions}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Who can use this identity" (PAP-17835 Surface C). Scope is a two-option
|
||||
* radio: all organization members, persisted as no audience members, or a
|
||||
* selected set. The dialog stays open on a denial so the selection survives.
|
||||
*/
|
||||
export function AudienceDialog({
|
||||
appName,
|
||||
grant,
|
||||
members,
|
||||
pending,
|
||||
error,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
appName: string;
|
||||
grant: ConnectionGrant;
|
||||
members: ConnectionAudienceMember[];
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onSave: (memberUserIds: string[]) => void;
|
||||
}) {
|
||||
const initialSelection = useMemo(() => audienceUserIds(grant), [grant]);
|
||||
const [scope, setScope] = useState<"all" | "selected">(initialSelection.size === 0 ? "all" : "selected");
|
||||
const [selected, setSelected] = useState<Set<string>>(initialSelection);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(initialSelection);
|
||||
setScope(initialSelection.size === 0 ? "all" : "selected");
|
||||
}, [initialSelection]);
|
||||
|
||||
const canSave = scope === "all" || selected.size > 0;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Who can use this identity</DialogTitle>
|
||||
<DialogDescription>
|
||||
{grantAccountLabel(grant)} · {appName}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<RadioCardGroup
|
||||
ariaLabel="Who can use this identity"
|
||||
value={scope}
|
||||
onValueChange={(next) => setScope(next as "all" | "selected")}
|
||||
options={[
|
||||
{
|
||||
value: "all",
|
||||
title: "All organization members",
|
||||
description: "Anyone in this organization can have work use this identity.",
|
||||
},
|
||||
{
|
||||
value: "selected",
|
||||
title: "Selected members",
|
||||
description: "Only the people you choose.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{scope === "selected" ? (
|
||||
<MemberMultiSelect
|
||||
members={members.map((member) => ({
|
||||
userId: member.userId,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
}))}
|
||||
selectedUserIds={selected}
|
||||
onChange={setSelected}
|
||||
triggerLabel={selected.size === 0
|
||||
? "Choose people"
|
||||
: `${selected.size} ${selected.size === 1 ? "person" : "people"} selected`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This controls whose work can use the identity. It does not change which agents have the
|
||||
connection.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<InlineBanner tone="warning" compact>
|
||||
{error}
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pending || !canSave}
|
||||
onClick={() => onSave(scope === "all" ? [] : [...selected])}
|
||||
>
|
||||
{pending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Save audience
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke confirmation (PAP-17835 Surface D). Revoke breaks active and future
|
||||
* runs, so it is an `AlertDialog` and the destructive action is not the initial
|
||||
* focus. The row survives afterwards showing Revoked, which keeps the context
|
||||
* and the reconnect path.
|
||||
*/
|
||||
export function RevokeGrantDialog({
|
||||
grant,
|
||||
providerName,
|
||||
pending,
|
||||
isOwnIdentity,
|
||||
credentialPolicy,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
grant: ConnectionGrant;
|
||||
providerName: string;
|
||||
pending: boolean;
|
||||
isOwnIdentity: boolean;
|
||||
credentialPolicy: ToolConnectionCredentialPolicy;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const personal = grant.kind === "user";
|
||||
const title = personal
|
||||
? isOwnIdentity
|
||||
? `Revoke your ${providerName} identity?`
|
||||
: `Revoke this ${providerName} identity?`
|
||||
: "Revoke the organization identity?";
|
||||
const body = personal
|
||||
? isOwnIdentity
|
||||
? "Agents will stop acting as you. Work that needs this identity can ask you to connect again."
|
||||
: "Agents will stop acting as this person. They can connect again themselves; no one else can do it for them."
|
||||
: credentialPolicy === "per_user"
|
||||
? "Installed agents lose this shared identity immediately."
|
||||
: "Eligible members and installed agents will lose this shared identity immediately.";
|
||||
|
||||
return (
|
||||
<AlertDialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{body}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending} autoFocus>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={pending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
Revoke identity
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +1,34 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { Loader2, PackageCheck, RefreshCw, X } from "lucide-react";
|
||||
import type { Agent, ToolCatalogEntry } from "@paperclipai/shared";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import type { Agent, ToolCatalogEntry, ToolConnectionCapabilities } from "@paperclipai/shared";
|
||||
import { useSearchParams } from "@/lib/router";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
|
||||
import { InlineBanner } from "@/components/InlineBanner";
|
||||
import { RadioCardGroup } from "@/components/ui/radio-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { brandChipBadge } from "@/lib/status-colors";
|
||||
import {
|
||||
autoExtendNotice,
|
||||
INSTALL_ALL_WARNING,
|
||||
installInfoNotice,
|
||||
type InstallState,
|
||||
} from "@/lib/tool-installs";
|
||||
import { type InstallState } from "@/lib/tool-installs";
|
||||
import { QuarantinedActionsReview } from "./SetupPanel";
|
||||
import type { AccessDraft, AppDetailSectionProps } from "./types";
|
||||
import type { AppDetailSectionProps } from "./types";
|
||||
|
||||
type ActionPermission = "off" | "allowed" | "ask";
|
||||
|
||||
/**
|
||||
* Permissions tab.
|
||||
*
|
||||
* Agent availability is expressed **once** (PAP-17859). This panel used to
|
||||
* stack a legacy "Who can use it" editor on top of "Available to agents",
|
||||
* asking the reader to hold two overlapping models of the same fact and to
|
||||
* guess which one wins. The install model — *Agents I pick / Any agent* — is
|
||||
* now the single visible source of truth.
|
||||
*
|
||||
* The runtime distinction survives untouched: an install still authorizes its
|
||||
* target server-side (`putConnectionInstalls` extends the app profile's
|
||||
* bindings), so "installed ⊆ permitted" holds without a second editor. What is
|
||||
* gone is the *user-facing* duplicate, and with it the hidden path that could
|
||||
* widen access from a control the reader could not see.
|
||||
*/
|
||||
export function PermissionsPanel({
|
||||
appName,
|
||||
access,
|
||||
agents,
|
||||
install,
|
||||
readOnly,
|
||||
|
|
@ -32,25 +38,25 @@ export function PermissionsPanel({
|
|||
askFirstIds,
|
||||
pending,
|
||||
installPending,
|
||||
onSaveAccess,
|
||||
onSaveInstall,
|
||||
onSetActionPermission,
|
||||
onReviewQuarantined,
|
||||
onRefreshActions,
|
||||
refreshPending,
|
||||
capabilities,
|
||||
}: Pick<
|
||||
AppDetailSectionProps,
|
||||
"access" | "agents" | "readOnly" | "canChange" | "quarantined" | "enabledIds" | "askFirstIds" | "pending"
|
||||
"agents" | "readOnly" | "canChange" | "quarantined" | "enabledIds" | "askFirstIds" | "pending"
|
||||
> & {
|
||||
appName: string;
|
||||
install: InstallState;
|
||||
installPending: boolean;
|
||||
onSaveAccess: (next: AccessDraft) => void;
|
||||
onSaveInstall: (next: InstallState) => void;
|
||||
onSetActionPermission: (id: string, next: ActionPermission) => void;
|
||||
onReviewQuarantined: (enabledIds: string[]) => void;
|
||||
onRefreshActions: () => void;
|
||||
refreshPending: boolean;
|
||||
/** Server verdict on what this caller may change here (PAP-17835). */
|
||||
capabilities: ToolConnectionCapabilities | undefined;
|
||||
}) {
|
||||
// Deep-link from the Test tab's "off" panel: ?focus={catalogEntryId} scrolls
|
||||
// to and highlights that action row.
|
||||
|
|
@ -58,12 +64,10 @@ export function PermissionsPanel({
|
|||
const focusId = searchParams.get("focus");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AccessSection access={access} agents={agents} disabled={pending} onSave={onSaveAccess} />
|
||||
<InstalledSection
|
||||
appName={appName}
|
||||
<AvailableToAgentsSection
|
||||
agents={agents}
|
||||
access={access}
|
||||
install={install}
|
||||
capabilities={capabilities}
|
||||
disabled={installPending}
|
||||
onSave={onSaveInstall}
|
||||
/>
|
||||
|
|
@ -76,6 +80,7 @@ export function PermissionsPanel({
|
|||
disabled={pending}
|
||||
refreshPending={refreshPending}
|
||||
focusId={focusId}
|
||||
canConfigure={capabilities?.canConfigure ?? false}
|
||||
onSetPermission={onSetActionPermission}
|
||||
onReviewQuarantined={onReviewQuarantined}
|
||||
onRefreshActions={onRefreshActions}
|
||||
|
|
@ -84,250 +89,124 @@ export function PermissionsPanel({
|
|||
);
|
||||
}
|
||||
|
||||
function AccessSection({
|
||||
access,
|
||||
/**
|
||||
* "Available to agents" (PAP-17835 Surface E).
|
||||
*
|
||||
* The old section exposed the runtime's own vocabulary — "permitted only",
|
||||
* "installed", an auto-extend warning — which asked the reader to hold two
|
||||
* overlapping concepts to answer one question. It is now the same two-choice
|
||||
* model the create flow uses: pick agents, or any agent. The runtime
|
||||
* distinction still exists in code; it just stopped being the user's problem.
|
||||
*
|
||||
* Every control is gated on a server capability. A viewer, or a member who may
|
||||
* not configure this connection, sees the summary and the agent list with no
|
||||
* controls at all rather than disabled ones.
|
||||
*/
|
||||
function AvailableToAgentsSection({
|
||||
agents,
|
||||
disabled,
|
||||
onSave,
|
||||
}: {
|
||||
access: AccessDraft;
|
||||
agents: Agent[];
|
||||
disabled: boolean;
|
||||
onSave: (next: AccessDraft) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<AccessDraft>(access);
|
||||
const liveAgents = agents.filter((a) => a.status !== "terminated");
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(access);
|
||||
}, [access, editing]);
|
||||
|
||||
const summary =
|
||||
access.mode === "all"
|
||||
? "Every agent can use it"
|
||||
: access.agentIds.size === 0
|
||||
? "No agents can use it"
|
||||
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"} can use it`;
|
||||
|
||||
const grantedAgents = liveAgents.filter((agent) => access.agentIds.has(agent.id));
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Who can use it</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{summary}</p>
|
||||
</div>
|
||||
{!editing && (
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!editing && access.mode === "specific" && grantedAgents.length > 0 && (
|
||||
<div className="space-y-0.5 pt-3">
|
||||
{grantedAgents.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>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${agent.name} access`}
|
||||
disabled={disabled}
|
||||
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => {
|
||||
const nextAgentIds = new Set(access.agentIds);
|
||||
nextAgentIds.delete(agent.id);
|
||||
onSave({ mode: "specific", agentIds: nextAgentIds });
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<div className="space-y-3 pt-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-1"
|
||||
checked={draft.mode === "all"}
|
||||
onChange={() => setDraft({ mode: "all", agentIds: new Set() })}
|
||||
/>
|
||||
<span>
|
||||
<span className="text-sm font-semibold text-foreground">All agents</span>
|
||||
<span className="block text-xs text-muted-foreground">Anyone you've added to Paperclip.</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-1"
|
||||
checked={draft.mode === "specific"}
|
||||
onChange={() => setDraft({ mode: "specific", agentIds: new Set(draft.agentIds) })}
|
||||
/>
|
||||
<span>
|
||||
<span className="text-sm font-semibold text-foreground">Only specific agents</span>
|
||||
<span className="block text-xs text-muted-foreground">Pick who can use it.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{draft.mode === "specific" && (
|
||||
<AgentMultiSelect
|
||||
agents={liveAgents}
|
||||
selectedAgentIds={draft.agentIds}
|
||||
onChange={(agentIds) => setDraft({ mode: "specific", agentIds })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onSave(draft);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)} disabled={disabled}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledSection({
|
||||
appName,
|
||||
agents,
|
||||
access,
|
||||
install,
|
||||
capabilities,
|
||||
disabled,
|
||||
onSave,
|
||||
}: {
|
||||
appName: string;
|
||||
agents: Agent[];
|
||||
access: AccessDraft;
|
||||
install: InstallState;
|
||||
capabilities: ToolConnectionCapabilities | undefined;
|
||||
disabled: boolean;
|
||||
onSave: (next: InstallState) => void;
|
||||
}) {
|
||||
const liveAgents = agents.filter((a) => a.status !== "terminated");
|
||||
const hasAccess = (agentId: string) => access.mode === "all" || access.agentIds.has(agentId);
|
||||
// Agents that are installed but not (yet) in the access set — installing on
|
||||
// them auto-extends access server-side. Surfaced amber so it's never silent.
|
||||
const extendingAgents =
|
||||
access.mode === "all"
|
||||
? []
|
||||
: [...install.agentIds].filter((id) => !access.agentIds.has(id));
|
||||
const installedCount = install.onAll ? liveAgents.length : install.agentIds.size;
|
||||
const canManage = capabilities?.canManageAgentInstalls ?? false;
|
||||
const canSetCompanyWide = capabilities?.canSetCompanyInstall ?? false;
|
||||
// "Agents I pick" is scoped to the agents this person may actually edit. The
|
||||
// server decides that set; the client never infers it from a role string.
|
||||
const editableAgentIds = capabilities?.editableAgentIds;
|
||||
const selectableAgents = editableAgentIds
|
||||
? liveAgents.filter((agent) => editableAgentIds.includes(agent.id))
|
||||
: liveAgents;
|
||||
const mode: "all" | "specific" = install.onAll ? "all" : "specific";
|
||||
const selectedAgents = liveAgents.filter((agent) => install.agentIds.has(agent.id));
|
||||
const summary = install.onAll
|
||||
? "Any agent"
|
||||
: install.agentIds.size === 0
|
||||
? "No agents yet"
|
||||
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"}`;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Installed on agents</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Whose harness carries {appName}'s tools on every run.
|
||||
</p>
|
||||
<h2 className="text-sm font-bold text-foreground">Available to agents</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{summary}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{disabled && <span className="text-xs text-muted-foreground">Saving…</span>}
|
||||
{disabled && <span className="text-xs text-muted-foreground">Saving…</span>}
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<div className="space-y-3 pt-4">
|
||||
<RadioCardGroup
|
||||
ariaLabel="Which agents can use this connection"
|
||||
value={mode}
|
||||
disabled={disabled}
|
||||
className="sm:grid-cols-2"
|
||||
onValueChange={(next) => {
|
||||
if (next === "all") onSave({ onAll: true, agentIds: new Set() });
|
||||
else onSave({ onAll: false, agentIds: new Set(install.agentIds) });
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "specific",
|
||||
title: "Agents I pick",
|
||||
description: "Choose one or more agents you can edit.",
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
title: "Any agent",
|
||||
description: canSetCompanyWide
|
||||
? "Make this connection available to every agent."
|
||||
: "Only someone who can configure this connection can choose this.",
|
||||
},
|
||||
].filter((option) => option.value !== "all" || canSetCompanyWide || install.onAll)}
|
||||
/>
|
||||
|
||||
{mode === "specific" ? (
|
||||
<AgentMultiSelect
|
||||
agents={selectableAgents}
|
||||
selectedAgentIds={install.agentIds}
|
||||
disabled={disabled}
|
||||
triggerLabel={
|
||||
install.agentIds.size === 0
|
||||
? "Choose agents"
|
||||
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"} selected`
|
||||
}
|
||||
emptyMessage="You cannot edit any agents yet."
|
||||
onChange={(agentIds) => onSave({ onAll: false, agentIds })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
// Read-only: the state is still fully legible, just not editable.
|
||||
<div className="pt-3">
|
||||
{install.onAll ? (
|
||||
<InstalledBadge label="Installed on all agents" />
|
||||
) : install.agentIds.size > 0 ? (
|
||||
<InstalledBadge label={`${installedCount} installed`} />
|
||||
<p className="text-sm text-muted-foreground">Every agent can use this connection.</p>
|
||||
) : selectedAgents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No agents have this connection yet.</p>
|
||||
) : (
|
||||
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
Permitted only — not installed on any agent
|
||||
</span>
|
||||
<div className="space-y-0.5">
|
||||
{selectedAgents.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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-4">
|
||||
<InlineBanner tone="info" compact>
|
||||
{installInfoNotice(appName)}
|
||||
</InlineBanner>
|
||||
|
||||
{!install.onAll && (
|
||||
<AgentMultiSelect
|
||||
agents={liveAgents}
|
||||
selectedAgentIds={install.agentIds}
|
||||
disabled={disabled}
|
||||
triggerLabel={
|
||||
install.agentIds.size === 0
|
||||
? "Choose agents to install on"
|
||||
: `${install.agentIds.size} ${install.agentIds.size === 1 ? "agent" : "agents"} installed`
|
||||
}
|
||||
getDescription={(agent) => (hasAccess(agent.id) ? "has access" : "no access yet")}
|
||||
renderNameSuffix={(agent) =>
|
||||
!hasAccess(agent.id) && install.agentIds.has(agent.id) ? (
|
||||
<span className={cn("rounded border px-1 py-0 text-xs font-medium", brandChipBadge.amber)}>
|
||||
will grant access
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
onChange={(agentIds) => onSave({ onAll: false, agentIds })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="flex items-start gap-3 py-2.5">
|
||||
<Checkbox
|
||||
checked={install.onAll}
|
||||
disabled={disabled}
|
||||
aria-label="Install on all agents"
|
||||
onCheckedChange={(checked) =>
|
||||
onSave(checked ? { onAll: true, agentIds: new Set() } : { onAll: false, agentIds: new Set() })
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-foreground">
|
||||
<span className="font-semibold">Install on all agents</span>
|
||||
<span className="mt-0.5 block text-muted-foreground">
|
||||
{INSTALL_ALL_WARNING}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{extendingAgents.length > 0 ? (
|
||||
<InlineBanner tone="warning" compact>
|
||||
<span>
|
||||
{autoExtendNotice(
|
||||
extendingAgents.length === 1
|
||||
? liveAgents.find((a) => a.id === extendingAgents[0])?.name ?? "1 agent"
|
||||
: `${extendingAgents.length} agents`,
|
||||
)}{" "}
|
||||
<span className="font-medium">
|
||||
Review the {extendingAgents.length} access change
|
||||
{extendingAgents.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</span>
|
||||
</InlineBanner>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium", brandChipBadge.green)}>
|
||||
<PackageCheck className="h-3 w-3" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsSection({
|
||||
readOnly,
|
||||
canChange,
|
||||
|
|
@ -337,6 +216,7 @@ function ActionsSection({
|
|||
disabled,
|
||||
refreshPending,
|
||||
focusId,
|
||||
canConfigure,
|
||||
onSetPermission,
|
||||
onReviewQuarantined,
|
||||
onRefreshActions,
|
||||
|
|
@ -349,6 +229,8 @@ function ActionsSection({
|
|||
disabled: boolean;
|
||||
refreshPending: boolean;
|
||||
focusId?: string | null;
|
||||
/** Server verdict: may this caller change this connection's configuration? */
|
||||
canConfigure: boolean;
|
||||
onSetPermission: (id: string, next: ActionPermission) => void;
|
||||
onReviewQuarantined: (enabledIds: string[]) => void;
|
||||
onRefreshActions: () => void;
|
||||
|
|
@ -359,28 +241,35 @@ function ActionsSection({
|
|||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Action permissions</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Choose what agents can do and what needs a human first.
|
||||
{canConfigure
|
||||
? "Choose what agents can do and what needs a human first."
|
||||
: "What agents can do, and what needs a human first."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{disabled && <span className="text-xs text-muted-foreground">Saving...</span>}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefreshActions}
|
||||
disabled={refreshPending || disabled}
|
||||
>
|
||||
{refreshPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Refresh actions
|
||||
</Button>
|
||||
</div>
|
||||
{/* Viewer rule D4: a forbidden action is omitted, not rendered disabled.
|
||||
Refreshing the catalog mutates the connection, so a caller who may
|
||||
not configure it never sees the control. */}
|
||||
{canConfigure ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{disabled && <span className="text-xs text-muted-foreground">Saving...</span>}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefreshActions}
|
||||
disabled={refreshPending || disabled}
|
||||
>
|
||||
{refreshPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Refresh actions
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{quarantined.length > 0 && (
|
||||
{canConfigure && quarantined.length > 0 && (
|
||||
<QuarantinedActionsReview
|
||||
entries={quarantined}
|
||||
disabled={disabled}
|
||||
|
|
@ -396,6 +285,7 @@ function ActionsSection({
|
|||
askFirstIds={askFirstIds}
|
||||
disabled={disabled}
|
||||
focusId={focusId}
|
||||
canConfigure={canConfigure}
|
||||
onSetPermission={onSetPermission}
|
||||
/>
|
||||
<ActionGroup
|
||||
|
|
@ -406,12 +296,19 @@ function ActionsSection({
|
|||
askFirstIds={askFirstIds}
|
||||
disabled={disabled}
|
||||
focusId={focusId}
|
||||
canConfigure={canConfigure}
|
||||
onSetPermission={onSetPermission}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_PERMISSION_LABELS: Record<ActionPermission, string> = {
|
||||
off: "Off",
|
||||
allowed: "Allowed",
|
||||
ask: "Ask a human first",
|
||||
};
|
||||
|
||||
function ActionGroup({
|
||||
title,
|
||||
hint,
|
||||
|
|
@ -420,6 +317,7 @@ function ActionGroup({
|
|||
askFirstIds,
|
||||
disabled,
|
||||
focusId,
|
||||
canConfigure,
|
||||
onSetPermission,
|
||||
}: {
|
||||
title: string;
|
||||
|
|
@ -429,6 +327,7 @@ function ActionGroup({
|
|||
askFirstIds: Set<string>;
|
||||
disabled: boolean;
|
||||
focusId?: string | null;
|
||||
canConfigure: boolean;
|
||||
onSetPermission: (id: string, next: ActionPermission) => void;
|
||||
}) {
|
||||
const focusRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -464,21 +363,28 @@ function ActionGroup({
|
|||
<div className="truncate text-xs text-muted-foreground">{action.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
aria-label={`${action.title ?? action.toolName} permission`}
|
||||
className={cn(
|
||||
"h-9 w-44 rounded-md border border-input bg-background px-3 text-sm text-foreground shadow-xs outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-(length:--rad-3) focus-visible:ring-ring/50",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onSetPermission(action.id, event.currentTarget.value as ActionPermission)}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="allowed">Allowed</option>
|
||||
<option value="ask">Ask a human first</option>
|
||||
</select>
|
||||
{canConfigure ? (
|
||||
<select
|
||||
aria-label={`${action.title ?? action.toolName} permission`}
|
||||
className={cn(
|
||||
"h-9 w-44 rounded-md border border-input bg-background px-3 text-sm text-foreground shadow-xs outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-(length:--rad-3) focus-visible:ring-ring/50",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onSetPermission(action.id, event.currentTarget.value as ActionPermission)}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="allowed">Allowed</option>
|
||||
<option value="ask">Ask a human first</option>
|
||||
</select>
|
||||
) : (
|
||||
// Read-only: the same fact, stated rather than offered.
|
||||
<span className="w-44 shrink-0 text-sm text-muted-foreground">
|
||||
{ACTION_PERMISSION_LABELS[value]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { ToolCatalogEntry, ToolConnection } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -14,8 +14,7 @@ export function SetupPanel({
|
|||
appToggleDisabled,
|
||||
onUpdateConfig,
|
||||
configUpdateDisabled,
|
||||
onStartOAuth,
|
||||
oauthStartDisabled,
|
||||
identities,
|
||||
}: Pick<
|
||||
AppDetailSectionProps,
|
||||
"connection" | "galleryEntry"
|
||||
|
|
@ -24,18 +23,20 @@ export function SetupPanel({
|
|||
appToggleDisabled: boolean;
|
||||
onUpdateConfig: (config: Record<string, unknown>) => void;
|
||||
configUpdateDisabled: boolean;
|
||||
onStartOAuth: () => void;
|
||||
oauthStartDisabled: boolean;
|
||||
/**
|
||||
* The Identities section (PAP-17835). It replaces the old generic OAuth
|
||||
* "workspace authorization" block, because that block could only ever describe
|
||||
* one shared identity and this connection may act as each person instead.
|
||||
*/
|
||||
identities?: ReactNode;
|
||||
}) {
|
||||
const description = galleryEntry?.description ?? null;
|
||||
const oauth = connection.config?.oauth;
|
||||
const hasOAuthSignIn = Boolean(oauth && typeof oauth === "object" && !Array.isArray(oauth));
|
||||
const isSmokeLabFixture = connection.config?.smokeLabFixture === "oauth-http";
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{description && (
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{identities}
|
||||
{appDefinitionSlug(galleryEntry) === "google-sheets" && (
|
||||
<GoogleSheetsAllowlistSection
|
||||
connection={connection}
|
||||
|
|
@ -46,23 +47,33 @@ export function SetupPanel({
|
|||
{appDefinitionSlug(galleryEntry) === "posthog" && (
|
||||
<PostHogConfigurationSection connection={connection} />
|
||||
)}
|
||||
{hasOAuthSignIn && (
|
||||
<OAuthConnectionSection
|
||||
connected={Boolean((oauth as Record<string, unknown>).connectedAt)}
|
||||
providerName={appDefinitionSlug(galleryEntry) === "notion"
|
||||
? "Notion"
|
||||
: appDefinitionSlug(galleryEntry) === "posthog"
|
||||
? "PostHog"
|
||||
: isSmokeLabFixture ? "Smoke OAuth" : "OAuth"}
|
||||
disabled={oauthStartDisabled}
|
||||
onStart={onStartOAuth}
|
||||
/>
|
||||
)}
|
||||
<AppLifecycleSection connection={connection} disabled={appToggleDisabled} onToggle={onToggleApp} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider label used in identity and revoke copy. Falls back to the app's own
|
||||
* display name so a pasted server never reads as a generic "OAuth".
|
||||
*/
|
||||
export function connectionProviderName(
|
||||
galleryEntry: Parameters<typeof appDefinitionSlug>[0],
|
||||
fallback: string,
|
||||
): string {
|
||||
switch (appDefinitionSlug(galleryEntry)) {
|
||||
case "notion":
|
||||
return "Notion";
|
||||
case "posthog":
|
||||
return "PostHog";
|
||||
case "gmail":
|
||||
return "Gmail";
|
||||
case "google-sheets":
|
||||
return "Google Sheets";
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function PostHogConfigurationSection({ connection }: { connection: ToolConnection }) {
|
||||
const raw = connection.config?.methodConfig;
|
||||
const config = raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
|
|
@ -97,38 +108,6 @@ function PostHogConfigurationSection({ connection }: { connection: ToolConnectio
|
|||
);
|
||||
}
|
||||
|
||||
function OAuthConnectionSection({
|
||||
connected,
|
||||
providerName,
|
||||
disabled,
|
||||
onStart,
|
||||
}: {
|
||||
connected: boolean;
|
||||
providerName: string;
|
||||
disabled: boolean;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">
|
||||
{connected ? `${providerName} connected` : `Connect with ${providerName}`}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{connected
|
||||
? "Your workspace authorization is active. Reconnect any time to replace it."
|
||||
: "Open the provider's consent page to finish connecting this app."}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" disabled={disabled} onClick={onStart}>
|
||||
{connected ? "Reconnect" : `Connect with ${providerName}`}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function currentSpreadsheetIds(connection: ToolConnection): string[] {
|
||||
const raw = connection.config?.allowedSpreadsheetIds;
|
||||
return Array.isArray(raw) ? raw.map((value) => String(value).trim()).filter(Boolean) : [];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
import type {
|
||||
ConnectionAudienceMember,
|
||||
ConnectionGrant,
|
||||
ConnectionGrantStatus,
|
||||
ToolConnectionCredentialPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Canonical user-facing vocabulary for connection identity (PAP-17835).
|
||||
*
|
||||
* The domain words — `grant`, `subjectUserId`, `credentialPolicy`, "empty
|
||||
* audience" — never reach product copy. Everything the identity surfaces render
|
||||
* resolves through this module so create, Setup, Permissions and the interaction
|
||||
* card cannot drift into three different names for the same thing.
|
||||
*/
|
||||
|
||||
export interface ActsAsSummary {
|
||||
title: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export function actsAsSummary(credentialPolicy: ToolConnectionCredentialPolicy): ActsAsSummary {
|
||||
switch (credentialPolicy) {
|
||||
case "per_user":
|
||||
return { title: "Acts as each person", detail: "Each person connects their own account." };
|
||||
case "per_user_with_fallback":
|
||||
return {
|
||||
title: "Uses a personal identity with organization fallback",
|
||||
detail: "Agents use your account when you have one, and the organization account otherwise.",
|
||||
};
|
||||
case "shared":
|
||||
default:
|
||||
return { title: "Acts as the organization", detail: "Agents share the organization identity." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Status copy is label text, never colour alone, so it survives a monochrome or
|
||||
* high-contrast rendering. "Not connected" is the explicit missing state — a
|
||||
* `per_user` connection with no personal grant must never read as connected.
|
||||
*/
|
||||
export function grantStatusLabel(status: ConnectionGrantStatus | null): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "Connected";
|
||||
case "needs_reauthorization":
|
||||
return "Needs attention";
|
||||
case "expired":
|
||||
return "Expired";
|
||||
case "revoked":
|
||||
return "Revoked";
|
||||
default:
|
||||
return "Not connected";
|
||||
}
|
||||
}
|
||||
|
||||
export type GrantStatusTone = "connected" | "attention" | "inactive" | "missing";
|
||||
|
||||
export function grantStatusTone(status: ConnectionGrantStatus | null): GrantStatusTone {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "connected";
|
||||
case "needs_reauthorization":
|
||||
case "expired":
|
||||
return "attention";
|
||||
case "revoked":
|
||||
return "inactive";
|
||||
default:
|
||||
return "missing";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's own label for the account behind a grant. Providers do not
|
||||
* always return safe tenant metadata, so this falls back to a neutral phrase
|
||||
* rather than exposing a secret name or ref.
|
||||
*/
|
||||
export function grantAccountLabel(
|
||||
grant: Pick<ConnectionGrant, "kind" | "providerTenant"> | null,
|
||||
options: { subjectLabel?: string | null } = {},
|
||||
): string {
|
||||
const tenantName = grant?.providerTenant?.name?.trim();
|
||||
if (tenantName) return tenantName;
|
||||
if (grant?.kind === "user") return options.subjectLabel?.trim() || "Connected account";
|
||||
return "Shared credential";
|
||||
}
|
||||
|
||||
/**
|
||||
* Audience summary for an organization grant. Zero members is "all organization
|
||||
* members" — the product never says "empty list", because that is a storage
|
||||
* detail and not how anyone thinks about who may use an identity.
|
||||
*/
|
||||
export function audienceSummary(grant: Pick<ConnectionGrant, "members"> | null): string {
|
||||
const count = grant?.members?.length ?? 0;
|
||||
if (count === 0) return "All organization members";
|
||||
return `${count} selected ${count === 1 ? "member" : "members"}`;
|
||||
}
|
||||
|
||||
export function audienceUserIds(grant: Pick<ConnectionGrant, "members"> | null): Set<string> {
|
||||
return new Set((grant?.members ?? [])
|
||||
.filter((member) => member.subjectType === "user")
|
||||
.map((member) => member.subjectId));
|
||||
}
|
||||
|
||||
export function organizationGrant(grants: ConnectionGrant[]): ConnectionGrant | null {
|
||||
// The default organization grant is the one the resolver reaches for, so it is
|
||||
// the one the Organization identity row describes.
|
||||
return grants.find((grant) => grant.kind === "organization" && grant.isDefault)
|
||||
?? grants.find((grant) => grant.kind === "organization")
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function personalGrantFor(grants: ConnectionGrant[], userId: string | null): ConnectionGrant | null {
|
||||
if (!userId) return null;
|
||||
return grants.find((grant) => grant.kind === "user" && grant.subjectUserId === userId) ?? null;
|
||||
}
|
||||
|
||||
export function otherPersonalGrants(grants: ConnectionGrant[], userId: string | null): ConnectionGrant[] {
|
||||
return grants.filter((grant) => grant.kind === "user" && grant.subjectUserId !== userId);
|
||||
}
|
||||
|
||||
export function memberLabel(
|
||||
members: ConnectionAudienceMember[],
|
||||
userId: string | null,
|
||||
): string | null {
|
||||
if (!userId) return null;
|
||||
const match = members.find((member) => member.userId === userId);
|
||||
if (!match) return null;
|
||||
return match.name?.trim() || match.email?.trim() || null;
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { appSourceConnectHref } from "./app-connect-policy";
|
|||
export const POPULAR_KEYS = ["zapier", "github", "slack", "notion", "posthog", "linear"];
|
||||
|
||||
/** Deep-link into the Connect wizard's bring-your-own-tool URL flow. */
|
||||
export const BYO_CONNECT_HREF = "/apps/byo";
|
||||
export const BYO_CONNECT_HREF = "/apps/connect?byo=1";
|
||||
|
||||
/** Zapier connects with the complete MCP URL issued by Zapier. */
|
||||
export const ZAPIER_CONNECT_HREF = "/apps/connect?byo=1&source=zapier";
|
||||
|
|
|
|||
|
|
@ -56,26 +56,29 @@ function promptTextarea(): HTMLTextAreaElement | undefined {
|
|||
*/
|
||||
describe("Paste a config — MCP config help", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
let root: ReturnType<typeof createRoot> | null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
copyTextToClipboardMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
root = createRoot(container);
|
||||
const nextRoot = createRoot(container);
|
||||
root = nextRoot;
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
nextRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<PasteConfigTab companyId="company-1" />
|
||||
|
|
@ -83,7 +86,7 @@ describe("Paste a config — MCP config help", () => {
|
|||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return root;
|
||||
return nextRoot;
|
||||
}
|
||||
|
||||
async function openHelp() {
|
||||
|
|
@ -143,7 +146,9 @@ describe("Paste a config — MCP config help", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("select the text above and copy it");
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain("select the text above and copy it");
|
||||
});
|
||||
});
|
||||
|
||||
it("makes no connection or import request when opened or copied", async () => {
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ function connectResult(overrides: Partial<ConnectToolAppResult> = {}): ConnectTo
|
|||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "none",
|
||||
credentialPolicy: "shared",
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
config: { url: "http://127.0.0.1:8848/mcp" },
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ const CONNECTIONS: ToolConnection[] = [
|
|||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
|
|
@ -309,6 +310,7 @@ const CONNECTIONS: ToolConnection[] = [
|
|||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ function notionConnection(overrides: Partial<ToolConnection> = {}): ToolConnecti
|
|||
ownership: "dcr",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
status: "active",
|
||||
transportConfig: { url: "https://mcp.notion.com/mcp" },
|
||||
config: {
|
||||
|
|
@ -106,8 +107,6 @@ function ConnectedHost() {
|
|||
appToggleDisabled={false}
|
||||
onUpdateConfig={() => undefined}
|
||||
configUpdateDisabled={false}
|
||||
onStartOAuth={() => undefined}
|
||||
oauthStartDisabled={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,14 +5,44 @@ import type {
|
|||
Agent,
|
||||
ToolCatalogEntry,
|
||||
ToolConnection,
|
||||
ToolConnectionCapabilities,
|
||||
} from "@paperclipai/shared";
|
||||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||
import {
|
||||
issueThreadInteractionFixtureMeta,
|
||||
pendingConnectionAuthorizationInteraction,
|
||||
resolvedConnectionAuthorizationInteraction,
|
||||
} from "@/fixtures/issueThreadInteractionFixtures";
|
||||
import type { RequestConfirmationInteraction } from "@/lib/issue-thread-interactions";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AgentToolsTab } from "@/pages/AgentToolsTab";
|
||||
import { PermissionsPanel } from "@/pages/apps/app-detail/PermissionsPanel";
|
||||
import { InstallStep } from "@/pages/apps/AppsConnect";
|
||||
import type { AccessDraft } from "@/pages/apps/app-detail/types";
|
||||
import { AccessStep } from "@/pages/apps/AppsConnect";
|
||||
import type { InstallState } from "@/lib/tool-installs";
|
||||
|
||||
const AGENT_IDS = ["a-sage", "a-atlas", "a-orion"];
|
||||
|
||||
/** A member who may configure this connection and edit every agent. */
|
||||
const FULL_CAPABILITIES: ToolConnectionCapabilities = {
|
||||
canConfigure: true,
|
||||
canCreateOrganizationGrant: true,
|
||||
canSetCompanyInstall: true,
|
||||
canConnectAsCurrentUser: true,
|
||||
canManageAgentInstalls: true,
|
||||
canViewOtherPersonalIdentities: false,
|
||||
editableAgentIds: AGENT_IDS,
|
||||
};
|
||||
|
||||
const VIEWER_CAPABILITIES: ToolConnectionCapabilities = {
|
||||
canConfigure: false,
|
||||
canCreateOrganizationGrant: false,
|
||||
canSetCompanyInstall: false,
|
||||
canConnectAsCurrentUser: false,
|
||||
canManageAgentInstalls: false,
|
||||
canViewOtherPersonalIdentities: false,
|
||||
editableAgentIds: [],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3b — Permitted vs Installed UX review harness (PAP-13634).
|
||||
// Renders the three changed surfaces at a real viewport so visual craft can be
|
||||
|
|
@ -99,13 +129,18 @@ type Story = StoryObj;
|
|||
|
||||
// --- Surface 1: App detail Permissions tab (PermissionsPanel) --------------
|
||||
|
||||
function PanelHarness({ access, install }: { access: AccessDraft; install: InstallState }) {
|
||||
function PanelHarness({
|
||||
install,
|
||||
capabilities = FULL_CAPABILITIES,
|
||||
}: {
|
||||
install: InstallState;
|
||||
capabilities?: ToolConnectionCapabilities;
|
||||
}) {
|
||||
const [state, setState] = useState(install);
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl bg-background p-6">
|
||||
<PermissionsPanel
|
||||
appName="Gmail"
|
||||
access={access}
|
||||
capabilities={capabilities}
|
||||
agents={AGENTS}
|
||||
install={state}
|
||||
readOnly={GMAIL_TOOLS.filter((t) => t.isReadOnly)}
|
||||
|
|
@ -116,7 +151,6 @@ function PanelHarness({ access, install }: { access: AccessDraft; install: Insta
|
|||
pending={false}
|
||||
installPending={false}
|
||||
refreshPending={false}
|
||||
onSaveAccess={() => {}}
|
||||
onSaveInstall={setState}
|
||||
onSetActionPermission={() => {}}
|
||||
onReviewQuarantined={() => {}}
|
||||
|
|
@ -126,36 +160,61 @@ function PanelHarness({ access, install }: { access: AccessDraft; install: Insta
|
|||
);
|
||||
}
|
||||
|
||||
export const AppDetailInstalledMixed: Story = {
|
||||
name: "1 · App detail — mixed install + auto-extend warning",
|
||||
export const AppDetailAgentsIPick: Story = {
|
||||
name: "1 · App detail — Agents I pick",
|
||||
render: () => (
|
||||
<PanelHarness
|
||||
access={{ mode: "specific", agentIds: new Set(["a-sage", "a-atlas"]) }}
|
||||
install={{ onAll: false, agentIds: new Set(["a-sage", "a-orion"]) }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const AppDetailInstalledOnAll: Story = {
|
||||
name: "1 · App detail — installed on all agents",
|
||||
export const AppDetailAnyAgent: Story = {
|
||||
name: "1 · App detail — Any agent",
|
||||
render: () => (
|
||||
<PanelHarness
|
||||
access={{ mode: "all", agentIds: new Set() }}
|
||||
install={{ onAll: true, agentIds: new Set() }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const AppDetailPermittedOnly: Story = {
|
||||
name: "1 · App detail — permitted only (not installed)",
|
||||
export const AppDetailNoAgentsYet: Story = {
|
||||
name: "1 · App detail — no agents yet",
|
||||
render: () => (
|
||||
<PanelHarness
|
||||
access={{ mode: "all", agentIds: new Set() }}
|
||||
install={{ onAll: false, agentIds: new Set() }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Viewer read-only (PAP-17835). Controls are absent, not disabled: a
|
||||
* policy-forbidden action is never rendered as something to try.
|
||||
*/
|
||||
export const AppDetailViewerReadOnly: Story = {
|
||||
name: "1 · App detail — viewer read-only",
|
||||
render: () => (
|
||||
<PanelHarness
|
||||
install={{ onAll: false, agentIds: new Set(["a-sage"]) }}
|
||||
capabilities={VIEWER_CAPABILITIES}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* A member who may pick agents but may not make the connection company-wide:
|
||||
* "Any agent" is omitted from the choice entirely.
|
||||
*/
|
||||
export const AppDetailMemberWithoutCompanyInstall: Story = {
|
||||
name: "1 · App detail — member without company-wide install",
|
||||
render: () => (
|
||||
<PanelHarness
|
||||
install={{ onAll: false, agentIds: new Set(["a-sage"]) }}
|
||||
capabilities={{ ...FULL_CAPABILITIES, canSetCompanyInstall: false }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Surface 2: Agent detail Tools tab (AgentToolsTab) ---------------------
|
||||
|
||||
function SeededAgentTools() {
|
||||
|
|
@ -195,18 +254,23 @@ export const AgentToolsInstalledApps: Story = {
|
|||
render: () => <SeededAgentTools />,
|
||||
};
|
||||
|
||||
// --- Surface 3: Connect flow Install step (InstallStep) --------------------
|
||||
// --- Surface 3: Connect flow Access step (AccessStep) ---------------------
|
||||
//
|
||||
// The separate "who can use it" + "install tools" pair is gone: one Access step
|
||||
// asks both questions before any credential is entered (PAP-17835).
|
||||
|
||||
function SeededInstallStep({
|
||||
access,
|
||||
accessAgentIds,
|
||||
initialMode,
|
||||
initialInstall,
|
||||
function SeededAccessStep({
|
||||
authKind,
|
||||
initialGrantKind,
|
||||
initialChoice,
|
||||
initialAgentIds,
|
||||
capabilities = { canSetCompanyInstall: true, editableAgentIds: AGENT_IDS },
|
||||
}: {
|
||||
access: "all" | "specific";
|
||||
accessAgentIds: Set<string>;
|
||||
initialMode: "none" | "specific" | "all";
|
||||
initialInstall: Set<string>;
|
||||
authKind: "oauth" | "api_key" | "none";
|
||||
initialGrantKind: "user" | "organization";
|
||||
initialChoice: "specific" | "all";
|
||||
initialAgentIds: Set<string>;
|
||||
capabilities?: { canSetCompanyInstall: boolean; editableAgentIds: string[] };
|
||||
}) {
|
||||
const client = useMemo(() => {
|
||||
const c = new QueryClient({
|
||||
|
|
@ -215,49 +279,128 @@ function SeededInstallStep({
|
|||
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
|
||||
return c;
|
||||
}, []);
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [ids, setIds] = useState(initialInstall);
|
||||
const [grantKind, setGrantKind] = useState(initialGrantKind);
|
||||
const [choice, setChoice] = useState(initialChoice);
|
||||
const [ids, setIds] = useState(initialAgentIds);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="bg-background p-6">
|
||||
<InstallStep
|
||||
<AccessStep
|
||||
appName="Gmail"
|
||||
providerName="Gmail"
|
||||
companyId={COMPANY}
|
||||
access={access}
|
||||
accessAgentIds={accessAgentIds}
|
||||
installMode={mode}
|
||||
setInstallMode={setMode}
|
||||
authKind={authKind}
|
||||
grantKind={grantKind}
|
||||
setGrantKind={setGrantKind}
|
||||
installChoice={choice}
|
||||
setInstallChoice={setChoice}
|
||||
installAgentIds={ids}
|
||||
setInstallAgentIds={setIds}
|
||||
submitting={false}
|
||||
capabilities={capabilities}
|
||||
submitLabel={authKind === "oauth" ? "Continue to Gmail" : "Save and continue"}
|
||||
onBack={() => {}}
|
||||
onFinish={() => {}}
|
||||
onContinue={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const ConnectInstallSpecific: Story = {
|
||||
name: "3 · Connect — Install step (specific + auto-extend)",
|
||||
export const ConnectAccessJustMePickedAgents: Story = {
|
||||
name: "3 · Connect Access — Just me + Agents I pick",
|
||||
render: () => (
|
||||
<SeededInstallStep
|
||||
access="specific"
|
||||
accessAgentIds={new Set(["a-sage", "a-atlas"])}
|
||||
initialMode="specific"
|
||||
initialInstall={new Set(["a-sage", "a-orion"])}
|
||||
<SeededAccessStep
|
||||
authKind="oauth"
|
||||
initialGrantKind="user"
|
||||
initialChoice="specific"
|
||||
initialAgentIds={new Set(["a-sage", "a-atlas"])}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ConnectInstallAll: Story = {
|
||||
name: "3 · Connect — Install step (all agents)",
|
||||
export const ConnectAccessOrganizationAnyAgent: Story = {
|
||||
name: "3 · Connect Access — Whole organization + Any agent",
|
||||
render: () => (
|
||||
<SeededInstallStep
|
||||
access="all"
|
||||
accessAgentIds={new Set()}
|
||||
initialMode="all"
|
||||
initialInstall={new Set()}
|
||||
<SeededAccessStep
|
||||
authKind="api_key"
|
||||
initialGrantKind="organization"
|
||||
initialChoice="all"
|
||||
initialAgentIds={new Set()}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** `authKind: none` has no identity to choose, so the question is not asked. */
|
||||
export const ConnectAccessNoIdentityRequired: Story = {
|
||||
name: "3 · Connect Access — no identity required",
|
||||
render: () => (
|
||||
<SeededAccessStep
|
||||
authKind="none"
|
||||
initialGrantKind="organization"
|
||||
initialChoice="specific"
|
||||
initialAgentIds={new Set(["a-sage"])}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Surface 4: the "Connect your Gmail to continue" card -------------------
|
||||
//
|
||||
// One `request_confirmation`, three readings (PAP-17859). The card no longer
|
||||
// falls through to the generic Approve / Revise… / Reject layout: consent is
|
||||
// the addressed person's alone, so the affordances change with the reader, and
|
||||
// a policy-forbidden action is omitted rather than shown greyed out.
|
||||
|
||||
const AUTHORIZATION_USER_LABELS = new Map<string, string>([
|
||||
[issueThreadInteractionFixtureMeta.currentUserId, "Carol"],
|
||||
]);
|
||||
|
||||
function AuthorizationCardHarness({
|
||||
interaction,
|
||||
currentUserId,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
currentUserId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl bg-background p-6">
|
||||
<IssueThreadInteractionCard
|
||||
interaction={interaction}
|
||||
agentMap={new Map()}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={AUTHORIZATION_USER_LABELS}
|
||||
onAcceptInteraction={async () => {}}
|
||||
onRejectInteraction={async () => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const AuthorizationAddressed: Story = {
|
||||
name: "4 · Connect Gmail — addressed user",
|
||||
render: () => (
|
||||
<AuthorizationCardHarness
|
||||
interaction={pendingConnectionAuthorizationInteraction}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const AuthorizationOtherReader: Story = {
|
||||
name: "4 · Connect Gmail — another reader waiting",
|
||||
render: () => (
|
||||
<AuthorizationCardHarness
|
||||
interaction={pendingConnectionAuthorizationInteraction}
|
||||
currentUserId="user-someone-else"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const AuthorizationResolved: Story = {
|
||||
name: "4 · Connect Gmail — resolved",
|
||||
render: () => (
|
||||
<AuthorizationCardHarness
|
||||
interaction={resolvedConnectionAuthorizationInteraction}
|
||||
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,319 @@
|
|||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type {
|
||||
ConnectionGrant,
|
||||
ConnectionGrantsResponse,
|
||||
ToolConnectionCapabilities,
|
||||
ToolConnectionCredentialPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { IdentitiesSection } from "@/pages/apps/app-detail/IdentitiesSection";
|
||||
import { actsAsSummary } from "@/pages/apps/connection-identity";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PAP-17835 — personal connection identity UX review harness.
|
||||
//
|
||||
// One story per item in the design's "Test and screenshot gate", so the whole
|
||||
// gate can be rendered and inspected at 1440x900 and 390x844 without seeding a
|
||||
// live company. Fixtures are self-contained.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CURRENT_USER = "user-carol";
|
||||
|
||||
const MEMBERS = [
|
||||
{ userId: CURRENT_USER, name: "Carol Danvers", email: "carol@example.com" },
|
||||
{ userId: "user-dotta", name: "Dotta", email: "dotta@example.com" },
|
||||
{ userId: "user-sam", name: "Sam Rivera", email: "sam@example.com" },
|
||||
{ userId: "user-priya", name: "Priya Raman", email: "priya@example.com" },
|
||||
];
|
||||
|
||||
const MEMBER_CAPABILITIES: ToolConnectionCapabilities = {
|
||||
canConfigure: true,
|
||||
canCreateOrganizationGrant: true,
|
||||
canSetCompanyInstall: true,
|
||||
canConnectAsCurrentUser: true,
|
||||
canManageAgentInstalls: true,
|
||||
canViewOtherPersonalIdentities: false,
|
||||
editableAgentIds: ["a-outreach", "a-research"],
|
||||
};
|
||||
|
||||
const MANAGER_CAPABILITIES: ToolConnectionCapabilities = {
|
||||
...MEMBER_CAPABILITIES,
|
||||
canViewOtherPersonalIdentities: true,
|
||||
};
|
||||
|
||||
/** A viewer sees the same legible state with no mutation controls at all. */
|
||||
const VIEWER_CAPABILITIES: ToolConnectionCapabilities = {
|
||||
canConfigure: false,
|
||||
canCreateOrganizationGrant: false,
|
||||
canSetCompanyInstall: false,
|
||||
canConnectAsCurrentUser: false,
|
||||
canManageAgentInstalls: false,
|
||||
canViewOtherPersonalIdentities: false,
|
||||
editableAgentIds: [],
|
||||
};
|
||||
|
||||
function grant(overrides: Partial<ConnectionGrant> = {}): ConnectionGrant {
|
||||
return {
|
||||
id: "grant-org",
|
||||
companyId: "company-1",
|
||||
connectionId: "conn-1",
|
||||
kind: "organization",
|
||||
subjectUserId: null,
|
||||
providerTenant: null,
|
||||
credentialSecretRefs: [],
|
||||
status: "active",
|
||||
isDefault: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: CURRENT_USER,
|
||||
revokedAt: null,
|
||||
revokedByAgentId: null,
|
||||
revokedByUserId: null,
|
||||
lastUsedAt: null,
|
||||
createdAt: new Date("2026-08-01T10:00:00Z"),
|
||||
updatedAt: new Date("2026-08-01T10:00:00Z"),
|
||||
members: [],
|
||||
capabilities: { canRevoke: true, canEditAudience: true },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function audienceMembers(userIds: string[]) {
|
||||
return userIds.map((userId, index) => ({
|
||||
id: `member-${index}`,
|
||||
companyId: "company-1",
|
||||
grantId: "grant-org",
|
||||
subjectType: "user" as const,
|
||||
subjectId: userId,
|
||||
createdAt: new Date("2026-08-01T10:00:00Z"),
|
||||
}));
|
||||
}
|
||||
|
||||
function personalGrant(overrides: Partial<ConnectionGrant> = {}): ConnectionGrant {
|
||||
return grant({
|
||||
id: "grant-carol",
|
||||
kind: "user",
|
||||
subjectUserId: CURRENT_USER,
|
||||
isDefault: false,
|
||||
lastUsedAt: new Date("2026-08-19T09:12:00Z"),
|
||||
capabilities: { canRevoke: true, canEditAudience: false },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the identity surface the way the Setup tab does, including the header
|
||||
* "acts as" sentence — that sentence is the at-a-glance answer the design
|
||||
* requires, so a screenshot of the rows alone would not show the whole state.
|
||||
*/
|
||||
function IdentitiesHarness({
|
||||
credentialPolicy = "per_user",
|
||||
grants,
|
||||
capabilities = MEMBER_CAPABILITIES,
|
||||
loading = false,
|
||||
error = false,
|
||||
audienceGrantId = null,
|
||||
audienceError = null,
|
||||
}: {
|
||||
credentialPolicy?: ToolConnectionCredentialPolicy;
|
||||
grants: ConnectionGrant[];
|
||||
capabilities?: ToolConnectionCapabilities;
|
||||
loading?: boolean;
|
||||
error?: boolean;
|
||||
audienceGrantId?: string | null;
|
||||
audienceError?: string | null;
|
||||
}) {
|
||||
const [openAudience, setOpenAudience] = useState<string | null>(audienceGrantId);
|
||||
const response: ConnectionGrantsResponse = {
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants,
|
||||
capabilities,
|
||||
currentUserId: CURRENT_USER,
|
||||
members: MEMBERS,
|
||||
};
|
||||
const actsAs = actsAsSummary(credentialPolicy);
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl bg-background p-6">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Gmail</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{actsAs.title}</span>
|
||||
{" · "}
|
||||
{actsAs.detail}
|
||||
</p>
|
||||
</header>
|
||||
<IdentitiesSection
|
||||
appName="Gmail"
|
||||
providerName="Gmail"
|
||||
credentialPolicy={credentialPolicy}
|
||||
grantsQuery={loading || error ? undefined : response}
|
||||
agents={[{ id: "agent-1", name: "Outreach agent", title: "Growth", status: "active" }]}
|
||||
agentsLoading={false}
|
||||
agentsError={false}
|
||||
loading={loading}
|
||||
error={error}
|
||||
connectPending={false}
|
||||
revokePending={false}
|
||||
delegationPending={false}
|
||||
audiencePending={false}
|
||||
audienceError={audienceError}
|
||||
audienceGrantId={openAudience}
|
||||
onOpenAudience={setOpenAudience}
|
||||
onCloseAudience={() => setOpenAudience(null)}
|
||||
onConnectAsMe={() => {}}
|
||||
onConnectOrganization={() => {}}
|
||||
onReconnectOrganization={() => {}}
|
||||
onRevokeGrant={() => {}}
|
||||
onReplaceDelegations={() => {}}
|
||||
onReplaceAudience={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Reviews/PAP-17835 Personal connection identity",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
// --- Gate 3: personal connected, organization missing ----------------------
|
||||
|
||||
export const SetupPersonalConnectedOrganizationMissing: Story = {
|
||||
name: "3 · Setup — your identity connected, organization missing",
|
||||
render: () => (
|
||||
<IdentitiesHarness
|
||||
grants={[personalGrant({ providerTenant: { name: "carol@example.com" } })]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const SetupPersonalNotConnected: Story = {
|
||||
name: "3b · Setup — your identity not connected (no silent fallback)",
|
||||
render: () => <IdentitiesHarness grants={[]} />,
|
||||
};
|
||||
|
||||
// --- Gate 4: organization identity with a selected audience, manager view ---
|
||||
|
||||
export const SetupManagerOversight: Story = {
|
||||
name: "4 · Setup — selected audience + manager oversight",
|
||||
render: () => (
|
||||
<IdentitiesHarness
|
||||
credentialPolicy="per_user_with_fallback"
|
||||
capabilities={MANAGER_CAPABILITIES}
|
||||
grants={[
|
||||
grant({
|
||||
providerTenant: { name: "Shared Gmail account" },
|
||||
members: audienceMembers(["user-dotta", "user-sam"]),
|
||||
}),
|
||||
personalGrant({ providerTenant: { name: "carol@example.com" } }),
|
||||
personalGrant({
|
||||
id: "grant-sam",
|
||||
subjectUserId: "user-sam",
|
||||
providerTenant: { name: "sam@example.com" },
|
||||
status: "needs_reauthorization",
|
||||
}),
|
||||
personalGrant({
|
||||
id: "grant-priya",
|
||||
subjectUserId: "user-priya",
|
||||
providerTenant: null,
|
||||
status: "revoked",
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Gate 5: audience editor, both scopes ----------------------------------
|
||||
|
||||
export const AudienceEditorAllMembers: Story = {
|
||||
name: "5 · Audience editor — all organization members",
|
||||
render: () => (
|
||||
<IdentitiesHarness
|
||||
credentialPolicy="shared"
|
||||
grants={[grant({ providerTenant: { name: "Shared Gmail account" } })]}
|
||||
audienceGrantId="grant-org"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const AudienceEditorSelectedMembers: Story = {
|
||||
name: "5b · Audience editor — selected members",
|
||||
render: () => (
|
||||
<IdentitiesHarness
|
||||
credentialPolicy="shared"
|
||||
grants={[
|
||||
grant({
|
||||
providerTenant: { name: "Shared Gmail account" },
|
||||
members: audienceMembers(["user-dotta", "user-sam"]),
|
||||
}),
|
||||
]}
|
||||
audienceGrantId="grant-org"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Gate 6: post-revoke state --------------------------------------------
|
||||
|
||||
export const SetupAfterRevoke: Story = {
|
||||
name: "6 · Setup — after revoke, row stays with a reconnect path",
|
||||
render: () => (
|
||||
<IdentitiesHarness
|
||||
grants={[
|
||||
grant({ providerTenant: { name: "Shared Gmail account" } }),
|
||||
personalGrant({ status: "revoked", providerTenant: { name: "carol@example.com" } }),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Gate 7: viewer read-only --------------------------------------------
|
||||
|
||||
export const SetupViewerReadOnly: Story = {
|
||||
name: "7 · Setup — viewer read-only",
|
||||
render: () => (
|
||||
<IdentitiesHarness
|
||||
capabilities={VIEWER_CAPABILITIES}
|
||||
grants={[
|
||||
grant({
|
||||
providerTenant: { name: "Shared Gmail account" },
|
||||
members: audienceMembers(["user-dotta"]),
|
||||
capabilities: { canRevoke: false, canEditAudience: false },
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// --- Gate 9: loading, empty, and a server denial --------------------------
|
||||
|
||||
export const SetupLoading: Story = {
|
||||
name: "9 · Setup — loading",
|
||||
render: () => <IdentitiesHarness grants={[]} loading />,
|
||||
};
|
||||
|
||||
export const SetupLoadFailed: Story = {
|
||||
name: "9b · Setup — identities could not be loaded",
|
||||
render: () => <IdentitiesHarness grants={[]} error />,
|
||||
};
|
||||
|
||||
/**
|
||||
* A refused audience save keeps the dialog open with the selection intact and
|
||||
* explains itself inline, rather than dropping the work into a toast.
|
||||
*/
|
||||
export const AudienceEditorServerDenial: Story = {
|
||||
name: "9c · Audience editor — server denial keeps the selection",
|
||||
render: () => (
|
||||
<IdentitiesHarness
|
||||
credentialPolicy="shared"
|
||||
grants={[
|
||||
grant({
|
||||
providerTenant: { name: "Shared Gmail account" },
|
||||
members: audienceMembers(["user-sam"]),
|
||||
}),
|
||||
]}
|
||||
audienceGrantId="grant-org"
|
||||
audienceError="Every audience member must be an active company member."
|
||||
/>
|
||||
),
|
||||
};
|
||||
Loading…
Reference in New Issue