Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}
}
Original file line number Diff line number Diff line change
@@ -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,
},
});
});
});
Original file line number Diff line number Diff line change
@@ -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 };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -42,6 +43,7 @@ import { GenericDeviceSyncService } from './services/generic-device-sync.service
controllers: [
OAuthController,
OAuthAppsController,
OAuthErrorsController,
ConnectionsController,
AdminIntegrationsController,
DynamicIntegrationsController,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
},
}));

Expand All @@ -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<ConnectionCheckRunnerService> = {}) =>
Expand Down Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export function ProviderDetailView({
const [gcpSelectedProjectIds, setGcpSelectedProjectIds] = useState<string[]>([]);
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(() => {
Expand Down Expand Up @@ -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 (
<>
<Stack gap="lg">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -129,6 +130,7 @@ export function PlatformIntegrations({ className, taskTemplates }: PlatformInteg
const [selectedCategory, setSelectedCategory] = useState<IntegrationCategory | 'All'>('All');
const [connectingProvider, setConnectingProvider] = useState<string | null>(null);
const hasHandledOAuthRef = useRef(false);
const hasHandledOAuthErrorRef = useRef(false);

// Custom integration dialog state
const [selectedCustomIntegration, setSelectedCustomIntegration] = useState<Integration | null>(
Expand Down Expand Up @@ -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])),
Expand Down
Loading
Loading