From 75758ecad8ccf748c3820debd54b0c015c4a291b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:37:58 -0400 Subject: [PATCH 1/4] fix(people): polish the redesigned People tab (requirement columns, filters, per-employee 2FA) (#3347) Polish the redesigned People tab (requirement columns, filters, per-employee 2FA) -- Descriptive screen-reader names for the Sync settings selects (the shortened visible labels were too terse as accessible names) --- .../(app)/[orgId]/people/all/components/TeamMembersClient.tsx | 2 +- .../[orgId]/people/all/components/TwoFactorSourceSelector.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 5c2da67817..b416669463 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -409,7 +409,7 @@ export function TeamMembersClient({ }} disabled={isSyncing || isDisablingSync || !canManageMembers} > - + {isSyncing ? ( <> diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TwoFactorSourceSelector.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TwoFactorSourceSelector.tsx index f3fc70b7d0..8a471ce00c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TwoFactorSourceSelector.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TwoFactorSourceSelector.tsx @@ -90,7 +90,7 @@ export function TwoFactorSourceSelector() { if (provider) void handleSourceChange(provider); }} > - + {selected ? (
{selected.logoUrl && ( From 22a7a905dada6daae0045d9158fba439b3f9409b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:11:06 -0400 Subject: [PATCH 2/4] improvement(people): increase 2FA source selection pool on identity-provider integrations (#3350) --- .../two-factor-source.controller.spec.ts | 31 +++++++++-- .../two-factor-source.controller.ts | 52 +++++++++++++------ .../services/check-results.service.spec.ts | 13 +++-- .../services/check-results.service.ts | 9 ++++ 4 files changed, 82 insertions(+), 23 deletions(-) diff --git a/apps/api/src/integration-platform/controllers/two-factor-source.controller.spec.ts b/apps/api/src/integration-platform/controllers/two-factor-source.controller.spec.ts index 2abc178348..a7180c5ddf 100644 --- a/apps/api/src/integration-platform/controllers/two-factor-source.controller.spec.ts +++ b/apps/api/src/integration-platform/controllers/two-factor-source.controller.spec.ts @@ -33,7 +33,12 @@ function makeController() { return new TwoFactorSourceController(mockCheckResults as never); } -function source(slug: string, connected: boolean, name = slug) { +function source( + slug: string, + connected: boolean, + name = slug, + category = 'Identity & Access', +) { return { slug, name, @@ -43,6 +48,7 @@ function source(slug: string, connected: boolean, name = slug) { connectionId: connected ? `conn_${slug}` : null, lastSyncAt: null, nextSyncAt: null, + category, }; } @@ -99,6 +105,19 @@ describe('TwoFactorSourceController.setTwoFactorSource', () => { expect(mockOrgUpdate).not.toHaveBeenCalled(); }); + it('rejects a connected, bound provider that is not an identity provider', async () => { + // A source can be bound to the 2FA task and connected, yet not be an identity + // provider (so it can't align to the People roster) — it must not be settable. + mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ + source('google-workspace', true, 'Google Workspace', 'Identity & Access'), + source('github', true, 'GitHub', 'Development'), + ]); + await expect( + makeController().setTwoFactorSource(ORG, { provider: 'github' }), + ).rejects.toBeInstanceOf(HttpException); + expect(mockOrgUpdate).not.toHaveBeenCalled(); + }); + it('sets a valid, connected, bound provider', async () => { mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ source('google-workspace', true), @@ -163,16 +182,18 @@ describe('TwoFactorSourceController.setTwoFactorSource', () => { }); describe('TwoFactorSourceController.getAvailableTwoFactorSources', () => { - it('returns bound sources with connection state (without the internal checkId)', async () => { + it('offers only identity-provider sources, without internal fields', async () => { mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ - source('google-workspace', true, 'Google Workspace'), - source('github', false, 'GitHub'), + source('google-workspace', true, 'Google Workspace', 'Identity & Access'), + // Bound to the 2FA task but not an identity provider — must be excluded. + source('github', false, 'GitHub', 'Development'), ]); const { providers } = await makeController().getAvailableTwoFactorSources(ORG); - expect(providers.map((p) => p.slug)).toEqual(['google-workspace', 'github']); + expect(providers.map((p) => p.slug)).toEqual(['google-workspace']); expect(providers[0]).not.toHaveProperty('checkId'); + expect(providers[0]).not.toHaveProperty('category'); expect(providers[0].connected).toBe(true); }); }); diff --git a/apps/api/src/integration-platform/controllers/two-factor-source.controller.ts b/apps/api/src/integration-platform/controllers/two-factor-source.controller.ts index deba61f724..b335a833c2 100644 --- a/apps/api/src/integration-platform/controllers/two-factor-source.controller.ts +++ b/apps/api/src/integration-platform/controllers/two-factor-source.controller.ts @@ -17,13 +17,27 @@ import { } from '@nestjs/swagger'; import { IsOptional, IsString } from 'class-validator'; import { db } from '@db'; -import { TASK_TEMPLATES } from '@trycompai/integration-platform'; +import { + TASK_TEMPLATES, + type IntegrationCategory, +} from '@trycompai/integration-platform'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; import { RequirePermission } from '../../auth/require-permission.decorator'; import { OrganizationId } from '../../auth/auth-context.decorator'; import { CheckResultsService } from '../services/check-results.service'; +/** + * Only identity-provider integrations are meaningful per-employee 2FA sources: + * they cover the whole workforce and key each person by email, so their results + * align with the People roster. Gating on category (rather than a hand-maintained + * list of slugs) means any future IdP in this category qualifies automatically, + * while non-identity integrations that merely expose a 2FA check do not. + */ +const TWO_FA_SOURCE_CATEGORIES = new Set([ + 'Identity & Access', +]); + // Body for POST /v1/integrations/sync/two-factor-source. Pass a provider slug to // set the org's 2FA source, or null/omit to clear it. Class (not inline type) so // swagger + the ValidationPipe whitelist accept it. @@ -123,10 +137,14 @@ export class TwoFactorSourceController { } if (provider) { - const sources = await this.checkResults.listSourcesBoundToTask( - organizationId, - TASK_TEMPLATES.twoFactorAuth, - ); + // Mirror the selector: only identity-provider sources are acceptable + // (see TWO_FA_SOURCE_CATEGORIES / getAvailableTwoFactorSources). + const sources = ( + await this.checkResults.listSourcesBoundToTask( + organizationId, + TASK_TEMPLATES.twoFactorAuth, + ) + ).filter((s) => TWO_FA_SOURCE_CATEGORIES.has(s.category)); const source = sources.find((s) => s.slug === provider); if (!source) { throw new HttpException( @@ -174,16 +192,20 @@ export class TwoFactorSourceController { organizationId, TASK_TEMPLATES.twoFactorAuth, ); - // Expose only what the selector needs (drop the internal checkId). - const providers = sources.map((s) => ({ - slug: s.slug, - name: s.name, - logoUrl: s.logoUrl, - connected: s.connected, - connectionId: s.connectionId, - lastSyncAt: s.lastSyncAt, - nextSyncAt: s.nextSyncAt, - })); + // Offer only identity-provider sources (see TWO_FA_SOURCE_CATEGORIES) — an + // integration that merely exposes a 2FA check but isn't a workforce identity + // provider doesn't map cleanly onto the People roster, so it's not shown. + const providers = sources + .filter((s) => TWO_FA_SOURCE_CATEGORIES.has(s.category)) + .map((s) => ({ + slug: s.slug, + name: s.name, + logoUrl: s.logoUrl, + connected: s.connected, + connectionId: s.connectionId, + lastSyncAt: s.lastSyncAt, + nextSyncAt: s.nextSyncAt, + })); return { providers }; } diff --git a/apps/api/src/integration-platform/services/check-results.service.spec.ts b/apps/api/src/integration-platform/services/check-results.service.spec.ts index 05a89dac1f..ea92e96b62 100644 --- a/apps/api/src/integration-platform/services/check-results.service.spec.ts +++ b/apps/api/src/integration-platform/services/check-results.service.spec.ts @@ -30,11 +30,12 @@ function makeService() { ); } -function boundManifest(id: string, name = id) { +function boundManifest(id: string, name = id, category = 'Identity & Access') { return { id, name, logoUrl: null, + category, checks: [{ id: 'two-factor-auth', taskMapping: TASK_TEMPLATES.twoFactorAuth }], }; } @@ -57,9 +58,9 @@ beforeEach(() => { describe('CheckResultsService.listSourcesBoundToTask', () => { it('returns only manifests bound to the task, with connection state + checkId', async () => { mockGetActiveManifests.mockReturnValue([ - boundManifest('google-workspace', 'Google Workspace'), + boundManifest('google-workspace', 'Google Workspace', 'Identity & Access'), unboundManifest('slack'), - boundManifest('github', 'GitHub'), + boundManifest('github', 'GitHub', 'Development'), ]); mockConnRepo.findActiveBySlugsAndOrg.mockResolvedValue( new Map([ @@ -80,8 +81,14 @@ describe('CheckResultsService.listSourcesBoundToTask', () => { connected: true, connectionId: 'c1', checkId: 'two-factor-auth', + // Category surfaces from the manifest so features can gate on it. + category: 'Identity & Access', }); expect(sources.find((s) => s.slug === 'github')?.connected).toBe(false); + // Category flows through for every bound source. + expect(sources.find((s) => s.slug === 'github')?.category).toBe( + 'Development', + ); }); }); diff --git a/apps/api/src/integration-platform/services/check-results.service.ts b/apps/api/src/integration-platform/services/check-results.service.ts index 313a42d94e..6326394fc8 100644 --- a/apps/api/src/integration-platform/services/check-results.service.ts +++ b/apps/api/src/integration-platform/services/check-results.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import type { Prisma } from '@db'; import { registry, + type IntegrationCategory, type IntegrationCheck, type IntegrationManifest, type TaskTemplateId, @@ -44,6 +45,13 @@ export interface CheckSourceInfo { connectionId: string | null; lastSyncAt: string | null; nextSyncAt: string | null; + /** + * The integration's catalog category (e.g. 'Identity & Access', 'Development'). + * Descriptive manifest metadata — features that only make sense for certain + * kinds of source (e.g. the People-tab 2FA column, which wants identity + * providers) filter on this. + */ + category: IntegrationCategory; } /** @@ -110,6 +118,7 @@ export class CheckResultsService { connectionId: connection?.id ?? null, lastSyncAt: connection?.lastSyncAt?.toISOString() ?? null, nextSyncAt: connection?.nextSyncAt?.toISOString() ?? null, + category: manifest.category, }; }); } From 0b54252e05d53d7f1118d473de1374f93dd02beb Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:32:45 -0400 Subject: [PATCH 3/4] feat(integrations): emit per-user rows from the Google Workspace employee-access check (#3352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(integrations): emit per-user rows from the Google Workspace employee-access check One row per person (resourceType 'user', resourceId = lowercased email) instead of a single org-level row with the roster buried in evidence — person-scoped features join check results to org members by email. Role resolution, filtering, and pagination unchanged; a zero-user run still emits one org-level summary row. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WLGUUjG6uLd6xvNhUpge1 * docs(skills): document the person-scoped check shape contract in check-results-service Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WLGUUjG6uLd6xvNhUpge1 * docs(skills): make the person-scoped shape contract self-contained The section referenced a skill file that lives outside the repo; inline the normative definition instead so the cross-reference isn't broken for repo readers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WLGUUjG6uLd6xvNhUpge1 --------- Co-authored-by: Claude Fable 5 --- .claude/skills/check-results-service/SKILL.md | 30 +++++ .../__tests__/employee-access.test.ts | 127 ++++++++++++++++++ .../checks/employee-access.ts | 53 +++++--- 3 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 packages/integration-platform/src/manifests/google-workspace/__tests__/employee-access.test.ts diff --git a/.claude/skills/check-results-service/SKILL.md b/.claude/skills/check-results-service/SKILL.md index f79904ebff..5caf3db7e7 100644 --- a/.claude/skills/check-results-service/SKILL.md +++ b/.claude/skills/check-results-service/SKILL.md @@ -52,6 +52,36 @@ Notes: - Empty array = "no data" (source not bound/connected, or never really ran). Never throws for "no results". +## Person-scoped results: the shape contract + +Checks about PEOPLE (employee access, 2FA/MFA, training) follow a standard emission shape — +this section is the canonical definition of it: + +- **One row per person** — never a single aggregate row with a roster buried in `evidence`. +- **`resourceType: 'user'`** (exactly this string). +- **`resourceId` = the person's email, lowercased + trimmed.** Fallback `username || id` + only when the provider genuinely exposes no email (such rows won't join to members — + acceptable, still visible in evidence views). +- **`evidence`** carries what the provider knows: `email`, `name`, `role`, `isAdmin`, + `status`, `lastLogin`, plus a `checkedAt` timestamp. +- **Access/inventory rows always emit as pass** (having access is information, not a + violation); compliance-gate checks (2FA, training) pass/fail per person; error paths + (bad creds, missing scopes) stay org-level rows. + +So a feature joining check results to org members does exactly this — no parsing, no AI: + +```ts +const rows = await checkResults.getLatestResultsForTask({ + organizationId, taskTemplateId, sourceSlug, resourceType: 'user', +}); +const forMember = rows.filter((r) => r.resourceId === member.email.toLowerCase()); +``` + +If a source returns zero `'user'` rows, its check hasn't been normalized to the standard +yet (or genuinely has no per-person data) — render that as "no per-person data from this +source", and fix the CHECK to emit the shape above. Never work around it by parsing +aggregate evidence in the feature. + ## The envelope you get back ```ts diff --git a/packages/integration-platform/src/manifests/google-workspace/__tests__/employee-access.test.ts b/packages/integration-platform/src/manifests/google-workspace/__tests__/employee-access.test.ts new file mode 100644 index 0000000000..a25eca1f9a --- /dev/null +++ b/packages/integration-platform/src/manifests/google-workspace/__tests__/employee-access.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'bun:test'; +import { employeeAccessCheck } from '../checks/employee-access'; +import type { CheckContext, CheckResult, CheckVariableValues } from '../../../types'; +import type { GoogleWorkspaceUser } from '../types'; + +const makeUser = (overrides: Partial & { primaryEmail: string }): GoogleWorkspaceUser => ({ + id: `id_${overrides.primaryEmail}`, + name: { givenName: 'Test', familyName: 'User', fullName: 'Test User' }, + isAdmin: false, + isDelegatedAdmin: false, + isEnrolledIn2Sv: true, + isEnforcedIn2Sv: true, + suspended: false, + archived: false, + creationTime: '2024-01-01T00:00:00Z', + lastLoginTime: '2026-01-01T00:00:00Z', + orgUnitPath: '/', + ...overrides, +}); + +async function runCheck( + users: GoogleWorkspaceUser[], + variables: CheckVariableValues = {}, +): Promise<{ passed: CheckResult[]; failed: CheckResult[] }> { + const passed: CheckResult[] = []; + const failed: CheckResult[] = []; + + const ctx: CheckContext = { + accessToken: 'tok', + credentials: {}, + variables, + connectionId: 'conn_1', + organizationId: 'org_1', + metadata: {}, + log: () => {}, + pass: (result) => { + passed.push(result as CheckResult); + }, + fail: (result) => { + failed.push(result as CheckResult); + }, + fetch: (async (path: string): Promise => { + if (path.includes('/roles')) { + return { items: [{ roleId: 'r1', roleName: 'Groups Admin' }] } as unknown as T; + } + if (path.includes('/roleassignments')) { + return { + items: users + .filter((u) => u.isDelegatedAdmin) + .map((u) => ({ roleId: 'r1', assignedTo: u.id })), + } as unknown as T; + } + if (path.includes('/users')) { + return { kind: 'k', users } as unknown as T; + } + throw new Error(`Unexpected fetch: ${path}`); + }) as CheckContext['fetch'], + fetchAllPages: (async () => []) as CheckContext['fetchAllPages'], + graphql: (async () => ({})) as CheckContext['graphql'], + } as CheckContext; + + await employeeAccessCheck.run(ctx); + return { passed, failed }; +} + +describe('employeeAccessCheck per-user emission', () => { + it('emits one user row per person, keyed by lowercased email', async () => { + const users = [ + makeUser({ primaryEmail: 'Admin@Example.com', isAdmin: true }), + makeUser({ primaryEmail: 'person@example.com' }), + ]; + + const { passed, failed } = await runCheck(users); + + expect(failed).toHaveLength(0); + expect(passed).toHaveLength(2); + expect(passed.every((r) => r.resourceType === 'user')).toBe(true); + expect(passed.map((r) => r.resourceId).sort()).toEqual([ + 'admin@example.com', + 'person@example.com', + ]); + }); + + it('carries role details in each row evidence', async () => { + const users = [ + makeUser({ primaryEmail: 'admin@example.com', isAdmin: true }), + makeUser({ primaryEmail: 'delegated@example.com', isDelegatedAdmin: true }), + makeUser({ primaryEmail: 'person@example.com' }), + ]; + + const { passed } = await runCheck(users); + + const byEmail = new Map(passed.map((r) => [r.resourceId, r])); + expect((byEmail.get('admin@example.com')?.evidence as { role: string }).role).toBe( + 'Super Admin', + ); + expect((byEmail.get('delegated@example.com')?.evidence as { role: string }).role).toBe( + 'Delegated Admin', + ); + expect((byEmail.get('person@example.com')?.evidence as { role: string }).role).toBe('User'); + expect((byEmail.get('person@example.com')?.evidence as { email: string }).email).toBe( + 'person@example.com', + ); + }); + + it('excludes suspended users by default (same filter as employee sync)', async () => { + const users = [ + makeUser({ primaryEmail: 'active@example.com' }), + makeUser({ primaryEmail: 'gone@example.com', suspended: true }), + ]; + + const { passed } = await runCheck(users); + + expect(passed.map((r) => r.resourceId)).toEqual(['active@example.com']); + }); + + it('emits a single org-level summary row when no users match the filters', async () => { + const users = [makeUser({ primaryEmail: 'gone@example.com', suspended: true })]; + + const { passed, failed } = await runCheck(users); + + expect(failed).toHaveLength(0); + expect(passed).toHaveLength(1); + expect(passed[0].resourceType).toBe('organization'); + expect(passed[0].resourceId).toBe('google-workspace'); + }); +}); diff --git a/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts b/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts index 6f9c87fa61..c80ff73ccf 100644 --- a/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts +++ b/packages/integration-platform/src/manifests/google-workspace/checks/employee-access.ts @@ -176,27 +176,42 @@ export const employeeAccessCheck: IntegrationCheck = { }; }); - // Group users by role for summary + // Group users by role for the summary log const superAdmins = activeUsers.filter((u) => u.isAdmin); const delegatedAdmins = activeUsers.filter((u) => u.isDelegatedAdmin && !u.isAdmin); - const regularUsers = activeUsers.filter((u) => !u.isAdmin && !u.isDelegatedAdmin); - - // Pass with the full employee list as evidence - ctx.pass({ - title: 'Employee Access List', - resourceType: 'organization', - resourceId: 'google-workspace', - description: `Retrieved ${activeUsers.length} employees from Google Workspace (${superAdmins.length} super admins, ${delegatedAdmins.length} delegated admins, ${regularUsers.length} regular users)`, - evidence: { - totalUsers: activeUsers.length, - superAdminCount: superAdmins.length, - delegatedAdminCount: delegatedAdmins.length, - regularUserCount: regularUsers.length, - reviewedAt: new Date().toISOString(), - employees: employeeList, - }, - }); - ctx.log('Google Workspace Employee Access check complete'); + const checkedAt = new Date().toISOString(); + + // No users after filtering is still a completed review — emit one org-level + // row so the run never stores zero results (which would read as "no evidence"). + if (employeeList.length === 0) { + ctx.pass({ + title: 'Employee Access List', + resourceType: 'organization', + resourceId: 'google-workspace', + description: `No active users matched the configured filters (${allUsers.length} total user records inspected)`, + evidence: { totalUsers: 0, inspectedUsers: allUsers.length, checkedAt }, + }); + ctx.log('Google Workspace Employee Access check complete: 0 users after filtering'); + return; + } + + // One row per person (resourceType 'user', resourceId = lowercased email) so + // person-scoped features can join results to org members by email. Access is + // an inventory, not a violation — every person row emits as pass; error paths + // keep their org-level rows. + for (const employee of employeeList) { + ctx.pass({ + title: 'Employee Access', + resourceType: 'user', + resourceId: employee.email.toLowerCase().trim(), + description: `${employee.name} has access to Google Workspace as ${employee.role}`, + evidence: { ...employee, checkedAt }, + }); + } + + ctx.log( + `Google Workspace Employee Access check complete: ${employeeList.length} users (${superAdmins.length} super admins, ${delegatedAdmins.length} delegated admins)`, + ); }, }; From 83f188c0499ca01726a328baf587e26f8e99ce95 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:15:41 -0400 Subject: [PATCH 4/4] feat(people): access tab on member details, per connected integration (#3349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(people): access tab on member details, per connected integration New tab on the member page aggregating the latest Employee Access check results from every connected integration bound to the Employee Access evidence task (CheckResultsService consumer #2). Rows show logo, name, access summary, and an honest match state (found / no match for this member / check not run); expanding reveals curated fields plus the raw record. Extractor tolerates both check shapes: per-user rows and org-level rosters embedded in evidence. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(integration-platform): universal ai fallback for person-in-evidence extraction Shared EvidenceExtractionService: deterministic shape matching, then an email pre-gate (absent email = confident not-found, no AI call), then a claude-haiku-4-5 structured-output fallback for unknown evidence shapes. Access tab consumes it; AI-extracted entries are labeled in the UI and unreadable shapes surface as 'Needs manual review' instead of a blank. Co-Authored-By: Claude Fable 5 * refactor(people): access tab reads the per-person check standard directly — drop AI extraction Person-scoped checks now emit one row per person (resourceType 'user', resourceId = lowercased email), so matching a member's access is a plain filter. Removes EvidenceExtractionService (deterministic ladder + AI fallback), its spec, module wiring, and the AI badge / unparsed state in the UI. New 'no-person-data' state distinguishes sources whose vendor API has no roster. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WLGUUjG6uLd6xvNhUpge1 * fix(people): harden access rows against API contract drift Fallback badge for unknown matchType values (the union is compile-time only; deserialized JSON can carry states this build doesn't know), and content-derived entry keys so uncontrolled
elements follow their entry through reorders instead of the array index. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WLGUUjG6uLd6xvNhUpge1 * fix(people): key access entries by a stable API-provided id Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WLGUUjG6uLd6xvNhUpge1 * fix(people): key access entries by the result row's database id Expose the IntegrationCheckResult id through the CheckResultRow envelope and use it as the entry id — unique and stable by construction, no positional derivation. Also order a run's result rows deterministically in the repository (Postgres guarantees no order without one). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WLGUUjG6uLd6xvNhUpge1 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../repositories/check-run.repository.ts | 4 + .../services/check-results.service.ts | 3 + .../src/people/people-access.service.spec.ts | 151 +++++++++++++ apps/api/src/people/people-access.service.ts | 138 ++++++++++++ apps/api/src/people/people.controller.spec.ts | 6 + apps/api/src/people/people.controller.ts | 33 +++ apps/api/src/people/people.module.ts | 6 +- .../[employeeId]/components/Employee.tsx | 7 + .../components/EmployeeAccess.test.tsx | 116 ++++++++++ .../components/EmployeeAccess.tsx | 204 ++++++++++++++++++ 10 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/people/people-access.service.spec.ts create mode 100644 apps/api/src/people/people-access.service.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeAccess.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeAccess.tsx diff --git a/apps/api/src/integration-platform/repositories/check-run.repository.ts b/apps/api/src/integration-platform/repositories/check-run.repository.ts index 8d360adbe6..f58c46a750 100644 --- a/apps/api/src/integration-platform/repositories/check-run.repository.ts +++ b/apps/api/src/integration-platform/repositories/check-run.repository.ts @@ -340,6 +340,10 @@ export class CheckRunRepository { checkRunId: run.id, ...(resourceType ? { resourceType } : {}), }, + // Deterministic order — without it Postgres may return the same run's + // rows in a different order per query, which breaks consumers that key + // UI state off row identity/position. + orderBy: { id: 'asc' }, }); return { run, results }; } diff --git a/apps/api/src/integration-platform/services/check-results.service.ts b/apps/api/src/integration-platform/services/check-results.service.ts index 6326394fc8..fe5f0c4c26 100644 --- a/apps/api/src/integration-platform/services/check-results.service.ts +++ b/apps/api/src/integration-platform/services/check-results.service.ts @@ -19,6 +19,8 @@ import { ConnectionRepository } from '../repositories/connection.repository'; * (e.g. with a zod schema) and reads only the fields it understands. */ export interface CheckResultRow { + /** Database id of this result row — unique and stable; safe as a UI key. */ + resultId: string; /** Provider-native identifier for the resource (email, bucket ARN, repo, …). */ resourceId: string; /** Kind of resource the check produced (e.g. 'user', 'bucket'). */ @@ -145,6 +147,7 @@ export class CheckResultsService { if (!latest) return []; return latest.results.map((r) => ({ + resultId: r.id, resourceId: r.resourceId, resourceType: r.resourceType, passed: r.passed, diff --git a/apps/api/src/people/people-access.service.spec.ts b/apps/api/src/people/people-access.service.spec.ts new file mode 100644 index 0000000000..cebc43fe62 --- /dev/null +++ b/apps/api/src/people/people-access.service.spec.ts @@ -0,0 +1,151 @@ +jest.mock('@db', () => ({ db: { member: { findFirst: jest.fn() } } })); + +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { + CheckResultRow, + CheckResultsService, +} from '../integration-platform/services/check-results.service'; +import { PeopleAccessService } from './people-access.service'; + +const memberFindFirst = db.member.findFirst as jest.Mock; + +function row(partial: Partial): CheckResultRow { + return { + resultId: 'icr_1', + resourceId: 'org', + resourceType: 'organization', + passed: true, + title: 'Access List', + description: null, + evidence: null, + collectedAt: new Date('2026-07-01T00:00:00Z'), + runId: 'run_1', + connectionId: 'conn_1', + ...partial, + }; +} + +const SOURCE = { + slug: 'google-workspace', + name: 'Google Workspace', + logoUrl: 'https://logo', + connected: true, + connectionId: 'conn_1', + checkId: 'employee-access', +}; + +describe('PeopleAccessService.getMemberAccess', () => { + const checkResults = { + listSourcesBoundToTask: jest.fn(), + getLatestResultsByCheck: jest.fn(), + }; + const service = new PeopleAccessService(checkResults as unknown as CheckResultsService); + + beforeEach(() => { + jest.clearAllMocks(); + memberFindFirst.mockResolvedValue({ id: 'mem_1', user: { email: 'Jane@X.com ' } }); + checkResults.listSourcesBoundToTask.mockResolvedValue([SOURCE]); + }); + + it('404s when the member is not in this organization', async () => { + memberFindFirst.mockResolvedValue(null); + await expect(service.getMemberAccess('org_1', 'mem_x')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('matches per-user rows by lowercased email and builds display entries', async () => { + checkResults.getLatestResultsByCheck.mockResolvedValue([ + row({ + resourceType: 'user', + resourceId: 'jane@x.com', + description: 'Jane has access to Google Workspace as Super Admin', + evidence: { + email: 'jane@x.com', + name: 'Jane', + role: 'Super Admin', + isAdmin: true, + orgUnit: null, + checkedAt: '2026-07-01T00:00:00Z', + roles: ['Super Admin'], + }, + }), + row({ resourceType: 'user', resourceId: 'other@x.com' }), + ]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources).toHaveLength(1); + expect(sources[0].matchType).toBe('matched'); + expect(sources[0].entries).toHaveLength(1); + const entry = sources[0].entries[0]; + expect(entry.id).toBe('icr_1'); + expect(entry.summary).toBe('Jane has access to Google Workspace as Super Admin'); + // Primitive evidence values become labeled fields; nulls, arrays, and + // timestamp keys are excluded; raw evidence is passed through for auditors. + expect(entry.fields).toEqual({ + Email: 'jane@x.com', + Name: 'Jane', + Role: 'Super Admin', + 'Is Admin': 'true', + }); + expect(entry.raw).toMatchObject({ email: 'jane@x.com' }); + }); + + it('reports not-matched when per-user rows exist but none for this member', async () => { + checkResults.getLatestResultsByCheck.mockResolvedValue([ + row({ resourceType: 'user', resourceId: 'other@x.com' }), + ]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources[0].matchType).toBe('not-matched'); + expect(sources[0].entries).toHaveLength(0); + }); + + it('reports no-person-data when the check ran but emits no user rows', async () => { + checkResults.getLatestResultsByCheck.mockResolvedValue([ + row({ resourceType: 'organization', resourceId: 'google-workspace' }), + ]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources[0].matchType).toBe('no-person-data'); + expect(sources[0].lastCheckedAt).toBe('2026-07-01T00:00:00.000Z'); + }); + + it('reports no-data when the check has never really run', async () => { + checkResults.getLatestResultsByCheck.mockResolvedValue([]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources[0].matchType).toBe('no-data'); + expect(sources[0].lastCheckedAt).toBeNull(); + }); + + it('skips sources that are not connected', async () => { + checkResults.listSourcesBoundToTask.mockResolvedValue([ + SOURCE, + { ...SOURCE, slug: 'slack', connected: false, connectionId: null }, + ]); + checkResults.getLatestResultsByCheck.mockResolvedValue([]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources.map((s) => s.slug)).toEqual(['google-workspace']); + expect(checkResults.getLatestResultsByCheck).toHaveBeenCalledTimes(1); + }); + + it('never matches when the member has no email', async () => { + memberFindFirst.mockResolvedValue({ id: 'mem_1', user: { email: null } }); + checkResults.getLatestResultsByCheck.mockResolvedValue([ + row({ resourceType: 'user', resourceId: 'jane@x.com' }), + ]); + + const { sources } = await service.getMemberAccess('org_1', 'mem_1'); + + expect(sources[0].matchType).toBe('not-matched'); + expect(sources[0].entries).toHaveLength(0); + }); +}); diff --git a/apps/api/src/people/people-access.service.ts b/apps/api/src/people/people-access.service.ts new file mode 100644 index 0000000000..1c5e39fbc3 --- /dev/null +++ b/apps/api/src/people/people-access.service.ts @@ -0,0 +1,138 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { TASK_TEMPLATES } from '@trycompai/integration-platform'; +import { + CheckResultsService, + type CheckResultRow, +} from '../integration-platform/services/check-results.service'; + +/** One access record for the member from one integration's check results. */ +export interface MemberAccessEntry { + /** The result row's database id — unique and stable; safe as a React key. */ + id: string; + summary: string; + /** Human-readable label -> value pairs pulled from the row's evidence. */ + fields: Record; + /** The raw result evidence, for auditors. */ + raw: unknown; +} + +/** One integration's access information for a single member. */ +export interface MemberAccessSource { + slug: string; + name: string; + logoUrl: string | null; + /** + * matched — per-user rows for this member's email were found + * not-matched — the source reports per-user rows, but none for this member + * no-person-data — the source's check ran but emits no per-user rows (its + * vendor API has no roster, or the check isn't normalized) + * no-data — the source's access check has never really run + */ + matchType: 'matched' | 'not-matched' | 'no-person-data' | 'no-data'; + entries: MemberAccessEntry[]; + lastCheckedAt: string | null; +} + +/** Evidence keys that duplicate row-level info or are noise in a field list. */ +const HIDDEN_EVIDENCE_KEYS = new Set(['checkedAt', 'fetchedAt', 'reviewedAt', 'raw']); +const MAX_FIELDS = 12; + +/** camelCase / snake_case -> "Title Case" label. */ +function labelize(key: string): string { + return key + .replace(/_/g, ' ') + .replace(/([a-z\d])([A-Z])/g, '$1 $2') + .replace(/^./, (c) => c.toUpperCase()); +} + +/** + * Flatten a result row into a display entry. Only primitive evidence values + * become fields — nested objects/arrays stay visible in `raw`. + */ +function toEntry(row: CheckResultRow): MemberAccessEntry { + const fields: Record = {}; + if (row.evidence && typeof row.evidence === 'object' && !Array.isArray(row.evidence)) { + for (const [key, value] of Object.entries(row.evidence)) { + if (Object.keys(fields).length >= MAX_FIELDS) break; + if (HIDDEN_EVIDENCE_KEYS.has(key) || value == null) continue; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + fields[labelize(key)] = String(value); + } + } + } + return { + id: row.resultId, + summary: row.description ?? row.title, + fields, + raw: row.evidence, + }; +} + +/** + * Aggregates a member's access across every connected integration whose check + * is bound to the Employee Access evidence task. Read-only consumer of the + * universal CheckResultsService. + * + * Matching is purely deterministic: person-scoped checks emit one row per + * person with resourceType 'user' and resourceId = lowercased email (see the + * check-results-service skill), so a member's access is exactly the rows whose + * resourceId equals their email. No evidence parsing, no AI. + */ +@Injectable() +export class PeopleAccessService { + constructor(private readonly checkResults: CheckResultsService) {} + + async getMemberAccess(organizationId: string, memberId: string) { + const member = await db.member.findFirst({ + where: { id: memberId, organizationId }, + select: { id: true, user: { select: { email: true } } }, + }); + if (!member) throw new NotFoundException('Member not found'); + const memberEmail = (member.user.email ?? '').toLowerCase().trim(); + + const sources = await this.checkResults.listSourcesBoundToTask( + organizationId, + TASK_TEMPLATES.employeeAccess, + ); + + const access: MemberAccessSource[] = await Promise.all( + sources + .filter((s) => s.connected && s.connectionId) + .map(async (s) => { + const results = await this.checkResults.getLatestResultsByCheck({ + organizationId, + connectionId: s.connectionId as string, + checkId: s.checkId, + }); + const userRows = results.filter((r) => r.resourceType === 'user'); + const memberRows = memberEmail + ? userRows.filter((r) => r.resourceId.toLowerCase().trim() === memberEmail) + : []; + const lastCheckedAt = results.length + ? new Date( + Math.max(...results.map((r) => new Date(r.collectedAt).getTime())), + ).toISOString() + : null; + const matchType: MemberAccessSource['matchType'] = + results.length === 0 + ? 'no-data' + : memberRows.length > 0 + ? 'matched' + : userRows.length > 0 + ? 'not-matched' + : 'no-person-data'; + return { + slug: s.slug, + name: s.name, + logoUrl: s.logoUrl, + matchType, + entries: memberRows.map(toEntry), + lastCheckedAt, + } satisfies MemberAccessSource; + }), + ); + + return { memberId, sources: access }; + } +} diff --git a/apps/api/src/people/people.controller.spec.ts b/apps/api/src/people/people.controller.spec.ts index 7e471dddfb..c259c07d2e 100644 --- a/apps/api/src/people/people.controller.spec.ts +++ b/apps/api/src/people/people.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; +import { PeopleAccessService } from './people-access.service'; import { AttachmentsService } from '../attachments/attachments.service'; import type { AuthContext } from '../auth/types'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; @@ -90,6 +91,10 @@ describe('PeopleController', () => { deleteAttachment: jest.fn(), }; + const mockPeopleAccessService = { + getMemberAccess: jest.fn(), + }; + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; const mockAuthContext: AuthContext = { @@ -108,6 +113,7 @@ describe('PeopleController', () => { providers: [ { provide: PeopleService, useValue: mockPeopleService }, { provide: PeopleInviteService, useValue: mockPeopleInviteService }, + { provide: PeopleAccessService, useValue: mockPeopleAccessService }, { provide: AttachmentsService, useValue: mockAttachmentsService }, ], }) diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts index 781993a741..176c09dd16 100644 --- a/apps/api/src/people/people.controller.ts +++ b/apps/api/src/people/people.controller.ts @@ -42,6 +42,7 @@ import { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; import { AttachmentEntityType } from '@db'; import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; +import { PeopleAccessService } from './people-access.service'; import { GET_ALL_PEOPLE_RESPONSES } from './schemas/get-all-people.responses'; import { CREATE_MEMBER_RESPONSES } from './schemas/create-member.responses'; import { BULK_CREATE_MEMBERS_RESPONSES } from './schemas/bulk-create-members.responses'; @@ -62,6 +63,7 @@ export class PeopleController { constructor( private readonly peopleService: PeopleService, private readonly peopleInviteService: PeopleInviteService, + private readonly peopleAccessService: PeopleAccessService, private readonly attachmentsService: AttachmentsService, ) {} @@ -373,6 +375,37 @@ export class PeopleController { }; } + @Get(':id/access') + @RequirePermission('member', 'read') + @ApiOperation({ + summary: "Member's access across connected integrations", + description: + 'Aggregates the latest Employee Access check results from every connected integration bound to the Employee Access task, matched to the member by email.', + }) + @ApiParam(PEOPLE_PARAMS.memberId) + async getMemberAccess( + @Param('id') memberId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const data = await this.peopleAccessService.getMemberAccess( + organizationId, + memberId, + ); + + return { + data, + authType: authContext.authType, + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + @Get(':id/training-videos') @RequirePermission('member', 'read') @ApiOperation({ summary: 'Get training video completions for a member' }) diff --git a/apps/api/src/people/people.module.ts b/apps/api/src/people/people.module.ts index 9590239747..fcd735b374 100644 --- a/apps/api/src/people/people.module.ts +++ b/apps/api/src/people/people.module.ts @@ -1,16 +1,18 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { IntegrationPlatformModule } from '../integration-platform/integration-platform.module'; import { AttachmentsModule } from '../attachments/attachments.module'; import { TimelinesModule } from '../timelines/timelines.module'; import { FleetService } from '../lib/fleet.service'; import { PeopleController } from './people.controller'; import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; +import { PeopleAccessService } from './people-access.service'; @Module({ - imports: [AuthModule, AttachmentsModule, TimelinesModule], + imports: [AuthModule, AttachmentsModule, TimelinesModule, IntegrationPlatformModule], controllers: [PeopleController], - providers: [PeopleService, PeopleInviteService, FleetService], + providers: [PeopleService, PeopleInviteService, PeopleAccessService, FleetService], exports: [PeopleService], }) export class PeopleModule {} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index c526b494e7..70f5f91b86 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -18,6 +18,7 @@ import { EmployeeBackgroundCheck } from './EmployeeBackgroundCheck'; import { EmployeeDetails } from './EmployeeDetails'; import { EmployeeDevice } from './EmployeeDevice'; import { EmployeePageHeader } from './EmployeePageHeader'; +import { EmployeeAccess } from './EmployeeAccess'; import { EmployeePolicies } from './EmployeePolicies'; import { EmployeeHipaaTraining, EmployeeTrainingVideos } from './EmployeeTraining'; import { isAuditorOnly } from './isAuditorOnly'; @@ -29,6 +30,7 @@ type EmployeeTab = | 'training' | 'hipaa' | 'device' + | 'access' | 'offboarding' | 'background-check'; @@ -85,6 +87,7 @@ export function Employee({ 'training', ...(hasHipaaFramework ? (['hipaa'] as EmployeeTab[]) : []), 'device', + 'access', ...(showBackgroundCheck ? (['background-check'] as EmployeeTab[]) : []), ...(employee.offboardDate ? (['offboarding'] as EmployeeTab[]) : []), ]; @@ -154,6 +157,7 @@ export function Employee({ Training Videos {hasHipaaFramework && HIPAA Training} Device + Access {showBackgroundCheck && ( Background Check )} @@ -181,6 +185,9 @@ export function Employee({ /> )} + + + ({ mockGet: vi.fn() })); + +vi.mock('@/lib/api-client', () => ({ apiClient: { get: mockGet } })); +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); +vi.mock('next/image', () => ({ + default: (props: { src: string; alt: string }) => {props.alt}, +})); + +const source = (overrides: object) => ({ + slug: 'google-workspace', + name: 'Google Workspace', + logoUrl: null, + matchType: 'matched', + entries: [{ id: 'run_1:0', summary: 'Super Admin', fields: { Role: 'Super Admin' }, raw: {} }], + lastCheckedAt: '2026-07-01T00:00:00Z', + ...overrides, +}); + +beforeEach(() => vi.clearAllMocks()); + +describe('EmployeeAccess', () => { + it('lists integrations with the member access summary and match state', async () => { + mockGet.mockResolvedValue({ + data: { data: { memberId: 'mem_1', sources: [source({}), source({ slug: 'okta', name: 'Okta', matchType: 'not-matched', entries: [] })] } }, + }); + + render(); + + await waitFor(() => expect(screen.getByText('Google Workspace')).toBeInTheDocument()); + expect(screen.getByText('Super Admin')).toBeInTheDocument(); + expect(screen.getByText('Access found')).toBeInTheDocument(); + expect(screen.getByText('No match for this member')).toBeInTheDocument(); + }); + + it('labels sources whose checks produce no per-person rows', async () => { + mockGet.mockResolvedValue({ + data: { + data: { + memberId: 'mem_3', + sources: [source({ matchType: 'no-person-data', entries: [] })], + }, + }, + }); + + render(); + + await waitFor(() => expect(screen.getByText('No per-person data')).toBeInTheDocument()); + }); + + it('expands a matched source to show fields and the raw record', async () => { + mockGet.mockResolvedValue({ + data: { + data: { + memberId: 'mem_4', + sources: [ + source({ + entries: [ + { id: 'run_1:0', summary: 'Editor seat', fields: { Role: 'Editor' }, raw: { role: 'editor' } }, + ], + }), + ], + }, + }, + }); + + render(); + + await waitFor(() => expect(screen.getByText('Google Workspace')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /Google Workspace/ })); + + expect(screen.getByText('Role')).toBeInTheDocument(); + expect(screen.getByText('Editor')).toBeInTheDocument(); + expect(screen.getByText('Raw record')).toBeInTheDocument(); + }); + + it('falls back to a neutral badge for an unknown matchType instead of crashing', async () => { + mockGet.mockResolvedValue({ + data: { + data: { + memberId: 'mem_5', + // A matchType this build doesn't know (API contract drift). + sources: [source({ matchType: 'something-new', entries: [] })], + }, + }, + }); + + render(); + + await waitFor(() => expect(screen.getByText('Google Workspace')).toBeInTheDocument()); + expect(screen.getByText('Check not run yet')).toBeInTheDocument(); + }); + + it('shows the connect empty state when no integration reports access', async () => { + mockGet.mockResolvedValue({ data: { data: { memberId: 'mem_2', sources: [] } } }); + + render(); + + await waitFor(() => + expect( + screen.getByText('No connected integrations report employee access yet.'), + ).toBeInTheDocument(), + ); + expect(screen.getByText('Browse integrations →')).toHaveAttribute( + 'href', + '/org_1/integrations', + ); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeAccess.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeAccess.tsx new file mode 100644 index 0000000000..336c789da0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeAccess.tsx @@ -0,0 +1,204 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { useState } from 'react'; +import useSWR from 'swr'; + +import { apiClient } from '@/lib/api-client'; +import { + Badge, + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, + Stack, + Text, +} from '@trycompai/design-system'; +import { ChevronDown, ChevronUp } from '@trycompai/design-system/icons'; + +interface MemberAccessEntry { + /** Stable unique id from the API — safe as a React key. */ + id: string; + summary: string; + fields: Record; + /** The raw result evidence, for auditors. */ + raw: unknown; +} + +interface MemberAccessSource { + slug: string; + name: string; + logoUrl: string | null; + matchType: 'matched' | 'not-matched' | 'no-person-data' | 'no-data'; + entries: MemberAccessEntry[]; + lastCheckedAt: string | null; +} + +interface MemberAccessResponse { + data: { memberId: string; sources: MemberAccessSource[] }; +} + +function useMemberAccess(memberId: string) { + return useSWR( + memberId ? ['member-access', memberId] : null, + async () => { + const response = await apiClient.get( + `/v1/people/${memberId}/access`, + ); + if (response.error || !response.data?.data) { + throw new Error(response.error || 'Failed to fetch access'); + } + return response.data.data; + }, + { revalidateOnFocus: false }, + ); +} + +const MATCH_BADGE: Record< + MemberAccessSource['matchType'], + { label: string; variant: 'accent' | 'secondary' | 'outline' } +> = { + matched: { label: 'Access found', variant: 'accent' }, + 'not-matched': { label: 'No match for this member', variant: 'secondary' }, + 'no-person-data': { label: 'No per-person data', variant: 'outline' }, + 'no-data': { label: 'Check not run yet', variant: 'outline' }, +}; + +function SourceRow({ source }: { source: MemberAccessSource }) { + const [expanded, setExpanded] = useState(false); + // TypeScript's union is compile-time only — matchType arrives as deserialized + // JSON, so an API value this build doesn't know yet must not crash the row. + const badge = MATCH_BADGE[source.matchType] ?? MATCH_BADGE['no-data']; + const canExpand = source.entries.length > 0; + + return ( +
+ + + {expanded && ( +
+ + {/* Keyed by the API's stable entry id: each entry contains an + uncontrolled
, so reconciliation must follow the + entry, not its position, when the list reorders. */} + {source.entries.map((entry) => ( +
+
+ {Object.entries(entry.fields).map(([label, value]) => ( +
+ + {label} + + {value} +
+ ))} +
+ {/* Raw record for auditors; scrolls inside itself, never the page. */} + {entry.raw != null && ( +
+ + Raw record + +
+                      {JSON.stringify(entry.raw, null, 2)}
+                    
+
+ )} +
+ ))} + {source.lastCheckedAt && ( + + Last checked {new Date(source.lastCheckedAt).toLocaleString()} + + )} + +
+ )} +
+ ); +} + +/** + * The member's access across every connected integration bound to the + * Employee Access evidence task, matched by email. Read-only. + */ +export function EmployeeAccess({ + memberId, + organizationId, +}: { + memberId: string; + organizationId: string; +}) { + const { data, error, isLoading } = useMemberAccess(memberId); + + return ( + + + Access + + + {isLoading ? ( + + ) : error ? ( + + Couldn't load access information. Try refreshing the page. + + ) : !data || data.sources.length === 0 ? ( + + + No connected integrations report employee access yet. + + + Browse integrations → + + + ) : ( + + + What this person can access in your connected tools, from each + integration's latest Employee Access check. + + {data.sources.map((source) => ( + + ))} + + )} + + + ); +}