diff --git a/apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts b/apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts index 91d1e3e1fc..7b59512f98 100644 --- a/apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts +++ b/apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts @@ -125,4 +125,22 @@ export class InternalIntegrationDebugController { checkId: body.checkId, }); } + + /** + * Read recently captured OAuth callback errors (recorded by the frontend on a + * failed connect). Use this to diagnose "the integration won't connect" for + * any org/provider — the exact provider error is here instead of lost. + */ + @Get('oauth-errors') + async listOAuthErrors( + @Query('organizationId') organizationId?: string, + @Query('providerSlug') providerSlug?: string, + @Query('limit') limit?: string, + ) { + return this.debugService.listOAuthErrors({ + organizationId, + providerSlug, + limit: parseOptionalInt(limit), + }); + } } diff --git a/apps/api/src/integration-platform/controllers/oauth-errors.controller.spec.ts b/apps/api/src/integration-platform/controllers/oauth-errors.controller.spec.ts new file mode 100644 index 0000000000..79c9ab61ea --- /dev/null +++ b/apps/api/src/integration-platform/controllers/oauth-errors.controller.spec.ts @@ -0,0 +1,65 @@ +jest.mock('@db', () => ({ + db: { integrationOAuthError: { create: jest.fn() } }, +})); +// The real auth guards import better-auth, which fails to load under jest's ESM +// shim. Mock them so the controller module can be imported; they're not under +// test here (we test the record() handler logic). +jest.mock('../../auth/hybrid-auth.guard', () => ({ HybridAuthGuard: class {} })); +jest.mock('../../auth/permission.guard', () => ({ PermissionGuard: class {} })); +jest.mock('../../auth/require-permission.decorator', () => ({ + RequirePermission: () => () => undefined, +})); +jest.mock('../../auth/auth-context.decorator', () => ({ + OrganizationId: () => () => undefined, + UserId: () => () => undefined, +})); + +import { db } from '@db'; +import { OAuthErrorsController } from './oauth-errors.controller'; + +const mockedDb = db as unknown as { + integrationOAuthError: { create: jest.Mock }; +}; + +describe('OAuthErrorsController', () => { + afterEach(() => jest.clearAllMocks()); + + it('records an OAuth error scoped to the org + user', async () => { + mockedDb.integrationOAuthError.create.mockResolvedValue({}); + const controller = new OAuthErrorsController(); + + const res = await controller.record('org_1', 'user_1', { + providerSlug: 'quickbooks-online', + error: 'token_exchange_failed', + errorDescription: 'Sandbox app not allowed', + }); + + expect(res).toEqual({ recorded: true }); + expect(mockedDb.integrationOAuthError.create).toHaveBeenCalledWith({ + data: { + organizationId: 'org_1', + userId: 'user_1', + providerSlug: 'quickbooks-online', + errorCode: 'token_exchange_failed', + errorDescription: 'Sandbox app not allowed', + }, + }); + }); + + it('handles a missing user and missing optional fields (nulls, no secrets)', async () => { + mockedDb.integrationOAuthError.create.mockResolvedValue({}); + const controller = new OAuthErrorsController(); + + await controller.record('org_2', undefined, { providerSlug: 'zoho-crm' }); + + expect(mockedDb.integrationOAuthError.create).toHaveBeenCalledWith({ + data: { + organizationId: 'org_2', + userId: null, + providerSlug: 'zoho-crm', + errorCode: null, + errorDescription: null, + }, + }); + }); +}); diff --git a/apps/api/src/integration-platform/controllers/oauth-errors.controller.ts b/apps/api/src/integration-platform/controllers/oauth-errors.controller.ts new file mode 100644 index 0000000000..1d641087db --- /dev/null +++ b/apps/api/src/integration-platform/controllers/oauth-errors.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; +import { db } from '@db'; +import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../../auth/permission.guard'; +import { RequirePermission } from '../../auth/require-permission.decorator'; +import { OrganizationId, UserId } from '../../auth/auth-context.decorator'; + +class RecordOAuthErrorDto { + @IsString() + @MaxLength(100) + providerSlug!: string; + + /** OAuth error code from the provider redirect (e.g. "access_denied"). */ + @IsOptional() + @IsString() + @MaxLength(200) + error?: string; + + /** Human-readable error description from the provider redirect. */ + @IsOptional() + @IsString() + @MaxLength(2000) + errorDescription?: string; +} + +/** + * Records an OAuth callback error that the frontend captured from the redirect + * URL (`?error=...&error_description=...`). This is the *capture* side of OAuth + * error visibility — it does NOT touch the shared OAuth callback flow. The + * stored rows are read back via the internal debug API + * (`GET /internal/integration-debug/oauth-errors`). + * + * Never receives or stores secrets: the frontend only forwards the error code + + * description, never the auth `code` or any token. + */ +@ApiExcludeController() +@Controller({ path: 'integrations/oauth-errors', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +export class OAuthErrorsController { + @Post() + @RequirePermission('integration', 'create') + async record( + @OrganizationId() organizationId: string, + @UserId() userId: string | undefined, + @Body() body: RecordOAuthErrorDto, + ): Promise<{ recorded: true }> { + await db.integrationOAuthError.create({ + data: { + organizationId, + userId: userId ?? null, + providerSlug: body.providerSlug, + errorCode: body.error ?? null, + errorDescription: body.errorDescription ?? null, + }, + }); + return { recorded: true }; + } +} diff --git a/apps/api/src/integration-platform/integration-platform.module.ts b/apps/api/src/integration-platform/integration-platform.module.ts index 23fc72dd4d..aa9c2a6954 100644 --- a/apps/api/src/integration-platform/integration-platform.module.ts +++ b/apps/api/src/integration-platform/integration-platform.module.ts @@ -3,6 +3,7 @@ import { AuthModule } from '../auth/auth.module'; import { CloudSecurityModule } from '../cloud-security/cloud-security.module'; import { OAuthController } from './controllers/oauth.controller'; import { OAuthAppsController } from './controllers/oauth-apps.controller'; +import { OAuthErrorsController } from './controllers/oauth-errors.controller'; import { ConnectionsController } from './controllers/connections.controller'; import { AdminIntegrationsController } from './controllers/admin-integrations.controller'; import { DynamicIntegrationsController } from './controllers/dynamic-integrations.controller'; @@ -42,6 +43,7 @@ import { GenericDeviceSyncService } from './services/generic-device-sync.service controllers: [ OAuthController, OAuthAppsController, + OAuthErrorsController, ConnectionsController, AdminIntegrationsController, DynamicIntegrationsController, diff --git a/apps/api/src/integration-platform/services/internal-integration-debug.service.spec.ts b/apps/api/src/integration-platform/services/internal-integration-debug.service.spec.ts index 8e6d6f0177..7fad14f7ab 100644 --- a/apps/api/src/integration-platform/services/internal-integration-debug.service.spec.ts +++ b/apps/api/src/integration-platform/services/internal-integration-debug.service.spec.ts @@ -3,6 +3,7 @@ jest.mock('@db', () => ({ integrationConnection: { findMany: jest.fn(), findUnique: jest.fn() }, integrationCredentialVersion: { findUnique: jest.fn(), findMany: jest.fn() }, integrationCheckRun: { findFirst: jest.fn(), findMany: jest.fn() }, + integrationOAuthError: { findMany: jest.fn() }, }, })); @@ -22,6 +23,7 @@ const mockedDb = db as unknown as { integrationConnection: { findMany: jest.Mock; findUnique: jest.Mock }; integrationCredentialVersion: { findUnique: jest.Mock; findMany: jest.Mock }; integrationCheckRun: { findFirst: jest.Mock; findMany: jest.Mock }; + integrationOAuthError: { findMany: jest.Mock }; }; const makeService = (runner: Partial = {}) => @@ -267,4 +269,35 @@ describe('InternalIntegrationDebugService', () => { expect(runCandidateCheck).not.toHaveBeenCalled(); }); }); + + describe('listOAuthErrors', () => { + it('filters by org + provider and clamps a non-numeric limit', async () => { + mockedDb.integrationOAuthError.findMany.mockResolvedValue([ + { + id: 'ioe_1', + organizationId: 'org_1', + providerSlug: 'quickbooks-online', + errorCode: 'token_exchange_failed', + errorDescription: 'sandbox', + createdAt: new Date(), + }, + ]); + const service = makeService(); + + const { errors, total } = await service.listOAuthErrors({ + organizationId: 'org_1', + providerSlug: 'quickbooks-online', + limit: Number('nope'), + }); + + expect(total).toBe(1); + expect(errors[0].errorCode).toBe('token_exchange_failed'); + const args = mockedDb.integrationOAuthError.findMany.mock.calls[0][0]; + expect(args.where).toEqual({ + organizationId: 'org_1', + providerSlug: 'quickbooks-online', + }); + expect(Number.isFinite(args.take)).toBe(true); + }); + }); }); diff --git a/apps/api/src/integration-platform/services/internal-integration-debug.service.ts b/apps/api/src/integration-platform/services/internal-integration-debug.service.ts index f1b74fd8c5..2055f73865 100644 --- a/apps/api/src/integration-platform/services/internal-integration-debug.service.ts +++ b/apps/api/src/integration-platform/services/internal-integration-debug.service.ts @@ -313,4 +313,30 @@ export class InternalIntegrationDebugService { checkId, }); } + + /** + * Read recently captured OAuth callback errors (recorded by the frontend when + * a connect redirects back with an error). Makes a failed connect diagnosable + * after the fact instead of being invisible. + */ + async listOAuthErrors(params: { + organizationId?: string; + providerSlug?: string; + limit?: number; + }) { + const { organizationId, providerSlug } = params; + const rawLimit = params.limit ?? NaN; + const limit = Number.isFinite(rawLimit) + ? Math.min(Math.max(rawLimit, 1), 200) + : 50; + const errors = await db.integrationOAuthError.findMany({ + where: { + ...(organizationId ? { organizationId } : {}), + ...(providerSlug ? { providerSlug } : {}), + }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + return { errors, total: errors.length }; + } } diff --git a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ProviderDetailView.tsx b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ProviderDetailView.tsx index 89504c5b59..d793abbab6 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ProviderDetailView.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/ProviderDetailView.tsx @@ -112,6 +112,7 @@ export function ProviderDetailView({ const [gcpSelectedProjectIds, setGcpSelectedProjectIds] = useState([]); const oauthBootstrapHandledRef = useRef(false); const settingsQueryHandledRef = useRef(false); + const oauthErrorHandledRef = useRef(false); // OAuth return (?success=true): strip query, detect org/projects (NOT services yet — user must select projects first) useEffect(() => { @@ -226,6 +227,33 @@ export function ProviderDetailView({ router.replace(`/${orgId}/integrations/${provider.id}`, { scroll: false }); }, [orgId, provider.id, router, searchParams, selectedConnection?.id]); + // Surface + record an OAuth failure that lands on the provider page. The + // backend redirects with `?error=...&error_description=...`; show the user the + // real reason and record it (best-effort) so failed connects are diagnosable. + useEffect(() => { + if (oauthErrorHandledRef.current) return; + const error = searchParams.get('error'); + if (!error) return; + + oauthErrorHandledRef.current = true; + const errorDescription = searchParams.get('error_description'); + toast.error(`Connection failed: ${errorDescription || error}`); + + void api + .post( + '/v1/integrations/oauth-errors', + { + providerSlug: provider.id, + error, + errorDescription: errorDescription ?? undefined, + }, + orgId, + ) + .catch(() => {}); + + router.replace(`/${orgId}/integrations/${provider.id}`, { scroll: false }); + }, [orgId, provider.id, router, searchParams]); + return ( <> diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx index f7457e2abb..d53c655249 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx @@ -7,6 +7,7 @@ import { useIntegrationMutations, useIntegrationProviders, } from '@/hooks/use-integration-platform'; +import { api } from '@/lib/api-client'; import { usePermissions } from '@/hooks/use-permissions'; import { useVendors } from '@/hooks/use-vendors'; import { Badge } from '@trycompai/ui/badge'; @@ -129,6 +130,7 @@ export function PlatformIntegrations({ className, taskTemplates }: PlatformInteg const [selectedCategory, setSelectedCategory] = useState('All'); const [connectingProvider, setConnectingProvider] = useState(null); const hasHandledOAuthRef = useRef(false); + const hasHandledOAuthErrorRef = useRef(false); // Custom integration dialog state const [selectedCustomIntegration, setSelectedCustomIntegration] = useState( @@ -329,6 +331,38 @@ export function PlatformIntegrations({ className, taskTemplates }: PlatformInteg window.history.replaceState({}, '', url.toString()); }, [searchParams, providers, loadingProviders, router, orgId]); + // Surface + record OAuth failures that land back here. The backend redirects + // with `?error=...&error_description=...`; previously that was shown to no one + // and stored nowhere. Now we tell the user the real reason and record it so a + // failed connect is diagnosable later (read via the internal debug API). + useEffect(() => { + if (hasHandledOAuthErrorRef.current) return; + + const error = searchParams.get('error'); + if (!error) return; + + hasHandledOAuthErrorRef.current = true; + const errorDescription = searchParams.get('error_description'); + const providerSlug = searchParams.get('provider') || 'unknown'; + + toast.error(`Connection failed: ${errorDescription || error}`); + + // Best-effort — never let recording (or its failure) affect the UI. + void api + .post( + '/v1/integrations/oauth-errors', + { providerSlug, error, errorDescription: errorDescription ?? undefined }, + orgId, + ) + .catch(() => {}); + + const url = new URL(window.location.href); + url.searchParams.delete('error'); + url.searchParams.delete('error_description'); + url.searchParams.delete('provider'); + window.history.replaceState({}, '', url.toString()); + }, [searchParams, orgId]); + // Create a map from templateId to taskId for quick lookup const templateToTaskMap = useMemo( () => new Map(taskTemplates.map((t) => [t.id, t.taskId])), diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx index aae08cffa5..f63f98c664 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx @@ -1,6 +1,10 @@ 'use client'; import { apiClient } from '@/app/lib/api-client'; +import { + loadColumnWidths, + saveColumnWidths, +} from '@/app/components/table/column-widths-cookie'; import { createColumnHelper, flexRender, @@ -12,7 +16,7 @@ import { import { Button } from '@trycompai/ui'; import { ArrowDown, ArrowUp, ArrowUpDown, Download, PencilIcon, Plus, Trash2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { ComboboxCell, DateCell, EditableCell, RelationalCell } from '../../../components/table'; import { EditFrameworkDialog } from './components/EditFrameworkDialog'; @@ -67,6 +71,9 @@ async function unlinkControlFromRequirement(requirementId: string, controlId: st const columnHelper = createColumnHelper(); +// FRAME-17: cookie key for this table's persisted column widths. +const REQUIREMENTS_COLS_COOKIE = 'fwk-requirements-col-widths'; + export function FrameworkRequirementsClientPage({ frameworkDetails, initialRequirements, @@ -191,7 +198,9 @@ export function FrameworkRequirementsClientPage({ columnHelper.accessor('description', { header: 'Description', size: 300, - maxSize: 300, + // FRAME-17: allow widening well past the default so long requirement + // text is readable inline once the column is resized. + maxSize: 1200, cell: ({ row, getValue }) => { const { identifier, name } = row.original; const titleSuffix = [identifier, name].filter(Boolean).join(' - '); @@ -251,6 +260,7 @@ export function FrameworkRequirementsClientPage({ id: 'actions', header: '', size: 50, + enableResizing: false, cell: ({ row }) => (