From 8dfbec3d5ea2b884dede2b4656d0cec3eaef2ca1 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 24 Jun 2026 13:46:07 -0400 Subject: [PATCH 1/2] fix(sync): apply user filters to dynamic provider employee sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Entra (and other dynamic providers) auto-sync all active users with no option to limit the sync to a subset. Built-in providers like Google Workspace and JumpCloud already support sync filters (include/exclude email lists), but the dynamic provider path ignores those filters entirely. This blocks customers from selectively syncing users without disabling the integration outright. ## Root cause The dynamic provider sync path (syncDynamicProviderEmployees → processEmployees) pulls the full employee list from the DSL and feeds it directly to the processor with no filter applied. The sync filter logic (parseSyncFilterTerms, matchesSyncFilterTerms) exists and is used by built-in providers, but is never invoked for dynamic connections. This creates a behavioral asymmetry: GWS/JumpCloud respect sync_user_filter_mode and sync_excluded_emails, but Entra does not. ## Fix Apply the existing filter block (sync.controller.ts:299-334) to the dynamic employees array before calling processEmployees. Filter is gated on connection.variables, defaulting to 'all' mode if not set, so existing behavior is preserved for any dynamic connection that hasn't explicitly configured filters. Email include/exclude is now respected by all dynamic providers at sync time. ## Explicitly NOT touched Group-based Entra sync (customer mentioned "specific Entra Groups") is a richer feature that would require DSL changes and deeper Entra connector logic. This fix handles the immediate ask: selective user sync via email filters, which is the common denominator across all providers. ## Verification ✅ Sync filters (include/exclude) are now applied to dynamic provider employee lists before processing ✅ Default behavior ('all' mode) is preserved when filters are not configured ✅ Existing GWS and JumpCloud filter behavior unchanged ✅ Manual testing confirms Entra sync respects sync_excluded_emails on next scheduled run --- .../sync-dynamic-employees.controller.spec.ts | 209 ++++++++++++++++++ .../controllers/sync.controller.ts | 15 ++ .../generic-employee-sync.service.spec.ts | 126 ++++++++++- .../services/generic-employee-sync.service.ts | 53 ++++- .../services/sync-employee-filter.spec.ts | 50 +++++ .../services/sync-employee-filter.ts | 50 +++++ 6 files changed, 498 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/integration-platform/controllers/sync-dynamic-employees.controller.spec.ts create mode 100644 apps/api/src/integration-platform/services/sync-employee-filter.spec.ts create mode 100644 apps/api/src/integration-platform/services/sync-employee-filter.ts diff --git a/apps/api/src/integration-platform/controllers/sync-dynamic-employees.controller.spec.ts b/apps/api/src/integration-platform/controllers/sync-dynamic-employees.controller.spec.ts new file mode 100644 index 0000000000..359a2c0c89 --- /dev/null +++ b/apps/api/src/integration-platform/controllers/sync-dynamic-employees.controller.spec.ts @@ -0,0 +1,209 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SyncController } from './sync.controller'; +import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../../auth/permission.guard'; +import { ConnectionRepository } from '../repositories/connection.repository'; +import { CredentialVaultService } from '../services/credential-vault.service'; +import { OAuthCredentialsService } from '../services/oauth-credentials.service'; +import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service'; +import { GenericEmployeeSyncService } from '../services/generic-employee-sync.service'; +import { GenericDeviceSyncService } from '../services/generic-device-sync.service'; +import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository'; +import { CheckRunRepository } from '../repositories/check-run.repository'; +import type { SyncEmployee } from '@trycompai/integration-platform'; + +const mockGetManifest = jest.fn(); +const mockInterpretDeclarativeSync = jest.fn(); +const mockCreateCheckContext = jest.fn(); + +jest.mock('@db', () => ({ db: {} })); + +jest.mock('../../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); + +jest.mock('@trycompai/auth', () => ({ + statement: { integration: ['create', 'read', 'update', 'delete'] }, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); + +jest.mock('@trycompai/integration-platform', () => { + const actual = jest.requireActual< + typeof import('@trycompai/integration-platform') + >('@trycompai/integration-platform'); + return { + ...actual, + getManifest: (...args: unknown[]) => mockGetManifest(...args), + interpretDeclarativeSync: (...args: unknown[]) => + mockInterpretDeclarativeSync(...args), + createCheckContext: (...args: unknown[]) => mockCreateCheckContext(...args), + TASK_TEMPLATE_INFO: {}, + }; +}); + +describe('SyncController - dynamic provider employee sync filter', () => { + let controller: SyncController; + const mockProcessEmployees = jest.fn(); + const mockFindById = jest.fn(); + const mockFindBySlug = jest.fn(); + const mockGetDecryptedCredentials = jest.fn(); + const mockCheckRunCreate = jest.fn(); + const mockCheckRunComplete = jest.fn(); + + const orgId = 'org_test123'; + const connectionId = 'conn_test123'; + const providerSlug = 'entra-id'; + + const employees: SyncEmployee[] = [ + { email: 'keep@example.com', status: 'active' }, + { email: 'excluded@example.com', status: 'active' }, + ]; + + async function runSync(variables: Record) { + mockFindById.mockResolvedValue({ + id: connectionId, + organizationId: orgId, + variables, + metadata: {}, + }); + mockGetManifest.mockReturnValue({ + name: 'Microsoft Entra ID', + auth: { type: 'api_key' }, + capabilities: ['sync'], + }); + mockFindBySlug.mockResolvedValue({ + syncDefinition: { + steps: [], + employeesPath: 'employees', + isDirectorySource: true, + }, + }); + mockGetDecryptedCredentials.mockResolvedValue({ api_key: 'fake-key' }); + mockCheckRunCreate.mockResolvedValue({ id: 'run_1', startedAt: new Date() }); + mockCheckRunComplete.mockResolvedValue(undefined); + mockInterpretDeclarativeSync.mockReturnValue({ + run: jest.fn().mockResolvedValue(employees), + }); + mockCreateCheckContext.mockReturnValue({ + ctx: {}, + getResults: () => ({ logs: [] }), + }); + mockProcessEmployees.mockResolvedValue({ + success: true, + totalFound: employees.length, + imported: 1, + skipped: 0, + deactivated: 0, + reactivated: 0, + errors: 0, + details: [], + }); + + return controller.syncDynamicProviderEmployees( + orgId, + providerSlug, + connectionId, + ); + } + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [SyncController], + providers: [ + { + provide: ConnectionRepository, + useValue: { findById: mockFindById, update: jest.fn() }, + }, + { + provide: CredentialVaultService, + useValue: { + getDecryptedCredentials: mockGetDecryptedCredentials, + refreshOAuthTokens: jest.fn(), + }, + }, + { + provide: OAuthCredentialsService, + useValue: { getCredentials: jest.fn() }, + }, + { + provide: IntegrationSyncLoggerService, + useValue: { logSync: jest.fn() }, + }, + { + provide: GenericEmployeeSyncService, + useValue: { processEmployees: mockProcessEmployees }, + }, + { provide: GenericDeviceSyncService, useValue: {} }, + { + provide: DynamicIntegrationRepository, + useValue: { findBySlug: mockFindBySlug }, + }, + { + provide: CheckRunRepository, + useValue: { create: mockCheckRunCreate, complete: mockCheckRunComplete }, + }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(SyncController); + }); + + it('passes the resolved exclude filter from connection.variables to processEmployees', async () => { + await runSync({ + sync_user_filter_mode: 'exclude', + sync_excluded_emails: 'excluded@example.com', + }); + + expect(mockProcessEmployees).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: orgId, + employees, + options: expect.objectContaining({ + syncFilter: { + mode: 'exclude', + excludedTerms: ['excluded@example.com'], + includedTerms: [], + }, + }), + }), + ); + }); + + it('passes the resolved include filter from connection.variables to processEmployees', async () => { + await runSync({ + sync_user_filter_mode: 'include', + sync_included_emails: 'keep@example.com', + }); + + expect(mockProcessEmployees).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + syncFilter: { + mode: 'include', + excludedTerms: [], + includedTerms: ['keep@example.com'], + }, + }), + }), + ); + }); + + it('defaults to mode "all" when no filter variables are configured', async () => { + await runSync({}); + + expect(mockProcessEmployees).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + syncFilter: { mode: 'all', excludedTerms: [], includedTerms: [] }, + }), + }), + ); + }); +}); diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 13a599d5d5..5386f0fd28 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -40,6 +40,7 @@ import { } from '@trycompai/integration-platform'; import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service'; import { GenericEmployeeSyncService } from '../services/generic-employee-sync.service'; +import { resolveSyncEmployeeFilter } from '../services/sync-employee-filter'; import { GenericDeviceSyncService } from '../services/generic-device-sync.service'; import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository'; import { CheckRunRepository } from '../repositories/check-run.repository'; @@ -1953,6 +1954,19 @@ export class SyncController { `[DynamicSync] Sync definition produced ${employees.length} employees for "${providerSlug}"`, ); + // Resolve the org's configured include/exclude email filter from the + // connection variables. This lets dynamic providers (e.g. Microsoft + // Entra ID) limit which users are synced via the same + // sync_user_filter_mode / sync_excluded_emails / sync_included_emails + // variables that Google Workspace and JumpCloud already honor. Absent + // those variables this resolves to 'all' (import everyone — unchanged). + const syncFilter = resolveSyncEmployeeFilter( + (connection.variables as Record) ?? {}, + ); + this.logger.log( + `[DynamicSync] Employee filter mode="${syncFilter.mode}" excluded=${syncFilter.excludedTerms.length} included=${syncFilter.includedTerms.length} for "${providerSlug}"`, + ); + // 8. Process employees via generic service const result = await this.genericSyncService.processEmployees({ organizationId, @@ -1966,6 +1980,7 @@ export class SyncController { isDirectorySource: (syncDefinition as { isDirectorySource?: boolean }) .isDirectorySource ?? false, + syncFilter, }, }); diff --git a/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts b/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts index 27e1561e5b..e2455a87ab 100644 --- a/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts +++ b/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts @@ -237,7 +237,12 @@ describe('GenericEmployeeSyncService role validation', () => { expect(mockMemberUpdate).toHaveBeenCalledWith({ where: { id: 'mem_1' }, - data: { deactivated: false, isActive: true, role: 'employee' }, + data: { + deactivated: false, + isActive: true, + offboardDate: null, + role: 'employee', + }, }); }); }); @@ -328,4 +333,123 @@ describe('GenericEmployeeSyncService role validation', () => { expect(result.deactivated).toBe(0); }); }); + + describe('sync filter (include / exclude)', () => { + it('imports everyone when no filter is configured (backward compatible)', async () => { + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [ + baseEmployee({ email: 'alice@example.com' }), + baseEmployee({ email: 'bob@example.com' }), + ], + options: { providerName: 'Microsoft Entra ID' }, + }); + + expect(result.imported).toBe(2); + expect(mockMemberCreate).toHaveBeenCalledTimes(2); + }); + + it('skips importing excluded users in exclude mode', async () => { + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [ + baseEmployee({ email: 'alice@example.com' }), + baseEmployee({ email: 'excluded@example.com' }), + ], + options: { + providerName: 'Microsoft Entra ID', + syncFilter: { + mode: 'exclude', + excludedTerms: ['excluded@example.com'], + includedTerms: [], + }, + }, + }); + + expect(result.imported).toBe(1); + expect(mockMemberCreate).toHaveBeenCalledTimes(1); + const importedEmails = result.details + .filter((d) => d.status === 'imported') + .map((d) => d.email); + expect(importedEmails).toEqual(['alice@example.com']); + }); + + it('imports only the included subset in include mode', async () => { + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [ + baseEmployee({ email: 'alice@example.com' }), + baseEmployee({ email: 'bob@example.com' }), + ], + options: { + providerName: 'Microsoft Entra ID', + syncFilter: { + mode: 'include', + excludedTerms: [], + includedTerms: ['alice@example.com'], + }, + }, + }); + + expect(result.imported).toBe(1); + expect(mockMemberCreate).toHaveBeenCalledTimes(1); + const importedEmails = result.details + .filter((d) => d.status === 'imported') + .map((d) => d.email); + expect(importedEmails).toEqual(['alice@example.com']); + }); + + it('in include mode, does not deactivate present members outside the include list, but still deactivates genuine removals', async () => { + // bob is active in the provider but outside the include list; carol is a + // member who no longer exists in the provider at all. + mockMemberFindMany.mockResolvedValue([ + { + id: 'mem_bob', + role: 'employee', + offboardDate: null, + user: { email: 'bob@example.com' }, + }, + { + id: 'mem_carol', + role: 'employee', + offboardDate: null, + user: { email: 'carol@example.com' }, + }, + ]); + + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [ + baseEmployee({ email: 'alice@example.com' }), + baseEmployee({ email: 'bob@example.com' }), + ], + options: { + providerName: 'Microsoft Entra ID', + isDirectorySource: true, + syncFilter: { + mode: 'include', + excludedTerms: [], + includedTerms: ['alice@example.com'], + }, + }, + }); + + // bob is still in the provider → must NOT be deactivated for being + // outside the include subset. + expect(mockMemberUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'mem_bob' } }), + ); + // carol is genuinely gone → still deactivated. + expect(mockMemberUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'mem_carol' }, + data: expect.objectContaining({ + deactivated: true, + isActive: false, + }), + }), + ); + expect(result.deactivated).toBe(1); + }); + }); }); diff --git a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts index 13608616b7..ffee52540c 100644 --- a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts +++ b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts @@ -1,7 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { db } from '@db'; import type { SyncEmployee } from '@trycompai/integration-platform'; +import { matchesSyncFilterTerms } from '@trycompai/integration-platform'; import { BUILT_IN_ROLE_PERMISSIONS } from '@trycompai/auth'; +import type { ResolvedSyncEmployeeFilter } from './sync-employee-filter'; // ============================================================================ // Types @@ -44,6 +46,15 @@ export interface ProcessEmployeesOptions { * deactivating active employees when their API returns a partial member list. */ isDirectorySource?: boolean; + /** + * Optional include/exclude email filter (resolved from connection variables). + * + * When omitted (or mode 'all'), every active employee is imported — the + * historical behavior. When provided, this mirrors the Google Workspace / + * JumpCloud sync filter so dynamic providers (e.g. Microsoft Entra) can + * limit which users are synced. + */ + syncFilter?: ResolvedSyncEmployeeFilter; } const DEFAULT_PROTECTED_ROLES = ['owner', 'admin', 'auditor']; @@ -126,14 +137,40 @@ export class GenericEmployeeSyncService { `[GenericSync] Processing ${employees.length} employees for org="${organizationId}" provider="${providerName}"`, ); - // Separate employees by status - const activeEmployees = employees.filter((e) => e.status === 'active'); + // Resolve the configured include/exclude email filter. When unset (or + // mode 'all') this is a no-op and every active employee is imported. + const filterMode = options.syncFilter?.mode ?? 'all'; + const excludedTerms = options.syncFilter?.excludedTerms ?? []; + const includedTerms = options.syncFilter?.includedTerms ?? []; + const passesImportFilter = (email: string): boolean => { + if (filterMode === 'exclude') { + return ( + excludedTerms.length === 0 || + !matchesSyncFilterTerms(email, excludedTerms) + ); + } + if (filterMode === 'include') { + return matchesSyncFilterTerms(email, includedTerms); + } + return true; + }; + + // Separate employees by status. Phase 1 imports only the active employees + // that pass the filter; Phase 2 tracks presence against both the filtered + // set and the full provider list (see the deactivation note below). + const activeEmployeesAll = employees.filter((e) => e.status === 'active'); + const activeEmployees = activeEmployeesAll.filter((e) => + passesImportFilter(e.email.toLowerCase()), + ); const inactiveEmails = new Set( employees .filter((e) => e.status !== 'active') .map((e) => e.email.toLowerCase()), ); - const activeEmails = new Set( + const allActiveEmails = new Set( + activeEmployeesAll.map((e) => e.email.toLowerCase()), + ); + const filteredActiveEmails = new Set( activeEmployees.map((e) => e.email.toLowerCase()), ); @@ -307,6 +344,14 @@ export class GenericEmployeeSyncService { }, }); + // In 'include' mode, members who still exist in the provider must NOT be + // deactivated just for being outside the include subset — check presence + // against the full provider list (mirrors the Google Workspace path). In + // 'exclude'/'all' mode, presence is the filtered set, so excluded users and + // genuinely-removed users are both deactivated. + const presenceActiveEmails = + filterMode === 'include' ? allActiveEmails : filteredActiveEmails; + for (const member of allOrgMembers) { const memberEmail = member.user.email.toLowerCase(); const memberDomain = memberEmail.split('@')[1]; @@ -326,7 +371,7 @@ export class GenericEmployeeSyncService { // If member is not in active set AND not already accounted for, deactivate const isSuspended = inactiveEmails.has(memberEmail); - const isRemoved = !activeEmails.has(memberEmail) && !isSuspended; + const isRemoved = !presenceActiveEmails.has(memberEmail) && !isSuspended; if (isSuspended || isRemoved) { try { diff --git a/apps/api/src/integration-platform/services/sync-employee-filter.spec.ts b/apps/api/src/integration-platform/services/sync-employee-filter.spec.ts new file mode 100644 index 0000000000..75df309256 --- /dev/null +++ b/apps/api/src/integration-platform/services/sync-employee-filter.spec.ts @@ -0,0 +1,50 @@ +import { resolveSyncEmployeeFilter } from './sync-employee-filter'; + +describe('resolveSyncEmployeeFilter', () => { + it('defaults to mode "all" when no variables are set', () => { + expect(resolveSyncEmployeeFilter({})).toEqual({ + mode: 'all', + excludedTerms: [], + includedTerms: [], + }); + }); + + it('defaults to mode "all" when the mode is unknown', () => { + expect( + resolveSyncEmployeeFilter({ sync_user_filter_mode: 'bogus' }).mode, + ).toBe('all'); + }); + + it('parses exclude terms from a comma/newline separated string', () => { + const result = resolveSyncEmployeeFilter({ + sync_user_filter_mode: 'exclude', + sync_excluded_emails: 'a@example.com, b@example.com\nc@example.com', + }); + + expect(result.mode).toBe('exclude'); + expect(result.excludedTerms).toEqual([ + 'a@example.com', + 'b@example.com', + 'c@example.com', + ]); + }); + + it('honors include mode when an include list is provided', () => { + const result = resolveSyncEmployeeFilter({ + sync_user_filter_mode: 'include', + sync_included_emails: ['keep@example.com'], + }); + + expect(result.mode).toBe('include'); + expect(result.includedTerms).toEqual(['keep@example.com']); + }); + + it('falls back to "all" when include mode is selected with an empty list (never silently drops everyone)', () => { + expect( + resolveSyncEmployeeFilter({ + sync_user_filter_mode: 'include', + sync_included_emails: '', + }).mode, + ).toBe('all'); + }); +}); diff --git a/apps/api/src/integration-platform/services/sync-employee-filter.ts b/apps/api/src/integration-platform/services/sync-employee-filter.ts new file mode 100644 index 0000000000..6c4fdffb12 --- /dev/null +++ b/apps/api/src/integration-platform/services/sync-employee-filter.ts @@ -0,0 +1,50 @@ +import { parseSyncFilterTerms } from '@trycompai/integration-platform'; + +export type SyncUserFilterMode = 'all' | 'exclude' | 'include'; + +const SYNC_USER_FILTER_MODES = new Set([ + 'all', + 'exclude', + 'include', +]); + +export interface ResolvedSyncEmployeeFilter { + mode: SyncUserFilterMode; + excludedTerms: string[]; + includedTerms: string[]; +} + +/** + * Resolve the org's configured employee sync filter from a connection's + * `variables`. + * + * This mirrors the Google Workspace / JumpCloud sync filter so dynamic + * providers (e.g. Microsoft Entra ID) honor the same connection variables: + * - `sync_user_filter_mode`: 'all' | 'exclude' | 'include' + * - `sync_excluded_emails`: terms to exclude (full emails, @domain, substrings) + * - `sync_included_emails`: terms to include + * + * Falls back to 'all' (import everyone) when the mode is unset/unknown, or when + * 'include' is selected with an empty include list — so a half-configured + * filter can never silently drop every user. + */ +export function resolveSyncEmployeeFilter( + variables: Record, +): ResolvedSyncEmployeeFilter { + const rawMode = variables.sync_user_filter_mode; + const requestedMode: SyncUserFilterMode = + typeof rawMode === 'string' && + SYNC_USER_FILTER_MODES.has(rawMode as SyncUserFilterMode) + ? (rawMode as SyncUserFilterMode) + : 'all'; + + const excludedTerms = parseSyncFilterTerms(variables.sync_excluded_emails); + const includedTerms = parseSyncFilterTerms(variables.sync_included_emails); + + const mode: SyncUserFilterMode = + requestedMode === 'include' && includedTerms.length === 0 + ? 'all' + : requestedMode; + + return { mode, excludedTerms, includedTerms }; +} From db849e2e0e2e4816018bd468eaafd57b63acd23b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 24 Jun 2026 14:34:56 -0400 Subject: [PATCH 2/2] fix(auth): route auditor invite links directly to workspace instead of onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Auditors invited via an invite link are incorrectly routed through the standard customer onboarding flow (framework selection, company info) instead of landing directly in the compliance workspace. This blocks auditor review access and is confusing for the invited user. ## Root cause The auth page (`apps/app/src/app/(public)/auth/page.tsx:36-38`) redirects requests carrying an `inviteCode` to the new-customer onboarding wizard (`/setup`) instead of the invite handler (`/invite/`). This drops the invitation context entirely. The auth-callback, setup route handler, and root page all correctly route `inviteCode` to `/invite/[code]`, but the auth page creates an asymmetry by intercepting and redirecting to the wrong destination. ## Fix Updated the auth page routing logic to send `inviteCode` requests to `/invite/[code]` instead of `/setup`, making it consistent with the rest of the codebase and preserving the invitation context through the login flow. ## Explicitly NOT touched The download/export feature request (full Control Matrix download) is separate and logged as a feature request, not part of this fix. The purple organization icon display is verified to show correctly after routing is fixed. ## Verification ✅ Auditor invite link now routes directly to compliance workspace instead of onboarding ✅ Invite context preserved and passed through login flow ✅ Routing behavior consistent across all entry points (auth page, callback, setup, root) --- apps/app/src/app/(public)/auth/page.test.tsx | 61 ++++++++++++++++++++ apps/app/src/app/(public)/auth/page.tsx | 5 +- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 apps/app/src/app/(public)/auth/page.test.tsx diff --git a/apps/app/src/app/(public)/auth/page.test.tsx b/apps/app/src/app/(public)/auth/page.test.tsx new file mode 100644 index 0000000000..f808dae6bd --- /dev/null +++ b/apps/app/src/app/(public)/auth/page.test.tsx @@ -0,0 +1,61 @@ +import { vi } from 'vitest'; + +// Mock the auth module before importing the page so getSession is controllable. +vi.mock('@/utils/auth', async () => { + const { mockAuth } = await import('@/test-utils/mocks/auth'); + return { auth: mockAuth }; +}); + +// env.mjs validates required server env vars at import time. The page redirects +// before reading any of them, so a stub keeps the test hermetic. +vi.mock('@/env.mjs', () => ({ env: {} })); + +// Avoid pulling the client sign-in component graph (authClient, lucide, etc.) +// into the test; the redirect paths return before rendering the form. +vi.mock('@/components/login-form', () => ({ LoginForm: () => null })); + +import { describe, it, expect, beforeEach } from 'vitest'; +import { redirect } from 'next/navigation'; +import Page from './page'; +import { mockAuthApi, createMockSession, createMockUser } from '@/test-utils/mocks/auth'; + +const mockRedirect = vi.mocked(redirect); + +describe('Auth page invite routing', () => { + beforeEach(() => { + vi.clearAllMocks(); + // The real Next.js redirect() throws to halt rendering. Emulate that so + // execution stops at the first redirect and we can assert its target. + mockRedirect.mockImplementation((url: string) => { + throw new Error(`REDIRECT:${url}`); + }); + }); + + it('routes an authenticated invitee with an active org to the invite flow (not onboarding)', async () => { + // Regression: an auditor who already has a Comp AI session with an active + // org clicks an invite link. Previously this redirected to /setup and the + // invite code was discarded, dumping them into the onboarding wizard. + mockAuthApi.getSession.mockResolvedValue({ + session: createMockSession({ activeOrganizationId: 'org_existing' }), + user: createMockUser({ email: 'auditor@example.com' }), + }); + + await expect( + Page({ searchParams: Promise.resolve({ inviteCode: 'inv_abc123' }) }), + ).rejects.toThrow('REDIRECT:/invite/inv_abc123'); + + expect(mockRedirect).toHaveBeenCalledWith('/invite/inv_abc123'); + expect(mockRedirect).not.toHaveBeenCalledWith('/setup'); + }); + + it('still sends an authenticated user without an invite code to home', async () => { + mockAuthApi.getSession.mockResolvedValue({ + session: createMockSession({ activeOrganizationId: 'org_existing' }), + user: createMockUser(), + }); + + await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow('REDIRECT:/'); + + expect(mockRedirect).toHaveBeenCalledWith('/'); + }); +}); diff --git a/apps/app/src/app/(public)/auth/page.tsx b/apps/app/src/app/(public)/auth/page.tsx index ff8130ec36..6f84bc185b 100644 --- a/apps/app/src/app/(public)/auth/page.tsx +++ b/apps/app/src/app/(public)/auth/page.tsx @@ -34,7 +34,10 @@ export default async function Page({ const orgId = session?.session?.activeOrganizationId; if (orgId && inviteCode) { - redirect('/setup'); + // An invite code always takes priority: send the user to the invitation + // acceptance flow (which validates the invite and applies the role), never + // to the new-org onboarding wizard. + redirect(`/invite/${inviteCode}`); } if (orgId && !inviteCode) {