Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/web/src/app/api/drives/[driveId]/integrations/audit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { db, count, eq, and, integrationAuditLog } from '@pagespace/db';
import { loggers } from '@pagespace/lib/server';
import { getDriveAccess } from '@pagespace/lib/services/drive-service';
import { isValidId } from '@pagespace/lib';
import {
getAuditLogsByDrive,
getAuditLogsByConnection,
getAuditLogsBySuccess,
} from '@pagespace/lib/integrations';

const AUTH_OPTIONS = { allow: ['session'] as const };

/**
* GET /api/drives/[driveId]/integrations/audit
* List integration audit logs for a drive.
* Query params: limit, offset, connectionId, success
*/
export async function GET(
request: Request,
context: { params: Promise<{ driveId: string }> }
) {
const { driveId } = await context.params;
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS);
if (isAuthError(auth)) return auth.error;

try {
// Require OWNER or ADMIN
const access = await getDriveAccess(driveId, auth.userId);
if (!access.isOwner && !access.isAdmin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
}

const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') ?? '50', 10) || 50, 200);
const offset = parseInt(searchParams.get('offset') ?? '0', 10) || 0;
const connectionId = searchParams.get('connectionId');
const successParam = searchParams.get('success');

if (connectionId && !isValidId(connectionId)) {
return NextResponse.json({ error: 'Invalid connectionId format' }, { status: 400 });
}

// Build where clause by accumulating conditions (always scoped to driveId)
const conditions = [eq(integrationAuditLog.driveId, driveId)];
if (connectionId) {
conditions.push(eq(integrationAuditLog.connectionId, connectionId));
}
if (successParam !== null) {
conditions.push(eq(integrationAuditLog.success, successParam === 'true'));
}
const whereClause = conditions.length === 1 ? conditions[0] : and(...conditions);

// Get total count and paginated logs in parallel
const [countResult, logs] = await Promise.all([
db.select({ count: count() }).from(integrationAuditLog).where(whereClause),
connectionId
? getAuditLogsByConnection(db, driveId, connectionId, { limit, offset })
: successParam !== null
Comment on lines 58 to 60

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Combine success filter with connection filter

IntegrationAuditLog sends both connectionId and success when both UI filters are selected, but this conditional always takes the connectionId path and ignores success, so users still see mixed success/failure rows and totals after choosing a status filter. The API should apply both predicates together when both query params are present to match the filtering controls.

Useful? React with 👍 / 👎.

? getAuditLogsBySuccess(db, driveId, successParam === 'true', { limit, offset })
: getAuditLogsByDrive(db, driveId, { limit, offset }),
]);

const total = Number(countResult[0]?.count ?? 0);

return NextResponse.json({
logs: logs.map((log) => ({
id: log.id,
driveId: log.driveId,
agentId: log.agentId,
userId: log.userId,
connectionId: log.connectionId,
toolName: log.toolName,
inputSummary: log.inputSummary,
success: log.success,
responseCode: log.responseCode,
errorType: log.errorType,
errorMessage: log.errorMessage,
durationMs: log.durationMs,
createdAt: log.createdAt,
})),
total,
});
} catch (error) {
loggers.api.error('Error fetching integration audit logs:', error as Error);
return NextResponse.json({ error: 'Failed to fetch audit logs' }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,29 @@ import {
const AUTH_OPTIONS_READ = { allow: ['session'] as const };
const AUTH_OPTIONS_WRITE = { allow: ['session'] as const, requireCSRF: true };

const toolSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
category: z.enum(['read', 'write', 'admin', 'dangerous']),
inputSchema: z.record(z.string(), z.unknown()).optional(),
execution: z.object({
type: z.literal('http'),
config: z.object({
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
pathTemplate: z.string().min(1),
}),
}),
});

const updateProviderSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
iconUrl: z.string().url().nullable().optional(),
documentationUrl: z.string().url().nullable().optional(),
config: z.record(z.string(), z.unknown()).optional(),
enabled: z.boolean().optional(),
addTools: z.array(toolSchema).optional(),
});

/**
Expand Down Expand Up @@ -87,7 +103,22 @@ export async function PUT(
);
}

const updated = await updateProvider(db, providerId, validation.data);
const { addTools, ...updateData } = validation.data;

// If addTools is provided, merge new tools into existing config.tools
if (addTools && addTools.length > 0) {
const baseConfig = (provider.config as Record<string, unknown>) ?? {};
const mergedConfig = { ...baseConfig, ...(updateData.config ?? {}) };
const existingTools = Array.isArray(mergedConfig.tools) ? mergedConfig.tools as { id: string }[] : [];
const newToolIds = new Set(addTools.map((t) => t.id));
const filteredExisting = existingTools.filter((t) => !newToolIds.has(t.id));
updateData.config = {
...mergedConfig,
tools: [...filteredExisting, ...addTools],
};
}

const updated = await updateProvider(db, providerId, updateData);
return NextResponse.json({ provider: updated });
} catch (error) {
loggers.api.error('Error updating provider:', error as Error);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { authenticateRequestWithOptions, isAuthError, verifyAdminAuth } from '@/lib/auth';
import { loggers } from '@pagespace/lib/server';
import { importOpenAPISpec } from '@pagespace/lib/integrations';

const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: true };

const importSchema = z.object({
spec: z.string().min(1, 'Spec content is required'),
selectedOperations: z.array(z.string()).optional(),
baseUrlOverride: z.string().url().optional(),
});

/**
* POST /api/integrations/providers/import-openapi
* Parse an OpenAPI spec and return the generated provider config.
* Admin only.
*/
export async function POST(request: Request) {
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS);
if (isAuthError(auth)) return auth.error;

const adminAuth = await verifyAdminAuth(request);
if (adminAuth instanceof NextResponse) {
return adminAuth;
}

try {
const body = await request.json();
const validation = importSchema.safeParse(body);

if (!validation.success) {
return NextResponse.json(
{ error: 'Validation failed', details: validation.error.flatten().fieldErrors },
{ status: 400 }
);
}

const { spec, selectedOperations, baseUrlOverride } = validation.data;

const result = await importOpenAPISpec(spec, {
selectedOperations,
baseUrlOverride,
});

return NextResponse.json({ result });
} catch (error) {
loggers.api.error('Error importing OpenAPI spec:', error as Error);
const message = error instanceof Error ? error.message : 'Failed to import OpenAPI spec';
return NextResponse.json({ error: message }, { status: 500 });
}
}
4 changes: 4 additions & 0 deletions apps/web/src/app/dashboard/[driveId]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { useDriveStore } from '@/hooks/useDrive';
import { RolesManager } from '@/components/settings/RolesManager';
import { DriveAISettings } from '@/components/settings/DriveAISettings';
import { DriveDeleteSection } from '@/components/settings/DriveDeleteSection';
import { DriveIntegrations } from '@/components/settings/DriveIntegrations';
import { IntegrationAuditLog } from '@/components/settings/IntegrationAuditLog';

export default function DriveSettingsPage() {
const params = useParams();
Expand Down Expand Up @@ -89,6 +91,8 @@ export default function DriveSettingsPage() {
<div className="space-y-6">
<RolesManager driveId={driveId} />
<DriveAISettings driveId={driveId} />
<DriveIntegrations driveId={driveId} />
<IntegrationAuditLog driveId={driveId} />

{/* Danger Zone - Only show to owners */}
{drive.isOwned && (
Expand Down
Loading