diff --git a/.claude/skills/check-results-service/SKILL.md b/.claude/skills/check-results-service/SKILL.md new file mode 100644 index 0000000000..f79904ebff --- /dev/null +++ b/.claude/skills/check-results-service/SKILL.md @@ -0,0 +1,136 @@ +--- +name: check-results-service +description: How to reuse ANY integration check's results in a feature via the universal CheckResultsService (apps/api integration-platform). Use whenever a feature needs data produced by an integration check — "show 2FA status on People", "surface AWS S3 findings in X", "reuse a check's results", "per-user/per-resource results from a connected integration", "which integrations feed task T". Read this BEFORE writing your own IntegrationCheckResult / CheckRunRepository query — don't hand-roll it. +--- + +# CheckResultsService — reuse any integration check's results + +## The one idea + +Integration checks (2FA, AWS S3 encryption, device posture, …) all write per-resource +results to one table (`IntegrationCheckResult`). **`CheckResultsService` is the single, +universal, read-only way to get those results into any feature.** It is deliberately +**feature-agnostic**: it fetches results and hands them back in a stable envelope, and it +does **not** know or care what the data means. + +> **Service = fetch the results (generic). Feature = interpret + map + present.** + +If you're about to query `IntegrationCheckResult` or `CheckRunRepository` directly from a +new feature — stop. Use this service instead. + +- Service: `apps/api/src/integration-platform/services/check-results.service.ts` +- Exported from `IntegrationPlatformModule` — inject it into any feature module. +- Reference consumer (copy this): the 2FA feature — `two-factor-source.controller.ts`. + +## When to use it + +Use it whenever a feature needs the output of an integration check: +- "Show each employee's 2FA status on the People tab" (the current consumer) +- "Surface which S3 buckets failed encryption inside feature X" +- "Show device compliance per user from the MDM check" +- "List which connected integrations can supply data for task T" + +## The API (3 methods) + +```ts +// 1. Discover sources: which connected integrations can feed a task, + connection state. +listSourcesBoundToTask(organizationId, taskTemplateId): Promise + +// 2. The primitive: full results of a check's latest REAL run for ONE connection. +getLatestResultsByCheck({ organizationId, connectionId, checkId, resourceType? }): Promise + +// 3. Convenience: results for a task-bound check from a chosen source (provider slug). +// Resolves task -> check and slug -> connection for you. +getLatestResultsForTask({ organizationId, taskTemplateId, sourceSlug, resourceType? }): Promise +``` + +Notes: +- "Latest REAL run" = newest run that isn't `inconclusive`/held and whose connection isn't + disconnected. Held runs (our-side self-heal) never leak to a feature. +- No row cap — you get the full result set (unlike the 30-row task-history display). +- `resourceType` filters rows (e.g. `'user'`, `'bucket'`). Omit to get all. +- Empty array = "no data" (source not bound/connected, or never really ran). Never throws + for "no results". + +## The envelope you get back + +```ts +interface CheckResultRow { + resourceId: string; // provider-native id: email (2FA), bucket ARN (S3), repo, … + resourceType: string; // 'user' | 'bucket' | … + passed: boolean; // did this resource pass the check + title: string; + description: string | null; + evidence: Prisma.JsonValue; // ← check-SPECIFIC payload. The service does NOT interpret it. + collectedAt: Date; + runId: string; + connectionId: string; +} +``` + +**The `evidence` rule (important):** the envelope is universal; the check-specific data lives +in `evidence` as raw JSON. The service never types or interprets it. **Your feature validates +`evidence` at its own edge with a zod schema** and reads only the fields it understands: + +```ts +const TwoFaEvidence = z.object({ isEnrolledIn2Sv: z.boolean() }); +const parsed = TwoFaEvidence.safeParse(row.evidence); +// use parsed.data.isEnrolledIn2Sv — never `as any` +``` + +For a simple pass/fail feature you often don't even need `evidence` — just use `row.passed` +and `row.resourceId` (that's all 2FA needs). + +## How to add a NEW consumer (step by step) + +Say a feature wants "which AWS S3 buckets failed encryption": + +1. Inject the service: add `CheckResultsService` to your feature's constructor (the module + already exports it; import `IntegrationPlatformModule` if your module doesn't already). +2. Get results with the primitive (S3 usually spans many AWS connections, so you may loop + connections from `listSourcesBoundToTask` or your own connection lookup): + ```ts + const rows = await this.checkResults.getLatestResultsByCheck({ + organizationId, + connectionId, + checkId: 'aws-s3-encryption', // the check's manifest id + resourceType: 'bucket', + }); + ``` + …or, if your feature lets the user PICK a source for a task (like 2FA does), use + `getLatestResultsForTask` with the task template id and the chosen `sourceSlug`. +3. Interpret in YOUR feature: map `resourceId`/`passed`, validate `evidence` with zod, shape + the response your UI needs. **Do not** add feature logic to the service. +4. Test with the service mocked (see `two-factor-source.controller.spec.ts`). + +## Worked example — the 2FA feature (reference implementation) + +The People-tab 2FA column is consumer #1. Study it end to end: + +- `two-factor-source.controller.ts` + - `available-2fa-sources` → `listSourcesBoundToTask(org, TASK_TEMPLATES.twoFactorAuth)` + - `two-factor-statuses` → `getLatestResultsForTask({ org, taskTemplateId: twoFactorAuth, + sourceSlug: org.twoFactorSource, resourceType: 'user' })`, then maps `resourceId`→email + (lowercased) and `passed`→`enabled`/`missing`. Emails with no row are resolved to + "Not provided" on the client, never a false "missing". +- The controller owns the 2FA-specific bits (the `Organization.twoFactorSource` column, the + enabled/missing interpretation). The generic fetch is all in the service. + +## Rules / do & don't + +- ✅ DO put every "read a check's results" path through this service. +- ✅ DO validate `evidence` with zod in your feature. No `as any`. +- ✅ DO treat an empty array as "no data / not provided" — never a failure. +- ❌ DON'T query `IntegrationCheckResult` / `CheckRunRepository` directly from a feature. +- ❌ DON'T add feature-specific interpretation (enabled/missing, domain mapping) to the + service — it must stay universal and read-only. +- ❌ DON'T assume a source produces per-`user` (or email-keyed) rows — that's per-check. If a + chosen source's check emits a different `resourceType` or a non-mappable `resourceId`, your + feature should degrade gracefully (e.g. "Not provided"), not crash. + +## Source selection is NOT part of this service + +Which integration an org uses for a purpose (e.g. `Organization.twoFactorSource`) is +feature-owned config, mirroring `employeeSyncProvider` / `deviceSyncProvider`. Add a column +per feature. (If a 3rd/4th feature needs it, consider generalizing selection then — not +before.) diff --git a/.claude/skills/responsive-ui/SKILL.md b/.claude/skills/responsive-ui/SKILL.md new file mode 100644 index 0000000000..26bde6e0d4 --- /dev/null +++ b/.claude/skills/responsive-ui/SKILL.md @@ -0,0 +1,98 @@ +--- +name: responsive-ui +description: MANDATORY for any UI work in apps/app, apps/portal, or packages/design-system — every component, page, or layout change must work on mobile (~375px), tablet (~768px), desktop (~1280px), and large desktop (~1920px) BY DEFAULT, without being asked. Read this before writing or editing any JSX/TSX that renders visible UI. Triggers on: new component, page, layout, table, toolbar, form, modal/sheet, dashboard, "build UI", "add a column", "add a filter", any styling change. +--- + +# Responsive UI — default, not a feature request + +## The rule + +**Every UI change ships working at all four device classes. Nobody has to ask.** + +| Device | Test width | Tailwind context | +|---|---|---| +| Mobile | 375px | base (no prefix) | +| Tablet | 768px | `md:` | +| Desktop | 1280px | `xl:` | +| Large desktop | 1920px | beyond `2xl:` | + +Tailwind is **mobile-first**: unprefixed classes are the mobile layout; prefixes add +behavior at wider screens (`sm:` 640, `md:` 768, `lg:` 1024, `xl:` 1280, `2xl:` 1536). +Write the mobile layout first, then widen — not the other way around. + +A change is NOT done until you have reasoned through (or better, viewed via the +`webapp-testing` skill / browser dev tools) what it looks like at 375, 768, 1280, +and 1920. If you only checked one width, you checked none. + +## Works WITH the design system — precedence rules + +This skill layers on top of the `ui` skill; **where they touch, the design-system +rules win**. Responsiveness is achieved THROUGH the DS, never around it: + +- **Never put responsive classes (or any `className`) on DS components** + (`Text`, `Stack`, `HStack`, `Badge`, `Button`, …) — they don't accept it. Put + breakpoint utilities on a wrapper `
` (the `ui` skill's "Layout with Wrapper + Divs" pattern): + ```tsx + // ✅ breakpoints on the wrapper, DS component untouched +
Active
+ // ❌ className on a DS component + Active + ``` +- **Reach for DS layout primitives first** (`PageLayout`, `Stack`, `HStack`, + `Section`) — they carry responsive spacing already. Raw responsive divs are for + what the primitives don't cover, not a replacement for them. +- **Arbitrary pixel values stay an anti-pattern** (`w-[847px]` ❌ — per the `ui` + skill). Prefer the standard Tailwind scale (`max-w-sm`, `w-48`, `max-w-xs`). A + fixed control width like `w-[200px]` is acceptable ONLY when it matches an + established pattern already used on that surface, and it must still carry a + responsive strategy (see below). + +## Non-negotiables + +1. **The page body never scrolls horizontally.** Wide content (tables, code, diagrams) + scrolls inside its own container, never the page. +2. **No fixed width without a responsive strategy.** A fixed-width control is only + acceptable if the element hides, shrinks, or wraps on smaller screens + (`hidden sm:block`, `w-full md:max-w-xs`, or a wrapping parent) — with the + breakpoint classes on a wrapper div, never on a DS component. +3. **Touch targets** on mobile: interactive elements ≥40px tall; don't rely on hover + for anything essential (no hover on touch devices). +4. **Test with real content lengths** — long names, emails, 33-item counts. Use + `truncate` / `min-w-0` on flex children that hold text. +5. **Large desktops:** content must not stretch into unreadable full-width lines — + cap with `max-w-*` containers where the layout doesn't already. + +## Repo patterns (use these, don't invent) + +- **Tables**: the design-system `Table` already wraps rows in `overflow-x-auto` — + wide tables scroll horizontally inside themselves on mobile. That is the accepted + pattern for dense admin tables. Do NOT try to reflow table columns per breakpoint; + do keep cells reasonable (`min-w-* max-w-*` on cell content is fine). +- **Toolbars / filter rows**: primary control (search) is `w-full md:max-w-[300px]`; + secondary controls (filters, source selectors) collapse on phones with + `hidden sm:block`. Example: the People tab toolbar in + `apps/app/.../people/all/components/TeamMembersClient.tsx`. +- **Page shells**: use design-system `PageLayout` / `Stack` / `HStack` — they carry + the responsive spacing; don't rebuild them with raw divs. +- **Grids**: `grid-cols-1 md:grid-cols-2 xl:grid-cols-3` style progressions — never a + fixed column count for all widths. +- **Sheets/Modals**: design-system `Sheet`/`Dialog` are responsive already; don't fix + their widths with arbitrary values. +- **Images/logos**: constrain (`max-w-full`, explicit small sizes); never let an + image force layout width. + +## Checklist before "done" (run mentally or in the browser) + +- [ ] 375px — nothing overflows the viewport; controls usable or intentionally hidden; text truncates instead of breaking layout +- [ ] 768px — secondary controls visible; layout uses available width sensibly +- [ ] 1280px — the intended "design" width; matches mockups +- [ ] 1920px — no absurdly stretched content; whitespace balanced +- [ ] Long content (names, emails, high counts) doesn't break any of the above +- [ ] Tests: if a component hides/reflows per breakpoint in a way that matters, its test asserts the class contract (e.g. `hidden sm:block`) + +## When you can skip a width + +Admin-only dense screens (e.g. internal tooling) may treat mobile as "usable via +horizontal scroll" rather than fully reflowed — but that must be a conscious choice +following the existing pattern of that page, never an accident of not checking. diff --git a/.claude/skills/ui/SKILL.md b/.claude/skills/ui/SKILL.md index 7dc3e4edfd..d0c1c2f387 100644 --- a/.claude/skills/ui/SKILL.md +++ b/.claude/skills/ui/SKILL.md @@ -135,3 +135,14 @@ import { Plus, X } from 'lucide-react';
// Hardcoded colors
// Arbitrary values ``` + +## Responsive (MANDATORY — read the `responsive-ui` skill) + +Every UI change must work on mobile (375px), tablet (768px), desktop (1280px), and +large desktop (1920px) **by default — nobody has to ask**. Tailwind is mobile-first: +write the base (mobile) layout, widen with `sm:`/`md:`/`lg:`/`xl:`. No fixed widths +without a responsive strategy (`hidden sm:block`, `w-full md:max-w-xs`, or a wrapping +parent). All the rules above still apply: breakpoint classes go on wrapper divs, never +on DS components, and arbitrary pixel values remain an anti-pattern. Wide tables scroll +inside the DS `Table` (`overflow-x-auto`), never the page. Full rules + repo patterns + +checklist: `.claude/skills/responsive-ui/SKILL.md`. diff --git a/CLAUDE.md b/CLAUDE.md index cfecc4842d..454a1e2b9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,7 @@ Every customer-facing API endpoint MUST have: - **DS components that do NOT accept `className`**: `Text`, `Stack`, `HStack`, `Badge`, `Button` — wrap in `
` for custom styling - **Layout**: Use `PageLayout`, `PageHeader`, `Stack`, `HStack`, `Section`, `SettingGroup` - **Patterns**: Sheet (`Sheet > SheetContent > SheetHeader + SheetBody`), Drawer, Collapsible +- **Responsive (MANDATORY)**: every UI change must work on mobile (375px), tablet (768px), desktop (1280px), and large desktop (1920px) by default — no one has to ask. See the `responsive-ui` skill for breakpoints, repo patterns, and the checklist. - **After editing any frontend component**: Run the `audit-design-system` skill to catch `@trycompai/ui` or `lucide-react` imports that should be migrated ## Data Fetching diff --git a/apps/api/CLAUDE.md b/apps/api/CLAUDE.md index 3bafe45ef3..f840a709b5 100644 --- a/apps/api/CLAUDE.md +++ b/apps/api/CLAUDE.md @@ -126,6 +126,10 @@ const module = await Test.createTestingModule({ - Use Prisma via `@trycompai/db` - Always scope queries by `organizationId` for multi-tenancy - Use transactions for operations that modify multiple records +- **Reusing an integration check's results in a feature?** Use `CheckResultsService` + (`integration-platform/services/check-results.service.ts`) — the universal, read-only way + to get any check's per-resource results. Do NOT query `IntegrationCheckResult` / + `CheckRunRepository` directly from a feature. See the `check-results-service` skill. ### Gotchas 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 new file mode 100644 index 0000000000..2abc178348 --- /dev/null +++ b/apps/api/src/integration-platform/controllers/two-factor-source.controller.spec.ts @@ -0,0 +1,260 @@ +import { HttpException } from '@nestjs/common'; + +jest.mock('@db', () => ({ + db: { + organization: { findUnique: jest.fn(), update: jest.fn() }, + }, +})); + +// Break the ESM better-auth import chain pulled in via HybridAuthGuard. +jest.mock('@trycompai/auth', () => ({ + statement: { integration: ['create', 'read', 'update', 'delete'] }, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); +jest.mock('../../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); + +import { db } from '@db'; +import { TwoFactorSourceController } from './two-factor-source.controller'; + +const mockOrgFindUnique = ( + db.organization as unknown as { findUnique: jest.Mock } +).findUnique; +const mockOrgUpdate = (db.organization as unknown as { update: jest.Mock }) + .update; + +const mockCheckResults = { + listSourcesBoundToTask: jest.fn(), + getLatestResultsForTask: jest.fn(), +}; + +function makeController() { + return new TwoFactorSourceController(mockCheckResults as never); +} + +function source(slug: string, connected: boolean, name = slug) { + return { + slug, + name, + logoUrl: null, + checkId: 'two-factor-auth', + connected, + connectionId: connected ? `conn_${slug}` : null, + lastSyncAt: null, + nextSyncAt: null, + }; +} + +const ORG = 'org_1'; + +beforeEach(() => { + jest.clearAllMocks(); + // Default: the org exists (individual tests override to simulate a stale/ + // deleted org context). + mockOrgFindUnique.mockResolvedValue({ id: ORG, twoFactorSource: null }); +}); + +describe('TwoFactorSourceController.getTwoFactorSource', () => { + it('returns the configured provider', async () => { + mockOrgFindUnique.mockResolvedValue({ twoFactorSource: 'google-workspace' }); + expect(await makeController().getTwoFactorSource(ORG)).toEqual({ + provider: 'google-workspace', + }); + }); + + it('throws when the org does not exist', async () => { + mockOrgFindUnique.mockResolvedValue(null); + await expect(makeController().getTwoFactorSource(ORG)).rejects.toBeInstanceOf( + HttpException, + ); + }); +}); + +describe('TwoFactorSourceController.setTwoFactorSource', () => { + it('rejects a blank/whitespace provider with 400', async () => { + await expect( + makeController().setTwoFactorSource(ORG, { provider: ' ' }), + ).rejects.toBeInstanceOf(HttpException); + expect(mockOrgUpdate).not.toHaveBeenCalled(); + }); + + it('rejects a provider not bound to the 2FA task', async () => { + mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ + source('google-workspace', true), + ]); + await expect( + makeController().setTwoFactorSource(ORG, { provider: 'slack' }), + ).rejects.toBeInstanceOf(HttpException); + expect(mockOrgUpdate).not.toHaveBeenCalled(); + }); + + it('rejects a bound provider that is not connected', async () => { + mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ + source('google-workspace', false), + ]); + await expect( + makeController().setTwoFactorSource(ORG, { provider: 'google-workspace' }), + ).rejects.toBeInstanceOf(HttpException); + expect(mockOrgUpdate).not.toHaveBeenCalled(); + }); + + it('sets a valid, connected, bound provider', async () => { + mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ + source('google-workspace', true), + ]); + mockOrgUpdate.mockResolvedValue({}); + + const result = await makeController().setTwoFactorSource(ORG, { + provider: 'google-workspace', + }); + + expect(result).toEqual({ success: true, provider: 'google-workspace' }); + expect(mockOrgUpdate).toHaveBeenCalledWith({ + where: { id: ORG }, + data: { twoFactorSource: 'google-workspace' }, + }); + }); + + it('clears the source when provider is null', async () => { + mockOrgUpdate.mockResolvedValue({}); + const result = await makeController().setTwoFactorSource(ORG, { + provider: null, + }); + expect(result).toEqual({ success: true, provider: null }); + expect(mockOrgUpdate).toHaveBeenCalledWith({ + where: { id: ORG }, + data: { twoFactorSource: null }, + }); + }); + + it('returns 404 (not 400 "not connected") when setting a provider for a missing org', async () => { + mockOrgFindUnique.mockResolvedValue(null); + // Even with sources resolvable, the missing org must win with a 404 — + // a dead org's empty connection list must not surface as a 400. + mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ + source('google-workspace', false), + ]); + + await expect( + makeController().setTwoFactorSource(ORG, { provider: 'google-workspace' }), + ).rejects.toMatchObject({ status: 404 }); + expect(mockOrgUpdate).not.toHaveBeenCalled(); + }); + + it('maps a missing org row (P2025) to 404 instead of a 500', async () => { + mockOrgUpdate.mockRejectedValue( + Object.assign(new Error('No record found'), { code: 'P2025' }), + ); + + await expect( + makeController().setTwoFactorSource(ORG, { provider: null }), + ).rejects.toMatchObject({ status: 404 }); + }); + + it('rethrows non-P2025 update errors untouched', async () => { + const dbError = new Error('connection lost'); + mockOrgUpdate.mockRejectedValue(dbError); + + await expect( + makeController().setTwoFactorSource(ORG, { provider: null }), + ).rejects.toBe(dbError); + }); +}); + +describe('TwoFactorSourceController.getAvailableTwoFactorSources', () => { + it('returns bound sources with connection state (without the internal checkId)', async () => { + mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ + source('google-workspace', true, 'Google Workspace'), + source('github', false, 'GitHub'), + ]); + + const { providers } = await makeController().getAvailableTwoFactorSources(ORG); + + expect(providers.map((p) => p.slug)).toEqual(['google-workspace', 'github']); + expect(providers[0]).not.toHaveProperty('checkId'); + expect(providers[0].connected).toBe(true); + }); +}); + +describe('TwoFactorSourceController.getTwoFactorStatuses', () => { + it('returns unconfigured when no source is set', async () => { + mockOrgFindUnique.mockResolvedValue({ twoFactorSource: null }); + expect(await makeController().getTwoFactorStatuses(ORG)).toEqual({ + configured: false, + source: null, + statuses: [], + }); + expect(mockCheckResults.getLatestResultsForTask).not.toHaveBeenCalled(); + }); + + it('404s on a missing org instead of reporting unconfigured', async () => { + mockOrgFindUnique.mockResolvedValue(null); + await expect( + makeController().getTwoFactorStatuses(ORG), + ).rejects.toMatchObject({ status: 404 }); + }); + + it('maps the service results to lowercased email + enabled/missing', async () => { + mockOrgFindUnique.mockResolvedValue({ twoFactorSource: 'google-workspace' }); + mockCheckResults.getLatestResultsForTask.mockResolvedValue([ + { resourceId: 'Alice@X.com', passed: true }, + { resourceId: 'bob@x.com', passed: false }, + ]); + + const result = await makeController().getTwoFactorStatuses(ORG); + + expect(mockCheckResults.getLatestResultsForTask).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: ORG, + sourceSlug: 'google-workspace', + resourceType: 'user', + }), + ); + expect(result).toEqual({ + configured: true, + source: 'google-workspace', + statuses: [ + { email: 'alice@x.com', status: 'enabled' }, + { email: 'bob@x.com', status: 'missing' }, + ], + }); + }); + + it('resolves conflicting rows for one email deterministically — a fail always wins', async () => { + mockOrgFindUnique.mockResolvedValue({ twoFactorSource: 'google-workspace' }); + mockCheckResults.listSourcesBoundToTask.mockResolvedValue([ + source('google-workspace', true), + ]); + + // Same email with pass+fail rows, in BOTH orders — result must not depend + // on iteration order. + for (const rows of [ + [ + { resourceId: 'dup@x.com', passed: true }, + { resourceId: 'dup@x.com', passed: false }, + ], + [ + { resourceId: 'dup@x.com', passed: false }, + { resourceId: 'dup@x.com', passed: true }, + ], + ]) { + mockCheckResults.getLatestResultsForTask.mockResolvedValue(rows); + const result = await makeController().getTwoFactorStatuses(ORG); + expect(result.statuses).toEqual([ + { email: 'dup@x.com', status: 'missing' }, + ]); + } + }); + + it('returns empty statuses when the source has no results', async () => { + mockOrgFindUnique.mockResolvedValue({ twoFactorSource: 'google-workspace' }); + mockCheckResults.getLatestResultsForTask.mockResolvedValue([]); + + expect(await makeController().getTwoFactorStatuses(ORG)).toEqual({ + configured: true, + source: 'google-workspace', + statuses: [], + }); + }); +}); 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 new file mode 100644 index 0000000000..deba61f724 --- /dev/null +++ b/apps/api/src/integration-platform/controllers/two-factor-source.controller.ts @@ -0,0 +1,241 @@ +import { + Controller, + Get, + Post, + Body, + HttpException, + HttpStatus, + Logger, + UseGuards, +} from '@nestjs/common'; +import { + ApiBody, + ApiOperation, + ApiPropertyOptional, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; +import { db } from '@db'; +import { TASK_TEMPLATES } 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'; + +// 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. +class SetTwoFactorSourceDto { + // UI sends organizationId in the body; ignored by the handler (derived from auth). + @ApiPropertyOptional({ + description: + 'Auto-resolved from your API key / session. You can omit this; it is ignored by the server.', + }) + @IsOptional() + @IsString() + organizationId?: string; + + @ApiPropertyOptional({ + description: + 'Provider slug whose 2FA check feeds the People-tab 2FA column (must be bound to the 2FA task — call available-2fa-sources for options). Pass null or omit to clear the source.', + example: 'google-workspace', + nullable: true, + }) + @IsOptional() + @IsString() + provider?: string | null; +} + +/** + * 2FA source configuration + per-employee 2FA status for the People tab. + * + * This controller owns only the 2FA-SPECIFIC concerns: the org's chosen source + * (`Organization.twoFactorSource`) and the enabled/missing interpretation. All + * the generic "read an integration check's results" work is delegated to + * {@link CheckResultsService} — it is consumer #1 of that universal service. + */ +@Controller({ path: 'integrations/sync', version: '1' }) +@ApiTags('Integrations') +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class TwoFactorSourceController { + private readonly logger = new Logger(TwoFactorSourceController.name); + + constructor(private readonly checkResults: CheckResultsService) {} + + /** + * Get the currently configured 2FA source provider. + */ + @Get('two-factor-source') + @ApiOperation({ summary: 'Get the configured 2FA source provider' }) + @RequirePermission('integration', 'read') + async getTwoFactorSource(@OrganizationId() organizationId: string) { + const org = await db.organization.findUnique({ + where: { id: organizationId }, + select: { twoFactorSource: true }, + }); + if (!org) { + throw new HttpException('Organization not found', HttpStatus.NOT_FOUND); + } + return { provider: org.twoFactorSource }; + } + + /** + * Set (or clear) the org's 2FA source provider. + */ + @Post('two-factor-source') + @ApiOperation({ summary: 'Set the 2FA source provider' }) + @ApiBody({ type: SetTwoFactorSourceDto }) + @RequirePermission('integration', 'update') + async setTwoFactorSource( + @OrganizationId() organizationId: string, + @Body() body: SetTwoFactorSourceDto, + ) { + // Only an explicit string (set) or null (clear) are valid. Reject anything + // else — non-string, or blank/whitespace — with a 400 rather than silently + // coercing it to null and clearing the org's configured source. + const rawProvider = body.provider; + if ( + rawProvider !== null && + rawProvider !== undefined && + (typeof rawProvider !== 'string' || rawProvider.trim().length === 0) + ) { + throw new HttpException( + 'provider must be a non-empty string or null', + HttpStatus.BAD_REQUEST, + ); + } + const provider = rawProvider ? rawProvider.trim() : null; + + // The org row must exist before any provider semantics are evaluated, so a + // stale/deleted org context always yields this endpoint's 404 contract + // (otherwise a dead org's empty connection list reads as a 400 + // "not connected"). The P2025 catch below only covers a deletion racing + // the update itself. + const org = await db.organization.findUnique({ + where: { id: organizationId }, + select: { id: true }, + }); + if (!org) { + throw new HttpException('Organization not found', HttpStatus.NOT_FOUND); + } + + if (provider) { + const sources = await this.checkResults.listSourcesBoundToTask( + organizationId, + TASK_TEMPLATES.twoFactorAuth, + ); + const source = sources.find((s) => s.slug === provider); + if (!source) { + throw new HttpException( + `Invalid 2FA source. Must be one of: ${sources.map((s) => s.slug).join(', ')}`, + HttpStatus.BAD_REQUEST, + ); + } + if (!source.connected) { + throw new HttpException( + `Provider ${provider} is not connected`, + HttpStatus.BAD_REQUEST, + ); + } + } + + try { + await db.organization.update({ + where: { id: organizationId }, + data: { twoFactorSource: provider }, + }); + } catch (err) { + // Stale/deleted org context: Prisma throws P2025 on a missing row — map + // it to the same 404 as this controller's other org lookups, not a 500. + if ((err as { code?: string }).code === 'P2025') { + throw new HttpException('Organization not found', HttpStatus.NOT_FOUND); + } + throw err; + } + + this.logger.log( + `Set 2FA source to ${provider ?? 'none'} for org ${organizationId}`, + ); + return { success: true, provider }; + } + + /** + * List integrations that can supply per-user 2FA status (all bound to the 2FA + * task), with each one's connection state for this org. + */ + @Get('available-2fa-sources') + @ApiOperation({ summary: 'List integrations that can supply per-user 2FA status' }) + @RequirePermission('integration', 'read') + async getAvailableTwoFactorSources(@OrganizationId() organizationId: string) { + const sources = await this.checkResults.listSourcesBoundToTask( + 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, + })); + return { providers }; + } + + /** + * Per-user 2FA status from the configured source's latest real check run. + * + * Returns only emails that had a result row; the People tab resolves any + * member without a matching row to "Not provided". Emails are lowercased to + * match the (lowercased) member emails on the join. + */ + @Get('two-factor-statuses') + @ApiOperation({ summary: 'Per-user 2FA status from the configured source' }) + @RequirePermission('integration', 'read') + async getTwoFactorStatuses(@OrganizationId() organizationId: string) { + const org = await db.organization.findUnique({ + where: { id: organizationId }, + select: { twoFactorSource: true }, + }); + // Missing org context is an error, not "unconfigured" — keep it distinct + // so callers never mistake invalid context for a valid empty state. + if (!org) { + throw new HttpException('Organization not found', HttpStatus.NOT_FOUND); + } + const source = org.twoFactorSource ?? null; + if (!source) { + return { configured: false, source: null, statuses: [] }; + } + + // Delegate the generic fetch; interpret 'user' rows here (2FA's concern). + const results = await this.checkResults.getLatestResultsForTask({ + organizationId, + taskTemplateId: TASK_TEMPLATES.twoFactorAuth, + sourceSlug: source, + resourceType: 'user', + }); + + // Dedupe by lowercased email. Result rows carry no ordering, so conflict + // resolution must not depend on iteration order: a failing row always wins + // (compliance-conservative — if any row says the user lacks 2FA, show + // missing; never upgrade back to enabled). + const byEmail = new Map(); + for (const result of results) { + const email = result.resourceId.toLowerCase().trim(); + if (!email) continue; + if (byEmail.get(email) === 'missing') continue; + byEmail.set(email, result.passed ? 'enabled' : 'missing'); + } + const statuses = Array.from(byEmail, ([email, status]) => ({ + email, + status, + })); + + return { configured: true, source, statuses }; + } +} diff --git a/apps/api/src/integration-platform/integration-platform.module.ts b/apps/api/src/integration-platform/integration-platform.module.ts index aa9c2a6954..344a45628b 100644 --- a/apps/api/src/integration-platform/integration-platform.module.ts +++ b/apps/api/src/integration-platform/integration-platform.module.ts @@ -14,6 +14,7 @@ import { VariablesController } from './controllers/variables.controller'; import { TaskIntegrationsController } from './controllers/task-integrations.controller'; import { WebhookController } from './controllers/webhook.controller'; import { SyncController } from './controllers/sync.controller'; +import { TwoFactorSourceController } from './controllers/two-factor-source.controller'; import { ServicesController } from './controllers/services.controller'; import { CredentialVaultService } from './services/credential-vault.service'; import { ConnectionService } from './services/connection.service'; @@ -37,6 +38,7 @@ import { DynamicCheckRepository } from './repositories/dynamic-check.repository' 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 { CheckResultsService } from './services/check-results.service'; @Module({ imports: [AuthModule, forwardRef(() => CloudSecurityModule)], @@ -54,6 +56,7 @@ import { GenericDeviceSyncService } from './services/generic-device-sync.service TaskIntegrationsController, WebhookController, SyncController, + TwoFactorSourceController, ServicesController, ], providers: [ @@ -71,6 +74,7 @@ import { GenericDeviceSyncService } from './services/generic-device-sync.service IntegrationSyncLoggerService, GenericEmployeeSyncService, GenericDeviceSyncService, + CheckResultsService, // Repositories ProviderRepository, ConnectionRepository, @@ -88,6 +92,9 @@ import { GenericDeviceSyncService } from './services/generic-device-sync.service OAuthCredentialsService, AutoCheckRunnerService, DynamicManifestLoaderService, + // Universal, feature-agnostic access to integration check results. Any + // feature module that needs to reuse check output injects this. + CheckResultsService, ], }) export class IntegrationPlatformModule {} diff --git a/apps/api/src/integration-platform/repositories/check-run.repository.spec.ts b/apps/api/src/integration-platform/repositories/check-run.repository.spec.ts index 821db6811d..ee4fbba814 100644 --- a/apps/api/src/integration-platform/repositories/check-run.repository.spec.ts +++ b/apps/api/src/integration-platform/repositories/check-run.repository.spec.ts @@ -3,9 +3,11 @@ jest.mock('@db', () => ({ integrationCheckRun: { groupBy: jest.fn(), findMany: jest.fn(), + findFirst: jest.fn(), }, integrationCheckResult: { count: jest.fn(), + findMany: jest.fn(), }, }, })); @@ -24,6 +26,12 @@ const mockFindMany = mockedCheckRun.findMany; const mockResultCount = ( db.integrationCheckResult as unknown as { count: jest.Mock } ).count; +const mockFindFirst = ( + db.integrationCheckRun as unknown as { findFirst: jest.Mock } +).findFirst; +const mockResultFindMany = ( + db.integrationCheckResult as unknown as { findMany: jest.Mock } +).findMany; function makeRun(opts: { id: string; @@ -286,3 +294,80 @@ describe('CheckRunRepository.countExceptedFailures', () => { }); }); }); + +describe('CheckRunRepository.findLatestResultsByConnectionAndCheck', () => { + const repo = new CheckRunRepository(); + const params = { + connectionId: 'conn_1', + checkId: 'two-factor-auth', + organizationId: 'org_1', + resourceType: 'user', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null and never loads results when there is no real run', async () => { + mockFindFirst.mockResolvedValue(null); + + const result = await repo.findLatestResultsByConnectionAndCheck(params); + + expect(result).toBeNull(); + expect(mockResultFindMany).not.toHaveBeenCalled(); + }); + + it('scopes the run to org + connection + check, excluding held runs and disconnected connections', async () => { + mockFindFirst.mockResolvedValue({ id: 'run_1' }); + mockResultFindMany.mockResolvedValue([]); + + await repo.findLatestResultsByConnectionAndCheck(params); + + expect(mockFindFirst).toHaveBeenCalledWith({ + where: { + connectionId: 'conn_1', + checkId: 'two-factor-auth', + // Held (inconclusive) runs must never surface to the customer. + status: { not: 'inconclusive' }, + connection: { + organizationId: 'org_1', + status: { not: 'disconnected' }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('loads the FULL result set for the latest run — no take cap — filtered by resourceType', async () => { + mockFindFirst.mockResolvedValue({ id: 'run_1' }); + const rows = [ + { id: 'icx_1', resourceId: 'a@x.com', passed: true }, + { id: 'icx_2', resourceId: 'b@x.com', passed: false }, + ]; + mockResultFindMany.mockResolvedValue(rows); + + const result = await repo.findLatestResultsByConnectionAndCheck(params); + + expect(mockResultFindMany).toHaveBeenCalledWith({ + where: { checkRunId: 'run_1', resourceType: 'user' }, + }); + // Every resource must map to a domain entity — the 30-row display cap is NOT reused. + expect(mockResultFindMany.mock.calls[0][0].take).toBeUndefined(); + expect(result).toEqual({ run: { id: 'run_1' }, results: rows }); + }); + + it('omits the resourceType filter when not provided (loads all rows)', async () => { + mockFindFirst.mockResolvedValue({ id: 'run_1' }); + mockResultFindMany.mockResolvedValue([]); + + await repo.findLatestResultsByConnectionAndCheck({ + connectionId: 'conn_1', + checkId: 'aws-s3-encryption', + organizationId: 'org_1', + }); + + expect(mockResultFindMany).toHaveBeenCalledWith({ + where: { checkRunId: 'run_1' }, + }); + }); +}); 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 38d07ac76b..8d360adbe6 100644 --- a/apps/api/src/integration-platform/repositories/check-run.repository.ts +++ b/apps/api/src/integration-platform/repositories/check-run.repository.ts @@ -301,6 +301,49 @@ export class CheckRunRepository { }); } + /** + * Full results of a (connection, check)'s most recent REAL run, scoped to an + * org. "Real" = never `inconclusive`/held, never a disconnected connection. + * + * This is the generic read behind {@link CheckResultsService} — any feature + * reusing check results goes through that service, not this method directly. + * Unlike the task-run-history read there is NO `take` cap: a consumer that + * maps every resource (e.g. one member per user row) needs the full set, so + * the 30-row display sample ({@link DISPLAY_RESULTS_PER_RUN}) is deliberately + * not reused here. Pass `resourceType` to load only rows of that kind (e.g. + * `'user'`); omit it to load all. Returns null when there is no real run. + */ + async findLatestResultsByConnectionAndCheck({ + connectionId, + checkId, + organizationId, + resourceType, + }: { + connectionId: string; + checkId: string; + organizationId: string; + resourceType?: string; + }) { + const run = await db.integrationCheckRun.findFirst({ + where: { + connectionId, + checkId, + status: { not: 'inconclusive' }, + connection: { organizationId, status: { not: 'disconnected' } }, + }, + orderBy: { createdAt: 'desc' }, + }); + if (!run) return null; + + const results = await db.integrationCheckResult.findMany({ + where: { + checkRunId: run.id, + ...(resourceType ? { resourceType } : {}), + }, + }); + return { run, results }; + } + /** * Get check runs for a connection */ diff --git a/apps/api/src/integration-platform/repositories/connection.repository.ts b/apps/api/src/integration-platform/repositories/connection.repository.ts index a4ba3be825..c88241cabd 100644 --- a/apps/api/src/integration-platform/repositories/connection.repository.ts +++ b/apps/api/src/integration-platform/repositories/connection.repository.ts @@ -61,6 +61,50 @@ export class ConnectionRepository { return this.findByProviderAndOrg(provider.id, organizationId); } + /** + * Active connections for a set of provider slugs, keyed by slug, in ONE query. + * When a provider has several active connections (e.g. AWS multi-account) the + * newest wins. Slugs with no active connection are simply absent from the map. + */ + async findActiveBySlugsAndOrg( + providerSlugs: string[], + organizationId: string, + ): Promise< + Map + > { + if (providerSlugs.length === 0) return new Map(); + + const connections = await db.integrationConnection.findMany({ + where: { + organizationId, + status: 'active', + provider: { slug: { in: providerSlugs } }, + }, + select: { + id: true, + lastSyncAt: true, + nextSyncAt: true, + provider: { select: { slug: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const bySlug = new Map< + string, + { id: string; lastSyncAt: Date | null; nextSyncAt: Date | null } + >(); + for (const connection of connections) { + if (!bySlug.has(connection.provider.slug)) { + bySlug.set(connection.provider.slug, { + id: connection.id, + lastSyncAt: connection.lastSyncAt, + nextSyncAt: connection.nextSyncAt, + }); + } + } + return bySlug; + } + async findByOrganization( organizationId: string, ): Promise { diff --git a/apps/api/src/integration-platform/services/README-check-results.md b/apps/api/src/integration-platform/services/README-check-results.md new file mode 100644 index 0000000000..fe0c05d5ff --- /dev/null +++ b/apps/api/src/integration-platform/services/README-check-results.md @@ -0,0 +1,39 @@ +# CheckResultsService — reuse any integration check's results + +`CheckResultsService` is the **single, universal, read-only** way for any feature to consume +the output of an integration check (2FA, AWS S3, device posture, …). It fetches per-resource +results and returns them in a stable, feature-agnostic envelope. It does **not** interpret +the data — that's the feature's job. + +> Service = fetch results (generic). Feature = interpret + map + present. + +**Full usage map, examples, and rules:** see the `check-results-service` skill +(`.claude/skills/check-results-service/SKILL.md`). + +## 30-second version + +```ts +// inject CheckResultsService (exported by IntegrationPlatformModule), then: + +// which connected integrations can feed a task, + connection state +await checkResults.listSourcesBoundToTask(orgId, TASK_TEMPLATES.twoFactorAuth); + +// full results of a check's latest real run for one connection +await checkResults.getLatestResultsByCheck({ organizationId, connectionId, checkId, resourceType }); + +// results for a task-bound check from a chosen source (resolves task->check, slug->connection) +await checkResults.getLatestResultsForTask({ organizationId, taskTemplateId, sourceSlug, resourceType }); +``` + +Each row is `{ resourceId, resourceType, passed, title, description, evidence, collectedAt, +runId, connectionId }`. The check-specific payload is `evidence` (raw JSON) — **validate it +with zod in your feature**; the service never types it. Empty array = "no data" (never throws). + +## Don't + +- Don't query `IntegrationCheckResult` / `CheckRunRepository` directly from a feature — use this service. +- Don't add feature-specific interpretation to the service — keep it universal and read-only. + +## Reference consumer + +The People-tab 2FA column: `../controllers/two-factor-source.controller.ts`. 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 new file mode 100644 index 0000000000..05a89dac1f --- /dev/null +++ b/apps/api/src/integration-platform/services/check-results.service.spec.ts @@ -0,0 +1,194 @@ +jest.mock('@db', () => ({ db: {} })); + +jest.mock('@trycompai/integration-platform', () => { + const actual = jest.requireActual< + typeof import('@trycompai/integration-platform') + >('@trycompai/integration-platform'); + return { + ...actual, + registry: { getActiveManifests: jest.fn() }, + }; +}); + +import { registry, TASK_TEMPLATES } from '@trycompai/integration-platform'; +import { CheckResultsService } from './check-results.service'; + +const mockGetActiveManifests = ( + registry as unknown as { getActiveManifests: jest.Mock } +).getActiveManifests; + +const mockCheckRunRepo = { findLatestResultsByConnectionAndCheck: jest.fn() }; +const mockConnRepo = { + findBySlugAndOrg: jest.fn(), + findActiveBySlugsAndOrg: jest.fn(), +}; + +function makeService() { + return new CheckResultsService( + mockCheckRunRepo as never, + mockConnRepo as never, + ); +} + +function boundManifest(id: string, name = id) { + return { + id, + name, + logoUrl: null, + checks: [{ id: 'two-factor-auth', taskMapping: TASK_TEMPLATES.twoFactorAuth }], + }; +} +function unboundManifest(id: string) { + return { + id, + name: id, + logoUrl: null, + checks: [{ id: 'other', taskMapping: TASK_TEMPLATES.codeChanges }], + }; +} + +const ORG = 'org_1'; +const TWO_FA = TASK_TEMPLATES.twoFactorAuth; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('CheckResultsService.listSourcesBoundToTask', () => { + it('returns only manifests bound to the task, with connection state + checkId', async () => { + mockGetActiveManifests.mockReturnValue([ + boundManifest('google-workspace', 'Google Workspace'), + unboundManifest('slack'), + boundManifest('github', 'GitHub'), + ]); + mockConnRepo.findActiveBySlugsAndOrg.mockResolvedValue( + new Map([ + ['google-workspace', { id: 'c1', lastSyncAt: null, nextSyncAt: null }], + ]), + ); + + const sources = await makeService().listSourcesBoundToTask(ORG, TWO_FA); + + // One batched lookup for all bound slugs — not one query per manifest. + expect(mockConnRepo.findActiveBySlugsAndOrg).toHaveBeenCalledWith( + ['google-workspace', 'github'], + ORG, + ); + expect(sources.map((s) => s.slug)).toEqual(['google-workspace', 'github']); + const gws = sources.find((s) => s.slug === 'google-workspace'); + expect(gws).toMatchObject({ + connected: true, + connectionId: 'c1', + checkId: 'two-factor-auth', + }); + expect(sources.find((s) => s.slug === 'github')?.connected).toBe(false); + }); +}); + +describe('CheckResultsService.getLatestResultsByCheck', () => { + it('maps repo rows into the generic envelope (evidence passed through untouched)', async () => { + const collectedAt = new Date('2026-01-01T00:00:00Z'); + mockCheckRunRepo.findLatestResultsByConnectionAndCheck.mockResolvedValue({ + run: { id: 'run_1' }, + results: [ + { + resourceId: 'a@x.com', + resourceType: 'user', + passed: true, + title: 'Has 2FA', + description: null, + evidence: { isEnrolledIn2Sv: true }, + collectedAt, + }, + ], + }); + + const rows = await makeService().getLatestResultsByCheck({ + organizationId: ORG, + connectionId: 'conn_1', + checkId: 'two-factor-auth', + resourceType: 'user', + }); + + expect(rows).toEqual([ + { + resourceId: 'a@x.com', + resourceType: 'user', + passed: true, + title: 'Has 2FA', + description: null, + evidence: { isEnrolledIn2Sv: true }, + collectedAt, + runId: 'run_1', + connectionId: 'conn_1', + }, + ]); + }); + + it('returns [] when there is no real run', async () => { + mockCheckRunRepo.findLatestResultsByConnectionAndCheck.mockResolvedValue( + null, + ); + const rows = await makeService().getLatestResultsByCheck({ + organizationId: ORG, + connectionId: 'conn_1', + checkId: 'two-factor-auth', + }); + expect(rows).toEqual([]); + }); +}); + +describe('CheckResultsService.getLatestResultsForTask', () => { + it('resolves task->check and slug->connection, then fetches results', async () => { + mockGetActiveManifests.mockReturnValue([boundManifest('google-workspace')]); + mockConnRepo.findBySlugAndOrg.mockResolvedValue({ + id: 'conn_1', + status: 'active', + }); + mockCheckRunRepo.findLatestResultsByConnectionAndCheck.mockResolvedValue({ + run: { id: 'run_1' }, + results: [], + }); + + await makeService().getLatestResultsForTask({ + organizationId: ORG, + taskTemplateId: TWO_FA, + sourceSlug: 'google-workspace', + resourceType: 'user', + }); + + expect( + mockCheckRunRepo.findLatestResultsByConnectionAndCheck, + ).toHaveBeenCalledWith({ + connectionId: 'conn_1', + checkId: 'two-factor-auth', + organizationId: ORG, + resourceType: 'user', + }); + }); + + it('returns [] when the source is not bound to the task', async () => { + mockGetActiveManifests.mockReturnValue([]); + const rows = await makeService().getLatestResultsForTask({ + organizationId: ORG, + taskTemplateId: TWO_FA, + sourceSlug: 'google-workspace', + }); + expect(rows).toEqual([]); + expect(mockConnRepo.findBySlugAndOrg).not.toHaveBeenCalled(); + }); + + it('returns [] when the source has no connection', async () => { + mockGetActiveManifests.mockReturnValue([boundManifest('google-workspace')]); + mockConnRepo.findBySlugAndOrg.mockResolvedValue(null); + const rows = await makeService().getLatestResultsForTask({ + organizationId: ORG, + taskTemplateId: TWO_FA, + sourceSlug: 'google-workspace', + }); + expect(rows).toEqual([]); + expect( + mockCheckRunRepo.findLatestResultsByConnectionAndCheck, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/integration-platform/services/check-results.service.ts b/apps/api/src/integration-platform/services/check-results.service.ts new file mode 100644 index 0000000000..313a42d94e --- /dev/null +++ b/apps/api/src/integration-platform/services/check-results.service.ts @@ -0,0 +1,186 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '@db'; +import { + registry, + type IntegrationCheck, + type IntegrationManifest, + type TaskTemplateId, +} from '@trycompai/integration-platform'; +import { CheckRunRepository } from '../repositories/check-run.repository'; +import { ConnectionRepository } from '../repositories/connection.repository'; + +/** + * One row of an integration check's output, in a stable, FEATURE-AGNOSTIC shape. + * + * The envelope (resourceId/passed/etc.) is universal across every check. The + * check-SPECIFIC payload lives in `evidence` (raw JSON) — this service does NOT + * interpret it. Each consuming feature validates `evidence` at its own edge + * (e.g. with a zod schema) and reads only the fields it understands. + */ +export interface CheckResultRow { + /** Provider-native identifier for the resource (email, bucket ARN, repo, …). */ + resourceId: string; + /** Kind of resource the check produced (e.g. 'user', 'bucket'). */ + resourceType: string; + /** Whether this resource passed the check. */ + passed: boolean; + title: string; + description: string | null; + /** Check-specific payload — interpret this in the feature, never here. */ + evidence: Prisma.JsonValue; + collectedAt: Date; + runId: string; + connectionId: string; +} + +/** A connected integration that can supply results for a given task. */ +export interface CheckSourceInfo { + slug: string; + name: string; + logoUrl: string | null; + /** The check on this source bound to the requested task. */ + checkId: string; + connected: boolean; + connectionId: string | null; + lastSyncAt: string | null; + nextSyncAt: string | null; +} + +/** + * Universal, read-only access to integration CHECK RESULTS. + * + * The single reuse point for any feature that wants to consume the output of an + * integration check — 2FA today, background checks / device posture / S3 config + * tomorrow. It is deliberately feature-agnostic: it fetches results and returns + * them in a stable envelope, and it does NOT know or care what the data means. + * Interpretation, domain mapping, and presentation are the feature's job. + * + * See `apps/api/src/integration-platform/services/README-check-results.md` + * (and the `check-results-service` skill) for the usage map + examples. + */ +@Injectable() +export class CheckResultsService { + constructor( + private readonly checkRunRepo: CheckRunRepository, + private readonly connectionRepository: ConnectionRepository, + ) {} + + /** + * Every active manifest — codebase OR dynamic — paired with its check bound to + * the task template. Dynamic manifests are merged into the same registry, so + * this is uniformly universal. Pairing (instead of re-finding the check later) + * lets the type system carry the "bound check exists" guarantee. + */ + private boundPairsForTask( + taskTemplateId: TaskTemplateId, + ): Array<{ manifest: IntegrationManifest; check: IntegrationCheck }> { + return registry.getActiveManifests().flatMap((manifest) => { + const check = manifest.checks?.find( + (c) => c.taskMapping === taskTemplateId, + ); + return check ? [{ manifest, check }] : []; + }); + } + + /** + * List the integrations that can supply results for a task in this org, with + * each one's connection state. Use this to build a "source" selector or to + * discover what's available. Returns ALL bound integrations (connected or not) + * so the caller can decide how to present unconnected ones. + */ + async listSourcesBoundToTask( + organizationId: string, + taskTemplateId: TaskTemplateId, + ): Promise { + const pairs = this.boundPairsForTask(taskTemplateId); + const connectionsBySlug = + await this.connectionRepository.findActiveBySlugsAndOrg( + pairs.map((p) => p.manifest.id), + organizationId, + ); + + return pairs.map(({ manifest, check }) => { + const connection = connectionsBySlug.get(manifest.id); + return { + slug: manifest.id, + name: manifest.name, + logoUrl: manifest.logoUrl ?? null, + checkId: check.id, + connected: !!connection, + connectionId: connection?.id ?? null, + lastSyncAt: connection?.lastSyncAt?.toISOString() ?? null, + nextSyncAt: connection?.nextSyncAt?.toISOString() ?? null, + }; + }); + } + + /** + * The primitive: full results of a specific check's latest real run for a + * specific connection. Everything else composes from this. Pass `resourceType` + * to load only rows of that kind. Returns [] when the check has never really run. + */ + async getLatestResultsByCheck({ + organizationId, + connectionId, + checkId, + resourceType, + }: { + organizationId: string; + connectionId: string; + checkId: string; + resourceType?: string; + }): Promise { + const latest = await this.checkRunRepo.findLatestResultsByConnectionAndCheck( + { connectionId, checkId, organizationId, resourceType }, + ); + if (!latest) return []; + + return latest.results.map((r) => ({ + resourceId: r.resourceId, + resourceType: r.resourceType, + passed: r.passed, + title: r.title, + description: r.description, + evidence: r.evidence, + collectedAt: r.collectedAt, + runId: latest.run.id, + connectionId, + })); + } + + /** + * Convenience: results for a task-bound check from a chosen source (provider + * slug). Resolves task -> check and slug -> connection, then fetches the + * latest real run. Returns [] when the source isn't bound/connected or has no + * real run. This is what a "pick a source, show its results" feature uses. + */ + async getLatestResultsForTask({ + organizationId, + taskTemplateId, + sourceSlug, + resourceType, + }: { + organizationId: string; + taskTemplateId: TaskTemplateId; + sourceSlug: string; + resourceType?: string; + }): Promise { + const pair = this.boundPairsForTask(taskTemplateId).find( + (p) => p.manifest.id === sourceSlug, + ); + if (!pair) return []; + + const connection = await this.connectionRepository.findBySlugAndOrg( + sourceSlug, + organizationId, + ); + if (!connection) return []; + + return this.getLatestResultsByCheck({ + organizationId, + connectionId: connection.id, + checkId: pair.check.id, + resourceType, + }); + } +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/DateRangeFilter.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/DateRangeFilter.tsx new file mode 100644 index 0000000000..c97dbf6e8f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/DateRangeFilter.tsx @@ -0,0 +1,194 @@ +'use client'; + +import { useState } from 'react'; +import { format } from 'date-fns'; +import { + Button, + Calendar, + Popover, + PopoverContent, + PopoverTrigger, +} from '@trycompai/design-system'; +import { Calendar as CalendarIcon, ChevronDown } from '@trycompai/design-system/icons'; + +const PRESETS = [ + { label: 'Last 7 days', days: 7 }, + { label: 'Last 30 days', days: 30 }, + { label: 'This quarter', days: 90 }, + { label: 'This year', days: 365 }, + { label: 'All time', days: 0 }, +] as const; + +function getPresetRange(days: number): { from: Date | undefined; to: Date | undefined } { + if (days === 0) return { from: undefined, to: undefined }; + const to = new Date(); + const from = new Date(); + from.setDate(from.getDate() - days); + from.setHours(0, 0, 0, 0); + return { from, to }; +} + +function getActivePresetLabel(from: Date | undefined, to: Date | undefined): string | null { + // Must be the exact PRESETS label so the chip renders as selected. + if (!from && !to) return 'All time'; + if (!from || !to) return null; + const diffDays = Math.round((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24)); + const now = new Date(); + const isToToday = Math.abs(to.getTime() - now.getTime()) < 1000 * 60 * 60 * 24; + if (!isToToday) return null; + for (const p of PRESETS) { + if (p.days === 0) continue; + if (Math.abs(diffDays - p.days) <= 1) return p.label; + } + return null; +} + +export function DateRangeFilter({ + label, + from, + to, + onApply, + onClear, +}: { + label: string; + from: Date | undefined; + to: Date | undefined; + onApply: (from: Date | undefined, to: Date | undefined) => void; + onClear: () => void; +}) { + const [open, setOpen] = useState(false); + const [draftFrom, setDraftFrom] = useState(from); + const [draftTo, setDraftTo] = useState(to); + const [activePreset, setActivePreset] = useState(null); + const [fromPickerOpen, setFromPickerOpen] = useState(false); + const [toPickerOpen, setToPickerOpen] = useState(false); + + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + setDraftFrom(from); + setDraftTo(to); + setActivePreset(getActivePresetLabel(from, to)); + } + // Nested pickers must never stay open across parent close/reopen. + setFromPickerOpen(false); + setToPickerOpen(false); + setOpen(isOpen); + }; + + const handlePreset = (days: number, presetLabel: string) => { + const range = getPresetRange(days); + setDraftFrom(range.from); + setDraftTo(range.to); + setActivePreset(presetLabel); + }; + + const handleApply = () => { + onApply(draftFrom, draftTo); + setOpen(false); + }; + + const handleClear = () => { + onClear(); + setOpen(false); + }; + + const displayLabel = from && to + ? `${format(from, 'MMM d')} – ${format(to, 'MMM d, yyyy')}` + : from + ? `From ${format(from, 'MMM d, yyyy')}` + : to + ? `Until ${format(to, 'MMM d, yyyy')}` + : 'Any time'; + + const labelId = `people-${label.toLowerCase()}-filter-label`; + + return ( +
+ + {label} + + + +
+ + {displayLabel} + +
+
+ +
+ + {label} between + + +
+ {PRESETS.map((p) => ( + + ))} +
+ +
+ + +
+ + {draftFrom ? format(draftFrom, 'MMM d, yyyy') : Start date} +
+
+ + { setDraftFrom(d ?? undefined); setActivePreset(null); setFromPickerOpen(false); }} + captionLayout="dropdown" + fromYear={2000} + toYear={new Date().getFullYear() + 1} + /> + +
+ + + +
+ + {draftTo ? format(draftTo, 'MMM d, yyyy') : End date} +
+
+ + { setDraftTo(d ?? undefined); setActivePreset(null); setToPickerOpen(false); }} + captionLayout="dropdown" + fromYear={2000} + toYear={new Date().getFullYear() + 1} + /> + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx index 0dd6c16e15..8682c8c9b4 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx @@ -86,29 +86,29 @@ describe('MemberRow device status', () => { vi.clearAllMocks(); }); - it('shows "Device 0/1" when deviceStatus is not-installed', () => { + it('shows Device as Missing when deviceStatus is not-installed', () => { renderMemberRow('not-installed'); - expect(screen.getByText('Device 0/1')).toBeInTheDocument(); + expect(screen.getByTestId('requirement-Device')).toHaveTextContent('Missing'); }); - it('shows dash when deviceStatus is omitted (no compliance obligation)', () => { + it('shows no device row when deviceStatus is omitted (no compliance obligation)', () => { renderMemberRow(); - expect(screen.queryByText(/^Device /)).not.toBeInTheDocument(); + expect(screen.queryByTestId('requirement-Device')).not.toBeInTheDocument(); }); - it('shows "Device 1/1" when deviceStatus is compliant', () => { + it('shows Device as Done when deviceStatus is compliant', () => { renderMemberRow('compliant'); - expect(screen.getByText('Device 1/1')).toBeInTheDocument(); + expect(screen.getByTestId('requirement-Device')).toHaveTextContent('Done'); }); - it('shows "Device 0/1" when deviceStatus is non-compliant', () => { + it('shows Device as Missing when deviceStatus is non-compliant', () => { renderMemberRow('non-compliant'); - expect(screen.getByText('Device 0/1')).toBeInTheDocument(); + expect(screen.getByTestId('requirement-Device')).toHaveTextContent('Missing'); }); - it('shows "Device 0/1" when deviceStatus is stale', () => { + it('shows Device as Missing when deviceStatus is stale', () => { renderMemberRow('stale'); - expect(screen.getByText('Device 0/1')).toBeInTheDocument(); + expect(screen.getByTestId('requirement-Device')).toHaveTextContent('Missing'); }); it('does not show device status for platform admin', () => { @@ -134,7 +134,7 @@ describe('MemberRow device status', () => { , ); - expect(screen.queryByText(/^Device /)).not.toBeInTheDocument(); + expect(screen.queryByTestId('requirement-Device')).not.toBeInTheDocument(); }); it('does not show device status for deactivated member', () => { @@ -160,7 +160,7 @@ describe('MemberRow device status', () => { , ); - expect(screen.queryByText(/^Device /)).not.toBeInTheDocument(); + expect(screen.queryByTestId('requirement-Device')).not.toBeInTheDocument(); }); it('does not show device status for member without compliance obligation (e.g. auditor)', () => { @@ -186,7 +186,7 @@ describe('MemberRow device status', () => { , ); - expect(screen.queryByText(/^Device /)).not.toBeInTheDocument(); + expect(screen.queryByTestId('requirement-Device')).not.toBeInTheDocument(); }); it('still shows device status for member with compliance obligation', () => { @@ -212,7 +212,7 @@ describe('MemberRow device status', () => { , ); - expect(screen.getByText('Device 1/1')).toBeInTheDocument(); + expect(screen.getByTestId('requirement-Device')).toHaveTextContent('Done'); }); it('hides the background-check task counter and verified tick when bypassed', () => { @@ -240,7 +240,7 @@ describe('MemberRow device status', () => { , ); - expect(screen.queryByText(/background check/i)).not.toBeInTheDocument(); + expect(screen.queryByTestId('requirement-Background')).not.toBeInTheDocument(); expect( screen.queryByLabelText('Employee has completed a background check'), ).not.toBeInTheDocument(); @@ -273,9 +273,96 @@ describe('MemberRow device status', () => { , ); - expect(screen.queryByText(/background check/i)).not.toBeInTheDocument(); + expect(screen.queryByTestId('requirement-Background')).not.toBeInTheDocument(); expect( screen.queryByLabelText('Employee has completed a background check'), ).not.toBeInTheDocument(); }); + + it('hides the Policies row when there are no required policies (0/0 is not-applicable)', () => { + render( + + + + +
, + ); + + expect(screen.queryByTestId('requirement-Policies')).not.toBeInTheDocument(); + }); +}); + +describe('MemberRow 2FA status', () => { + function render2fa({ + member = baseMember, + twoFactorStatus, + }: { + member?: MemberWithUser; + twoFactorStatus?: 'enabled' | 'missing' | 'not-provided'; + }) { + return render( + + + + +
, + ); + } + + it('shows no 2FA row when no source is configured (status undefined)', () => { + render2fa({}); + expect(screen.queryByTestId('requirement-2FA')).not.toBeInTheDocument(); + }); + + it.each([ + ['enabled', 'Done'], + ['missing', 'Missing'], + ['not-provided', 'Not provided'], + ] as const)('renders %s as "%s"', (status, text) => { + render2fa({ twoFactorStatus: status }); + expect(screen.getByTestId('requirement-2FA')).toHaveTextContent(text); + }); + + it('shows the 2FA row even for platform admins (2FA applies to all members)', () => { + const adminMember = { + ...baseMember, + user: { ...baseMember.user, role: 'admin' as const }, + } as MemberWithUser; + + render2fa({ member: adminMember, twoFactorStatus: 'enabled' }); + expect(screen.getByTestId('requirement-2FA')).toHaveTextContent('Done'); + }); + + it('hides the 2FA row for deactivated members', () => { + const deactivatedMember = { + ...baseMember, + deactivated: true, + } as MemberWithUser; + + render2fa({ member: deactivatedMember, twoFactorStatus: 'missing' }); + expect(screen.queryByTestId('requirement-2FA')).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index 7a7f067806..dfbe6aaa98 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -25,7 +25,6 @@ import { DropdownMenuTrigger, HStack, Label, - Skeleton, TableCell, TableRow, Text, @@ -43,9 +42,37 @@ import { toast } from 'sonner'; import { BackgroundCheckVerifiedTick } from '../../components/BackgroundCheckVerifiedTick'; import { MultiRoleCombobox } from './MultiRoleCombobox'; import { RemoveDeviceAlert } from './RemoveDeviceAlert'; +import { + RequirementBadge, + RequirementCount, + RequirementDash, + RequirementLoading, +} from './RequirementCell'; + +export type RequirementColumnKey = + | 'policies' + | 'training' + | 'hipaa' + | 'device' + | 'background' + | 'twoFactor'; + +export const ALL_REQUIREMENT_COLUMNS: RequirementColumnKey[] = [ + 'policies', + 'training', + 'hipaa', + 'device', + 'background', + 'twoFactor', +]; import { RemoveMemberAlert } from './RemoveMemberAlert'; import type { CustomRoleOption } from './MultiRoleCombobox'; -import type { BackgroundCheckStatus, MemberWithUser, TaskCompletion } from './TeamMembers'; +import type { + BackgroundCheckStatus, + MemberWithUser, + TaskCompletion, + TwoFactorStatus, +} from './TeamMembers'; interface MemberRowProps { member: MemberWithUser; @@ -62,6 +89,10 @@ interface MemberRowProps { isDeviceStatusLoading?: boolean; backgroundCheckStatus?: BackgroundCheckStatus; backgroundCheckStepEnabled?: boolean; + /** From the org's configured 2FA source; absent when no source is configured. */ + twoFactorStatus?: TwoFactorStatus; + /** Which requirement columns the table is rendering, in order. */ + requirementColumns?: RequirementColumnKey[]; } function getInitials(name?: string | null, email?: string | null): string { @@ -99,20 +130,6 @@ function isBackgroundCheckComplete(status?: BackgroundCheckStatus): boolean { return status === 'completed' || status === 'completed_with_flags'; } -interface TaskCountItem { - label: string; - completed: number; - total: number; -} - -function TaskCountLabel({ item }: { item: TaskCountItem }) { - return ( - - {item.label} {item.completed}/{item.total} - - ); -} - export function MemberRow({ member, onRemove, @@ -128,6 +145,8 @@ export function MemberRow({ isDeviceStatusLoading = false, backgroundCheckStatus, backgroundCheckStepEnabled = true, + twoFactorStatus, + requirementColumns = ALL_REQUIREMENT_COLUMNS, }: MemberRowProps) { const { orgId } = useParams<{ orgId: string }>(); @@ -157,52 +176,85 @@ export function MemberRow({ const hasCompletedBackgroundCheck = isBackgroundCheckComplete(backgroundCheckStatus); const memberExempt = member.backgroundCheckExempt === true; const shouldShowTaskRequirements = !isPlatformAdmin && !isDeactivated; - const taskItems: TaskCountItem[] = []; - - if (taskCompletion) { - taskItems.push({ - label: 'Policies', - completed: taskCompletion.policies.completed, - total: taskCompletion.policies.total, - }); - - if (taskCompletion.training.total > 0) { - taskItems.push({ - label: 'Training', - completed: taskCompletion.training.completed, - total: taskCompletion.training.total, - }); - } - - if (taskCompletion.hipaa) { - taskItems.push({ - label: 'HIPAA', - completed: taskCompletion.hipaa.completed, - total: taskCompletion.hipaa.total, - }); + // One cell per requirement column. A muted dash = the requirement doesn't + // apply to this member (org flag off, exempt, auditor-only, admin, 0 total). + const renderRequirementCell = (key: RequirementColumnKey) => { + switch (key) { + case 'policies': + return taskCompletion && taskCompletion.policies.total > 0 ? ( + + ) : ( + + ); + case 'training': + return taskCompletion && taskCompletion.training.total > 0 ? ( + + ) : ( + + ); + case 'hipaa': + return taskCompletion?.hipaa ? ( + = taskCompletion.hipaa.total + ? 'done' + : 'missing' + } + /> + ) : ( + + ); + case 'device': + if (!shouldShowTaskRequirements) return ; + if (deviceStatus) { + return ( + + ); + } + return isDeviceStatusLoading ? : ; + case 'background': + return shouldShowTaskRequirements && + backgroundCheckStepEnabled && + !memberExempt && + !isAuditorOnly ? ( + + ) : ( + + ); + case 'twoFactor': + // 2FA applies to EVERY non-deactivated member (platform admins + // included). Absent status = no 2FA source configured for the org. + return !isDeactivated && twoFactorStatus ? ( + + ) : ( + + ); } - } - - if (shouldShowTaskRequirements && (deviceStatus || isDeviceStatusLoading)) { - taskItems.push({ - label: 'Device', - completed: deviceStatus === 'compliant' ? 1 : 0, - total: 1, - }); - } - - if (shouldShowTaskRequirements && backgroundCheckStepEnabled && !memberExempt && !isAuditorOnly) { - taskItems.push({ - label: 'Background check', - completed: hasCompletedBackgroundCheck ? 1 : 0, - total: 1, - }); - } - - const visibleTaskTotal = taskItems.reduce((sum, item) => sum + item.total, 0); - const visibleTaskCompleted = taskItems.reduce((sum, item) => sum + item.completed, 0); - const taskProgressPercent = - visibleTaskTotal > 0 ? Math.round((visibleTaskCompleted / visibleTaskTotal) * 100) : 0; + }; const handleEditRolesClick = () => { setSelectedRoles(parseRoles(member.role)); @@ -351,33 +403,10 @@ export function MemberRow({ )} - {/* TASKS */} - - {taskItems.length > 0 ? ( -
-
-
-
-
- {taskItems.map((item) => ( - - ))} - {shouldShowTaskRequirements && isDeviceStatusLoading && ( -
- -
- )} -
-
- ) : ( - - — - - )} - + {/* Requirement columns */} + {requirementColumns.map((key) => ( + {renderRequirementCell(key)} + ))} {/* ACTIONS */} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx index 09d7c528a2..d26916450f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx @@ -74,12 +74,12 @@ function renderRow({ describe('MemberRow background check status', () => { it('shows background check as incomplete in the tasks column', () => { renderRow({ backgroundCheckStatus: 'invited' }); - expect(screen.getByText('Background check 0/1')).toBeInTheDocument(); + expect(screen.getByTestId('requirement-Background')).toHaveTextContent('Missing'); }); it('shows background check as complete in the tasks column', () => { renderRow({ backgroundCheckStatus: 'completed_with_flags' }); - expect(screen.getByText('Background check 1/1')).toBeInTheDocument(); + expect(screen.getByTestId('requirement-Background')).toHaveTextContent('Done'); expect(screen.getByLabelText('Employee has completed a background check')).toBeInTheDocument(); }); @@ -90,6 +90,6 @@ describe('MemberRow background check status', () => { it('does not show background check tracking for auditor-only members', () => { renderRow({ backgroundCheckStatus: 'invited', role: 'auditor' }); - expect(screen.queryByText(/Background check/)).not.toBeInTheDocument(); + expect(screen.queryByTestId('requirement-Background')).not.toBeInTheDocument(); }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx index a8183d43f7..11e713b46b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx @@ -29,12 +29,15 @@ interface PendingInvitationRowProps { }; onCancel: (invitationId: string) => Promise; canCancel: boolean; + /** How many requirement columns the table renders (one dash per column). */ + requirementColumnCount?: number; } export function PendingInvitationRow({ invitation, onCancel, canCancel, + requirementColumnCount = 1, }: PendingInvitationRowProps) { const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); const [isCancelling, setIsCancelling] = useState(false); @@ -110,12 +113,14 @@ export function PendingInvitationRow({ - {/* TASKS */} - - - — - - + {/* Requirement columns — never applicable before the invite is accepted */} + {Array.from({ length: requirementColumnCount }).map((_, i) => ( + + + — + + + ))} {/* ACTIONS */} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFilters.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFilters.test.tsx new file mode 100644 index 0000000000..06a83b4744 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFilters.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { PeopleFilters } from './PeopleFilters'; + +const noop = vi.fn(); + +function renderFilters(overrides: Partial[0]> = {}) { + return render( + , + ); +} + +describe('PeopleFilters', () => { + it('shows no count badge or chips when nothing is filtered', () => { + renderFilters(); + expect(screen.getByText('Filters')).toBeInTheDocument(); + expect(screen.queryByText(/Status:/)).not.toBeInTheDocument(); + }); + + it('shows the active count and a removable chip per applied filter', () => { + const onStatusChange = vi.fn(); + renderFilters({ + statusFilter: 'deactivated', + roleFilter: 'admin', + onStatusChange, + }); + + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('Status: Deactivated')).toBeInTheDocument(); + expect(screen.getByText('Role: Admin')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Remove filter: Status: Deactivated')); + expect(onStatusChange).toHaveBeenCalledWith(null); + }); + + it('shows a date chip that clears via its remove button', () => { + const onOnboardClear = vi.fn(); + renderFilters({ onboardFrom: new Date('2026-06-01'), onOnboardClear }); + + const chip = screen.getByText(/Onboarded from/); + expect(chip).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText(/Remove filter: Onboarded/)); + expect(onOnboardClear).toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFilters.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFilters.tsx new file mode 100644 index 0000000000..174e7409c1 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFilters.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { + Badge, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@trycompai/design-system'; +import { Close, Filter } from '@trycompai/design-system/icons'; + +import { format } from 'date-fns'; + +import { DateRangeFilter } from './DateRangeFilter'; + +const STATUS_LABELS: Record = { + all: 'All People', + active: 'Active', + pending: 'Pending', + deactivated: 'Deactivated', +}; +const ROLE_LABELS: Record = { + owner: 'Owner', + admin: 'Admin', + auditor: 'Auditor', + employee: 'Employee', + contractor: 'Contractor', +}; + +function rangeLabel(from: Date | undefined, to: Date | undefined): string { + if (from && to) return `${format(from, 'MMM d')} – ${format(to, 'MMM d')}`; + if (from) return `from ${format(from, 'MMM d')}`; + return `until ${format(to as Date, 'MMM d')}`; +} + +/** Removable chip for one applied filter — clearable without opening the popover. */ +function FilterChip({ label, onRemove }: { label: string; onRemove: () => void }) { + return ( + + {label} + + + ); +} + +interface PeopleFiltersProps { + statusFilter: string; + hasOffboardFilter: boolean; + onStatusChange: (value: string | null) => void; + roleFilter: string; + onRoleChange: (value: string | null) => void; + onboardFrom: Date | undefined; + onboardTo: Date | undefined; + onOnboardApply: (from: Date | undefined, to: Date | undefined) => void; + onOnboardClear: () => void; + offboardFrom: Date | undefined; + offboardTo: Date | undefined; + onOffboardApply: (from: Date | undefined, to: Date | undefined) => void; + onOffboardClear: () => void; +} + +/** + * All People-list filters behind one funnel button: Status, Role, and the + * Onboarded/Offboarded date ranges. The trigger shows how many filters are + * active so a filtered list is never a mystery. + */ +export function PeopleFilters({ + statusFilter, + hasOffboardFilter, + onStatusChange, + roleFilter, + onRoleChange, + onboardFrom, + onboardTo, + onOnboardApply, + onOnboardClear, + offboardFrom, + offboardTo, + onOffboardApply, + onOffboardClear, +}: PeopleFiltersProps) { + const activeCount = [ + statusFilter, + roleFilter, + onboardFrom || onboardTo, + offboardFrom || offboardTo, + ].filter(Boolean).length; + + return ( +
+ + {/* PopoverTrigger renders its own
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/RequirementCell.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/RequirementCell.tsx new file mode 100644 index 0000000000..2448cb91c0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/RequirementCell.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { Badge, Skeleton, Text } from '@trycompai/design-system'; + +/** Muted dash for a requirement that doesn't apply to this member. */ +export function RequirementDash() { + return ( + + — + + ); +} + +/** + * Fractional requirement (policies, training): a colored count. 'primary' is + * the brand green, matching the Done badge; amber = partial; muted = none. + */ +export function RequirementCount({ + label, + completed, + total, +}: { + label: string; + completed: number; + total: number; +}) { + const isComplete = total > 0 && completed >= total; + return ( +
+ 0 ? 'warning' : 'muted'} + > + {completed}/{total} + +
+ ); +} + +/** + * Binary requirement (HIPAA, device, background, 2FA): a status badge. + * 'not-provided' = the source had no data for this member — distinct from an + * explicit 'missing' failure. + */ +export function RequirementBadge({ + label, + state, +}: { + label: string; + state: 'done' | 'missing' | 'not-provided'; +}) { + return ( +
+ {state === 'not-provided' ? ( + Not provided + ) : ( + + {state === 'done' ? 'Done' : 'Missing'} + + )} +
+ ); +} + +/** Loading placeholder while a requirement's status is still resolving. */ +export function RequirementLoading() { + return ( +
+ +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx index ebfb9338ff..3bb7d9bcb9 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx @@ -7,8 +7,13 @@ import { db } from '@db/server'; import { getEmployeeSyncConnections } from '../data/queries'; import { TeamMembersClient } from './TeamMembersClient'; import type { BackgroundCheckStatus } from '../../[employeeId]/components/backgroundCheckTypes'; +import { + buildTwoFactorStatusMap, + type TwoFactorStatusesResponse, +} from './two-factor-status-map'; export type { BackgroundCheckStatus }; +export type { TwoFactorStatus } from './two-factor-status-map'; export interface MemberWithUser extends Member { user: User; @@ -54,12 +59,15 @@ export async function TeamMembers(props: TeamMembersProps) { return null; } - // Fetch members and invitations from API - const [membersRes, invitationsRes] = await Promise.all([ + // Fetch members, invitations, and 2FA statuses from API + const [membersRes, invitationsRes, twoFactorRes] = await Promise.all([ serverApi.get<{ data: MemberWithUser[]; count: number }>( '/v1/people?includeDeactivated=true', ), serverApi.get<{ data: Invitation[] }>('/v1/auth/invitations'), + serverApi.get( + '/v1/integrations/sync/two-factor-statuses', + ), ]); const members: MemberWithUser[] = Array.isArray(membersRes.data?.data) @@ -71,6 +79,13 @@ export async function TeamMembers(props: TeamMembersProps) { const data: TeamMembersData = { members, pendingInvitations }; + // Empty when no 2FA source is configured (or the fetch failed) — rows then + // show no 2FA entry rather than a wrong one. + const twoFactorStatusMap = buildTwoFactorStatusMap( + members, + twoFactorRes.data ?? undefined, + ); + // Fetch employee sync connections server-side const employeeSyncData = await getEmployeeSyncConnections(organizationId); @@ -96,16 +111,26 @@ export async function TeamMembers(props: TeamMembersProps) { const backgroundCheckStepEnabled = orgFlags?.backgroundCheckStepEnabled === true; const hasHipaaFramework = !!hipaaInstance; - if (employeeMembers.length > 0) { + const policies = await db.policy.findMany({ + where: { + organizationId, + isRequiredToSign: true, + status: 'published', + isArchived: false, + }, + }); + + // Column visibility must come from ORG-LEVEL tracking, not from member data: + // an org with tracking enabled but no compliance members yet still shows the + // columns (as dashes), never silently hides them. + const requirementTracking = { + policies: policies.length > 0, + training: + orgFlags?.securityTrainingStepEnabled === true && trainingVideosData.length > 0, + hipaa: hasHipaaFramework, + }; - const policies = await db.policy.findMany({ - where: { - organizationId, - isRequiredToSign: true, - status: 'published', - isArchived: false, - }, - }); + if (employeeMembers.length > 0) { const employeeIds = employeeMembers.map((m) => m.id); const trainingCompletions = orgFlags?.securityTrainingStepEnabled @@ -165,6 +190,8 @@ export async function TeamMembers(props: TeamMembersProps) { taskCompletionMap={taskCompletionMap} complianceMemberIds={complianceMemberIds} backgroundCheckStepEnabled={backgroundCheckStepEnabled} + twoFactorStatusMap={twoFactorStatusMap} + requirementTracking={requirementTracking} /> ); } 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 bb4d9772d9..5c2da67817 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 @@ -1,6 +1,7 @@ 'use client'; import Image from 'next/image'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -14,7 +15,6 @@ import useSWR from 'swr'; import type { Invitation } from '@db'; import { Button, - Calendar, Empty, EmptyDescription, EmptyHeader, @@ -38,7 +38,7 @@ import { TableHeader, TableRow, } from '@trycompai/design-system'; -import { Calendar as CalendarIcon, ChevronDown, InProgress, Search } from '@trycompai/design-system/icons'; +import { InProgress, Search, SettingsAdjust } from '@trycompai/design-system/icons'; import { apiClient } from '@/lib/api-client'; import { useMemo } from 'react'; @@ -46,9 +46,16 @@ import { useAgentDevices } from '../../devices/hooks/useAgentDevices'; import { useFleetHosts } from '../../devices/hooks/useFleetHosts'; import { buildDisplayItems, filterDisplayItems } from './filter-members'; import { computeDeviceStatusMap } from './compute-device-status-map'; -import { MemberRow } from './MemberRow'; +import { MemberRow, type RequirementColumnKey } from './MemberRow'; +import { PeopleFilters } from './PeopleFilters'; import { PendingInvitationRow } from './PendingInvitationRow'; -import type { MemberWithUser, TaskCompletion, TeamMembersData } from './TeamMembers'; +import { TwoFactorSourceSelector } from './TwoFactorSourceSelector'; +import type { + MemberWithUser, + TaskCompletion, + TeamMembersData, + TwoFactorStatus, +} from './TeamMembers'; import type { EmployeeSyncConnectionsData } from '../data/queries'; import { useEmployeeSync } from '../hooks/useEmployeeSync'; @@ -68,6 +75,9 @@ interface TeamMembersClientProps { taskCompletionMap: Record; complianceMemberIds: string[]; backgroundCheckStepEnabled: boolean; + twoFactorStatusMap: Record; + /** Org-level tracking flags — column visibility never depends on member data. */ + requirementTracking: { policies: boolean; training: boolean; hipaa: boolean }; } export function TeamMembersClient({ @@ -79,6 +89,8 @@ export function TeamMembersClient({ taskCompletionMap, complianceMemberIds, backgroundCheckStepEnabled, + twoFactorStatusMap, + requirementTracking, }: TeamMembersClientProps) { const { agentDevices, isLoading: isAgentDevicesLoading } = useAgentDevices(); const { fleetHosts, isLoading: isFleetHostsLoading } = useFleetHosts(); @@ -88,6 +100,24 @@ export function TeamMembersClient({ () => computeDeviceStatusMap({ agentDevices, fleetHosts, complianceMemberIds }), [agentDevices, fleetHosts, complianceMemberIds], ); + + // Which requirement columns the table shows, in order. A column only exists + // when the underlying tracking applies to this org (flag on / framework + // present / 2FA source configured), so orgs never see empty dash columns. + const requirementColumns = useMemo< + Array<{ key: RequirementColumnKey; label: string }> + >(() => { + const cols: Array<{ key: RequirementColumnKey; label: string }> = []; + if (requirementTracking.policies) cols.push({ key: 'policies', label: 'POLICIES' }); + if (requirementTracking.training) cols.push({ key: 'training', label: 'TRAINING' }); + if (requirementTracking.hipaa) cols.push({ key: 'hipaa', label: 'HIPAA' }); + if (complianceMemberIds.length > 0) cols.push({ key: 'device', label: 'DEVICE' }); + if (backgroundCheckStepEnabled) + cols.push({ key: 'background', label: 'BACKGROUND' }); + if (Object.keys(twoFactorStatusMap).length > 0) + cols.push({ key: 'twoFactor', label: '2FA' }); + return cols; + }, [requirementTracking, complianceMemberIds, backgroundCheckStepEnabled, twoFactorStatusMap]); const router = useRouter(); const [searchQuery, setSearchQuery] = useState(''); const [roleFilter, setRoleFilter] = useState(''); @@ -300,7 +330,10 @@ export function TeamMembersClient({ return ( {/* Search and Filters */} -
+ {/* Left = query (search, filters); right = view configuration (data + sources) — the Linear/Stripe toolbar convention. */} +
+
@@ -313,71 +346,57 @@ export function TeamMembersClient({ />
- {/* Status Filter Select */} -
- -
- {/* Role Filter Select */} -
- -
- { setOnboardFrom(from); setOnboardTo(to); setPage(1); }} - onClear={() => { setOnboardFrom(undefined); setOnboardTo(undefined); setPage(1); }} - /> - { setOffboardFrom(from); setOffboardTo(to); setPage(1); }} - onClear={() => { setOffboardFrom(undefined); setOffboardTo(undefined); setPage(1); }} + { + setStatusFilter(value ?? ''); + setPage(1); + }} + roleFilter={roleFilter} + onRoleChange={(value) => { + setRoleFilter(value === 'all' ? '' : (value ?? '')); + setPage(1); + }} + onboardFrom={onboardFrom} + onboardTo={onboardTo} + onOnboardApply={(from, to) => { setOnboardFrom(from); setOnboardTo(to); setPage(1); }} + onOnboardClear={() => { setOnboardFrom(undefined); setOnboardTo(undefined); setPage(1); }} + offboardFrom={offboardFrom} + offboardTo={offboardTo} + onOffboardApply={(from, to) => { setOffboardFrom(from); setOffboardTo(to); setPage(1); }} + onOffboardClear={() => { setOffboardFrom(undefined); setOffboardTo(undefined); setPage(1); }} /> +
+ {/* Source settings (sync / 2FA) — settings, not filters, so they get + their own compact popover, symmetric with the Filters button. */} + + +
+ + Sync settings +
+
+ +
+ {!hasAnyConnection && ( +
+ People + + Connect an integration + + +
+ )} {hasAnyConnection && ( -
-
+
+
+ + People + { + const provider = String(value); + if (provider) void handleSourceChange(provider); + }} + > + + {selected ? ( +
+ {selected.logoUrl && ( + + )} + {selected.name} +
+ ) : ( + Not shown + )} +
+ +
+ Where each person's 2FA status comes from +
+ + Not shown + + {connectedSources.map((p) => ( + +
+ {p.logoUrl && ( + + )} + {p.name} + {selectedSource === p.slug && ( + Active + )} +
+
+ ))} +
+ +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/two-factor-status-map.test.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/two-factor-status-map.test.ts new file mode 100644 index 0000000000..5515207bd7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/two-factor-status-map.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { buildTwoFactorStatusMap } from './two-factor-status-map'; + +const members = [ + { id: 'mem_1', user: { email: 'Alice@X.com' } }, + { id: 'mem_2', user: { email: 'bob@x.com' } }, + { id: 'mem_3', user: { email: 'carol@x.com' } }, + { id: 'mem_4', user: { email: null } }, +]; + +describe('buildTwoFactorStatusMap', () => { + it('returns an empty map when no source is configured', () => { + expect( + buildTwoFactorStatusMap(members, { + configured: false, + source: null, + statuses: [], + }), + ).toEqual({}); + }); + + it('returns an empty map when the response is missing (fetch failed)', () => { + expect(buildTwoFactorStatusMap(members, undefined)).toEqual({}); + }); + + it('joins by email case-insensitively and resolves absence to not-provided', () => { + const map = buildTwoFactorStatusMap(members, { + configured: true, + source: 'google-workspace', + statuses: [ + { email: 'alice@x.com', status: 'enabled' }, + { email: 'BOB@x.com', status: 'missing' }, + ], + }); + + expect(map).toEqual({ + mem_1: 'enabled', // matched despite different casing on both sides + mem_2: 'missing', + mem_3: 'not-provided', // no result row for this member + mem_4: 'not-provided', // member without an email can never match + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/two-factor-status-map.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/two-factor-status-map.ts new file mode 100644 index 0000000000..4ad34491cc --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/two-factor-status-map.ts @@ -0,0 +1,34 @@ +export type TwoFactorStatus = 'enabled' | 'missing' | 'not-provided'; + +/** Shape of GET /v1/integrations/sync/two-factor-statuses. */ +export interface TwoFactorStatusesResponse { + configured: boolean; + source: string | null; + statuses: Array<{ email: string; status: 'enabled' | 'missing' }>; +} + +/** + * Join the 2FA source's per-email results onto members by lowercased email. + * + * Returns an empty map when no 2FA source is configured (callers render no 2FA + * row at all). A member with no matching result row gets 'not-provided' — + * absence means the source had no data for them, which must never read as an + * explicit 'missing' failure. + */ +export function buildTwoFactorStatusMap( + members: Array<{ id: string; user: { email: string | null } }>, + response: TwoFactorStatusesResponse | undefined, +): Record { + if (!response?.configured) return {}; + + const byEmail = new Map( + response.statuses.map((s) => [s.email.toLowerCase().trim(), s.status]), + ); + + const map: Record = {}; + for (const member of members) { + const email = member.user.email?.toLowerCase().trim(); + map[member.id] = (email && byEmail.get(email)) || 'not-provided'; + } + return map; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/hooks/use2faSource.ts b/apps/app/src/app/(app)/[orgId]/people/all/hooks/use2faSource.ts new file mode 100644 index 0000000000..a40b5651be --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/hooks/use2faSource.ts @@ -0,0 +1,121 @@ +'use client'; + +import { apiClient } from '@/lib/api-client'; +import { toast } from 'sonner'; +import useSWR from 'swr'; + +export interface TwoFactorSourceProviderInfo { + slug: string; + name: string; + logoUrl: string | null; + connected: boolean; + connectionId: string | null; + lastSyncAt: string | null; + nextSyncAt: string | null; +} + +interface Use2faSourceOptions { + organizationId: string; + /** + * When false, the hook makes no API calls (used to fully disable it for users + * who lack the integration:update permission, so no 2FA-source API is hit). + */ + enabled?: boolean; +} + +interface Use2faSourceReturn { + selectedSource: string | null; + isLoading: boolean; + error?: string; + availableSources: TwoFactorSourceProviderInfo[]; + setSource: (provider: string | null) => Promise; + hasAnyConnection: boolean; +} + +/** + * The org's 2FA source: which connected integration (bound to the 2FA evidence + * task) supplies the per-employee 2FA column on the People tab. + */ +export function use2faSource({ + organizationId, + enabled = true, +}: Use2faSourceOptions): Use2faSourceReturn { + const { + data: sourceData, + error: sourceError, + isLoading: isLoadingSource, + mutate: mutateSource, + } = useSWR<{ provider: string | null }>( + enabled + ? `/v1/integrations/sync/two-factor-source?organizationId=${organizationId}` + : null, + async (url: string) => { + const res = await apiClient.get<{ provider: string | null }>(url); + if (res.error) throw new Error(res.error); + return res.data as { provider: string | null }; + }, + { revalidateOnFocus: false }, + ); + + const { + data: availableData, + error: availableError, + isLoading: isLoadingAvailable, + } = useSWR<{ providers: TwoFactorSourceProviderInfo[] }>( + enabled + ? `/v1/integrations/sync/available-2fa-sources?organizationId=${organizationId}` + : null, + async (url: string) => { + const res = await apiClient.get<{ + providers: TwoFactorSourceProviderInfo[]; + }>(url); + if (res.error) throw new Error(res.error); + return res.data as { providers: TwoFactorSourceProviderInfo[] }; + }, + { revalidateOnFocus: false }, + ); + + const selectedSource = sourceData?.provider ?? null; + const availableSources = Array.isArray(availableData?.providers) + ? availableData.providers + : []; + + const setSource = async (provider: string | null): Promise => { + try { + const response = await apiClient.post( + `/v1/integrations/sync/two-factor-source?organizationId=${organizationId}`, + { provider }, + ); + if (response.error) { + toast.error('Failed to set 2FA source'); + return false; + } + + mutateSource({ provider }, false); + + if (provider) { + const name = + availableSources.find((p) => p.slug === provider)?.name ?? provider; + toast.success(`${name} set as your 2FA source`); + } + return true; + } catch { + toast.error('Failed to set 2FA source'); + return false; + } + }; + + const fetchError = sourceError ?? availableError; + + return { + selectedSource, + // Both requests feed the selector; report loading until BOTH settle so + // consumers never render a half-resolved state (e.g. sources without the + // current selection). + isLoading: isLoadingSource || isLoadingAvailable, + error: fetchError instanceof Error ? fetchError.message : undefined, + availableSources, + setSource, + hasAnyConnection: availableSources.some((p) => p.connected), + }; +} diff --git a/apps/app/src/styles/globals.css b/apps/app/src/styles/globals.css index 3fd9e4858f..529a0d1132 100644 --- a/apps/app/src/styles/globals.css +++ b/apps/app/src/styles/globals.css @@ -129,3 +129,22 @@ body { -webkit-text-fill-color: transparent; animation: thinking-shimmer 3.5s ease-in-out infinite; } + +/* Data tables scroll horizontally inside their own container (DS Table wraps + rows in [data-slot='table-scroll']). macOS overlay scrollbars are invisible + until scrolled, so wide tables (e.g. People requirement columns) gave no hint + that more columns exist — force a slim, always-visible horizontal scrollbar. */ +[data-slot='table-scroll'] { + scrollbar-width: thin; + scrollbar-color: hsl(var(--border)) transparent; +} +[data-slot='table-scroll']::-webkit-scrollbar { + height: 8px; +} +[data-slot='table-scroll']::-webkit-scrollbar-thumb { + background: hsl(var(--border)); + border-radius: 9999px; +} +[data-slot='table-scroll']::-webkit-scrollbar-track { + background: transparent; +} diff --git a/packages/db/prisma/migrations/20260701120000_add_two_factor_source/migration.sql b/packages/db/prisma/migrations/20260701120000_add_two_factor_source/migration.sql new file mode 100644 index 0000000000..66e00d5518 --- /dev/null +++ b/packages/db/prisma/migrations/20260701120000_add_two_factor_source/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."Organization" ADD COLUMN "twoFactorSource" TEXT; diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index 406dfef709..b1cf46f429 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -29,6 +29,10 @@ model Organization { // When set, the scheduled sync will import devices from this provider deviceSyncProvider String? + // 2FA source provider (e.g., 'google-workspace') + // When set, the People tab reads per-user 2FA status from this integration's latest check run + twoFactorSource String? + apiKeys ApiKey[] mcpOrgBindings McpOrgBinding[] auditLog AuditLog[]