mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
fix(apps): include connection lifecycle activity
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8ed119deb6
commit
5f7926a5d5
9 files changed
+449
-223
No files matched your search
@@ -3455,6 +3455,16 @@ rl.on("line", (line) => {
|
||||
createdAt: new Date(now - 500),
|
||||
},
|
||||
]).returning();
|
||||
const [connectedEvent] = await db.insert(activityLog).values({
|
||||
companyId: company.id,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
action: "tool_app.connected",
|
||||
entityType: "tool_connection",
|
||||
entityId: connection!.id,
|
||||
details: { galleryKey: "mail" },
|
||||
createdAt: new Date(now - 45 * 24 * 60 * 60 * 1000),
|
||||
}).returning();
|
||||
|
||||
const app = createGatewayRouteApp(db, createTestToolGatewayService(db), {
|
||||
type: "board",
|
||||
@@ -3467,13 +3477,21 @@ rl.on("line", (line) => {
|
||||
|
||||
const allActivity = await request(app)
|
||||
.get("/api/tool-gateway/audit")
|
||||
.query({ companyId: company.id, window: "24h" });
|
||||
.query({ companyId: company.id });
|
||||
expect(allActivity.status).toBe(200);
|
||||
expect(allActivity.body.events.map((event: { id: string }) => event.id)).toEqual([
|
||||
callEvents[2]!.id,
|
||||
callEvents[0]!.id,
|
||||
callEvents[1]!.id,
|
||||
connectedEvent!.id,
|
||||
]);
|
||||
expect(allActivity.body.events.find((event: { id: string }) => event.id === connectedEvent!.id)).toMatchObject({
|
||||
action: "tool_connection.app_connected",
|
||||
connectionId: connection!.id,
|
||||
applicationId: application!.id,
|
||||
appDisplayName: "Mail",
|
||||
lifecycleType: "app_connected",
|
||||
});
|
||||
|
||||
const firstPage = await request(app)
|
||||
.get("/api/tool-gateway/audit")
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Router, type Request, type Response } from "express";
|
||||
import { and, desc, eq, gte, ilike, inArray, lt, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, toolApplications, toolCallEvents, toolConnections, toolInvocations } from "@paperclipai/db";
|
||||
import { humanizeConnectionDisplayName, type PermissionKey } from "@paperclipai/shared";
|
||||
import {
|
||||
humanizeConnectionDisplayName,
|
||||
type PermissionKey,
|
||||
type ToolConnectionLifecycleEventType,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
createToolMcpGatewaySchema,
|
||||
createToolMcpGatewayTokenSchema,
|
||||
@@ -12,6 +16,7 @@ import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getActorInfo } fr
|
||||
import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js";
|
||||
import { forbidden, HttpError } from "../errors.js";
|
||||
import { accessService } from "../services/index.js";
|
||||
import { listConnectionLifecycleEvents } from "../services/tool-connection-activity.js";
|
||||
|
||||
const TOOL_ACTIVITY_EVENT_TYPES = [
|
||||
"call_completed",
|
||||
@@ -21,11 +26,12 @@ const TOOL_ACTIVITY_EVENT_TYPES = [
|
||||
"approval_resolved",
|
||||
] as const;
|
||||
|
||||
const TOOL_GATEWAY_WINDOWS: Record<string, number> = {
|
||||
const TOOL_GATEWAY_WINDOWS: Record<string, number | null> = {
|
||||
"1h": 60 * 60 * 1000,
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||
all: null,
|
||||
};
|
||||
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
@@ -653,7 +659,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
|
||||
const appFilter = typeof req.query.app === "string" ? req.query.app.trim() : null;
|
||||
const agentFilter = typeof req.query.agent === "string" ? req.query.agent.trim() : null;
|
||||
const outcomeFilter = typeof req.query.outcome === "string" ? req.query.outcome.trim() : null;
|
||||
const windowFilter = typeof req.query.window === "string" ? req.query.window.trim() : "24h";
|
||||
const windowFilter = typeof req.query.window === "string" ? req.query.window.trim() : "all";
|
||||
const searchRaw = typeof req.query.search === "string" ? req.query.search.trim() : null;
|
||||
const cursorRaw = typeof req.query.cursor === "string" ? req.query.cursor.trim() : null;
|
||||
if (gatewayFilter && !uuidPattern.test(gatewayFilter)) {
|
||||
@@ -669,7 +675,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
|
||||
return;
|
||||
}
|
||||
if (!(windowFilter in TOOL_GATEWAY_WINDOWS)) {
|
||||
res.status(400).json({ error: "window must be one of 1h, 24h, 7d, 30d" });
|
||||
res.status(400).json({ error: "window must be one of 1h, 24h, 7d, 30d, all" });
|
||||
return;
|
||||
}
|
||||
const cursor = cursorRaw ? decodeAuditCursor(cursorRaw) : null;
|
||||
@@ -678,11 +684,13 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const windowMs = TOOL_GATEWAY_WINDOWS[windowFilter];
|
||||
const windowStartedAt = windowMs === null ? null : new Date(Date.now() - windowMs);
|
||||
const conditions = [
|
||||
eq(toolCallEvents.companyId, companyId),
|
||||
inArray(toolCallEvents.eventType, TOOL_ACTIVITY_EVENT_TYPES),
|
||||
gte(toolCallEvents.createdAt, new Date(Date.now() - TOOL_GATEWAY_WINDOWS[windowFilter])),
|
||||
];
|
||||
if (windowStartedAt) conditions.push(gte(toolCallEvents.createdAt, windowStartedAt));
|
||||
if (cursor) {
|
||||
conditions.push(or(
|
||||
lt(toolCallEvents.createdAt, cursor.createdAt),
|
||||
@@ -715,6 +723,8 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
|
||||
// Free-text search runs server-side: resolve the term against agent / app /
|
||||
// connection names first, then OR those matched IDs with direct matches on
|
||||
// the event type, tool name, and reason code so paginating stays honest.
|
||||
let matchedAgentIds: string[] = [];
|
||||
let matchedConnectionIds: string[] = [];
|
||||
if (searchRaw) {
|
||||
const like = `%${searchRaw.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`;
|
||||
const [matchAgents, matchApps, matchConnections] = await Promise.all([
|
||||
@@ -725,9 +735,22 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
|
||||
db.select({ id: toolConnections.id }).from(toolConnections)
|
||||
.where(and(eq(toolConnections.companyId, companyId), ilike(toolConnections.name, like))),
|
||||
]);
|
||||
const matchedAgentIds = matchAgents.map((r) => r.id);
|
||||
matchedAgentIds = matchAgents.map((r) => r.id);
|
||||
const matchedAppIds = matchApps.map((r) => r.id);
|
||||
const matchedConnectionIds = matchConnections.map((r) => r.id);
|
||||
matchedConnectionIds = matchConnections.map((r) => r.id);
|
||||
if (matchedAppIds.length > 0) {
|
||||
const appConnections = await db
|
||||
.select({ id: toolConnections.id })
|
||||
.from(toolConnections)
|
||||
.where(and(
|
||||
eq(toolConnections.companyId, companyId),
|
||||
inArray(toolConnections.applicationId, matchedAppIds),
|
||||
));
|
||||
matchedConnectionIds = [...new Set([
|
||||
...matchedConnectionIds,
|
||||
...appConnections.map((connection) => connection.id),
|
||||
])];
|
||||
}
|
||||
const searchClauses = [
|
||||
ilike(toolCallEvents.eventType, like),
|
||||
ilike(toolCallEvents.toolName, like),
|
||||
@@ -781,36 +804,112 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
|
||||
.orderBy(desc(toolCallEvents.createdAt), desc(toolCallEvents.id))
|
||||
.limit(limit + 1);
|
||||
|
||||
const hasMore = page.length > limit;
|
||||
const visible = hasMore ? page.slice(0, limit) : page;
|
||||
let lifecycleConnectionIds: string[] | undefined;
|
||||
if (gatewayFilter || outcomeFilter) {
|
||||
lifecycleConnectionIds = [];
|
||||
} else if (appFilter) {
|
||||
lifecycleConnectionIds = (await db
|
||||
.select({ id: toolConnections.id })
|
||||
.from(toolConnections)
|
||||
.where(and(
|
||||
eq(toolConnections.companyId, companyId),
|
||||
or(eq(toolConnections.id, appFilter), eq(toolConnections.applicationId, appFilter)),
|
||||
)))
|
||||
.map((row) => row.id);
|
||||
}
|
||||
|
||||
const lifecycleEvents = await listConnectionLifecycleEvents(db, {
|
||||
companyId,
|
||||
connectionIds: lifecycleConnectionIds,
|
||||
agentId: agentFilter,
|
||||
since: windowStartedAt,
|
||||
cursor,
|
||||
search: searchRaw,
|
||||
matchedAgentIds,
|
||||
matchedConnectionIds,
|
||||
limit: limit + 1,
|
||||
});
|
||||
const candidates = [
|
||||
...page.map((item) => ({ kind: "call" as const, id: item.row.id, createdAt: item.row.createdAt, item })),
|
||||
...lifecycleEvents.map((item) => ({ kind: "lifecycle" as const, id: item.id, createdAt: item.createdAt, item })),
|
||||
].sort((a, b) => {
|
||||
const byTime = b.createdAt.getTime() - a.createdAt.getTime();
|
||||
return byTime !== 0 ? byTime : b.id.localeCompare(a.id);
|
||||
});
|
||||
const hasMore = candidates.length > limit;
|
||||
const visible = candidates.slice(0, limit);
|
||||
const agentIds = [...new Set(visible.flatMap((item) => [
|
||||
item.row.agentId,
|
||||
item.invocationAgentId,
|
||||
item.kind === "call" ? item.item.row.agentId : item.item.agentId,
|
||||
item.kind === "call" ? item.item.invocationAgentId : null,
|
||||
]).filter((id): id is string => Boolean(id)))];
|
||||
const applicationIds = [...new Set(visible.flatMap((item) => [
|
||||
item.row.applicationId,
|
||||
item.invocationApplicationId,
|
||||
item.kind === "call" ? item.item.row.applicationId : null,
|
||||
item.kind === "call" ? item.item.invocationApplicationId : null,
|
||||
]).filter((id): id is string => Boolean(id)))];
|
||||
const connectionIds = [...new Set(visible.flatMap((item) => [
|
||||
item.row.connectionId,
|
||||
item.invocationConnectionId,
|
||||
item.kind === "call" ? item.item.row.connectionId : item.item.connectionId,
|
||||
item.kind === "call" ? item.item.invocationConnectionId : null,
|
||||
]).filter((id): id is string => Boolean(id)))];
|
||||
const [agentRows, applicationRows, connectionRows] = await Promise.all([
|
||||
const [agentRows, connectionRows] = await Promise.all([
|
||||
agentIds.length > 0
|
||||
? db.select({ id: agents.id, name: agents.name }).from(agents).where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds)))
|
||||
: [],
|
||||
applicationIds.length > 0
|
||||
? db.select({ id: toolApplications.id, name: toolApplications.name }).from(toolApplications).where(and(eq(toolApplications.companyId, companyId), inArray(toolApplications.id, applicationIds)))
|
||||
: [],
|
||||
connectionIds.length > 0
|
||||
? db.select({ id: toolConnections.id, name: toolConnections.name, applicationId: toolConnections.applicationId }).from(toolConnections).where(and(eq(toolConnections.companyId, companyId), inArray(toolConnections.id, connectionIds)))
|
||||
: [],
|
||||
]);
|
||||
const allApplicationIds = [...new Set([
|
||||
...applicationIds,
|
||||
...connectionRows.map((row) => row.applicationId),
|
||||
])];
|
||||
const applicationRows = allApplicationIds.length > 0
|
||||
? await db.select({ id: toolApplications.id, name: toolApplications.name }).from(toolApplications)
|
||||
.where(and(eq(toolApplications.companyId, companyId), inArray(toolApplications.id, allApplicationIds)))
|
||||
: [];
|
||||
const agentsById = new Map(agentRows.map((row) => [row.id, row]));
|
||||
const applicationsById = new Map(applicationRows.map((row) => [row.id, row]));
|
||||
const connectionsById = new Map(connectionRows.map((row) => [row.id, row]));
|
||||
|
||||
const events = visible.map((item) => {
|
||||
const events = visible.map((candidate) => {
|
||||
if (candidate.kind === "lifecycle") {
|
||||
const lifecycle = candidate.item;
|
||||
const connection = connectionsById.get(lifecycle.connectionId) ?? null;
|
||||
const applicationId = connection?.applicationId ?? null;
|
||||
const application = applicationId ? applicationsById.get(applicationId) ?? null : null;
|
||||
const appDisplayName = connection
|
||||
? humanizeConnectionDisplayName(connection)
|
||||
: application
|
||||
? humanizeConnectionDisplayName(application.name)
|
||||
: null;
|
||||
return {
|
||||
id: lifecycle.id,
|
||||
companyId,
|
||||
action: `tool_connection.${lifecycle.type}`,
|
||||
actorType: lifecycle.actorType,
|
||||
actorId: lifecycle.actorId,
|
||||
entityType: "tool_connection",
|
||||
entityId: lifecycle.connectionId,
|
||||
details: { ...(lifecycle.details ?? {}), lifecycleType: lifecycle.type },
|
||||
createdAt: lifecycle.createdAt,
|
||||
runId: null,
|
||||
agentId: lifecycle.agentId,
|
||||
agentDisplayName: lifecycle.agentId
|
||||
? agentsById.get(lifecycle.agentId)?.name ?? "Unknown agent"
|
||||
: null,
|
||||
actorDisplayName: lifecycle.actorDisplayName,
|
||||
applicationId,
|
||||
connectionId: lifecycle.connectionId,
|
||||
appDisplayName,
|
||||
applicationDisplayName: application ? humanizeConnectionDisplayName(application.name) : null,
|
||||
connectionDisplayName: connection ? humanizeConnectionDisplayName(connection) : null,
|
||||
toolDisplayName: null,
|
||||
lifecycleType: lifecycle.type,
|
||||
normalizedOutcome: "unknown" as const,
|
||||
invocation: null,
|
||||
};
|
||||
}
|
||||
|
||||
const item = candidate.item;
|
||||
const row = item.row;
|
||||
const agentId = row.agentId ?? item.invocationAgentId;
|
||||
const connectionId = row.connectionId ?? item.invocationConnectionId;
|
||||
@@ -857,12 +956,14 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
|
||||
runId: row.runId,
|
||||
agentId,
|
||||
agentDisplayName: agentId ? agentsById.get(agentId)?.name ?? "Unknown agent" : null,
|
||||
actorDisplayName: agentId ? agentsById.get(agentId)?.name ?? "Unknown agent" : null,
|
||||
applicationId,
|
||||
connectionId,
|
||||
appDisplayName,
|
||||
applicationDisplayName: application ? humanizeConnectionDisplayName(application.name) : null,
|
||||
connectionDisplayName: connection ? humanizeConnectionDisplayName(connection) : null,
|
||||
toolDisplayName: rawToolName ? humanizeConnectionDisplayName(rawToolName) : null,
|
||||
lifecycleType: null as ToolConnectionLifecycleEventType | null,
|
||||
normalizedOutcome: normalizedAuditOutcome(row.eventType, row.outcome, row.decision),
|
||||
invocation: item.invocationId && item.invocationToolName && item.invocationStatus && item.invocationApprovalState
|
||||
? {
|
||||
@@ -883,7 +984,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
|
||||
};
|
||||
});
|
||||
|
||||
const last = visible.at(-1)?.row;
|
||||
const last = visible.at(-1);
|
||||
res.json({
|
||||
events,
|
||||
nextCursor: hasMore && last ? encodeAuditCursor({ createdAt: last.createdAt, id: last.id }) : null,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFileSync } from "node:fs";
|
||||
import { and, asc, desc, eq, gte, inArray, isNull, lt, max, ne, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
connectionGrants,
|
||||
connectionTokenIssuances,
|
||||
@@ -78,8 +77,6 @@ import type {
|
||||
ToolActionRequestListItem,
|
||||
ToolActionRequestStatus,
|
||||
ToolConnectionActivityResponse,
|
||||
ToolConnectionLifecycleEvent,
|
||||
ToolConnectionLifecycleEventType,
|
||||
ToolAppConnectionActionSummary,
|
||||
ToolExampleInstallResult,
|
||||
ToolExampleSmokeCheck,
|
||||
@@ -146,6 +143,7 @@ import {
|
||||
} from "./tool-profile-binding-precedence.js";
|
||||
import { recordToolRuntimeAuditWriteFailure, TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC } from "./tool-runtime-metrics.js";
|
||||
import { createToolRuntimeSupervisor, ToolRuntimeSupervisorError } from "./tool-runtime-supervisor.js";
|
||||
import { listConnectionLifecycleEvents } from "./tool-connection-activity.js";
|
||||
|
||||
type ActorInfo = {
|
||||
actorType?: "agent" | "user" | "system" | "plugin";
|
||||
@@ -1351,47 +1349,6 @@ function userFallbackName(userId: string): string {
|
||||
return userId;
|
||||
}
|
||||
|
||||
/** Activity-log actions that map to a connection lifecycle event on the Activity tab (PAP-11284). */
|
||||
const LIFECYCLE_ACTIVITY_LOG_ACTIONS = [
|
||||
"tool_app.connected",
|
||||
"tool_app.oauth_connected",
|
||||
"tool_example.installed",
|
||||
"tool_app.reconnected",
|
||||
"tool_connection.archived",
|
||||
"tool_connection.updated",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Map a connection-scoped activity-log row to a lifecycle event type, or null
|
||||
* when it isn't an operator-visible lifecycle change. A `tool_connection.updated`
|
||||
* row only surfaces when the route tagged it with a `lifecycle` discriminator
|
||||
* (pause/resume/allowlist); plain settings edits stay out of the feed.
|
||||
*/
|
||||
function activityLogActionToLifecycleType(
|
||||
action: string,
|
||||
details: Record<string, unknown> | null,
|
||||
): ToolConnectionLifecycleEventType | null {
|
||||
switch (action) {
|
||||
case "tool_app.connected":
|
||||
case "tool_app.oauth_connected":
|
||||
case "tool_example.installed":
|
||||
return "app_connected";
|
||||
case "tool_app.reconnected":
|
||||
return "reconnected";
|
||||
case "tool_connection.archived":
|
||||
return "disconnected";
|
||||
case "tool_connection.updated": {
|
||||
const lifecycle = typeof details?.lifecycle === "string" ? details.lifecycle : null;
|
||||
if (lifecycle === "paused") return "app_paused";
|
||||
if (lifecycle === "resumed") return "app_resumed";
|
||||
if (lifecycle === "allowlist_changed") return "allowlist_changed";
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function denialReasonForDecision(
|
||||
invocation: typeof toolInvocations.$inferSelect,
|
||||
latestAuditEvent: typeof toolCallEvents.$inferSelect | null,
|
||||
@@ -7585,137 +7542,6 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the connection lifecycle timeline for the Activity tab (PAP-11284) by
|
||||
* surfacing two existing audit sources scoped to this connection:
|
||||
* - `activity_log` rows (connect / pause / resume / allowlist / reconnect / disconnect)
|
||||
* - `tool_access_audit_events` catalog refreshes that quarantined new actions
|
||||
* Actors are resolved to display names (agent name or user name/email).
|
||||
*/
|
||||
async function listConnectionLifecycleEvents(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
limit: number,
|
||||
): Promise<ToolConnectionLifecycleEvent[]> {
|
||||
const [logRows, quarantineRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, connection.companyId),
|
||||
eq(activityLog.entityType, "tool_connection"),
|
||||
eq(activityLog.entityId, connection.id),
|
||||
inArray(activityLog.action, [...LIFECYCLE_ACTIVITY_LOG_ACTIONS]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt))
|
||||
.limit(limit),
|
||||
db
|
||||
.select()
|
||||
.from(toolAccessAuditEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(toolAccessAuditEvents.companyId, connection.companyId),
|
||||
eq(toolAccessAuditEvents.connectionId, connection.id),
|
||||
eq(toolAccessAuditEvents.action, "tool_connection.catalog_refresh"),
|
||||
sql`(${toolAccessAuditEvents.details}->>'quarantinedCount')::int > 0`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(toolAccessAuditEvents.createdAt))
|
||||
.limit(limit),
|
||||
]);
|
||||
|
||||
type Pending = {
|
||||
id: string;
|
||||
type: ToolConnectionLifecycleEventType;
|
||||
actorType: ToolConnectionLifecycleEvent["actorType"];
|
||||
actorId: string | null;
|
||||
agentId: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
const pending: Pending[] = [];
|
||||
|
||||
for (const row of logRows) {
|
||||
const type = activityLogActionToLifecycleType(row.action, row.details ?? null);
|
||||
if (!type) continue;
|
||||
pending.push({
|
||||
id: row.id,
|
||||
type,
|
||||
actorType: (row.actorType as Pending["actorType"]) ?? "system",
|
||||
actorId: row.actorId ?? null,
|
||||
agentId: row.agentId ?? null,
|
||||
details: row.details ?? null,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of quarantineRows) {
|
||||
const count = Number((row.details as Record<string, unknown> | null)?.quarantinedCount ?? 0);
|
||||
pending.push({
|
||||
id: row.id,
|
||||
type: "actions_quarantined",
|
||||
actorType: (row.actorType as Pending["actorType"]) ?? "system",
|
||||
actorId: row.actorId ?? null,
|
||||
agentId: null,
|
||||
details: { count: Number.isFinite(count) ? count : 0 },
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
pending.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
const limited = pending.slice(0, limit);
|
||||
|
||||
// Resolve actor display names in batch. Agent actors carry their id in
|
||||
// `agentId` (activity log) or `actorId` (audit events); user actors carry a
|
||||
// user id in `actorId`.
|
||||
const agentIds = new Set<string>();
|
||||
const userIds = new Set<string>();
|
||||
for (const item of limited) {
|
||||
if (item.agentId) agentIds.add(item.agentId);
|
||||
if (item.actorType === "agent" && item.actorId) agentIds.add(item.actorId);
|
||||
if (item.actorType === "user" && item.actorId && item.actorId !== "board") userIds.add(item.actorId);
|
||||
}
|
||||
const agentRows = agentIds.size
|
||||
? await db
|
||||
.select({ id: agents.id, name: agents.name })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, connection.companyId), inArray(agents.id, [...agentIds])))
|
||||
: [];
|
||||
const userRows = userIds.size
|
||||
? await db
|
||||
.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, [...userIds]))
|
||||
: [];
|
||||
const agentNames = new Map(agentRows.map((agent) => [agent.id, agent.name]));
|
||||
const userNames = new Map(
|
||||
userRows.map((user) => [user.id, user.name?.trim() || user.email?.trim() || user.id]),
|
||||
);
|
||||
|
||||
return limited.map((item) => {
|
||||
let actorDisplayName: string | null = null;
|
||||
if (item.agentId) actorDisplayName = agentNames.get(item.agentId) ?? null;
|
||||
else if (item.actorType === "agent" && item.actorId) actorDisplayName = agentNames.get(item.actorId) ?? null;
|
||||
else if (item.actorType === "user" && item.actorId) {
|
||||
actorDisplayName = item.actorId === "board"
|
||||
? "The board"
|
||||
: userNames.get(item.actorId) ?? userFallbackName(item.actorId);
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
connectionId: connection.id,
|
||||
type: item.type,
|
||||
actorType: item.actorType,
|
||||
actorId: item.actorId,
|
||||
agentId: item.agentId,
|
||||
actorDisplayName,
|
||||
details: item.details,
|
||||
createdAt: item.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
approvedStdioTemplates: async (companyId: string): Promise<ToolStdioCommandTemplate[]> => {
|
||||
const adminTemplates = await db
|
||||
@@ -8490,7 +8316,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
]),
|
||||
);
|
||||
|
||||
const lifecycleEvents = await listConnectionLifecycleEvents(connection, safeLimit);
|
||||
const lifecycleEvents = await listConnectionLifecycleEvents(db, {
|
||||
companyId: connection.companyId,
|
||||
connectionIds: [connection.id],
|
||||
limit: safeLimit,
|
||||
});
|
||||
|
||||
return {
|
||||
connectionId: connection.id,
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { and, desc, eq, gte, ilike, inArray, lt, or, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
toolAccessAuditEvents,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ToolConnectionLifecycleEvent,
|
||||
ToolConnectionLifecycleEventType,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
/** Activity-log actions rendered as connection lifecycle rows. */
|
||||
export const CONNECTION_LIFECYCLE_ACTIONS = [
|
||||
"tool_app.connected",
|
||||
"tool_app.oauth_connected",
|
||||
"tool_example.installed",
|
||||
"tool_app.reconnected",
|
||||
"tool_connection.archived",
|
||||
"tool_connection.updated",
|
||||
] as const;
|
||||
|
||||
export function connectionLifecycleType(
|
||||
action: string,
|
||||
details: Record<string, unknown> | null,
|
||||
): ToolConnectionLifecycleEventType | null {
|
||||
switch (action) {
|
||||
case "tool_app.connected":
|
||||
case "tool_app.oauth_connected":
|
||||
case "tool_example.installed":
|
||||
return "app_connected";
|
||||
case "tool_app.reconnected":
|
||||
return "reconnected";
|
||||
case "tool_connection.archived":
|
||||
return "disconnected";
|
||||
case "tool_connection.updated": {
|
||||
const lifecycle = typeof details?.lifecycle === "string" ? details.lifecycle : null;
|
||||
if (lifecycle === "paused") return "app_paused";
|
||||
if (lifecycle === "resumed") return "app_resumed";
|
||||
if (lifecycle === "allowlist_changed") return "allowlist_changed";
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type ActivityCursor = { createdAt: Date; id: string };
|
||||
|
||||
type ListConnectionLifecycleEventsInput = {
|
||||
companyId: string;
|
||||
/** Omit for every connection in the company. An empty list returns no rows. */
|
||||
connectionIds?: string[];
|
||||
agentId?: string | null;
|
||||
since?: Date | null;
|
||||
cursor?: ActivityCursor | null;
|
||||
search?: string | null;
|
||||
matchedAgentIds?: string[];
|
||||
matchedConnectionIds?: string[];
|
||||
limit: number;
|
||||
};
|
||||
|
||||
function userFallbackName(userId: string): string {
|
||||
if (userId === "local-board") return "Board";
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the lifecycle half of a connection activity timeline. Both the
|
||||
* per-connection page and the aggregate Apps Activity page call this helper so
|
||||
* they cannot drift onto different tables or event vocabularies.
|
||||
*/
|
||||
export async function listConnectionLifecycleEvents(
|
||||
db: Db,
|
||||
input: ListConnectionLifecycleEventsInput,
|
||||
): Promise<ToolConnectionLifecycleEvent[]> {
|
||||
if (input.connectionIds?.length === 0) return [];
|
||||
|
||||
const safeLimit = Math.max(1, Math.min(101, Math.floor(input.limit)));
|
||||
const activityConditions: SQL[] = [
|
||||
eq(activityLog.companyId, input.companyId),
|
||||
eq(activityLog.entityType, "tool_connection"),
|
||||
inArray(activityLog.action, [...CONNECTION_LIFECYCLE_ACTIONS]),
|
||||
];
|
||||
const quarantineConditions: SQL[] = [
|
||||
eq(toolAccessAuditEvents.companyId, input.companyId),
|
||||
eq(toolAccessAuditEvents.action, "tool_connection.catalog_refresh"),
|
||||
sql`(${toolAccessAuditEvents.details}->>'quarantinedCount')::int > 0`,
|
||||
];
|
||||
|
||||
if (input.connectionIds) {
|
||||
activityConditions.push(inArray(activityLog.entityId, input.connectionIds));
|
||||
quarantineConditions.push(inArray(toolAccessAuditEvents.connectionId, input.connectionIds));
|
||||
}
|
||||
if (input.agentId) {
|
||||
activityConditions.push(eq(activityLog.agentId, input.agentId));
|
||||
}
|
||||
if (input.since) {
|
||||
activityConditions.push(gte(activityLog.createdAt, input.since));
|
||||
quarantineConditions.push(gte(toolAccessAuditEvents.createdAt, input.since));
|
||||
}
|
||||
if (input.cursor) {
|
||||
activityConditions.push(or(
|
||||
lt(activityLog.createdAt, input.cursor.createdAt),
|
||||
and(eq(activityLog.createdAt, input.cursor.createdAt), lt(activityLog.id, input.cursor.id)),
|
||||
)!);
|
||||
quarantineConditions.push(or(
|
||||
lt(toolAccessAuditEvents.createdAt, input.cursor.createdAt),
|
||||
and(
|
||||
eq(toolAccessAuditEvents.createdAt, input.cursor.createdAt),
|
||||
lt(toolAccessAuditEvents.id, input.cursor.id),
|
||||
),
|
||||
)!);
|
||||
}
|
||||
if (input.search) {
|
||||
const like = `%${input.search.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`;
|
||||
const activitySearch: SQL[] = [
|
||||
ilike(activityLog.action, like),
|
||||
sql`${activityLog.details}::text ilike ${like}`,
|
||||
];
|
||||
const quarantineSearch: SQL[] = [
|
||||
ilike(toolAccessAuditEvents.action, like),
|
||||
sql`${toolAccessAuditEvents.details}::text ilike ${like}`,
|
||||
];
|
||||
if (input.matchedAgentIds?.length) {
|
||||
activitySearch.push(inArray(activityLog.agentId, input.matchedAgentIds));
|
||||
}
|
||||
if (input.matchedConnectionIds?.length) {
|
||||
activitySearch.push(inArray(activityLog.entityId, input.matchedConnectionIds));
|
||||
quarantineSearch.push(inArray(toolAccessAuditEvents.connectionId, input.matchedConnectionIds));
|
||||
}
|
||||
activityConditions.push(or(...activitySearch)!);
|
||||
quarantineConditions.push(or(...quarantineSearch)!);
|
||||
}
|
||||
|
||||
const [logRows, quarantineRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(and(...activityConditions))
|
||||
.orderBy(desc(activityLog.createdAt), desc(activityLog.id))
|
||||
.limit(safeLimit),
|
||||
input.agentId
|
||||
? []
|
||||
: db
|
||||
.select()
|
||||
.from(toolAccessAuditEvents)
|
||||
.where(and(...quarantineConditions))
|
||||
.orderBy(desc(toolAccessAuditEvents.createdAt), desc(toolAccessAuditEvents.id))
|
||||
.limit(safeLimit),
|
||||
]);
|
||||
|
||||
type Pending = Omit<ToolConnectionLifecycleEvent, "actorDisplayName">;
|
||||
const pending: Pending[] = [];
|
||||
for (const row of logRows) {
|
||||
const type = connectionLifecycleType(row.action, row.details ?? null);
|
||||
if (!type) continue;
|
||||
pending.push({
|
||||
id: row.id,
|
||||
connectionId: row.entityId,
|
||||
type,
|
||||
actorType: (row.actorType as Pending["actorType"]) ?? "system",
|
||||
actorId: row.actorId ?? null,
|
||||
agentId: row.agentId ?? null,
|
||||
details: row.details ?? null,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
}
|
||||
for (const row of quarantineRows) {
|
||||
if (!row.connectionId) continue;
|
||||
const count = Number((row.details as Record<string, unknown> | null)?.quarantinedCount ?? 0);
|
||||
pending.push({
|
||||
id: row.id,
|
||||
connectionId: row.connectionId,
|
||||
type: "actions_quarantined",
|
||||
actorType: (row.actorType as Pending["actorType"]) ?? "system",
|
||||
actorId: row.actorId ?? null,
|
||||
agentId: null,
|
||||
details: { count: Number.isFinite(count) ? count : 0 },
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
pending.sort((a, b) => {
|
||||
const byTime = b.createdAt.getTime() - a.createdAt.getTime();
|
||||
return byTime !== 0 ? byTime : b.id.localeCompare(a.id);
|
||||
});
|
||||
const limited = pending.slice(0, safeLimit);
|
||||
|
||||
const agentIds = new Set<string>();
|
||||
const userIds = new Set<string>();
|
||||
for (const item of limited) {
|
||||
if (item.agentId) agentIds.add(item.agentId);
|
||||
if (item.actorType === "agent" && item.actorId) agentIds.add(item.actorId);
|
||||
if (item.actorType === "user" && item.actorId && item.actorId !== "board") userIds.add(item.actorId);
|
||||
}
|
||||
const [agentRows, userRows] = await Promise.all([
|
||||
agentIds.size
|
||||
? db
|
||||
.select({ id: agents.id, name: agents.name })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, input.companyId), inArray(agents.id, [...agentIds])))
|
||||
: [],
|
||||
userIds.size
|
||||
? db
|
||||
.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, [...userIds]))
|
||||
: [],
|
||||
]);
|
||||
const agentNames = new Map(agentRows.map((agent) => [agent.id, agent.name]));
|
||||
const userNames = new Map(
|
||||
userRows.map((user) => [user.id, user.name?.trim() || user.email?.trim() || user.id]),
|
||||
);
|
||||
|
||||
return limited.map((item) => {
|
||||
let actorDisplayName: string | null = null;
|
||||
if (item.agentId) actorDisplayName = agentNames.get(item.agentId) ?? null;
|
||||
else if (item.actorType === "agent" && item.actorId) {
|
||||
actorDisplayName = agentNames.get(item.actorId) ?? null;
|
||||
} else if (item.actorType === "user" && item.actorId) {
|
||||
actorDisplayName = item.actorId === "board"
|
||||
? "The board"
|
||||
: userNames.get(item.actorId) ?? userFallbackName(item.actorId);
|
||||
}
|
||||
return { ...item, actorDisplayName };
|
||||
});
|
||||
}
|
||||
+4
-1
@@ -38,6 +38,7 @@ import type {
|
||||
AppDefinition,
|
||||
ToolAppsAttentionResponse,
|
||||
ToolConnectionActivityResponse,
|
||||
ToolConnectionLifecycleEventType,
|
||||
ToolConnectionTestAgentsResponse,
|
||||
ToolConnectionTestCallResult,
|
||||
ToolConnectionTestCallStatus,
|
||||
@@ -203,10 +204,12 @@ export interface ToolGatewayActivityEvent extends ToolGatewayAuditRow {
|
||||
applicationId: string | null;
|
||||
connectionId: string | null;
|
||||
agentDisplayName: string | null;
|
||||
actorDisplayName?: string | null;
|
||||
appDisplayName: string | null;
|
||||
applicationDisplayName: string | null;
|
||||
connectionDisplayName: string | null;
|
||||
toolDisplayName: string | null;
|
||||
lifecycleType?: ToolConnectionLifecycleEventType | null;
|
||||
normalizedOutcome: ToolAuditOutcome;
|
||||
invocation: {
|
||||
id: string;
|
||||
@@ -229,7 +232,7 @@ export type ToolGatewayActivityResponse = {
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
export type ToolAuditWindow = "1h" | "24h" | "7d" | "30d";
|
||||
export type ToolAuditWindow = "1h" | "24h" | "7d" | "30d" | "all";
|
||||
|
||||
export interface ListActivityParams {
|
||||
gateway?: string | null;
|
||||
|
||||
@@ -133,11 +133,36 @@ describe("AuditTab", () => {
|
||||
expect(container.textContent).toContain("Gmail");
|
||||
expect(container.textContent).toContain("Blocked");
|
||||
expect(container.textContent).toContain("Recorded by Paperclip — entries can't be edited.");
|
||||
expect(listActivityMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ window: "all" }));
|
||||
// Vocabulary gate: no raw tool ID or ops terms in the sentence list.
|
||||
expect(container.textContent).not.toContain("mail:send_email");
|
||||
expect(container.textContent).not.toContain("server-authoritative");
|
||||
});
|
||||
|
||||
it("renders connection lifecycle rows from the per-connection activity source", async () => {
|
||||
listActivityMock.mockResolvedValue({
|
||||
events: [event({
|
||||
id: "lifecycle-1",
|
||||
action: "tool_connection.app_connected",
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
agentId: null,
|
||||
agentDisplayName: null,
|
||||
actorDisplayName: "Dotta",
|
||||
lifecycleType: "app_connected",
|
||||
normalizedOutcome: "unknown",
|
||||
toolDisplayName: null,
|
||||
details: { lifecycleType: "app_connected" },
|
||||
})],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Dotta connected Gmail");
|
||||
expect(container.textContent).not.toContain("RecordedDotta connected Gmail");
|
||||
});
|
||||
|
||||
it("expands a row to show the plain reason, the matched rule, and the Details collapse", async () => {
|
||||
await render();
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ const OUTCOME_FILTERS: { value: string; label: string }[] = [
|
||||
];
|
||||
|
||||
const WINDOW_FILTERS: { value: ToolAuditWindow; label: string }[] = [
|
||||
{ value: "all", label: "All time" },
|
||||
{ value: "1h", label: "Last 1 hour" },
|
||||
{ value: "24h", label: "Last 24 hours" },
|
||||
{ value: "7d", label: "Last 7 days" },
|
||||
@@ -86,8 +87,38 @@ function formattedArguments(details: Record<string, unknown> | null): string | u
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycleSummary(event: ToolGatewayActivityEvent): string | null {
|
||||
if (!event.lifecycleType) return null;
|
||||
const who = event.actorDisplayName ?? event.agentDisplayName ?? "Someone";
|
||||
const app = event.appDisplayName ?? event.connectionDisplayName ?? "this app";
|
||||
const count = detailNumber(event.details, "count") ?? 0;
|
||||
const added = detailNumber(event.details, "added") ?? 0;
|
||||
const removed = detailNumber(event.details, "removed") ?? 0;
|
||||
switch (event.lifecycleType) {
|
||||
case "app_connected":
|
||||
return `${who} connected ${app}`;
|
||||
case "app_paused":
|
||||
return `${who} paused ${app}`;
|
||||
case "app_resumed":
|
||||
return `${who} resumed ${app}`;
|
||||
case "reconnected":
|
||||
return `${who} reconnected ${app}`;
|
||||
case "disconnected":
|
||||
return `${who} disconnected ${app}`;
|
||||
case "allowlist_changed":
|
||||
if (added > 0 && removed === 0) return `${who} added ${added} allowed ${added === 1 ? "item" : "items"} in ${app}`;
|
||||
if (removed > 0 && added === 0) return `${who} removed ${removed} allowed ${removed === 1 ? "item" : "items"} in ${app}`;
|
||||
return `${who} updated the allowlist for ${app}`;
|
||||
case "actions_quarantined":
|
||||
return `${count} new ${count === 1 ? "action needs" : "actions need"} review in ${app}`;
|
||||
default:
|
||||
return `${who} updated ${app}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain-words "why" for the row expander, keyed off the reason code. */
|
||||
function plainReason(event: ToolGatewayActivityEvent): string {
|
||||
if (event.lifecycleType) return "This connection change was recorded in the app's activity history.";
|
||||
const code = detailString(event.details, "reasonCode");
|
||||
if (code === "permitted_connections_not_installed") {
|
||||
return "Permitted connections were not installed, so their tools were not added to this run.";
|
||||
@@ -138,6 +169,7 @@ function ActivityRow({
|
||||
const who = event.agentDisplayName ?? "An agent";
|
||||
const action = event.toolDisplayName ?? "an action";
|
||||
const app = event.appDisplayName ?? event.connectionDisplayName ?? event.applicationDisplayName ?? null;
|
||||
const lifecycle = lifecycleSummary(event);
|
||||
const rawTool = detailString(event.details, "tool") ?? detailString(event.details, "toolName");
|
||||
|
||||
const issueId = detailString(event.details, "issueId");
|
||||
@@ -180,7 +212,9 @@ function ActivityRow({
|
||||
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
{isRuntimeMcpDeliveryDiagnostic ? (
|
||||
{lifecycle ? (
|
||||
<span className="block text-foreground">{lifecycle}</span>
|
||||
) : isRuntimeMcpDeliveryDiagnostic ? (
|
||||
<span className="block text-foreground">
|
||||
<span className="font-medium">{who}</span>'s run received 0 MCP servers —{" "}
|
||||
<span className="font-medium">{permittedNotInstalledCount ?? permittedNotInstalledConnections.length}</span>{" "}
|
||||
@@ -199,7 +233,7 @@ function ActivityRow({
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 whitespace-nowrap">
|
||||
<OutcomeChip outcome={event.normalizedOutcome} />
|
||||
{event.lifecycleType ? null : <OutcomeChip outcome={event.normalizedOutcome} />}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· <RelativeTime value={event.createdAt} />
|
||||
</span>
|
||||
@@ -293,7 +327,7 @@ export function AuditTab({ companyId }: { companyId: string }) {
|
||||
const [app, setApp] = useState<string>(ALL);
|
||||
const [agent, setAgent] = useState<string>(ALL);
|
||||
const [outcome, setOutcome] = useState<string>(ALL);
|
||||
const [windowKey, setWindowKey] = useState<ToolAuditWindow>("24h");
|
||||
const [windowKey, setWindowKey] = useState<ToolAuditWindow>("all");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
@@ -329,7 +363,7 @@ export function AuditTab({ companyId }: { companyId: string }) {
|
||||
search: search || undefined,
|
||||
};
|
||||
const hasActiveFilters =
|
||||
app !== ALL || agent !== ALL || outcome !== ALL || windowKey !== "24h" || search.length > 0;
|
||||
app !== ALL || agent !== ALL || outcome !== ALL || windowKey !== "all" || search.length > 0;
|
||||
|
||||
const activity = useInfiniteQuery({
|
||||
queryKey: queryKeys.tools.activity(companyId, {
|
||||
@@ -354,7 +388,7 @@ export function AuditTab({ companyId }: { companyId: string }) {
|
||||
setApp(ALL);
|
||||
setAgent(ALL);
|
||||
setOutcome(ALL);
|
||||
setWindowKey("24h");
|
||||
setWindowKey("all");
|
||||
setSearchInput("");
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("ToolsAccess", () => {
|
||||
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: target, replace: true }));
|
||||
});
|
||||
|
||||
it("uses Profiles as the developer surface entry point", async () => {
|
||||
it("uses Profiles as the developer entry point without a second page shell", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.querySelector('a[href="/apps/advanced/profiles"]')?.textContent).toContain(
|
||||
@@ -112,7 +112,8 @@ describe("ToolsAccess", () => {
|
||||
mockParams.tab = "profiles";
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Developer tools");
|
||||
expect(container.textContent).not.toContain("Developer tools");
|
||||
expect(container.textContent).toContain("Tool profiles");
|
||||
expect(container.firstElementChild?.classList.contains("max-w-5xl")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { Settings2, Wrench } from "lucide-react";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { Link, Navigate, useParams } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
@@ -132,20 +132,5 @@ export function ToolsAccess() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-4 sm:p-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-xl font-bold text-foreground">Developer tools</h1>
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-2xl text-sm text-muted-foreground">
|
||||
Apps is the simple way to connect tools. This Developer area is for wiring your own
|
||||
servers, tokens, and rules by hand — most teams never need it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-(--sz-300px)">{renderTab(activeTab, selectedCompanyId)}</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="max-w-5xl">{renderTab(activeTab, selectedCompanyId)}</div>;
|
||||
}
|
||||
Reference in new issue
Block a user