fix: close phase 4 connection QA gaps

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-21 05:46:49 +00:00
1 parent 9d758eacda
commit dea8e86bf4
12 files changed
+499 -66

No files matched your search

@@ -36,12 +36,12 @@ describe("connection grants phase 4 migration", () => {
expect(migrationSql).toContain(`ON CONFLICT DO NOTHING`);
});
it("fails ambiguous personal-secret ownership closed with reauthorization guidance", () => {
expect(migrationSql).toContain(`Cannot migrate personal connection credential`);
expect(migrationSql).toContain(`Reauthorize the affected personal connection grants`);
expect(migrationSql).toContain(`count(DISTINCT g."subject_user_id") <> 1`);
expect(migrationSql).toContain(`other_g."kind" <> 'user'`);
expect(migrationSql).toContain(`jsonb_array_elements(c."credential_secret_refs")`);
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(`"status" = 'needs_reauthorization'`);
expect(migrationSql).toContain(`"health_status" = 'missing_secret'`);
});
});
@@ -109,7 +109,7 @@ describeEmbeddedPostgres("connection grants phase 4 executable migration", () =>
)
`;
}
return { companyId, secretId, ownerUserId: input.ownerUserIds[0]! };
return { companyId, connectionId, secretId, ownerUserId: input.ownerUserIds[0]! };
}
try {
@@ -133,20 +133,56 @@ describeEmbeddedPostgres("connection grants phase 4 executable migration", () =>
const ambiguous = await seedLegacyCredential({ ownerUserIds: ["alice", "bob"] });
await rewindMigration();
await expect(applyPendingMigrations(database.connectionString)).rejects.toThrow(
/legacy ownership is ambiguous/,
);
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}
`).toEqual([
{ 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 }]);
await sql`DELETE FROM "company_secrets" WHERE "company_id" = ${ambiguous.companyId}`;
await sql`DELETE FROM "companies" WHERE "id" = ${ambiguous.companyId}`;
await seedLegacyCredential({
const mixed = await seedLegacyCredential({
ownerUserIds: ["carol"],
includeOrganizationGrant: true,
includeConnectionReference: true,
});
await expect(applyPendingMigrations(database.connectionString)).rejects.toThrow(
/legacy ownership is ambiguous/,
);
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: "draft",
enabled: false,
health_status: "missing_secret",
credential_secret_refs: [],
}]);
expect(await sql<{ status: string; credential_secret_refs: unknown[] }[]>`
SELECT "status", "credential_secret_refs"
FROM "connection_grants"
WHERE "company_id" = ${mixed.companyId}
ORDER BY "kind"
`).toEqual([
{ status: "needs_reauthorization", credential_secret_refs: [] },
{ status: "needs_reauthorization", credential_secret_refs: [] },
]);
} finally {
await sql.end();
}
@@ -19,56 +19,104 @@ ALTER TABLE "connection_grant_delegations" ADD CONSTRAINT "connection_grant_dele
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
DO $$
DECLARE
ambiguous_secret record;
BEGIN
-- A legacy company-scoped credential can only become user-scoped when exactly
-- one personal owner references it and no organization grant or connection
-- also references it. Ambiguous rows fail closed in-place: remove every live
-- reference, require reauthorization, and disable affected connections. This
-- keeps the instance bootable while ensuring the credential is never assigned
-- to an arbitrary user.
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",
count(DISTINCT g."subject_user_id") AS "personal_owner_count",
count(*) FILTER (WHERE other_g."id" IS NOT NULL) AS "organization_reference_count",
count(*) FILTER (WHERE c."id" IS NOT NULL) AS "connection_reference_count"
INTO ambiguous_secret
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
LEFT JOIN "connection_grants" other_g
ON other_g."company_id" = s."company_id"
AND other_g."kind" <> 'user'
AND EXISTS (
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 jsonb_array_elements(other_g."credential_secret_refs") other_ref
WHERE other_ref ->> 'secretId' = s."id"::text
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
)
LEFT JOIN "tool_connections" c
ON c."company_id" = s."company_id"
AND EXISTS (
OR EXISTS (
SELECT 1
FROM jsonb_array_elements(c."credential_secret_refs") connection_ref
WHERE connection_ref ->> 'secretId' = s."id"::text
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
);
--> 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
)
WHERE s."id"::text = personal_ref ->> 'secretId'
AND s."scope" = 'company'
GROUP BY s."id"
HAVING count(DISTINCT g."subject_user_id") <> 1
OR count(*) FILTER (WHERE other_g."id" IS NOT NULL) > 0
OR count(*) FILTER (WHERE c."id" IS NOT NULL) > 0
LIMIT 1;
IF FOUND THEN
RAISE EXCEPTION 'Cannot migrate personal connection credential %: legacy ownership is ambiguous', ambiguous_secret."secret_id"
USING ERRCODE = '23514',
DETAIL = format(
'personal owners=%s, organization grant references=%s, connection references=%s',
ambiguous_secret."personal_owner_count",
ambiguous_secret."organization_reference_count",
ambiguous_secret."connection_reference_count"
),
HINT = 'Reauthorize the affected personal connection grants with one credential per user before retrying this migration.';
END IF;
END $$;
), '[]'::jsonb),
"status" = 'needs_reauthorization',
"is_default" = false,
"updated_at" = now()
WHERE 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
WITH ambiguous_secrets AS (
SELECT "secret_id", "company_id" FROM "phase4_ambiguous_personal_secrets"
)
UPDATE "tool_connections" connection_row
SET
"credential_secret_refs" = COALESCE((
SELECT jsonb_agg(ref)
FROM jsonb_array_elements(connection_row."credential_secret_refs") ref
WHERE NOT EXISTS (
SELECT 1
FROM ambiguous_secrets ambiguous
WHERE ambiguous."company_id" = connection_row."company_id"
AND ref ->> 'secretId' = ambiguous."secret_id"::text
)
), '[]'::jsonb),
"status" = 'draft',
"enabled" = false,
"health_status" = 'missing_secret',
"health_message" = 'Legacy personal credential ownership was ambiguous. Reauthorize this connection.',
"last_error" = 'oauth_reauthorization_required',
"updated_at" = now()
WHERE EXISTS (
SELECT 1
FROM jsonb_array_elements(connection_row."credential_secret_refs") ref
JOIN ambiguous_secrets ambiguous
ON ambiguous."company_id" = connection_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",
+42 -1
View File
@@ -1,5 +1,5 @@
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,
@@ -478,6 +478,47 @@ describeEmbeddedPostgres("access service", () => {
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
});
it("destroys every owned personal secret on suspension, including unreferenced secrets", 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 ownedSecrets = await db.insert(companySecrets).values({
companyId: company.id,
scope: "user",
ownerUserId: member.principalId,
userSecretDefinitionId: definition.id,
key: `oauth-${randomUUID()}`,
name: "Orphaned personal credential",
}).returning();
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(inArray(
companySecrets.id,
ownedSecrets.map((secret) => secret.id),
))).toHaveLength(0);
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);
@@ -912,6 +912,67 @@ describeEmbeddedPostgres("tool access service", () => {
]));
});
it("enforces delegation owner and manager permissions through the HTTP routes", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
await grantBoardUser(db, company.id, "alice", [], "member");
await grantBoardUser(db, company.id, "mallory", [], "member");
await grantBoardUser(db, company.id, "manager", ["tools:manage_connections"], "operator");
const { connection } = await createBrokerConnection(db, company.id);
const grant = await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection.id,
kind: "user",
subjectUserId: "alice",
status: "active",
isDefault: false,
}).returning().then((rows) => rows[0]!);
const nonOwner = await request(createRouteApp(
db,
boardSessionActor(company.id, "member", "mallory"),
))
.post(`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations`)
.send({ agentId: agent.id });
expect(nonOwner.status).toBe(403);
const created = await request(createRouteApp(
db,
boardSessionActor(company.id, "member", "alice"),
))
.post(`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations`)
.send({ agentId: agent.id });
expect(created.status).toBe(201);
expect(created.body).toMatchObject({ grantId: grant.id, agentId: agent.id });
const unrelatedRevoke = await request(createRouteApp(
db,
boardSessionActor(company.id, "member", "mallory"),
)).delete(
`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations/${created.body.id}`,
);
expect(unrelatedRevoke.status).toBe(403);
const managerRevoke = await request(createRouteApp(
db,
boardSessionActor(company.id, "operator", "manager"),
)).delete(
`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations/${created.body.id}`,
);
expect(managerRevoke.status).toBe(200);
expect(await db.select().from(connectionGrantDelegations).where(eq(
connectionGrantDelegations.id,
created.body.id,
))).toHaveLength(0);
expect(await db.select().from(toolAccessAuditEvents).where(eq(
toolAccessAuditEvents.connectionId,
connection.id,
))).toEqual(expect.arrayContaining([
expect.objectContaining({ action: "connection_grant.delegated", actorId: "alice" }),
expect.objectContaining({ action: "connection_grant.delegation_revoked", actorId: "manager" }),
]));
});
it("serializes delegation creation behind membership removal so reauthorization cannot revive stale consent", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
@@ -1038,6 +1099,7 @@ describeEmbeddedPostgres("tool access service", () => {
expect(inactiveOwner.status).toBe(403);
expect(inactiveOwner.body).toMatchObject({
code: "grant_owner_membership_inactive",
remediation: { action: "restore_membership_or_reconnect" },
});
expect(inactiveOwner.body.error).toContain("not an active company member");
});
@@ -3714,7 +3776,7 @@ describeEmbeddedPostgres("tool access service", () => {
issueId: issue.id,
kind: "request_confirmation",
status: "pending",
title: "Connect your account",
title: "Connect your Slack to continue",
});
expect(interaction.payload).toMatchObject({ target: { href: started.authorizationUrl } });
+1
View File
@@ -101,6 +101,7 @@ export function errorHandler(
"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) {
+6 -5
View File
@@ -50,16 +50,17 @@ export function accessService(db: Db) {
eq(connectionGrants.subjectUserId, userId),
));
const grantIds = ownedGrants.map((grant) => grant.id);
const referencedSecretIds = [...new Set(ownedGrants.flatMap((grant) =>
grant.credentialSecretRefs.map((ref) => ref.secretId),
))];
const ownedSecrets = referencedSecretIds.length === 0 ? [] : await tx.select({
// Membership removal is an ownership boundary, not merely a grant cleanup.
// Destroy every personal secret owned by the departing user, including
// orphaned rows that are not referenced by a current grant. Restricting the
// lookup to grant refs leaves recoverable credentials on disk and lets them
// reappear if the same identity is later reactivated.
const ownedSecrets = await tx.select({
id: companySecrets.id,
}).from(companySecrets).where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.scope, "user"),
eq(companySecrets.ownerUserId, userId),
inArray(companySecrets.id, referencedSecretIds),
));
const secretIds = ownedSecrets.map((secret) => secret.id);
const removedDelegations = grantIds.length === 0 ? [] : await tx
+1 -1
View File
@@ -2353,7 +2353,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
key: `connection:${input.connection.uid}:delegation:${input.ownerUserId}:${input.agentId}`,
revisionId: input.connection.updatedAt.toISOString(),
label: `Delegate ${input.connection.name}`,
href: `/${company?.issuePrefix ?? ""}/apps/${input.connection.id}/setup`,
href: `/${company?.issuePrefix ?? ""}/apps/${input.connection.id}/setup#personal-identity`,
},
};
const [existing] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
+21
View File
@@ -53,6 +53,8 @@ import type {
UpdateToolMcpGateway,
CreateToolTrustRuleFromActionRequest,
ToolRedactedValueSummary,
ConnectionGrant,
ConnectionGrantDelegation,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -75,6 +77,10 @@ export type ToolPoliciesResponse = { policies: ToolPolicy[] };
export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] };
export type ToolGalleryResponse = { apps: AppDefinition[] };
export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] };
export type ConnectionGrantsResponse = {
connection: { id: string; uid: string };
grants: ConnectionGrant[];
};
export type CreateGatewayTokenInput = Omit<CreateToolMcpGatewayToken, "expiresAt"> & {
expiresAt?: string | Date | null;
};
@@ -306,6 +312,21 @@ export const toolsApi = {
`/tool-connections/${connectionId}/installs`,
{ installs },
),
// --- Identity grants ---
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}`,
),
createConnection: (companyId: string, input: CreateToolConnectionInput) =>
api.post<ToolConnection>(`/companies/${companyId}/tools/connections`, input),
updateConnection: (connectionId: string, input: UpdateToolConnectionInput) =>
+2
View File
@@ -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,
+77
View File
@@ -21,6 +21,9 @@ 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 createConnectionGrantDelegationMock = vi.hoisted(() => vi.fn());
const revokeConnectionGrantDelegationMock = vi.hoisted(() => vi.fn());
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
const getSessionMock = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
@@ -50,6 +53,14 @@ vi.mock("@/api/tools", () => ({
archiveConnection: vi.fn(),
refreshCatalog: (connectionId: string) => refreshCatalogMock(connectionId),
startOAuth: (connectionId: string) => startOAuthMock(connectionId),
listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId),
createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) =>
createConnectionGrantDelegationMock(connectionId, grantId, agentId),
revokeConnectionGrantDelegation: (
connectionId: string,
grantId: string,
delegationId: string,
) => revokeConnectionGrantDelegationMock(connectionId, grantId, delegationId),
reconnectConnection: vi.fn(),
},
}));
@@ -265,6 +276,16 @@ describe("AppDetail", () => {
authorizationUrl: "https://example.test/oauth",
expiresAt: "2026-07-10T00:00:00.000Z",
});
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [],
});
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 },
@@ -993,4 +1014,60 @@ describe("AppDetail", () => {
expect(body.length).toBeGreaterThan(0);
expect(body).not.toContain(authorizationUrl);
});
it("lets the personal grant owner delegate autonomous access to a named agent", async () => {
getConnectionMock.mockResolvedValue(connection({ credentialPolicy: "per_user" }));
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [{
id: "grant-user",
companyId: "company-1",
connectionId: "conn-1",
kind: "user",
subjectUserId: "user-1",
providerTenant: null,
credentialSecretRefs: [],
status: "active",
isDefault: false,
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"),
delegations: [],
}],
});
await renderAppDetail();
const trigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Allow autonomous access");
expect(trigger).toBeTruthy();
await act(async () => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const agentCheckbox = document.body.querySelector<HTMLElement>('[aria-label="Allow Coder"]');
expect(agentCheckbox).toBeTruthy();
await act(async () => {
agentCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
Array.from(document.body.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",
);
});
});
+68 -2
View File
@@ -76,6 +76,11 @@ export function AppDetail() {
queryFn: () => toolsApi.getConnection(connectionId),
enabled: !!connectionId && !!activeTab,
});
const grantsQuery = useQuery({
queryKey: queryKeys.tools.connectionGrants(connectionId),
queryFn: () => toolsApi.listConnectionGrants(connectionId),
enabled: !!connectionId && activeTab === "setup",
});
const installsQuery = useQuery({
queryKey: queryKeys.tools.connectionInstalls(connectionId),
queryFn: () => toolsApi.getConnectionInstalls(connectionId),
@@ -104,7 +109,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),
@@ -120,7 +127,7 @@ export function AppDetail() {
const sessionQuery = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
enabled: activeTab === "activity",
enabled: activeTab === "setup" || activeTab === "activity",
});
const connection = connectionQuery.data;
@@ -299,6 +306,53 @@ export function AppDetail() {
}),
});
const invalidateGrants = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connectionGrants(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
};
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",
});
},
});
const removeApp = useMutation({
mutationFn: () => toolsApi.archiveConnection(connectionId),
onSuccess: () => {
@@ -472,6 +526,18 @@ export function AppDetail() {
onUpdateConfig={(config) => updateConfig.mutate(config)}
oauthStartDisabled={startOAuth.isPending}
onStartOAuth={() => startOAuth.mutate()}
personalGrant={(grantsQuery.data?.grants ?? []).find((grant) =>
grant.kind === "user" && grant.subjectUserId === sessionQuery.data?.user?.id
)}
agents={agents}
delegationLoading={grantsQuery.isLoading || sessionQuery.isLoading || agentsQuery.isLoading}
delegationError={grantsQuery.isError || sessionQuery.isError || agentsQuery.isError}
delegationPending={replaceDelegations.isPending}
onReplaceDelegations={(grant, agentIds) => replaceDelegations.mutate({
grantId: grant.id,
currentDelegations: grant.delegations ?? [],
agentIds,
})}
/>
<AdvancedPanel
connection={connection}
+79 -1
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import type { ToolCatalogEntry, ToolConnection } from "@paperclipai/shared";
import type { ConnectionGrant, ToolCatalogEntry, ToolConnection } from "@paperclipai/shared";
import { AgentMultiSelect, type AgentMultiSelectOption } from "@/components/AgentMultiSelect";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
@@ -16,6 +17,12 @@ export function SetupPanel({
configUpdateDisabled,
onStartOAuth,
oauthStartDisabled,
personalGrant,
agents = [],
delegationLoading = false,
delegationError = false,
delegationPending = false,
onReplaceDelegations,
}: Pick<
AppDetailSectionProps,
"connection" | "galleryEntry"
@@ -26,6 +33,12 @@ export function SetupPanel({
configUpdateDisabled: boolean;
onStartOAuth: () => void;
oauthStartDisabled: boolean;
personalGrant?: ConnectionGrant;
agents?: AgentMultiSelectOption[];
delegationLoading?: boolean;
delegationError?: boolean;
delegationPending?: boolean;
onReplaceDelegations?: (grant: ConnectionGrant, agentIds: string[]) => void;
}) {
const description = galleryEntry?.description ?? null;
const oauth = connection.config?.oauth;
@@ -58,11 +71,76 @@ export function SetupPanel({
onStart={onStartOAuth}
/>
)}
{connection.credentialPolicy === "per_user" && onReplaceDelegations && (
<PersonalDelegationSection
grant={personalGrant}
agents={agents}
loading={delegationLoading}
error={delegationError}
pending={delegationPending}
onReplace={onReplaceDelegations}
/>
)}
<AppLifecycleSection connection={connection} disabled={appToggleDisabled} onToggle={onToggleApp} />
</div>
);
}
function PersonalDelegationSection({
grant,
agents,
loading,
error,
pending,
onReplace,
}: {
grant?: ConnectionGrant;
agents: AgentMultiSelectOption[];
loading: boolean;
error: boolean;
pending: boolean;
onReplace: (grant: ConnectionGrant, agentIds: string[]) => void;
}) {
if (loading) {
return <p id="personal-identity" className="text-sm text-muted-foreground">Loading autonomous access…</p>;
}
if (error) {
return <p id="personal-identity" className="text-sm text-destructive">Couldn’t load autonomous access.</p>;
}
if (!grant || grant.status !== "active") return null;
const delegatedAgentIds = new Set((grant.delegations ?? []).map((delegation) => delegation.agentId));
const delegationCount = delegatedAgentIds.size;
return (
<section id="personal-identity" className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-bold text-foreground">Your identity</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
Select the named agents that may use your identity in autonomous runs.
</p>
</div>
<AgentMultiSelect
agents={agents}
selectedAgentIds={delegatedAgentIds}
pending={pending}
disabled={pending}
triggerFullWidth={false}
triggerSize="sm"
triggerLabel={delegationCount === 0
? "Allow autonomous access"
: `${delegationCount} ${delegationCount === 1 ? "agent" : "agents"} allowed`}
headerContent={(
<p className="text-xs text-muted-foreground">
Only selected agents can use this personal identity without you present.
</p>
)}
onSave={(next) => onReplace(grant, [...next])}
/>
</section>
);
}
function PostHogConfigurationSection({ connection }: { connection: ToolConnection }) {
const raw = connection.config?.methodConfig;
const config = raw && typeof raw === "object" && !Array.isArray(raw)