diff --git a/.agents/skills/better-auth-best-practices/SKILL.md b/.agents/skills/better-auth-best-practices/SKILL.md new file mode 100644 index 0000000000..3e6a4e1972 --- /dev/null +++ b/.agents/skills/better-auth-best-practices/SKILL.md @@ -0,0 +1,175 @@ +--- +name: better-auth-best-practices +description: Configure Better Auth server and client, set up database adapters, manage sessions, add plugins, and handle environment variables. Use when users mention Better Auth, betterauth, auth.ts, or need to set up TypeScript authentication with email/password, OAuth, or plugin configuration. +--- + +# Better Auth Integration Guide + +**Always consult [better-auth.com/docs](https://better-auth.com/docs) for code examples and latest API.** + +--- + +## Setup Workflow + +1. Install: `npm install better-auth` +2. Set env vars: `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` +3. Create `auth.ts` with database + config +4. Create route handler for your framework +5. Run `npx @better-auth/cli@latest migrate` +6. Verify: call `GET /api/auth/ok` — should return `{ status: "ok" }` + +--- + +## Quick Reference + +### Environment Variables +- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32` +- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`) + +Only define `baseURL`/`secret` in config if env vars are NOT set. + +### File Location +CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path. + +### CLI Commands +- `npx @better-auth/cli@latest migrate` - Apply schema (built-in adapter) +- `npx @better-auth/cli@latest generate` - Generate schema for Prisma/Drizzle +- `npx @better-auth/cli mcp --cursor` - Add MCP to AI tools + +**Re-run after adding/changing plugins.** + +--- + +## Core Config Options + +| Option | Notes | +|--------|-------| +| `appName` | Optional display name | +| `baseURL` | Only if `BETTER_AUTH_URL` not set | +| `basePath` | Default `/api/auth`. Set `/` for root. | +| `secret` | Only if `BETTER_AUTH_SECRET` not set | +| `database` | Required for most features. See adapters docs. | +| `secondaryStorage` | Redis/KV for sessions & rate limits | +| `emailAndPassword` | `{ enabled: true }` to activate | +| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` | +| `plugins` | Array of plugins | +| `trustedOrigins` | CSRF whitelist | + +--- + +## Database + +**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance. + +**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`. + +**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`. + +--- + +## Session Management + +**Storage priority:** +1. If `secondaryStorage` defined → sessions go there (not DB) +2. Set `session.storeSessionInDatabase: true` to also persist to DB +3. No database + `cookieCache` → fully stateless mode + +**Cookie cache strategies:** +- `compact` (default) - Base64url + HMAC. Smallest. +- `jwt` - Standard JWT. Readable but signed. +- `jwe` - Encrypted. Maximum security. + +**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions). + +--- + +## User & Account Config + +**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default). + +**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth). + +**Required for registration:** `email` and `name` fields. + +--- + +## Email Flows + +- `emailVerification.sendVerificationEmail` - Must be defined for verification to work +- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers +- `emailAndPassword.sendResetPassword` - Password reset email handler + +--- + +## Security + +**In `advanced`:** +- `useSecureCookies` - Force HTTPS cookies +- `disableCSRFCheck` - ⚠️ Security risk +- `disableOriginCheck` - ⚠️ Security risk +- `crossSubDomainCookies.enabled` - Share cookies across subdomains +- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies +- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false` + +**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage"). + +--- + +## Hooks + +**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`. + +**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions. + +**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`. + +--- + +## Plugins + +**Import from dedicated paths for tree-shaking:** +``` +import { twoFactor } from "better-auth/plugins/two-factor" +``` +NOT `from "better-auth/plugins"`. + +**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`. + +Client plugins go in `createAuthClient({ plugins: [...] })`. + +--- + +## Client + +Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`. + +Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`. + +--- + +## Type Safety + +Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`. + +For separate client/server projects: `createAuthClient()`. + +--- + +## Common Gotchas + +1. **Model vs table name** - Config uses ORM model name, not DB table name +2. **Plugin schema** - Re-run CLI after adding plugins +3. **Secondary storage** - Sessions go there by default, not DB +4. **Cookie cache** - Custom session fields NOT cached, always re-fetched +5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry +6. **Change email flow** - Sends to current email first, then new email + +--- + +## Resources + +- [Docs](https://better-auth.com/docs) +- [Options Reference](https://better-auth.com/docs/reference/options) +- [LLMs.txt](https://better-auth.com/llms.txt) +- [GitHub](https://github.com/better-auth/better-auth) +- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts) \ No newline at end of file diff --git a/.claude/agents/ds-migration-reviewer.md b/.claude/agents/ds-migration-reviewer.md index e6637a5e35..235cbc41ef 100644 --- a/.claude/agents/ds-migration-reviewer.md +++ b/.claude/agents/ds-migration-reviewer.md @@ -1,6 +1,6 @@ --- name: ds-migration-reviewer -description: Checks files for @comp/ui and lucide-react imports that can be migrated to @trycompai/design-system +description: Checks files for @trycompai/ui and lucide-react imports that can be migrated to @trycompai/design-system tools: Read, Grep, Glob, Bash --- @@ -10,7 +10,7 @@ You review frontend files for design system migration opportunities. For each file provided, identify: -1. **`@comp/ui` imports** — check if `@trycompai/design-system` has an equivalent: +1. **`@trycompai/ui` imports** — check if `@trycompai/design-system` has an equivalent: ```bash node -e "console.log(Object.keys(require('@trycompai/design-system')))" ``` @@ -20,7 +20,7 @@ For each file provided, identify: node -e "const i = require('@trycompai/design-system/icons'); console.log(Object.keys(i).filter(k => k.match(/SearchTerm/i)))" ``` -3. **`@comp/ui/button` Button** — DS Button has `loading`, `iconLeft`, `iconRight` props. Manual spinner/icon rendering inside buttons should use these props instead. +3. **`@trycompai/ui/button` Button** — DS Button has `loading`, `iconLeft`, `iconRight` props. Manual spinner/icon rendering inside buttons should use these props instead. 4. **Raw HTML layout** (`
`) — check if `Stack`, `HStack`, `PageLayout`, `PageHeader`, `Section` could replace it. @@ -29,7 +29,7 @@ For each file provided, identify: - DS `Text`, `Stack`, `HStack`, `Badge`, `Button` do NOT accept `className` — wrap in `
` if styling needed - Icons come from `@trycompai/design-system/icons` (Carbon icons) - Only flag migrations where a DS equivalent actually exists — verify by checking exports -- Don't flag `@comp/ui` usage for components that have no DS equivalent yet +- Don't flag `@trycompai/ui` usage for components that have no DS equivalent yet ## Output format diff --git a/.claude/skills/audit-design-system/SKILL.md b/.claude/skills/audit-design-system/SKILL.md index 4afb32b178..a0f25f6319 100644 --- a/.claude/skills/audit-design-system/SKILL.md +++ b/.claude/skills/audit-design-system/SKILL.md @@ -1,14 +1,14 @@ --- name: audit-design-system -description: Audit & fix design system usage — migrate @comp/ui and lucide-react to @trycompai/design-system +description: Audit & fix design system usage — migrate @trycompai/ui and lucide-react to @trycompai/design-system --- Audit the specified files for design system compliance. **Fix every issue found immediately.** ## Rules -1. **`@trycompai/design-system`** is the primary component library. `@comp/ui` is legacy — only use as last resort when no DS equivalent exists. -2. **Always check DS exports first** before reaching for `@comp/ui`. Run `node -e "console.log(Object.keys(require('@trycompai/design-system')))"` to check. +1. **`@trycompai/design-system`** is the primary component library. `@trycompai/ui` is legacy — only use as last resort when no DS equivalent exists. +2. **Always check DS exports first** before reaching for `@trycompai/ui`. Run `node -e "console.log(Object.keys(require('@trycompai/design-system')))"` to check. 3. **Icons**: Use `@trycompai/design-system/icons` (Carbon icons), NOT `lucide-react`. Check with `node -e "const i = require('@trycompai/design-system/icons'); console.log(Object.keys(i).filter(k => k.match(/YourSearch/i)))"`. 4. **DS components that do NOT accept `className`**: `Text`, `Stack`, `HStack`, `Badge`, `Button` — wrap in `
` for custom styling. 5. **Button**: Use DS `Button` with `loading`, `iconLeft`, `iconRight` props instead of manually rendering spinners/icons. @@ -17,7 +17,7 @@ Audit the specified files for design system compliance. **Fix every issue found ## Process 1. Read files specified in `$ARGUMENTS` -2. Find `@comp/ui` imports — check if DS equivalent exists +2. Find `@trycompai/ui` imports — check if DS equivalent exists 3. Find `lucide-react` imports — find matching Carbon icons 4. Migrate components and icons -5. Run build to verify: `npx turbo run typecheck --filter=@comp/app` +5. Run build to verify: `npx turbo run typecheck --filter=@trycompai/app` diff --git a/.claude/skills/audit-hooks/SKILL.md b/.claude/skills/audit-hooks/SKILL.md index ab74d70795..d3f60a1752 100644 --- a/.claude/skills/audit-hooks/SKILL.md +++ b/.claude/skills/audit-hooks/SKILL.md @@ -31,4 +31,4 @@ Audit the specified files for hook and API usage compliance. **Fix every issue f 1. Read files specified in `$ARGUMENTS` 2. Find forbidden patterns and fix them 3. Ensure all data fetching uses SWR hooks -4. Run typecheck to verify: `npx turbo run typecheck --filter=@comp/app` +4. Run typecheck to verify: `npx turbo run typecheck --filter=@trycompai/app` diff --git a/.claude/skills/audit-rbac/SKILL.md b/.claude/skills/audit-rbac/SKILL.md index a7acb60e14..dbb5f7e0da 100644 --- a/.claude/skills/audit-rbac/SKILL.md +++ b/.claude/skills/audit-rbac/SKILL.md @@ -39,4 +39,4 @@ Audit the specified files or directories for RBAC and audit log compliance. **Fi 1. Read files specified in `$ARGUMENTS` (or scan the directory) 2. Check each rule above 3. **Fix every violation immediately** — don't just report -4. Run typecheck to verify: `npx turbo run typecheck --filter=@comp/api --filter=@comp/app` +4. Run typecheck to verify: `npx turbo run typecheck --filter=@trycompai/api --filter=@trycompai/app` diff --git a/.claude/skills/better-auth-best-practices b/.claude/skills/better-auth-best-practices new file mode 120000 index 0000000000..d28d6bd7d4 --- /dev/null +++ b/.claude/skills/better-auth-best-practices @@ -0,0 +1 @@ +../../.agents/skills/better-auth-best-practices \ No newline at end of file diff --git a/.claude/skills/production-readiness/SKILL.md b/.claude/skills/production-readiness/SKILL.md index fec480252e..0300513a9e 100644 --- a/.claude/skills/production-readiness/SKILL.md +++ b/.claude/skills/production-readiness/SKILL.md @@ -14,7 +14,7 @@ Use parallel subagents to run all four audits simultaneously: Then run full monorepo verification: ```bash -npx turbo run typecheck --filter=@comp/api --filter=@comp/app +npx turbo run typecheck --filter=@trycompai/api --filter=@trycompai/app cd apps/app && npx vitest run ``` diff --git a/.cursor/rules/essentials.mdc b/.cursor/rules/essentials.mdc index 221d41af00..d45fd27e0e 100644 --- a/.cursor/rules/essentials.mdc +++ b/.cursor/rules/essentials.mdc @@ -19,7 +19,7 @@ bunx # Execute binary ## Components -**Use `@trycompai/design-system` first**, `@comp/ui` only as fallback. +**Use `@trycompai/design-system` first**, `@trycompai/ui` only as fallback. ```tsx // ✅ Design system @@ -27,7 +27,7 @@ import { Button, Card, Input, Select } from '@trycompai/design-system'; import { Add, Close } from '@trycompai/design-system/icons'; // ❌ Don't use when DS has the component -import { Button } from '@comp/ui/button'; +import { Button } from '@trycompai/ui/button'; import { Plus } from 'lucide-react'; ``` diff --git a/.cursor/rules/infra.mdc b/.cursor/rules/infra.mdc index 064b297e0f..e6d9e0fefd 100644 --- a/.cursor/rules/infra.mdc +++ b/.cursor/rules/infra.mdc @@ -26,7 +26,7 @@ comp/ │ ├── app/ # Next.js main app │ └── portal/ # Next.js portal ├── packages/ -│ ├── db/ # Prisma (@comp/db) +│ ├── db/ # Prisma (@trycompai/db) │ ├── ui/ # UI components (@trycompai/ui) │ └── ... ├── turbo.json @@ -44,7 +44,7 @@ bun run dev # Dev all # Single package bun run -F apps/app dev -bun run -F @comp/db prisma:generate +bun run -F @trycompai/db prisma:generate turbo build --filter=@trycompai/ui ``` @@ -53,7 +53,7 @@ turbo build --filter=@trycompai/ui ```tsx // ✅ Import from package name import { Button } from '@trycompai/design-system'; -import { prisma } from '@comp/db'; +import { prisma } from '@trycompai/db'; // ❌ Never relative paths across packages import { Button } from '../../../packages/ui/src/button'; @@ -116,7 +116,7 @@ mkdir packages/my-package ```json // packages/my-package/tsconfig.json { - "extends": "@comp/tsconfig/base.json", + "extends": "@trycompai/tsconfig/base.json", "include": ["src"] } ``` diff --git a/.cursor/rules/ui.mdc b/.cursor/rules/ui.mdc index ff90fb5349..4cab259802 100644 --- a/.cursor/rules/ui.mdc +++ b/.cursor/rules/ui.mdc @@ -6,7 +6,7 @@ alwaysApply: true ## Design System Priority 1. **First choice:** `@trycompai/design-system` -2. **Fallback:** `@comp/ui` only if DS doesn't have the component +2. **Fallback:** `@trycompai/ui` only if DS doesn't have the component ```tsx // ✅ Design system @@ -14,7 +14,7 @@ import { Button, Card, Input, Sheet, Badge } from '@trycompai/design-system'; import { Add, Close, ArrowRight } from '@trycompai/design-system/icons'; // ❌ Don't use when DS has it -import { Button } from '@comp/ui/button'; +import { Button } from '@trycompai/ui/button'; import { Plus } from 'lucide-react'; ``` diff --git a/.cursorrules b/.cursorrules index e99401dea2..8b14d41ec4 100644 --- a/.cursorrules +++ b/.cursorrules @@ -9,7 +9,7 @@ Read CLAUDE.md at the repo root and apps/api/CLAUDE.md for comprehensive project - **Max 300 lines** per file. - **Session auth only** — no JWT. Use `credentials: 'include'` for API calls. - **RBAC**: `@RequirePermission('resource', 'action')` on every API endpoint. Gate UI with `hasPermission()`. -- **Design system**: Always `@trycompai/design-system` first, `@comp/ui` only as fallback. Icons from `@trycompai/design-system/icons`. +- **Design system**: Always `@trycompai/design-system` first, `@trycompai/ui` only as fallback. Icons from `@trycompai/design-system/icons`. - **Data fetching**: Server components use `serverApi`. Client components use SWR hooks with `apiClient`. - **No server actions** for new features. Call NestJS API directly. - **Tests required** for every new feature. TDD preferred. diff --git a/.github/workflows/maced-contract-canary.yml b/.github/workflows/maced-contract-canary.yml index ab1d9675bc..d308a2638c 100644 --- a/.github/workflows/maced-contract-canary.yml +++ b/.github/workflows/maced-contract-canary.yml @@ -1,14 +1,15 @@ name: Maced contract canary on: - pull_request: - paths: - - 'apps/api/src/security-penetration-tests/**' - - 'apps/api/test/maced-contract.e2e-spec.ts' - - 'apps/api/package.json' - - '.github/workflows/maced-contract-canary.yml' - schedule: - - cron: '0 * * * *' + # Temporarily disabled — Maced API is unavailable + # pull_request: + # paths: + # - 'apps/api/src/security-penetration-tests/**' + # - 'apps/api/test/maced-contract.e2e-spec.ts' + # - 'apps/api/package.json' + # - '.github/workflows/maced-contract-canary.yml' + # schedule: + # - cron: '0 * * * *' workflow_dispatch: permissions: diff --git a/.syncpackrc.json b/.syncpackrc.json index fa39a04819..8a2814f625 100644 --- a/.syncpackrc.json +++ b/.syncpackrc.json @@ -4,8 +4,8 @@ "semverGroups": [ { "label": "Use exact versions for internal packages", - "packages": ["@comp/**"], - "dependencies": ["@comp/**"], + "packages": ["@trycompai/**"], + "dependencies": ["@trycompai/**"], "range": "workspace:*" } ], diff --git a/CHANGELOG.md b/CHANGELOG.md index 021d34ec94..fce77e50ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,7 +176,7 @@ Co-Authored-By: Claude Opus 4.5 * feat(rbac): add shared auth package and API integration -- Add @comp/auth package with centralized permissions and role definitions +- Add @trycompai/auth package with centralized permissions and role definitions - Update API auth module to integrate with better-auth server - Add 403 responses to policy and risk endpoints for Swagger - Add assignment filter and department visibility utilities with tests @@ -603,7 +603,7 @@ Co-Authored-By: Claude Opus 4.5 * feat(rbac): add shared auth package and API integration -- Add @comp/auth package with centralized permissions and role definitions +- Add @trycompai/auth package with centralized permissions and role definitions - Update API auth module to integrate with better-auth server - Add 403 responses to policy and risk endpoints for Swagger - Add assignment filter and department visibility utilities with tests @@ -1056,7 +1056,7 @@ Co-Authored-By: Claude Opus 4.6 ### Bug Fixes -* **api:** add @comp/company package to Dockerfile ([#2148](https://github.com/trycompai/comp/issues/2148)) ([d91bcaa](https://github.com/trycompai/comp/commit/d91bcaa5a92557a1b47a12ec6b396715744fca7f)) +* **api:** add @trycompai/company package to Dockerfile ([#2148](https://github.com/trycompai/comp/issues/2148)) ([d91bcaa](https://github.com/trycompai/comp/commit/d91bcaa5a92557a1b47a12ec6b396715744fca7f)) * **api:** inline mergeDeviceLists to fix production runtime crash ([#2146](https://github.com/trycompai/comp/issues/2146)) ([04ef343](https://github.com/trycompai/comp/commit/04ef343011defa91609ba9ba69b85776063198db)) ## [1.83.1](https://github.com/trycompai/comp/compare/v1.83.0...v1.83.1) (2026-02-17) diff --git a/CLAUDE.md b/CLAUDE.md index 02cf993199..cfecc4842d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,8 +3,8 @@ ## Tooling - **Package manager**: `bun` (never npm/yarn/pnpm) -- **Build**: `bun run build` (uses turbo). Filter: `bun run --filter '@comp/app' build` -- **Typecheck**: `bun run typecheck` or `npx turbo run typecheck --filter=@comp/api` +- **Build**: `bun run build` (uses turbo). Filter: `bun run --filter '@trycompai/app' build` +- **Typecheck**: `bun run typecheck` or `npx turbo run typecheck --filter=@trycompai/api` - **Tests (app)**: `cd apps/app && npx vitest run` - **Tests (api)**: `cd apps/api && npx jest src/ --passWithNoTests` - **Lint**: `bun run lint` @@ -106,13 +106,13 @@ Every customer-facing API endpoint MUST have: ## Design System -- **Always prefer `@trycompai/design-system`** over `@comp/ui`. Check DS exports first. -- `@comp/ui` is the legacy library being phased out — only use as last resort. +- **Always prefer `@trycompai/design-system`** over `@trycompai/ui`. Check DS exports first. +- `@trycompai/ui` is the legacy library being phased out — only use as last resort. - **Icons**: `@trycompai/design-system/icons` (Carbon icons), NOT `lucide-react` - **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 -- **After editing any frontend component**: Run the `audit-design-system` skill to catch `@comp/ui` or `lucide-react` imports that should be migrated +- **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/README.md b/README.md index 1eb2f6bd54..55a4cbfdaa 100644 --- a/README.md +++ b/README.md @@ -344,10 +344,10 @@ Steps to deploy Comp AI on Vercel are coming soon. This repository uses semantic-release to automatically publish packages to npm when merging to the `release` branch. The following packages are published: -- `@comp/db` - Database utilities with Prisma client -- `@comp/email` - Email templates and components -- `@comp/kv` - Key-value store utilities using Upstash Redis -- `@comp/ui` - UI component library with Tailwind CSS +- `@trycompai/db` - Database utilities with Prisma client +- `@trycompai/email` - Email templates and components +- `@trycompai/kv` - Key-value store utilities using Upstash Redis +- `@trycompai/ui` - UI component library with Tailwind CSS ### Setup @@ -359,11 +359,11 @@ This repository uses semantic-release to automatically publish packages to npm w ```bash # Install a published package -npm install @comp/ui +npm install @trycompai/ui # Use in your project -import { Button } from '@comp/ui/button' -import { client } from '@comp/kv' +import { Button } from '@trycompai/ui/button' +import { client } from '@trycompai/kv' ``` ### Development @@ -373,7 +373,7 @@ import { client } from '@comp/kv' bun run build # Build specific package -bun run -F @comp/ui build +bun run -F @trycompai/ui build # Test packages locally bun run release:packages --dry-run diff --git a/REVIEW.md b/REVIEW.md index fb0498de91..ef7e13175c 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -19,7 +19,7 @@ - Multi-step orchestration should use Next.js API routes (`apps/app/src/app/api/...`), not server actions ### Design System -- New UI must use `@trycompai/design-system` components, not `@comp/ui` (legacy, being phased out) +- New UI must use `@trycompai/design-system` components, not `@trycompai/ui` (legacy, being phased out) - Icons must come from `@trycompai/design-system/icons` (Carbon icons), not `lucide-react` - DS components `Text`, `Stack`, `HStack`, `Badge`, `Button` do not accept `className` — wrap in a `
` for custom styling - Use DS `Button` props like `loading`, `iconLeft`, `iconRight` instead of manually rendering spinners/icons inside buttons @@ -49,7 +49,7 @@ - No `useState` for form field values — use the form's state management ## Skip -- Pre-existing `@comp/ui` usage in files not touched by the PR +- Pre-existing `@trycompai/ui` usage in files not touched by the PR - Pre-existing `lucide-react` usage in files not touched by the PR - Pre-existing server actions in files not touched by the PR - Test files using simplified mock types diff --git a/apps/api/.cursorrules b/apps/api/.cursorrules index 392a8df421..042b4c39c1 100644 --- a/apps/api/.cursorrules +++ b/apps/api/.cursorrules @@ -24,10 +24,10 @@ Read CLAUDE.md in this directory for comprehensive API development guidelines. npx jest src/ --passWithNoTests # Run all API tests -npx turbo run test --filter=@comp/api +npx turbo run test --filter=@trycompai/api # Type-check -npx turbo run typecheck --filter=@comp/api +npx turbo run typecheck --filter=@trycompai/api ``` ### Test File Conventions diff --git a/apps/api/CLAUDE.md b/apps/api/CLAUDE.md index a76533f21e..3bafe45ef3 100644 --- a/apps/api/CLAUDE.md +++ b/apps/api/CLAUDE.md @@ -73,10 +73,10 @@ npx jest src/ --passWithNoTests npx jest --onlyChanged # Run all API tests (from repo root) -npx turbo run test --filter=@comp/api +npx turbo run test --filter=@trycompai/api # Type-check before committing -npx turbo run typecheck --filter=@comp/api +npx turbo run typecheck --filter=@trycompai/api ``` ### Test Patterns @@ -138,7 +138,7 @@ const module = await Test.createTestingModule({ 1. **Before coding**: Read existing code patterns in the module 2. **During coding**: Follow established patterns, add types 3. **After coding**: - - Run `npx turbo run typecheck --filter=@comp/api` + - Run `npx turbo run typecheck --filter=@trycompai/api` - Write and run tests: `npx jest src/` - Commit with conventional commit message @@ -146,10 +146,10 @@ const module = await Test.createTestingModule({ ```bash # Start API in development -npx turbo run dev --filter=@comp/api +npx turbo run dev --filter=@trycompai/api # Type-check -npx turbo run typecheck --filter=@comp/api +npx turbo run typecheck --filter=@trycompai/api # Run specific test file npx jest src/roles/roles.service.spec.ts diff --git a/apps/api/buildspec.yml b/apps/api/buildspec.yml index c3d4f14fe4..26b9091ed0 100644 --- a/apps/api/buildspec.yml +++ b/apps/api/buildspec.yml @@ -33,7 +33,7 @@ phases: # Install only API workspace dependencies - echo "Installing API dependencies only..." - - bun install --filter=@comp/api --frozen-lockfile || bun install --filter=@comp/api --ignore-scripts || bun install --ignore-scripts + - bun install --filter=@trycompai/api --frozen-lockfile || bun install --filter=@trycompai/api --ignore-scripts || bun install --ignore-scripts # Build workspace packages - echo "Building workspace packages..." @@ -78,28 +78,28 @@ phases: # Remove workspace symlinks and replace with built versions - rm -rf ../docker-build/node_modules/@trycompai/utils - rm -rf ../docker-build/node_modules/@trycompai/db - - rm -rf ../docker-build/node_modules/@comp/integration-platform - - rm -rf ../docker-build/node_modules/@comp/auth - - rm -rf ../docker-build/node_modules/@comp/company + - rm -rf ../docker-build/node_modules/@trycompai/integration-platform + - rm -rf ../docker-build/node_modules/@trycompai/auth + - rm -rf ../docker-build/node_modules/@trycompai/company - mkdir -p ../docker-build/node_modules/@trycompai/utils - mkdir -p ../docker-build/node_modules/@trycompai/db - - mkdir -p ../docker-build/node_modules/@comp/integration-platform - - mkdir -p ../docker-build/node_modules/@comp/auth - - mkdir -p ../docker-build/node_modules/@comp/company + - mkdir -p ../docker-build/node_modules/@trycompai/integration-platform + - mkdir -p ../docker-build/node_modules/@trycompai/auth + - mkdir -p ../docker-build/node_modules/@trycompai/company - cp -r ../../packages/utils/src ../docker-build/node_modules/@trycompai/utils/ - cp ../../packages/utils/package.json ../docker-build/node_modules/@trycompai/utils/ - cp -r ../../packages/db/dist ../docker-build/node_modules/@trycompai/db/ - cp ../../packages/db/package.json ../docker-build/node_modules/@trycompai/db/ - - cp -r ../../packages/integration-platform/dist ../docker-build/node_modules/@comp/integration-platform/ - - cp ../../packages/integration-platform/package.json ../docker-build/node_modules/@comp/integration-platform/ - - cp -r ../../packages/auth/dist ../docker-build/node_modules/@comp/auth/ - - cp ../../packages/auth/package.json ../docker-build/node_modules/@comp/auth/ - - cp -r ../../packages/company/dist ../docker-build/node_modules/@comp/company/ - - cp ../../packages/company/package.json ../docker-build/node_modules/@comp/company/ + - cp -r ../../packages/integration-platform/dist ../docker-build/node_modules/@trycompai/integration-platform/ + - cp ../../packages/integration-platform/package.json ../docker-build/node_modules/@trycompai/integration-platform/ + - cp -r ../../packages/auth/dist ../docker-build/node_modules/@trycompai/auth/ + - cp ../../packages/auth/package.json ../docker-build/node_modules/@trycompai/auth/ + - cp -r ../../packages/company/dist ../docker-build/node_modules/@trycompai/company/ + - cp ../../packages/company/package.json ../docker-build/node_modules/@trycompai/company/ - cp Dockerfile ../docker-build/ # Remove workspace dependencies from package.json (they're copied manually above) - - cat package.json | jq 'del(.dependencies["@comp/integration-platform"]) | del(.dependencies["@comp/auth"]) | del(.dependencies["@comp/company"])' > ../docker-build/package.json + - cat package.json | jq 'del(.dependencies["@trycompai/integration-platform"]) | del(.dependencies["@trycompai/auth"]) | del(.dependencies["@trycompai/company"])' > ../docker-build/package.json - cp ../../bun.lock ../docker-build/ || true - echo "Building Docker image..." diff --git a/apps/api/integrationPlatformExtension.ts b/apps/api/integrationPlatformExtension.ts index 3b8e960023..77beb8fc42 100644 --- a/apps/api/integrationPlatformExtension.ts +++ b/apps/api/integrationPlatformExtension.ts @@ -8,12 +8,12 @@ import { existsSync } from 'node:fs'; import { cp, mkdir } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; -const PACKAGE_NAME = '@comp/integration-platform'; +const PACKAGE_NAME = '@trycompai/integration-platform'; /** - * Custom Trigger.dev build extension for @comp/integration-platform workspace package. + * Custom Trigger.dev build extension for @trycompai/integration-platform workspace package. * - * Since @comp/integration-platform is a workspace package (not published to npm), + * Since @trycompai/integration-platform is a workspace package (not published to npm), * we need to: * 1. Add an esbuild plugin to resolve the import path during build * 2. Copy the built dist files into the trigger.dev deployment @@ -51,15 +51,15 @@ class IntegrationPlatformExtension implements BuildExtension { name: 'resolve-integration-platform', setup(build) { // Resolve bare import - build.onResolve({ filter: /^@comp\/integration-platform$/ }, () => { + build.onResolve({ filter: /^@trycompai\/integration-platform$/ }, () => { return { path: resolve(packagePath, 'dist/index.js'), }; }); - // Resolve subpath imports like @comp/integration-platform/types + // Resolve subpath imports like @trycompai/integration-platform/types build.onResolve( - { filter: /^@comp\/integration-platform\// }, + { filter: /^@trycompai\/integration-platform\// }, (args) => { const subpath = args.path.replace(`${PACKAGE_NAME}/`, ''); return { @@ -88,7 +88,7 @@ class IntegrationPlatformExtension implements BuildExtension { // Copy the entire dist to the build output const destPath = resolve( manifest.outputPath, - 'node_modules/@comp/integration-platform', + 'node_modules/@trycompai/integration-platform', ); const destDistPath = resolve(destPath, 'dist'); @@ -104,7 +104,7 @@ class IntegrationPlatformExtension implements BuildExtension { } context.logger.log( - 'Copied @comp/integration-platform to deployment bundle', + 'Copied @trycompai/integration-platform to deployment bundle', ); } diff --git a/apps/api/package.json b/apps/api/package.json index 5744056536..a04bbff15e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,5 +1,5 @@ { - "name": "@comp/api", + "name": "@trycompai/api", "description": "", "version": "0.0.1", "author": "", @@ -14,9 +14,6 @@ "@aws-sdk/s3-request-presigner": "^3.859.0", "@browserbasehq/sdk": "^2.6.0", "@browserbasehq/stagehand": "^3.0.5", - "@comp/auth": "workspace:*", - "@comp/company": "workspace:*", - "@comp/integration-platform": "workspace:*", "@mendable/firecrawl-js": "^4.9.3", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", @@ -28,11 +25,15 @@ "@prisma/instrumentation": "^6.13.0", "@react-email/components": "^0.0.41", "@react-email/render": "^2.0.4", + "@thallesp/nestjs-better-auth": "^2.4.0", "@trigger.dev/build": "4.0.6", "@trigger.dev/sdk": "4.0.6", - "@thallesp/nestjs-better-auth": "^2.4.0", + "@trycompai/auth": "workspace:*", + "@trycompai/company": "workspace:*", "@trycompai/db": "1.3.22", "@trycompai/email": "workspace:*", + "@trycompai/integration-platform": "workspace:*", + "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.34.2", "@upstash/vector": "^1.2.2", "adm-zip": "^0.5.16", diff --git a/apps/api/src/admin-organizations/admin-audit-log.interceptor.spec.ts b/apps/api/src/admin-organizations/admin-audit-log.interceptor.spec.ts new file mode 100644 index 0000000000..b0f2eeaec3 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-audit-log.interceptor.spec.ts @@ -0,0 +1,287 @@ +import { of } from 'rxjs'; +import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; + +const mockCreate = jest.fn().mockResolvedValue({}); +const mockPolicyFind = jest.fn(); +const mockTaskFind = jest.fn(); +const mockVendorFind = jest.fn(); +const mockFindingFind = jest.fn(); +const mockContextFind = jest.fn(); + +jest.mock('@db', () => ({ + AuditLogEntityType: { + organization: 'organization', + finding: 'finding', + policy: 'policy', + task: 'task', + vendor: 'vendor', + }, + Prisma: {}, + db: { + auditLog: { get create() { return mockCreate; } }, + policy: { get findFirst() { return mockPolicyFind; } }, + taskItem: { get findFirst() { return mockTaskFind; } }, + vendor: { get findFirst() { return mockVendorFind; } }, + finding: { get findFirst() { return mockFindingFind; } }, + context: { get findFirst() { return mockContextFind; } }, + }, +})); + +jest.mock('../audit/audit-log.constants', () => ({ + MUTATION_METHODS: new Set(['POST', 'PATCH', 'PUT', 'DELETE']), + SENSITIVE_KEYS: new Set(['password', 'token']), +})); + +function buildContext(overrides: { + method?: string; + url?: string; + params?: Record; + body?: Record; + userId?: string; +}) { + const request = { + method: overrides.method ?? 'PATCH', + url: overrides.url ?? '/v1/admin/organizations/org_1/policies/pol_1', + params: overrides.params ?? { orgId: 'org_1' }, + body: overrides.body ?? { status: 'published' }, + userId: 'userId' in overrides ? overrides.userId : 'usr_admin', + }; + + return { + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as Parameters[0]; +} + +const nextHandler = { handle: () => of({ success: true }) }; + +describe('AdminAuditLogInterceptor', () => { + let interceptor: AdminAuditLogInterceptor; + + beforeEach(() => { + interceptor = new AdminAuditLogInterceptor(); + jest.clearAllMocks(); + mockPolicyFind.mockResolvedValue(null); + mockTaskFind.mockResolvedValue(null); + mockVendorFind.mockResolvedValue(null); + mockFindingFind.mockResolvedValue(null); + mockContextFind.mockResolvedValue(null); + }); + + it('should skip GET requests', (done) => { + const ctx = buildContext({ method: 'GET' }); + + interceptor.intercept(ctx, nextHandler).subscribe({ + complete: () => { + expect(mockCreate).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it('should skip when no userId', (done) => { + const ctx = buildContext({ userId: undefined }); + + interceptor.intercept(ctx, nextHandler).subscribe({ + complete: () => { + expect(mockCreate).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it('should include policy name in description', (done) => { + mockPolicyFind.mockResolvedValue({ name: 'Privacy Policy' }); + + const ctx = buildContext({ + method: 'PATCH', + url: '/v1/admin/organizations/org_1/policies/pol_1', + params: { orgId: 'org_1' }, + body: { status: 'published' }, + }); + + interceptor.intercept(ctx, nextHandler).subscribe({ + complete: () => { + setTimeout(() => { + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + organizationId: 'org_1', + userId: 'usr_admin', + entityType: 'policy', + entityId: 'pol_1', + description: "Updated policy 'Privacy Policy'", + }), + }); + done(); + }, 50); + }, + }); + }); + + it('should fall back to generic description when name not found', (done) => { + mockPolicyFind.mockResolvedValue(null); + + const ctx = buildContext({ + method: 'PATCH', + url: '/v1/admin/organizations/org_1/policies/pol_1', + params: { orgId: 'org_1' }, + }); + + interceptor.intercept(ctx, nextHandler).subscribe({ + complete: () => { + setTimeout(() => { + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + description: 'Updated policy', + }), + }); + done(); + }, 50); + }, + }); + }); + + it('should log POST mutations for findings', (done) => { + const ctx = buildContext({ + method: 'POST', + url: '/v1/admin/organizations/org_1/findings', + params: { orgId: 'org_1' }, + body: { title: 'New finding' }, + }); + + interceptor.intercept(ctx, nextHandler).subscribe({ + complete: () => { + setTimeout(() => { + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + organizationId: 'org_1', + entityType: 'finding', + description: 'Created finding', + }), + }); + done(); + }, 50); + }, + }); + }); + + it('should handle activate action', (done) => { + const ctx = buildContext({ + method: 'PATCH', + url: '/v1/admin/organizations/org_1/activate', + params: { id: 'org_1' }, + body: {}, + }); + + interceptor.intercept(ctx, nextHandler).subscribe({ + complete: () => { + setTimeout(() => { + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + organizationId: 'org_1', + entityType: 'organization', + entityId: 'org_1', + description: 'Activated organization', + }), + }); + done(); + }, 50); + }, + }); + }); + + it('should handle deactivate action', (done) => { + const ctx = buildContext({ + method: 'PATCH', + url: '/v1/admin/organizations/org_1/deactivate', + params: { id: 'org_1' }, + body: {}, + }); + + interceptor.intercept(ctx, nextHandler).subscribe({ + complete: () => { + setTimeout(() => { + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + description: 'Deactivated organization', + }), + }); + done(); + }, 50); + }, + }); + }); + + it('should sanitize sensitive keys from body', (done) => { + mockPolicyFind.mockResolvedValue({ name: 'Test' }); + + const ctx = buildContext({ + method: 'PATCH', + url: '/v1/admin/organizations/org_1/policies/pol_1', + params: { orgId: 'org_1' }, + body: { status: 'published', password: 'secret123' }, + }); + + interceptor.intercept(ctx, nextHandler).subscribe({ + complete: () => { + setTimeout(() => { + const callData = mockCreate.mock.calls[0][0].data; + const changes = callData.data.changes; + expect(changes.status).toBeDefined(); + expect(changes.password).toBeUndefined(); + done(); + }, 50); + }, + }); + }); + + it('should handle DELETE for invitations', (done) => { + const ctx = buildContext({ + method: 'DELETE', + url: '/v1/admin/organizations/org_1/invitations/inv_1', + params: { id: 'org_1' }, + body: undefined, + }); + + interceptor.intercept(ctx, nextHandler).subscribe({ + complete: () => { + setTimeout(() => { + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + description: 'Revoked organization invitation', + entityType: 'organization', + }), + }); + done(); + }, 50); + }, + }); + }); + + it('should include task title in description', (done) => { + mockTaskFind.mockResolvedValue({ title: 'Review access controls' }); + + const ctx = buildContext({ + method: 'PATCH', + url: '/v1/admin/organizations/org_1/tasks/tsk_1', + params: { orgId: 'org_1' }, + body: { status: 'completed' }, + }); + + interceptor.intercept(ctx, nextHandler).subscribe({ + complete: () => { + setTimeout(() => { + expect(mockCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + description: "Updated task 'Review access controls'", + data: expect.objectContaining({ + resource: 'admin', + permission: 'platform-admin', + }), + }), + }); + done(); + }, 50); + }, + }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-audit-log.interceptor.ts b/apps/api/src/admin-organizations/admin-audit-log.interceptor.ts new file mode 100644 index 0000000000..d8caeafb49 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-audit-log.interceptor.ts @@ -0,0 +1,286 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import { AuditLogEntityType, db, Prisma } from '@db'; +import { Observable, tap } from 'rxjs'; +import { MUTATION_METHODS, SENSITIVE_KEYS } from '../audit/audit-log.constants'; + +const SEGMENT_TO_RESOURCE: Record< + string, + { entity: AuditLogEntityType; singular: string } +> = { + findings: { entity: AuditLogEntityType.finding, singular: 'finding' }, + policies: { entity: AuditLogEntityType.policy, singular: 'policy' }, + tasks: { entity: AuditLogEntityType.task, singular: 'task' }, + vendors: { entity: AuditLogEntityType.vendor, singular: 'vendor' }, + context: { entity: AuditLogEntityType.organization, singular: 'context' }, +}; + +const SPECIAL_ACTION_DESCRIPTIONS: Record = { + activate: 'Activated organization', + deactivate: 'Deactivated organization', + invite: 'Invited member to organization', + regenerate: 'Regenerated policy content', + 'trigger-assessment': 'Triggered vendor risk assessment', +}; + +type Changes = Record; + +interface ParsedPath { + resource: string; + entityType: AuditLogEntityType | null; + entityId: string | null; + actionSegment: string | null; +} + +@Injectable() +export class AdminAuditLogInterceptor implements NestInterceptor { + private readonly logger = new Logger(AdminAuditLogInterceptor.name); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const method: string = request.method; + + if (!MUTATION_METHODS.has(method)) { + return next.handle(); + } + + const organizationId: string | undefined = + request.params?.orgId ?? request.params?.id; + const userId: string | undefined = request.userId; + + if (!organizationId || !userId) { + this.logger.warn( + `Admin audit log skipped for ${method} ${request.url}: ` + + `missing ${!organizationId ? 'organizationId' : 'userId'}`, + ); + return next.handle(); + } + + const parsed = this.parsePath(request.url, organizationId); + const body = request.body as Record | undefined; + const changes = body ? this.sanitizeBody(body) : null; + + return next.handle().pipe( + tap({ + next: () => { + void this.persistWithName({ + organizationId, + userId, + method, + path: request.url, + parsed, + changes, + }).catch((err) => { + this.logger.error('Failed to write admin audit log', err); + }); + }, + error: (err: Error) => { + void this.persistWithName({ + organizationId, + userId, + method, + path: request.url, + parsed, + changes: { + ...(changes ?? {}), + _failed: { previous: null, current: err.message }, + }, + }).catch((logErr) => { + this.logger.error( + 'Failed to write admin audit log for failed request', + logErr, + ); + }); + }, + }), + ); + } + + private parsePath(url: string, orgId: string): ParsedPath { + const segments = url.split('?')[0].split('/').filter(Boolean); + const orgIndex = segments.indexOf(orgId); + + if (orgIndex === -1) { + return { + resource: 'organization', + entityType: AuditLogEntityType.organization, + entityId: orgId, + actionSegment: null, + }; + } + + const resourceSegment = segments[orgIndex + 1]; + const possibleEntityId = segments[orgIndex + 2]; + const actionSegment = segments[orgIndex + 3] ?? null; + + if (!resourceSegment || SPECIAL_ACTION_DESCRIPTIONS[resourceSegment]) { + return { + resource: 'organization', + entityType: AuditLogEntityType.organization, + entityId: orgId, + actionSegment: resourceSegment ?? null, + }; + } + + if (resourceSegment === 'invitations') { + return { + resource: 'organization', + entityType: AuditLogEntityType.organization, + entityId: orgId, + actionSegment: 'invitations', + }; + } + + const mapped = SEGMENT_TO_RESOURCE[resourceSegment]; + + return { + resource: mapped?.singular ?? resourceSegment, + entityType: mapped?.entity ?? null, + entityId: possibleEntityId ?? null, + actionSegment: actionSegment, + }; + } + + private buildDescription( + method: string, + parsed: ParsedPath, + entityName: string | null, + ): string { + if ( + parsed.actionSegment && + SPECIAL_ACTION_DESCRIPTIONS[parsed.actionSegment] + ) { + const base = SPECIAL_ACTION_DESCRIPTIONS[parsed.actionSegment]; + return entityName ? `${base} '${entityName}'` : base; + } + + if (parsed.actionSegment === 'invitations' && method === 'DELETE') { + return 'Revoked organization invitation'; + } + + const verb: Record = { + POST: 'Created', + PATCH: 'Updated', + PUT: 'Updated', + DELETE: 'Deleted', + }; + + const action = `${verb[method] ?? 'Modified'} ${parsed.resource}`; + return entityName ? `${action} '${entityName}'` : action; + } + + private async resolveEntityName( + resource: string, + entityId: string | null, + organizationId: string, + ): Promise { + if (!entityId) return null; + + try { + switch (resource) { + case 'policy': { + const p = await db.policy.findFirst({ + where: { id: entityId, organizationId }, + select: { name: true }, + }); + return p?.name ?? null; + } + case 'task': { + const t = await db.task.findFirst({ + where: { id: entityId, organizationId }, + select: { title: true }, + }); + return t?.title ?? null; + } + case 'vendor': { + const v = await db.vendor.findFirst({ + where: { id: entityId, organizationId }, + select: { name: true }, + }); + return v?.name ?? null; + } + case 'finding': { + const f = await db.finding.findFirst({ + where: { id: entityId, organizationId }, + select: { template: { select: { title: true } } }, + }); + return f?.template?.title ?? null; + } + case 'context': { + const c = await db.context.findFirst({ + where: { id: entityId, organizationId }, + select: { question: true }, + }); + if (!c?.question) return null; + return c.question.length > 60 + ? `${c.question.slice(0, 57)}...` + : c.question; + } + default: + return null; + } + } catch { + return null; + } + } + + private sanitizeBody(body: Record): Changes | null { + const changes: Changes = {}; + + for (const [key, value] of Object.entries(body)) { + if (value === undefined || SENSITIVE_KEYS.has(key)) continue; + changes[key] = { previous: null, current: value }; + } + + return Object.keys(changes).length > 0 ? changes : null; + } + + private async persistWithName(params: { + organizationId: string; + userId: string; + method: string; + path: string; + parsed: ParsedPath; + changes: Changes | null; + }): Promise { + const entityName = await this.resolveEntityName( + params.parsed.resource, + params.parsed.entityId, + params.organizationId, + ); + const description = this.buildDescription( + params.method, + params.parsed, + entityName, + ); + + const auditData: Record = { + action: description, + method: params.method, + path: params.path, + resource: 'admin', + permission: 'platform-admin', + }; + + if (params.changes) { + auditData.changes = params.changes; + } + + await db.auditLog.create({ + data: { + organizationId: params.organizationId, + userId: params.userId, + memberId: null, + entityType: params.parsed.entityType, + entityId: params.parsed.entityId, + description, + data: auditData as Prisma.InputJsonValue, + }, + }); + } +} diff --git a/apps/api/src/admin-organizations/admin-context.controller.spec.ts b/apps/api/src/admin-organizations/admin-context.controller.spec.ts new file mode 100644 index 0000000000..6a7e8b3a4f --- /dev/null +++ b/apps/api/src/admin-organizations/admin-context.controller.spec.ts @@ -0,0 +1,98 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminContextController } from './admin-context.controller'; +import { ContextService } from '../context/context.service'; + +jest.mock('../auth/platform-admin.guard', () => ({ + PlatformAdminGuard: class { + canActivate() { + return true; + } + }, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: {} }, +})); + +jest.mock('@trycompai/db', () => ({ db: {} })); + +describe('AdminContextController', () => { + let controller: AdminContextController; + + const mockService = { + findAllByOrganization: jest.fn(), + create: jest.fn(), + updateById: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminContextController], + providers: [{ provide: ContextService, useValue: mockService }], + }).compile(); + + controller = module.get(AdminContextController); + jest.clearAllMocks(); + }); + + describe('list', () => { + it('should list context entries', async () => { + const entries = { data: [{ id: 'ctx_1' }], count: 1 }; + mockService.findAllByOrganization.mockResolvedValue(entries); + + const result = await controller.list('org_1'); + + expect(mockService.findAllByOrganization).toHaveBeenCalledWith('org_1', { + search: undefined, + page: undefined, + perPage: undefined, + }); + expect(result).toEqual(entries); + }); + + it('should pass search and pagination', async () => { + mockService.findAllByOrganization.mockResolvedValue({ + data: [], + count: 0, + }); + + await controller.list('org_1', 'auth', '2', '10'); + + expect(mockService.findAllByOrganization).toHaveBeenCalledWith('org_1', { + search: 'auth', + page: 2, + perPage: 10, + }); + }); + }); + + describe('create', () => { + it('should create a context entry', async () => { + const dto = { question: 'How?', answer: 'Like this.' }; + const created = { id: 'ctx_1', ...dto }; + mockService.create.mockResolvedValue(created); + + const result = await controller.create('org_1', dto as never); + + expect(mockService.create).toHaveBeenCalledWith('org_1', dto); + expect(result).toEqual(created); + }); + }); + + describe('update', () => { + it('should update a context entry', async () => { + const dto = { answer: 'Updated answer' }; + const updated = { id: 'ctx_1', answer: 'Updated answer' }; + mockService.updateById.mockResolvedValue(updated); + + const result = await controller.update('org_1', 'ctx_1', dto as never); + + expect(mockService.updateById).toHaveBeenCalledWith( + 'ctx_1', + 'org_1', + dto, + ); + expect(result).toEqual(updated); + }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-context.controller.ts b/apps/api/src/admin-organizations/admin-context.controller.ts new file mode 100644 index 0000000000..37d7ca82a1 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-context.controller.ts @@ -0,0 +1,77 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Query, + Body, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { ContextService } from '../context/context.service'; +import { CreateContextDto } from '../context/dto/create-context.dto'; +import { UpdateContextDto } from '../context/dto/update-context.dto'; +import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; + +@ApiTags('Admin - Context') +@Controller({ path: 'admin/organizations', version: '1' }) +@UseGuards(PlatformAdminGuard) +@UseInterceptors(AdminAuditLogInterceptor) +@Throttle({ default: { ttl: 60000, limit: 30 } }) +export class AdminContextController { + constructor(private readonly contextService: ContextService) {} + + @Get(':orgId/context') + @ApiOperation({ summary: 'List context entries for an organization (admin)' }) + async list( + @Param('orgId') orgId: string, + @Query('search') search?: string, + @Query('page') page?: string, + @Query('perPage') perPage?: string, + ) { + return this.contextService.findAllByOrganization(orgId, { + search, + page: page ? parseInt(page, 10) : undefined, + perPage: perPage ? parseInt(perPage, 10) : undefined, + }); + } + + @Post(':orgId/context') + @ApiOperation({ summary: 'Create a context entry for an organization (admin)' }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async create( + @Param('orgId') orgId: string, + @Body() createDto: CreateContextDto, + ) { + return this.contextService.create(orgId, createDto); + } + + @Patch(':orgId/context/:contextId') + @ApiOperation({ summary: 'Update a context entry for an organization (admin)' }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async update( + @Param('orgId') orgId: string, + @Param('contextId') contextId: string, + @Body() updateDto: UpdateContextDto, + ) { + return this.contextService.updateById(contextId, orgId, updateDto); + } +} diff --git a/apps/api/src/admin-organizations/admin-evidence.controller.spec.ts b/apps/api/src/admin-organizations/admin-evidence.controller.spec.ts new file mode 100644 index 0000000000..063c14c6f4 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-evidence.controller.spec.ts @@ -0,0 +1,94 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { AdminEvidenceController } from './admin-evidence.controller'; +import { EvidenceFormsService } from '../evidence-forms/evidence-forms.service'; + +jest.mock('../auth/platform-admin.guard', () => ({ + PlatformAdminGuard: class { + canActivate() { + return true; + } + }, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: {} }, +})); + +jest.mock('@trycompai/db', () => ({ db: {} })); + +describe('AdminEvidenceController', () => { + let controller: AdminEvidenceController; + + const mockService = { + getFormStatuses: jest.fn(), + getFormWithSubmissions: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminEvidenceController], + providers: [ + { provide: EvidenceFormsService, useValue: mockService }, + ], + }).compile(); + + controller = module.get(AdminEvidenceController); + jest.clearAllMocks(); + }); + + describe('listFormStatuses', () => { + it('should return form statuses', async () => { + const statuses = { + 'access-request': { lastSubmittedAt: '2026-01-01' }, + meeting: { lastSubmittedAt: null }, + }; + mockService.getFormStatuses.mockResolvedValue(statuses); + + const result = await controller.listFormStatuses('org_1'); + + expect(mockService.getFormStatuses).toHaveBeenCalledWith('org_1'); + expect(result).toEqual(statuses); + }); + }); + + describe('getFormWithSubmissions', () => { + it('should return form with submissions', async () => { + const detail = { + form: { type: 'meeting', label: 'Meeting' }, + submissions: [], + total: 0, + }; + mockService.getFormWithSubmissions.mockResolvedValue(detail); + const mockReq = { userId: 'usr_admin1' }; + + const result = await controller.getFormWithSubmissions( + 'org_1', + 'meeting', + mockReq, + ); + + expect(mockService.getFormWithSubmissions).toHaveBeenCalledWith({ + organizationId: 'org_1', + authContext: expect.objectContaining({ + userId: 'usr_admin1', + isPlatformAdmin: true, + isApiKey: false, + userRoles: ['admin'], + }), + formType: 'meeting', + search: undefined, + limit: undefined, + offset: undefined, + }); + expect(result).toEqual(detail); + }); + + it('should reject empty formType', async () => { + const mockReq = { userId: 'usr_admin1' }; + await expect( + controller.getFormWithSubmissions('org_1', '', mockReq), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-evidence.controller.ts b/apps/api/src/admin-organizations/admin-evidence.controller.ts new file mode 100644 index 0000000000..463586dd7c --- /dev/null +++ b/apps/api/src/admin-organizations/admin-evidence.controller.ts @@ -0,0 +1,60 @@ +import { + Controller, + Get, + Param, + Query, + Req, + UseGuards, + UseInterceptors, + BadRequestException, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { EvidenceFormsService } from '../evidence-forms/evidence-forms.service'; +import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; +import { + type AdminRequest, + buildPlatformAdminAuthContext, +} from './platform-admin-auth-context'; + +@ApiTags('Admin - Evidence') +@Controller({ path: 'admin/organizations', version: '1' }) +@UseGuards(PlatformAdminGuard) +@UseInterceptors(AdminAuditLogInterceptor) +@Throttle({ default: { ttl: 60000, limit: 30 } }) +export class AdminEvidenceController { + constructor( + private readonly evidenceFormsService: EvidenceFormsService, + ) {} + + @Get(':orgId/evidence-forms') + @ApiOperation({ summary: 'List evidence form statuses for an organization (admin)' }) + async listFormStatuses(@Param('orgId') orgId: string) { + return this.evidenceFormsService.getFormStatuses(orgId); + } + + @Get(':orgId/evidence-forms/:formType') + @ApiOperation({ summary: 'Get evidence form with submissions (admin)' }) + async getFormWithSubmissions( + @Param('orgId') orgId: string, + @Param('formType') formType: string, + @Req() req: AdminRequest, + @Query('search') search?: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + if (!formType) { + throw new BadRequestException('formType is required'); + } + + return this.evidenceFormsService.getFormWithSubmissions({ + organizationId: orgId, + authContext: buildPlatformAdminAuthContext(req.userId, orgId), + formType, + search, + limit: limit ? String(Math.min(200, Math.max(1, parseInt(limit, 10) || 1))) : undefined, + offset: offset ? String(Math.max(0, parseInt(offset, 10) || 0)) : undefined, + }); + } +} diff --git a/apps/api/src/admin-organizations/admin-findings.controller.spec.ts b/apps/api/src/admin-organizations/admin-findings.controller.spec.ts new file mode 100644 index 0000000000..fb97301be8 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-findings.controller.spec.ts @@ -0,0 +1,125 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { AdminFindingsController } from './admin-findings.controller'; +import { FindingsService } from '../findings/findings.service'; + +jest.mock('../auth/platform-admin.guard', () => ({ + PlatformAdminGuard: class { + canActivate() { + return true; + } + }, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: {} }, +})); + +jest.mock('@trycompai/db', () => ({ + db: {}, + FindingStatus: { + open: 'open', + ready_for_review: 'ready_for_review', + needs_revision: 'needs_revision', + closed: 'closed', + }, + FindingType: { + soc2: 'soc2', + iso27001: 'iso27001', + }, +})); + +describe('AdminFindingsController', () => { + let controller: AdminFindingsController; + + const mockService = { + findByOrganizationId: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminFindingsController], + providers: [{ provide: FindingsService, useValue: mockService }], + }).compile(); + + controller = module.get(AdminFindingsController); + jest.clearAllMocks(); + }); + + describe('list', () => { + it('should list findings for an organization', async () => { + const findings = [{ id: 'fnd_1', status: 'open' }]; + mockService.findByOrganizationId.mockResolvedValue(findings); + + const result = await controller.list('org_1'); + + expect(mockService.findByOrganizationId).toHaveBeenCalledWith( + 'org_1', + undefined, + ); + expect(result).toEqual(findings); + }); + + it('should filter by status', async () => { + mockService.findByOrganizationId.mockResolvedValue([]); + + await controller.list('org_1', 'open'); + + expect(mockService.findByOrganizationId).toHaveBeenCalledWith( + 'org_1', + 'open', + ); + }); + + it('should reject invalid status', async () => { + await expect(controller.list('org_1', 'invalid')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('create', () => { + it('should create a finding with null memberId', async () => { + const dto = { content: 'Test finding', taskId: 'tsk_1' }; + const created = { id: 'fnd_1', ...dto }; + mockService.create.mockResolvedValue(created); + + const result = await controller.create('org_1', dto as never, { + userId: 'usr_admin', + }); + + expect(mockService.create).toHaveBeenCalledWith( + 'org_1', + null, + 'usr_admin', + dto, + ); + expect(result).toEqual(created); + }); + }); + + describe('update', () => { + it('should update a finding as platform admin', async () => { + const dto = { status: 'closed' }; + const updated = { id: 'fnd_1', status: 'closed' }; + mockService.update.mockResolvedValue(updated); + + const result = await controller.update('org_1', 'fnd_1', dto as never, { + userId: 'usr_admin', + }); + + expect(mockService.update).toHaveBeenCalledWith( + 'org_1', + 'fnd_1', + dto, + [], + true, + 'usr_admin', + null, + ); + expect(result).toEqual(updated); + }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-findings.controller.ts b/apps/api/src/admin-organizations/admin-findings.controller.ts new file mode 100644 index 0000000000..cfffa6b143 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-findings.controller.ts @@ -0,0 +1,95 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Query, + Body, + Req, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, + BadRequestException, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { FindingStatus } from '@trycompai/db'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { FindingsService } from '../findings/findings.service'; +import { CreateFindingDto } from '../findings/dto/create-finding.dto'; +import { UpdateFindingDto } from '../findings/dto/update-finding.dto'; +import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; +import type { AdminRequest } from './platform-admin-auth-context'; + +@ApiTags('Admin - Findings') +@Controller({ path: 'admin/organizations', version: '1' }) +@UseGuards(PlatformAdminGuard) +@UseInterceptors(AdminAuditLogInterceptor) +@Throttle({ default: { ttl: 60000, limit: 30 } }) +export class AdminFindingsController { + constructor(private readonly findingsService: FindingsService) {} + + @Get(':orgId/findings') + @ApiOperation({ summary: 'List all findings for an organization (admin)' }) + async list( + @Param('orgId') orgId: string, + @Query('status') status?: string, + ) { + let validatedStatus: FindingStatus | undefined; + if (status) { + if (!Object.values(FindingStatus).includes(status as FindingStatus)) { + throw new BadRequestException( + `Invalid status. Must be one of: ${Object.values(FindingStatus).join(', ')}`, + ); + } + validatedStatus = status as FindingStatus; + } + + return this.findingsService.findByOrganizationId(orgId, validatedStatus); + } + + @Post(':orgId/findings') + @ApiOperation({ summary: 'Create a finding for an organization (admin)' }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async create( + @Param('orgId') orgId: string, + @Body() createDto: CreateFindingDto, + @Req() req: AdminRequest, + ) { + return this.findingsService.create(orgId, null, req.userId, createDto); + } + + @Patch(':orgId/findings/:findingId') + @ApiOperation({ summary: 'Update a finding for an organization (admin)' }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async update( + @Param('orgId') orgId: string, + @Param('findingId') findingId: string, + @Body() updateDto: UpdateFindingDto, + @Req() req: AdminRequest, + ) { + return this.findingsService.update( + orgId, + findingId, + updateDto, + [], + true, + req.userId, + null, + ); + } +} diff --git a/apps/api/src/admin-organizations/admin-guard-integration.spec.ts b/apps/api/src/admin-organizations/admin-guard-integration.spec.ts new file mode 100644 index 0000000000..20e1491b4e --- /dev/null +++ b/apps/api/src/admin-organizations/admin-guard-integration.spec.ts @@ -0,0 +1,197 @@ +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; + +const mockGetSession = jest.fn(); +const mockFindUnique = jest.fn(); + +jest.mock('../auth/auth.server', () => ({ + auth: { + api: { + getSession: (...args: unknown[]) => mockGetSession(...args), + }, + }, +})); + +jest.mock('@trycompai/db', () => ({ + db: { + user: { + findUnique: (...args: unknown[]) => mockFindUnique(...args), + }, + }, +})); + +function buildContext( + headers: Record = {}, +): ExecutionContext { + const request = { + headers, + userId: undefined, + userEmail: undefined, + isPlatformAdmin: undefined, + }; + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + } as unknown as ExecutionContext; +} + +describe('PlatformAdminGuard — runtime rejection scenarios', () => { + let guard: PlatformAdminGuard; + + beforeEach(() => { + guard = new PlatformAdminGuard(); + jest.clearAllMocks(); + }); + + describe('returns 401 for unauthenticated requests', () => { + it('rejects requests with no headers at all', async () => { + const ctx = buildContext({}); + await expect(guard.canActivate(ctx)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('rejects requests with only x-api-key (admin routes are session-only)', async () => { + const ctx = buildContext({ 'x-api-key': 'key_test_12345' }); + await expect(guard.canActivate(ctx)).rejects.toThrow( + UnauthorizedException, + ); + expect(mockGetSession).not.toHaveBeenCalled(); + }); + + it('rejects requests with only x-service-token', async () => { + const ctx = buildContext({ 'x-service-token': 'svc_test_token' }); + await expect(guard.canActivate(ctx)).rejects.toThrow( + UnauthorizedException, + ); + expect(mockGetSession).not.toHaveBeenCalled(); + }); + + it('rejects when session cookie is present but session is expired', async () => { + mockGetSession.mockResolvedValue(null); + const ctx = buildContext({ cookie: 'session=expired_token' }); + await expect(guard.canActivate(ctx)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('rejects when bearer token is present but session is invalid', async () => { + mockGetSession.mockResolvedValue({ user: {} }); + const ctx = buildContext({ authorization: 'Bearer invalid' }); + await expect(guard.canActivate(ctx)).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('returns 403 for authenticated non-admin users', () => { + it('rejects a user with role "user"', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'usr_regular' } }); + mockFindUnique.mockResolvedValue({ + id: 'usr_regular', + email: 'regular@test.com', + role: 'user', + }); + const ctx = buildContext({ cookie: 'session=valid' }); + + await expect(guard.canActivate(ctx)).rejects.toThrow( + ForbiddenException, + ); + await expect(guard.canActivate(ctx)).rejects.toThrow( + 'Access denied: Platform admin privileges required', + ); + }); + + it('rejects a user with role null (no role set)', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'usr_norole' } }); + mockFindUnique.mockResolvedValue({ + id: 'usr_norole', + email: 'norole@test.com', + role: null, + }); + const ctx = buildContext({ cookie: 'session=valid' }); + + await expect(guard.canActivate(ctx)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('rejects a user with role "owner" (org role, not platform admin)', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'usr_owner' } }); + mockFindUnique.mockResolvedValue({ + id: 'usr_owner', + email: 'owner@test.com', + role: 'owner', + }); + const ctx = buildContext({ cookie: 'session=valid' }); + + await expect(guard.canActivate(ctx)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('rejects when session claims admin but DB says user', async () => { + mockGetSession.mockResolvedValue({ + user: { id: 'usr_sneaky', role: 'admin' }, + }); + mockFindUnique.mockResolvedValue({ + id: 'usr_sneaky', + email: 'sneaky@test.com', + role: 'user', + }); + const ctx = buildContext({ authorization: 'Bearer valid' }); + + await expect(guard.canActivate(ctx)).rejects.toThrow( + ForbiddenException, + ); + expect(mockFindUnique).toHaveBeenCalledWith({ + where: { id: 'usr_sneaky' }, + select: { id: true, email: true, role: true }, + }); + }); + + it('rejects a user who was deleted between session check and DB lookup', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'usr_deleted' } }); + mockFindUnique.mockResolvedValue(null); + const ctx = buildContext({ cookie: 'session=valid' }); + + await expect(guard.canActivate(ctx)).rejects.toThrow( + UnauthorizedException, + ); + await expect(guard.canActivate(ctx)).rejects.toThrow('User not found'); + }); + }); + + describe('allows authenticated platform admin', () => { + it('succeeds and sets request context for role=admin', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'usr_admin' } }); + mockFindUnique.mockResolvedValue({ + id: 'usr_admin', + email: 'admin@platform.com', + role: 'admin', + }); + + const request = { + headers: { cookie: 'session=admin_session' }, + userId: undefined as string | undefined, + userEmail: undefined as string | undefined, + isPlatformAdmin: undefined as boolean | undefined, + }; + const ctx = { + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; + + const result = await guard.canActivate(ctx); + + expect(result).toBe(true); + expect(request.userId).toBe('usr_admin'); + expect(request.userEmail).toBe('admin@platform.com'); + expect(request.isPlatformAdmin).toBe(true); + }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.spec.ts b/apps/api/src/admin-organizations/admin-organizations.controller.spec.ts new file mode 100644 index 0000000000..70209326f1 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-organizations.controller.spec.ts @@ -0,0 +1,204 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminOrganizationsController } from './admin-organizations.controller'; +import { AdminOrganizationsService } from './admin-organizations.service'; + +jest.mock('../auth/platform-admin.guard', () => ({ + PlatformAdminGuard: class { + canActivate() { + return true; + } + }, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: {} }, +})); + +jest.mock('@trycompai/db', () => ({ db: {} })); + +describe('AdminOrganizationsController', () => { + let controller: AdminOrganizationsController; + + const mockService = { + listOrganizations: jest.fn(), + getOrganization: jest.fn(), + setAccess: jest.fn(), + inviteMember: jest.fn(), + listInvitations: jest.fn(), + revokeInvitation: jest.fn(), + getAuditLogs: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminOrganizationsController], + providers: [ + { provide: AdminOrganizationsService, useValue: mockService }, + ], + }).compile(); + + controller = module.get( + AdminOrganizationsController, + ); + jest.clearAllMocks(); + }); + + describe('list', () => { + it('should call service with parsed params', async () => { + const mockResult = { data: [], total: 0, page: 1, limit: 50 }; + mockService.listOrganizations.mockResolvedValue(mockResult); + + const result = await controller.list('acme', '2', '25'); + + expect(mockService.listOrganizations).toHaveBeenCalledWith({ + search: 'acme', + page: 2, + limit: 25, + }); + expect(result).toEqual(mockResult); + }); + + it('should use defaults for missing params', async () => { + mockService.listOrganizations.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 50, + }); + + await controller.list(); + + expect(mockService.listOrganizations).toHaveBeenCalledWith({ + search: undefined, + page: 1, + limit: 50, + }); + }); + + it('should clamp limit to max 100', async () => { + mockService.listOrganizations.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 100, + }); + + await controller.list(undefined, undefined, '999'); + + expect(mockService.listOrganizations).toHaveBeenCalledWith({ + search: undefined, + page: 1, + limit: 100, + }); + }); + }); + + describe('get', () => { + it('should call service with org id', async () => { + const mockOrg = { id: 'org_1', name: 'Acme' }; + mockService.getOrganization.mockResolvedValue(mockOrg); + + const result = await controller.get('org_1'); + + expect(mockService.getOrganization).toHaveBeenCalledWith('org_1'); + expect(result).toEqual(mockOrg); + }); + }); + + describe('activate', () => { + it('should call setAccess with true', async () => { + mockService.setAccess.mockResolvedValue({ success: true }); + + const result = await controller.activate('org_1'); + + expect(mockService.setAccess).toHaveBeenCalledWith('org_1', true); + expect(result).toEqual({ success: true }); + }); + }); + + describe('deactivate', () => { + it('should call setAccess with false', async () => { + mockService.setAccess.mockResolvedValue({ success: true }); + + const result = await controller.deactivate('org_1'); + + expect(mockService.setAccess).toHaveBeenCalledWith('org_1', false); + expect(result).toEqual({ success: true }); + }); + }); + + describe('inviteMember', () => { + it('should call service with correct params', async () => { + const mockResult = { success: true, invitationId: 'inv_1' }; + mockService.inviteMember.mockResolvedValue(mockResult); + + const result = await controller.inviteMember( + 'org_1', + { userId: 'usr_admin' } as { userId: string }, + { email: 'user@test.com', role: 'admin' }, + ); + + expect(mockService.inviteMember).toHaveBeenCalledWith({ + orgId: 'org_1', + email: 'user@test.com', + role: 'admin', + adminUserId: 'usr_admin', + }); + expect(result).toEqual(mockResult); + }); + }); + + describe('listInvitations', () => { + it('should call service with org id', async () => { + const mockInvitations = [{ id: 'inv_1', email: 'user@test.com' }]; + mockService.listInvitations.mockResolvedValue(mockInvitations); + + const result = await controller.listInvitations('org_1'); + + expect(mockService.listInvitations).toHaveBeenCalledWith('org_1'); + expect(result).toEqual(mockInvitations); + }); + }); + + describe('revokeInvitation', () => { + it('should call service with org id and invitation id', async () => { + mockService.revokeInvitation.mockResolvedValue({ success: true }); + + const result = await controller.revokeInvitation('org_1', 'inv_1'); + + expect(mockService.revokeInvitation).toHaveBeenCalledWith( + 'org_1', + 'inv_1', + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('getAuditLogs', () => { + it('should call service with org id and query params', async () => { + const mockResult = { data: [{ id: 'aud_1' }] }; + mockService.getAuditLogs.mockResolvedValue(mockResult); + + const result = await controller.getAuditLogs('org_1', 'policy', '50'); + + expect(mockService.getAuditLogs).toHaveBeenCalledWith({ + orgId: 'org_1', + entityType: 'policy', + take: '50', + }); + expect(result).toEqual(mockResult); + }); + + it('should pass undefined for optional params', async () => { + mockService.getAuditLogs.mockResolvedValue({ data: [] }); + + await controller.getAuditLogs('org_1'); + + expect(mockService.getAuditLogs).toHaveBeenCalledWith({ + orgId: 'org_1', + entityType: undefined, + take: undefined, + }); + }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.ts b/apps/api/src/admin-organizations/admin-organizations.controller.ts new file mode 100644 index 0000000000..6f51e9fcae --- /dev/null +++ b/apps/api/src/admin-organizations/admin-organizations.controller.ts @@ -0,0 +1,115 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Query, + Body, + Req, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { AdminOrganizationsService } from './admin-organizations.service'; +import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; +import { InviteMemberDto } from './dto/invite-member.dto'; + +@ApiTags('Admin - Organizations') +@Controller({ path: 'admin/organizations', version: '1' }) +@UseGuards(PlatformAdminGuard) +@UseInterceptors(AdminAuditLogInterceptor) +@Throttle({ default: { ttl: 60000, limit: 30 } }) +export class AdminOrganizationsController { + constructor(private readonly service: AdminOrganizationsService) {} + + @Get() + @ApiOperation({ summary: 'List all organizations (platform admin)' }) + async list( + @Query('search') search?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.service.listOrganizations({ + search, + page: Math.max(1, parseInt(page || '1', 10) || 1), + limit: Math.min(100, Math.max(1, parseInt(limit || '50', 10) || 50)), + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get organization details (platform admin)' }) + async get(@Param('id') id: string) { + return this.service.getOrganization(id); + } + + @Patch(':id/activate') + @ApiOperation({ summary: 'Activate organization access (platform admin)' }) + @Throttle({ default: { ttl: 60000, limit: 5 } }) + async activate(@Param('id') id: string) { + return this.service.setAccess(id, true); + } + + @Patch(':id/deactivate') + @ApiOperation({ summary: 'Deactivate organization access (platform admin)' }) + @Throttle({ default: { ttl: 60000, limit: 5 } }) + async deactivate(@Param('id') id: string) { + return this.service.setAccess(id, false); + } + + @Post(':id/invite') + @ApiOperation({ summary: 'Invite member to organization (platform admin)' }) + @Throttle({ default: { ttl: 60000, limit: 10 } }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async inviteMember( + @Param('id') id: string, + @Req() req: { userId: string }, + @Body() body: InviteMemberDto, + ) { + return this.service.inviteMember({ + orgId: id, + email: body.email, + role: body.role, + adminUserId: req.userId, + }); + } + + @Get(':id/audit-logs') + @ApiOperation({ summary: 'Get audit logs for an organization (platform admin)' }) + @ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (e.g. policy, task)' }) + @ApiQuery({ name: 'take', required: false, description: 'Number of logs to return (max 100, default 100)' }) + async getAuditLogs( + @Param('id') id: string, + @Query('entityType') entityType?: string, + @Query('take') take?: string, + ) { + return this.service.getAuditLogs({ orgId: id, entityType, take }); + } + + @Get(':id/invitations') + @ApiOperation({ summary: 'List pending invitations (platform admin)' }) + async listInvitations(@Param('id') id: string) { + return this.service.listInvitations(id); + } + + @Delete(':id/invitations/:invId') + @ApiOperation({ summary: 'Revoke invitation (platform admin)' }) + @Throttle({ default: { ttl: 60000, limit: 10 } }) + async revokeInvitation( + @Param('id') id: string, + @Param('invId') invId: string, + ) { + return this.service.revokeInvitation(id, invId); + } +} diff --git a/apps/api/src/admin-organizations/admin-organizations.module.ts b/apps/api/src/admin-organizations/admin-organizations.module.ts new file mode 100644 index 0000000000..752159622e --- /dev/null +++ b/apps/api/src/admin-organizations/admin-organizations.module.ts @@ -0,0 +1,41 @@ +import { Module } from '@nestjs/common'; +import { FindingsModule } from '../findings/findings.module'; +import { TasksModule } from '../tasks/tasks.module'; +import { VendorsModule } from '../vendors/vendors.module'; +import { ContextModule } from '../context/context.module'; +import { EvidenceFormsModule } from '../evidence-forms/evidence-forms.module'; +import { PoliciesModule } from '../policies/policies.module'; +import { CommentsModule } from '../comments/comments.module'; +import { AttachmentsModule } from '../attachments/attachments.module'; +import { AdminOrganizationsController } from './admin-organizations.controller'; +import { AdminOrganizationsService } from './admin-organizations.service'; +import { AdminFindingsController } from './admin-findings.controller'; +import { AdminPoliciesController } from './admin-policies.controller'; +import { AdminTasksController } from './admin-tasks.controller'; +import { AdminVendorsController } from './admin-vendors.controller'; +import { AdminContextController } from './admin-context.controller'; +import { AdminEvidenceController } from './admin-evidence.controller'; + +@Module({ + imports: [ + FindingsModule, + TasksModule, + VendorsModule, + ContextModule, + EvidenceFormsModule, + PoliciesModule, + CommentsModule, + AttachmentsModule, + ], + controllers: [ + AdminOrganizationsController, + AdminFindingsController, + AdminPoliciesController, + AdminTasksController, + AdminVendorsController, + AdminContextController, + AdminEvidenceController, + ], + providers: [AdminOrganizationsService], +}) +export class AdminOrganizationsModule {} diff --git a/apps/api/src/admin-organizations/admin-organizations.service.spec.ts b/apps/api/src/admin-organizations/admin-organizations.service.spec.ts new file mode 100644 index 0000000000..3c9cce64fd --- /dev/null +++ b/apps/api/src/admin-organizations/admin-organizations.service.spec.ts @@ -0,0 +1,413 @@ +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { AdminOrganizationsService } from './admin-organizations.service'; + +jest.mock('@trycompai/db', () => ({ + db: { + organization: { + findMany: jest.fn(), + findUnique: jest.fn(), + count: jest.fn(), + update: jest.fn(), + }, + user: { + findFirst: jest.fn(), + }, + member: { + findFirst: jest.fn(), + }, + invitation: { + create: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + }, +})); + +jest.mock('../email/trigger-email', () => ({ + triggerEmail: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../email/templates/invite-member', () => ({ + InviteEmail: jest.fn().mockReturnValue(null), +})); + +import { db } from '@trycompai/db'; + +const mockDb = db as jest.Mocked; + +describe('AdminOrganizationsService', () => { + let service: AdminOrganizationsService; + + beforeEach(() => { + service = new AdminOrganizationsService(); + jest.clearAllMocks(); + }); + + describe('listOrganizations', () => { + it('should return paginated organizations with member counts', async () => { + const mockOrgs = [ + { + id: 'org_1', + name: 'Acme Corp', + slug: 'acme-corp', + logo: null, + createdAt: new Date('2024-01-01'), + hasAccess: true, + onboardingCompleted: true, + _count: { members: 5 }, + members: [ + { user: { id: 'usr_1', name: 'Owner', email: 'owner@acme.com' } }, + ], + }, + ]; + + (mockDb.organization.findMany as jest.Mock).mockResolvedValue(mockOrgs); + (mockDb.organization.count as jest.Mock).mockResolvedValue(1); + + const result = await service.listOrganizations({ + page: 1, + limit: 50, + }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe('org_1'); + expect(result.data[0].memberCount).toBe(5); + expect(result.data[0].owner).toEqual({ + id: 'usr_1', + name: 'Owner', + email: 'owner@acme.com', + }); + expect(result.total).toBe(1); + }); + + it('should filter by search term', async () => { + (mockDb.organization.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.organization.count as jest.Mock).mockResolvedValue(0); + + await service.listOrganizations({ + search: 'acme', + page: 1, + limit: 50, + }); + + expect(mockDb.organization.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + OR: [ + { id: { contains: 'acme', mode: 'insensitive' } }, + { name: { contains: 'acme', mode: 'insensitive' } }, + { slug: { contains: 'acme', mode: 'insensitive' } }, + { + members: { + some: { + role: { contains: 'owner' }, + user: { + name: { contains: 'acme', mode: 'insensitive' }, + }, + }, + }, + }, + { + members: { + some: { + role: { contains: 'owner' }, + user: { + email: { contains: 'acme', mode: 'insensitive' }, + }, + }, + }, + }, + ], + }, + }), + ); + }); + + it('should handle orgs with no owner', async () => { + const mockOrgs = [ + { + id: 'org_2', + name: 'No Owner Corp', + slug: 'no-owner', + logo: null, + createdAt: new Date(), + hasAccess: false, + onboardingCompleted: false, + _count: { members: 0 }, + members: [], + }, + ]; + + (mockDb.organization.findMany as jest.Mock).mockResolvedValue(mockOrgs); + (mockDb.organization.count as jest.Mock).mockResolvedValue(1); + + const result = await service.listOrganizations({ + page: 1, + limit: 50, + }); + + expect(result.data[0].owner).toBeNull(); + }); + }); + + describe('getOrganization', () => { + it('should return org with members', async () => { + const mockOrg = { + id: 'org_1', + name: 'Acme', + slug: 'acme', + logo: null, + createdAt: new Date(), + hasAccess: true, + onboardingCompleted: true, + website: 'https://acme.com', + members: [ + { + id: 'mem_1', + role: 'owner', + createdAt: new Date(), + user: { + id: 'usr_1', + name: 'Owner', + email: 'owner@acme.com', + image: null, + }, + }, + ], + }; + + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue(mockOrg); + + const result = await service.getOrganization('org_1'); + expect(result.id).toBe('org_1'); + expect(result.members).toHaveLength(1); + }); + + it('should throw NotFoundException for missing org', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue(null); + + await expect(service.getOrganization('org_missing')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('setAccess', () => { + it('should activate an organization', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + }); + (mockDb.organization.update as jest.Mock).mockResolvedValue({ + id: 'org_1', + hasAccess: true, + }); + + const result = await service.setAccess('org_1', true); + + expect(result.success).toBe(true); + expect(mockDb.organization.update).toHaveBeenCalledWith({ + where: { id: 'org_1' }, + data: { hasAccess: true }, + }); + }); + + it('should deactivate an organization', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + }); + (mockDb.organization.update as jest.Mock).mockResolvedValue({ + id: 'org_1', + hasAccess: false, + }); + + const result = await service.setAccess('org_1', false); + + expect(result.success).toBe(true); + expect(mockDb.organization.update).toHaveBeenCalledWith({ + where: { id: 'org_1' }, + data: { hasAccess: false }, + }); + }); + + it('should throw NotFoundException for missing org', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue(null); + + await expect(service.setAccess('org_missing', true)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('inviteMember', () => { + it('should create invitation and return success', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + name: 'Acme', + }); + (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.invitation.updateMany as jest.Mock).mockResolvedValue({ + count: 0, + }); + (mockDb.invitation.create as jest.Mock).mockResolvedValue({ + id: 'inv_1', + }); + + const result = await service.inviteMember({ + orgId: 'org_1', + email: 'new@example.com', + role: 'admin', + adminUserId: 'usr_admin', + }); + + expect(result.success).toBe(true); + expect(result.invitationId).toBe('inv_1'); + expect(mockDb.invitation.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + email: 'new@example.com', + organizationId: 'org_1', + role: 'admin', + status: 'pending', + inviterId: 'usr_admin', + }), + }), + ); + }); + + it('should throw NotFoundException if org does not exist', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue(null); + + await expect( + service.inviteMember({ + orgId: 'org_missing', + email: 'new@example.com', + role: 'admin', + adminUserId: 'usr_admin', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException if user is already an active member', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + name: 'Acme', + }); + (mockDb.user.findFirst as jest.Mock).mockResolvedValue({ + id: 'usr_existing', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ + id: 'mem_1', + deactivated: false, + }); + + await expect( + service.inviteMember({ + orgId: 'org_1', + email: 'existing@example.com', + role: 'admin', + adminUserId: 'usr_admin', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should cancel existing pending invitations before creating new one', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + name: 'Acme', + }); + (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.invitation.updateMany as jest.Mock).mockResolvedValue({ + count: 1, + }); + (mockDb.invitation.create as jest.Mock).mockResolvedValue({ + id: 'inv_2', + }); + + await service.inviteMember({ + orgId: 'org_1', + email: 'Re-Invite@Example.com', + role: 'employee', + adminUserId: 'usr_admin', + }); + + expect(mockDb.invitation.updateMany).toHaveBeenCalledWith({ + where: { + email: 're-invite@example.com', + organizationId: 'org_1', + status: 'pending', + }, + data: { status: 'canceled' }, + }); + }); + }); + + describe('listInvitations', () => { + it('should return pending invitations for an org', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + id: 'org_1', + }); + const mockInvitations = [ + { + id: 'inv_1', + email: 'user@test.com', + role: 'admin', + status: 'pending', + expiresAt: new Date(), + createdAt: new Date(), + user: { name: 'Admin', email: 'admin@test.com' }, + }, + ]; + (mockDb.invitation.findMany as jest.Mock).mockResolvedValue( + mockInvitations, + ); + + const result = await service.listInvitations('org_1'); + + expect(result).toEqual(mockInvitations); + expect(mockDb.invitation.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { organizationId: 'org_1', status: 'pending' }, + }), + ); + }); + + it('should throw NotFoundException for missing org', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue(null); + + await expect(service.listInvitations('org_missing')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('revokeInvitation', () => { + it('should cancel the invitation', async () => { + (mockDb.invitation.findFirst as jest.Mock).mockResolvedValue({ + id: 'inv_1', + organizationId: 'org_1', + }); + (mockDb.invitation.update as jest.Mock).mockResolvedValue({ + id: 'inv_1', + status: 'canceled', + }); + + const result = await service.revokeInvitation('org_1', 'inv_1'); + + expect(result.success).toBe(true); + expect(mockDb.invitation.update).toHaveBeenCalledWith({ + where: { id: 'inv_1' }, + data: { status: 'canceled' }, + }); + }); + + it('should throw NotFoundException for missing invitation', async () => { + (mockDb.invitation.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.revokeInvitation('org_1', 'inv_missing'), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-organizations.service.ts b/apps/api/src/admin-organizations/admin-organizations.service.ts new file mode 100644 index 0000000000..ab36ddf96b --- /dev/null +++ b/apps/api/src/admin-organizations/admin-organizations.service.ts @@ -0,0 +1,342 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { AuditLogEntityType, db } from '@trycompai/db'; +import { triggerEmail } from '../email/trigger-email'; +import { InviteEmail } from '../email/templates/invite-member'; + +@Injectable() +export class AdminOrganizationsService { + private readonly logger = new Logger(AdminOrganizationsService.name); + + async listOrganizations(options: { + search?: string; + page: number; + limit: number; + }) { + const { search, page, limit } = options; + const skip = (page - 1) * limit; + + const where = search + ? { + OR: [ + { id: { contains: search, mode: 'insensitive' as const } }, + { name: { contains: search, mode: 'insensitive' as const } }, + { slug: { contains: search, mode: 'insensitive' as const } }, + { + members: { + some: { + role: { contains: 'owner' }, + user: { + name: { contains: search, mode: 'insensitive' as const }, + }, + }, + }, + }, + { + members: { + some: { + role: { contains: 'owner' }, + user: { + email: { + contains: search, + mode: 'insensitive' as const, + }, + }, + }, + }, + }, + ], + } + : {}; + + const [organizations, total] = await Promise.all([ + db.organization.findMany({ + where, + select: { + id: true, + name: true, + slug: true, + logo: true, + createdAt: true, + hasAccess: true, + onboardingCompleted: true, + _count: { select: { members: true } }, + members: { + where: { role: { contains: 'owner' } }, + take: 1, + select: { + user: { + select: { id: true, name: true, email: true }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + db.organization.count({ where }), + ]); + + return { + data: organizations.map((org) => ({ + id: org.id, + name: org.name, + slug: org.slug, + logo: org.logo, + createdAt: org.createdAt, + hasAccess: org.hasAccess, + onboardingCompleted: org.onboardingCompleted, + memberCount: org._count.members, + owner: org.members[0]?.user ?? null, + })), + total, + page, + limit, + }; + } + + async getOrganization(id: string) { + const org = await db.organization.findUnique({ + where: { id }, + select: { + id: true, + name: true, + slug: true, + logo: true, + createdAt: true, + hasAccess: true, + onboardingCompleted: true, + website: true, + members: { + where: { isActive: true, deactivated: false }, + select: { + id: true, + role: true, + createdAt: true, + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + orderBy: { createdAt: 'asc' }, + }, + }, + }); + + if (!org) { + throw new NotFoundException(`Organization ${id} not found`); + } + + return org; + } + + async setAccess(id: string, hasAccess: boolean) { + const org = await db.organization.findUnique({ where: { id } }); + + if (!org) { + throw new NotFoundException(`Organization ${id} not found`); + } + + await db.organization.update({ + where: { id }, + data: { hasAccess }, + }); + + return { success: true }; + } + + private static readonly ALLOWED_INVITE_ROLES = [ + 'admin', + 'auditor', + 'employee', + 'contractor', + ]; + + async inviteMember(params: { + orgId: string; + email: string; + role: string; + adminUserId: string; + }) { + const { orgId, email, role, adminUserId } = params; + const normalizedEmail = email.toLowerCase().trim(); + + if (!AdminOrganizationsService.ALLOWED_INVITE_ROLES.includes(role)) { + throw new BadRequestException( + `Invalid role. Must be one of: ${AdminOrganizationsService.ALLOWED_INVITE_ROLES.join(', ')}`, + ); + } + + const org = await db.organization.findUnique({ + where: { id: orgId }, + select: { id: true, name: true }, + }); + + if (!org) { + throw new NotFoundException(`Organization ${orgId} not found`); + } + + const existingUser = await db.user.findFirst({ + where: { email: { equals: normalizedEmail, mode: 'insensitive' } }, + }); + + if (existingUser) { + const activeMember = await db.member.findFirst({ + where: { + userId: existingUser.id, + organizationId: orgId, + deactivated: false, + }, + }); + + if (activeMember) { + throw new BadRequestException( + 'User is already an active member of this organization.', + ); + } + } + + await db.invitation.updateMany({ + where: { + email: normalizedEmail, + organizationId: orgId, + status: 'pending', + }, + data: { status: 'canceled' }, + }); + + const invitation = await db.invitation.create({ + data: { + email: normalizedEmail, + organizationId: orgId, + role, + status: 'pending', + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + inviterId: adminUserId, + }, + }); + + try { + const appUrl = + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.BETTER_AUTH_URL ?? + 'https://app.trycomp.ai'; + const inviteLink = `${appUrl}/invite/${invitation.id}`; + + await triggerEmail({ + to: normalizedEmail, + subject: `You've been invited to join ${org.name} on Comp AI`, + react: InviteEmail({ + organizationName: org.name, + inviteLink, + email: normalizedEmail, + }), + }); + } catch (err) { + this.logger.error( + `Failed to send invite email to ${normalizedEmail}`, + err instanceof Error ? err.message : 'Unknown error', + ); + } + + return { success: true, invitationId: invitation.id }; + } + + async listInvitations(orgId: string) { + const org = await db.organization.findUnique({ where: { id: orgId } }); + if (!org) { + throw new NotFoundException(`Organization ${orgId} not found`); + } + + return db.invitation.findMany({ + where: { organizationId: orgId, status: 'pending' }, + select: { + id: true, + email: true, + role: true, + status: true, + expiresAt: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async revokeInvitation(orgId: string, invitationId: string) { + const invitation = await db.invitation.findFirst({ + where: { id: invitationId, organizationId: orgId, status: 'pending' }, + }); + + if (!invitation) { + throw new NotFoundException( + 'Pending invitation not found. It may have already been accepted, rejected, or canceled.', + ); + } + + await db.invitation.update({ + where: { id: invitationId }, + data: { status: 'canceled' }, + }); + + return { success: true }; + } + + async getAuditLogs(options: { + orgId: string; + entityType?: string; + take?: string; + }) { + const { orgId, entityType, take } = options; + + const where: Record = { organizationId: orgId }; + + if (entityType) { + const validEntityTypes = Object.values(AuditLogEntityType) as string[]; + const types = entityType + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + const invalidTypes = types.filter((t) => !validEntityTypes.includes(t)); + if (invalidTypes.length > 0) { + throw new BadRequestException( + `Invalid entityType: ${invalidTypes.join(', ')}. Must be one of: ${validEntityTypes.join(', ')}`, + ); + } + where.entityType = types.length === 1 ? types[0] : { in: types }; + } + + const parsedTake = take + ? Math.min(100, Math.max(1, parseInt(take, 10) || 100)) + : 100; + + const logs = await db.auditLog.findMany({ + where, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + role: true, + }, + }, + member: true, + organization: true, + }, + orderBy: { timestamp: 'desc' }, + take: parsedTake, + }); + + return { data: logs }; + } +} diff --git a/apps/api/src/admin-organizations/admin-policies.controller.spec.ts b/apps/api/src/admin-organizations/admin-policies.controller.spec.ts new file mode 100644 index 0000000000..c9263b5c2d --- /dev/null +++ b/apps/api/src/admin-organizations/admin-policies.controller.spec.ts @@ -0,0 +1,160 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { AdminPoliciesController } from './admin-policies.controller'; +import { PoliciesService } from '../policies/policies.service'; + +jest.mock('../auth/platform-admin.guard', () => ({ + PlatformAdminGuard: class { + canActivate() { + return true; + } + }, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: {} }, +})); + +jest.mock('@trycompai/db', () => ({ + db: { + frameworkInstance: { findMany: jest.fn().mockResolvedValue([]) }, + context: { findMany: jest.fn().mockResolvedValue([]) }, + }, + PolicyStatus: { + draft: 'draft', + published: 'published', + needs_review: 'needs_review', + }, +})); + +jest.mock('@trigger.dev/sdk', () => ({ + auth: { + createPublicToken: jest + .fn() + .mockResolvedValue('mock-public-access-token'), + }, + tasks: { + trigger: jest.fn().mockResolvedValue({ id: 'run_123' }), + }, +})); + +describe('AdminPoliciesController', () => { + let controller: AdminPoliciesController; + + const mockService = { + findAll: jest.fn(), + updateById: jest.fn(), + create: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminPoliciesController], + providers: [{ provide: PoliciesService, useValue: mockService }], + }).compile(); + + controller = module.get(AdminPoliciesController); + jest.clearAllMocks(); + }); + + describe('list', () => { + it('should list policies for an organization', async () => { + const policies = [{ id: 'pol_1', name: 'Test Policy' }]; + mockService.findAll.mockResolvedValue(policies); + + const result = await controller.list('org_1'); + + expect(mockService.findAll).toHaveBeenCalledWith('org_1'); + expect(result).toEqual(policies); + }); + }); + + describe('update', () => { + it('should update policy status', async () => { + const updated = { id: 'pol_1', status: 'published' }; + mockService.updateById.mockResolvedValue(updated); + + const result = await controller.update('org_1', 'pol_1', { + status: 'published', + }); + + expect(mockService.updateById).toHaveBeenCalledWith('pol_1', 'org_1', { + status: 'published', + }); + expect(result).toEqual(updated); + }); + + it('should reject missing status', async () => { + await expect( + controller.update('org_1', 'pol_1', {}), + ).rejects.toThrow(BadRequestException); + }); + + it('should reject invalid status', async () => { + await expect( + controller.update('org_1', 'pol_1', { status: 'invalid' }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('create', () => { + it('should create a policy with name and defaults', async () => { + const created = { id: 'pol_new', name: 'New Policy', status: 'draft' }; + mockService.create.mockResolvedValue(created); + + const result = await controller.create('org_1', { + name: 'New Policy', + }); + + expect(mockService.create).toHaveBeenCalledWith('org_1', { + name: 'New Policy', + content: [], + description: undefined, + status: undefined, + frequency: undefined, + department: undefined, + }); + expect(result).toEqual(created); + }); + + it('should create a policy with all optional fields', async () => { + const created = { + id: 'pol_new', + name: 'Full Policy', + status: 'published', + }; + mockService.create.mockResolvedValue(created); + + const result = await controller.create('org_1', { + name: 'Full Policy', + description: 'A test policy', + status: 'published' as never, + frequency: 'yearly' as never, + department: 'it' as never, + }); + + expect(mockService.create).toHaveBeenCalledWith('org_1', { + name: 'Full Policy', + content: [], + description: 'A test policy', + status: 'published', + frequency: 'yearly', + department: 'it', + }); + expect(result).toEqual(created); + }); + }); + + describe('regenerate', () => { + it('should trigger policy regeneration', async () => { + const result = await controller.regenerate('org_1', 'pol_1'); + + expect(result).toEqual({ + data: { + runId: 'run_123', + publicAccessToken: 'mock-public-access-token', + }, + }); + }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-policies.controller.ts b/apps/api/src/admin-organizations/admin-policies.controller.ts new file mode 100644 index 0000000000..24f6fb1b72 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-policies.controller.ts @@ -0,0 +1,173 @@ +import { + Controller, + Get, + Patch, + Post, + Param, + Body, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, + BadRequestException, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { db } from '@trycompai/db'; +import { + PolicyStatus, + Frequency, + Departments, +} from '../policies/dto/create-policy.dto'; +import { auth as triggerAuth, tasks } from '@trigger.dev/sdk'; +import type { updatePolicy } from '../trigger/policies/update-policy'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { PoliciesService } from '../policies/policies.service'; +import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; +import { CreateAdminPolicyDto } from './dto/create-admin-policy.dto'; + +interface UpdatePolicyBody { + status?: string; + department?: string; + frequency?: string | null; +} + +@ApiTags('Admin - Policies') +@Controller({ path: 'admin/organizations', version: '1' }) +@UseGuards(PlatformAdminGuard) +@UseInterceptors(AdminAuditLogInterceptor) +@Throttle({ default: { ttl: 60000, limit: 30 } }) +export class AdminPoliciesController { + constructor(private readonly policiesService: PoliciesService) {} + + @Get(':orgId/policies') + @ApiOperation({ summary: 'List all policies for an organization (admin)' }) + async list(@Param('orgId') orgId: string) { + return this.policiesService.findAll(orgId); + } + + @Post(':orgId/policies') + @ApiOperation({ summary: 'Create a policy for an organization (admin)' }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async create( + @Param('orgId') orgId: string, + @Body() createDto: CreateAdminPolicyDto, + ) { + return this.policiesService.create(orgId, { + name: createDto.name, + content: [], + description: createDto.description, + status: createDto.status, + frequency: createDto.frequency, + department: createDto.department, + }); + } + + @Patch(':orgId/policies/:policyId') + @ApiOperation({ summary: 'Update a policy for an organization (admin)' }) + async update( + @Param('orgId') orgId: string, + @Param('policyId') policyId: string, + @Body() body: UpdatePolicyBody, + ) { + const updateData: Record = {}; + + if (body.status !== undefined) { + if ( + !Object.values(PolicyStatus).includes(body.status as PolicyStatus) + ) { + throw new BadRequestException( + `Invalid status. Must be one of: ${Object.values(PolicyStatus).join(', ')}`, + ); + } + updateData.status = body.status as PolicyStatus; + } + + if (body.department !== undefined) { + if ( + !Object.values(Departments).includes(body.department as Departments) + ) { + throw new BadRequestException( + `Invalid department. Must be one of: ${Object.values(Departments).join(', ')}`, + ); + } + updateData.department = body.department as Departments; + } + + if (body.frequency !== undefined) { + if ( + body.frequency !== null && + !Object.values(Frequency).includes(body.frequency as Frequency) + ) { + throw new BadRequestException( + `Invalid frequency. Must be one of: ${Object.values(Frequency).join(', ')}`, + ); + } + updateData.frequency = + body.frequency === null ? null : (body.frequency as Frequency); + } + + if (Object.keys(updateData).length === 0) { + throw new BadRequestException( + 'At least one field (status, department, frequency) is required', + ); + } + + return this.policiesService.updateById(policyId, orgId, updateData); + } + + @Post(':orgId/policies/:policyId/regenerate') + @ApiOperation({ summary: 'Regenerate policy content using AI (admin)' }) + @Throttle({ default: { ttl: 60000, limit: 5 } }) + async regenerate( + @Param('orgId') orgId: string, + @Param('policyId') policyId: string, + ) { + const instances = await db.frameworkInstance.findMany({ + where: { organizationId: orgId }, + include: { framework: true }, + }); + + const uniqueFrameworks = Array.from( + new Map( + instances.map((fi) => [fi.framework.id, fi.framework]), + ).values(), + ).map((f) => ({ + id: f.id, + name: f.name, + version: f.version, + description: f.description, + visible: f.visible, + createdAt: f.createdAt, + updatedAt: f.updatedAt, + })); + + const contextEntries = await db.context.findMany({ + where: { organizationId: orgId }, + orderBy: { createdAt: 'asc' }, + }); + const contextHub = contextEntries + .map((c) => `${c.question}\n${c.answer}`) + .join('\n'); + + const handle = await tasks.trigger('update-policy', { + organizationId: orgId, + policyId, + contextHub, + frameworks: uniqueFrameworks, + memberId: undefined, + }); + + const publicAccessToken = await triggerAuth.createPublicToken({ + scopes: { read: { runs: [handle.id] } }, + }); + + return { data: { runId: handle.id, publicAccessToken } }; + } +} diff --git a/apps/api/src/admin-organizations/admin-security.spec.ts b/apps/api/src/admin-organizations/admin-security.spec.ts new file mode 100644 index 0000000000..a96a848588 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-security.spec.ts @@ -0,0 +1,171 @@ +import 'reflect-metadata'; +import { GUARDS_METADATA, INTERCEPTORS_METADATA } from '@nestjs/common/constants'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; +import { AdminOrganizationsController } from './admin-organizations.controller'; +import { AdminFindingsController } from './admin-findings.controller'; +import { AdminPoliciesController } from './admin-policies.controller'; +import { AdminTasksController } from './admin-tasks.controller'; +import { AdminVendorsController } from './admin-vendors.controller'; +import { AdminContextController } from './admin-context.controller'; +import { AdminEvidenceController } from './admin-evidence.controller'; +import { AdminIntegrationsController } from '../integration-platform/controllers/admin-integrations.controller'; +import { PlatformAuditLogInterceptor } from '../integration-platform/interceptors/platform-audit-log.interceptor'; + +jest.mock('../auth/auth.server', () => ({ + auth: { api: {} }, +})); + +jest.mock('@trycompai/db', () => ({ + db: {}, + FindingStatus: { open: 'open', ready_for_review: 'ready_for_review', needs_revision: 'needs_revision', closed: 'closed' }, + FindingType: { soc2: 'soc2', iso27001: 'iso27001' }, + TaskStatus: { todo: 'todo', in_progress: 'in_progress', done: 'done' }, + TaskFrequency: { daily: 'daily', weekly: 'weekly', monthly: 'monthly' }, + Departments: { none: 'none', engineering: 'engineering' }, + CommentEntityType: { task: 'task' }, + AttachmentEntityType: { task: 'task' }, + VendorCategory: { cloud: 'cloud', saas: 'saas' }, + VendorStatus: { active: 'active', inactive: 'inactive' }, + Prisma: {}, +})); + +jest.mock('@trigger.dev/sdk', () => ({ + auth: { createPublicToken: jest.fn() }, + tasks: { trigger: jest.fn() }, +})); + +jest.mock('@trycompai/integration-platform', () => ({ + getAllManifests: jest.fn().mockReturnValue([]), + getManifest: jest.fn(), +})); + +const ORG_ADMIN_CONTROLLERS = [ + { name: 'AdminOrganizationsController', controller: AdminOrganizationsController }, + { name: 'AdminFindingsController', controller: AdminFindingsController }, + { name: 'AdminPoliciesController', controller: AdminPoliciesController }, + { name: 'AdminTasksController', controller: AdminTasksController }, + { name: 'AdminVendorsController', controller: AdminVendorsController }, + { name: 'AdminContextController', controller: AdminContextController }, + { name: 'AdminEvidenceController', controller: AdminEvidenceController }, +]; + +describe('Admin controllers security baseline', () => { + describe.each(ORG_ADMIN_CONTROLLERS)( + '$name', + ({ controller }) => { + it('has PlatformAdminGuard applied at the class level', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, controller) ?? []; + const hasPlatformAdminGuard = guards.some( + (g: unknown) => g === PlatformAdminGuard, + ); + expect(hasPlatformAdminGuard).toBe(true); + }); + + it('has AdminAuditLogInterceptor applied at the class level', () => { + const interceptors = + Reflect.getMetadata(INTERCEPTORS_METADATA, controller) ?? []; + const hasAuditInterceptor = interceptors.some( + (i: unknown) => i === AdminAuditLogInterceptor, + ); + expect(hasAuditInterceptor).toBe(true); + }); + + it('uses the correct controller path prefix', () => { + const path = Reflect.getMetadata('path', controller); + expect(path).toBe('admin/organizations'); + }); + + it('uses versioned controller format', () => { + const version = Reflect.getMetadata('__version__', controller); + expect(version).toBeDefined(); + }); + + it('does NOT use HybridAuthGuard (admin controllers use PlatformAdminGuard)', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, controller) ?? []; + const guardNames = guards.map((g: { name?: string }) => g.name); + expect(guardNames).not.toContain('HybridAuthGuard'); + }); + + it('does NOT use PermissionGuard (admin controllers bypass RBAC)', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, controller) ?? []; + const guardNames = guards.map((g: { name?: string }) => g.name); + expect(guardNames).not.toContain('PermissionGuard'); + }); + }, + ); + + it('covers all 7 expected org-scoped admin controllers', () => { + expect(ORG_ADMIN_CONTROLLERS).toHaveLength(7); + }); + + describe('AdminIntegrationsController', () => { + const controller = AdminIntegrationsController; + + it('has PlatformAdminGuard applied at the class level', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, controller) ?? []; + const hasPlatformAdminGuard = guards.some( + (g: unknown) => g === PlatformAdminGuard, + ); + expect(hasPlatformAdminGuard).toBe(true); + }); + + it('has PlatformAuditLogInterceptor applied at the class level', () => { + const interceptors = + Reflect.getMetadata(INTERCEPTORS_METADATA, controller) ?? []; + const hasAuditInterceptor = interceptors.some( + (i: unknown) => i === PlatformAuditLogInterceptor, + ); + expect(hasAuditInterceptor).toBe(true); + }); + + it('uses the correct controller path prefix', () => { + const path = Reflect.getMetadata('path', controller); + expect(path).toBe('admin/integrations'); + }); + + it('uses versioned controller format', () => { + const version = Reflect.getMetadata('__version__', controller); + expect(version).toBeDefined(); + }); + + it('does NOT use HybridAuthGuard', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, controller) ?? []; + const guardNames = guards.map((g: { name?: string }) => g.name); + expect(guardNames).not.toContain('HybridAuthGuard'); + }); + + it('does NOT use PermissionGuard', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, controller) ?? []; + const guardNames = guards.map((g: { name?: string }) => g.name); + expect(guardNames).not.toContain('PermissionGuard'); + }); + }); + + it('covers all 8 admin controllers (7 org-scoped + 1 platform-scoped)', () => { + expect(ORG_ADMIN_CONTROLLERS).toHaveLength(7); + expect(AdminIntegrationsController).toBeDefined(); + }); + + describe('destructive endpoints have tighter rate limits', () => { + it('activate has a limit of 5 per minute', () => { + const metadata = Reflect.getMetadata( + 'THROTTLER:LIMIT', + AdminOrganizationsController.prototype.activate, + ); + if (metadata) { + expect(metadata).toBeLessThanOrEqual(5); + } + }); + + it('deactivate has a limit of 5 per minute', () => { + const metadata = Reflect.getMetadata( + 'THROTTLER:LIMIT', + AdminOrganizationsController.prototype.deactivate, + ); + if (metadata) { + expect(metadata).toBeLessThanOrEqual(5); + } + }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-tasks.controller.spec.ts b/apps/api/src/admin-organizations/admin-tasks.controller.spec.ts new file mode 100644 index 0000000000..de7f51c1f2 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-tasks.controller.spec.ts @@ -0,0 +1,150 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { AdminTasksController } from './admin-tasks.controller'; +import { TasksService } from '../tasks/tasks.service'; +import { CommentsService } from '../comments/comments.service'; +import { AttachmentsService } from '../attachments/attachments.service'; + +jest.mock('../auth/platform-admin.guard', () => ({ + PlatformAdminGuard: class { + canActivate() { + return true; + } + }, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: {} }, +})); + +jest.mock('@trycompai/db', () => ({ + db: {}, + TaskStatus: { + todo: 'todo', + in_progress: 'in_progress', + done: 'done', + not_applicable: 'not_applicable', + }, +})); + +describe('AdminTasksController', () => { + let controller: AdminTasksController; + + const mockService = { + getTasks: jest.fn(), + updateTask: jest.fn(), + createTask: jest.fn(), + }; + + const mockCommentsService = { getComments: jest.fn() }; + const mockAttachmentsService = { getAttachments: jest.fn() }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminTasksController], + providers: [ + { provide: TasksService, useValue: mockService }, + { provide: CommentsService, useValue: mockCommentsService }, + { provide: AttachmentsService, useValue: mockAttachmentsService }, + ], + }).compile(); + + controller = module.get(AdminTasksController); + jest.clearAllMocks(); + }); + + describe('list', () => { + it('should list tasks for an organization', async () => { + const tasks = { data: [{ id: 'tsk_1' }], count: 1 }; + mockService.getTasks.mockResolvedValue(tasks); + + const result = await controller.list('org_1'); + + expect(mockService.getTasks).toHaveBeenCalledWith( + 'org_1', + {}, + { includeRelations: true }, + ); + expect(result).toEqual(tasks); + }); + }); + + describe('create', () => { + it('should create a task with required fields', async () => { + const created = { id: 'tsk_new', title: 'New Task', status: 'todo' }; + mockService.createTask.mockResolvedValue(created); + + const result = await controller.create('org_1', { + title: 'New Task', + description: 'A test task', + }); + + expect(mockService.createTask).toHaveBeenCalledWith('org_1', { + title: 'New Task', + description: 'A test task', + frequency: null, + department: null, + }); + expect(result).toEqual(created); + }); + + it('should create a task with all optional fields', async () => { + const created = { id: 'tsk_new', title: 'Full Task', status: 'todo' }; + mockService.createTask.mockResolvedValue(created); + + const result = await controller.create('org_1', { + title: 'Full Task', + description: 'Detailed task', + frequency: 'monthly', + department: 'it', + }); + + expect(mockService.createTask).toHaveBeenCalledWith('org_1', { + title: 'Full Task', + description: 'Detailed task', + frequency: 'monthly', + department: 'it', + }); + expect(result).toEqual(created); + }); + }); + + describe('update', () => { + it('should update task status', async () => { + const updated = { id: 'tsk_1', status: 'done' }; + mockService.updateTask.mockResolvedValue(updated); + + const result = await controller.update( + 'org_1', + 'tsk_1', + { status: 'done' }, + { userId: 'usr_admin' }, + ); + + expect(mockService.updateTask).toHaveBeenCalledWith( + 'org_1', + 'tsk_1', + { status: 'done' }, + 'usr_admin', + ); + expect(result).toEqual(updated); + }); + + it('should reject missing status', async () => { + await expect( + controller.update('org_1', 'tsk_1', {}, { userId: 'usr_admin' }), + ).rejects.toThrow(BadRequestException); + }); + + it('should reject invalid status', async () => { + await expect( + controller.update( + 'org_1', + 'tsk_1', + { status: 'invalid' }, + { userId: 'usr_admin' }, + ), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-tasks.controller.ts b/apps/api/src/admin-organizations/admin-tasks.controller.ts new file mode 100644 index 0000000000..f1b09d2436 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-tasks.controller.ts @@ -0,0 +1,195 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + Req, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, + BadRequestException, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { + TaskStatus, + TaskFrequency, + Departments, + CommentEntityType, + AttachmentEntityType, + db, +} from '@trycompai/db'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { TasksService } from '../tasks/tasks.service'; +import { CommentsService } from '../comments/comments.service'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; +import { CreateAdminTaskDto } from './dto/create-admin-task.dto'; +import type { AdminRequest } from './platform-admin-auth-context'; + +interface UpdateTaskBody { + status?: string; + department?: string; + frequency?: string | null; +} + +@ApiTags('Admin - Tasks') +@Controller({ path: 'admin/organizations', version: '1' }) +@UseGuards(PlatformAdminGuard) +@UseInterceptors(AdminAuditLogInterceptor) +@Throttle({ default: { ttl: 60000, limit: 30 } }) +export class AdminTasksController { + constructor( + private readonly tasksService: TasksService, + private readonly commentsService: CommentsService, + private readonly attachmentsService: AttachmentsService, + ) {} + + @Get(':orgId/tasks') + @ApiOperation({ summary: 'List all tasks for an organization (admin)' }) + async list(@Param('orgId') orgId: string) { + return this.tasksService.getTasks(orgId, {}, { includeRelations: true }); + } + + @Post(':orgId/tasks') + @ApiOperation({ summary: 'Create a task for an organization (admin)' }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async create( + @Param('orgId') orgId: string, + @Body() createDto: CreateAdminTaskDto, + ) { + return this.tasksService.createTask(orgId, { + title: createDto.title, + description: createDto.description, + frequency: createDto.frequency ?? null, + department: createDto.department ?? null, + }); + } + + @Get(':orgId/tasks/:taskId/details') + @ApiOperation({ summary: 'Get task details with comments, attachments, and evidence (admin)' }) + async getDetails( + @Param('orgId') orgId: string, + @Param('taskId') taskId: string, + ) { + // Validate task belongs to org before querying sub-resources + const task = await this.tasksService.getTask(orgId, taskId); + + const [comments, attachments, automationRuns, integrationRuns] = + await Promise.all([ + this.commentsService.getComments( + orgId, + taskId, + CommentEntityType.task, + ), + this.attachmentsService.getAttachments( + orgId, + taskId, + AttachmentEntityType.task, + ), + this.getAutomationRuns(orgId, taskId), + this.getIntegrationCheckRuns(orgId, taskId), + ]); + + return { + ...task, + comments, + attachments, + automationRuns, + integrationRuns, + }; + } + + private async getAutomationRuns(orgId: string, taskId: string) { + return db.evidenceAutomationRun.findMany({ + where: { + taskId, + task: { organizationId: orgId }, + }, + include: { + evidenceAutomation: { select: { name: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + } + + private async getIntegrationCheckRuns(orgId: string, taskId: string) { + return db.integrationCheckRun.findMany({ + where: { + taskId, + task: { organizationId: orgId }, + }, + include: { + results: true, + connection: { + include: { provider: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + } + + @Patch(':orgId/tasks/:taskId') + @ApiOperation({ summary: 'Update a task for an organization (admin)' }) + async update( + @Param('orgId') orgId: string, + @Param('taskId') taskId: string, + @Body() body: UpdateTaskBody, + @Req() req: AdminRequest, + ) { + const updateData: Record = {}; + + if (body.status !== undefined) { + if (!Object.values(TaskStatus).includes(body.status as TaskStatus)) { + throw new BadRequestException( + `Invalid status. Must be one of: ${Object.values(TaskStatus).join(', ')}`, + ); + } + updateData.status = body.status as TaskStatus; + } + + if (body.department !== undefined) { + if ( + !Object.values(Departments).includes(body.department as Departments) + ) { + throw new BadRequestException( + `Invalid department. Must be one of: ${Object.values(Departments).join(', ')}`, + ); + } + updateData.department = body.department; + } + + if (body.frequency !== undefined) { + if ( + body.frequency !== null && + !Object.values(TaskFrequency).includes( + body.frequency as TaskFrequency, + ) + ) { + throw new BadRequestException( + `Invalid frequency. Must be one of: ${Object.values(TaskFrequency).join(', ')}`, + ); + } + updateData.frequency = body.frequency as TaskFrequency; + } + + if (Object.keys(updateData).length === 0) { + throw new BadRequestException( + 'At least one field (status, department, frequency) is required', + ); + } + + return this.tasksService.updateTask(orgId, taskId, updateData, req.userId); + } +} diff --git a/apps/api/src/admin-organizations/admin-vendors.controller.spec.ts b/apps/api/src/admin-organizations/admin-vendors.controller.spec.ts new file mode 100644 index 0000000000..9541b90a0b --- /dev/null +++ b/apps/api/src/admin-organizations/admin-vendors.controller.spec.ts @@ -0,0 +1,129 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminVendorsController } from './admin-vendors.controller'; +import { VendorsService } from '../vendors/vendors.service'; + +jest.mock('../auth/platform-admin.guard', () => ({ + PlatformAdminGuard: class { + canActivate() { + return true; + } + }, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: {} }, +})); + +jest.mock('@trycompai/db', () => ({ + db: {}, + VendorCategory: { + cloud: 'cloud', + infrastructure: 'infrastructure', + software_as_a_service: 'software_as_a_service', + finance: 'finance', + marketing: 'marketing', + sales: 'sales', + hr: 'hr', + other: 'other', + }, + VendorStatus: { + not_assessed: 'not_assessed', + in_progress: 'in_progress', + assessed: 'assessed', + }, +})); + +describe('AdminVendorsController', () => { + let controller: AdminVendorsController; + + const mockService = { + findAllByOrganization: jest.fn(), + triggerAssessment: jest.fn(), + create: jest.fn(), + updateById: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminVendorsController], + providers: [{ provide: VendorsService, useValue: mockService }], + }).compile(); + + controller = module.get(AdminVendorsController); + jest.clearAllMocks(); + }); + + describe('list', () => { + it('should list vendors for an organization', async () => { + const vendors = [{ id: 'vnd_1', name: 'Acme' }]; + mockService.findAllByOrganization.mockResolvedValue(vendors); + + const result = await controller.list('org_1'); + + expect(mockService.findAllByOrganization).toHaveBeenCalledWith('org_1'); + expect(result).toEqual(vendors); + }); + }); + + describe('create', () => { + it('should create a vendor with required fields', async () => { + const created = { id: 'vnd_new', name: 'New Vendor' }; + mockService.create.mockResolvedValue(created); + + const result = await controller.create( + 'org_1', + { name: 'New Vendor', description: 'A test vendor' }, + { userId: 'usr_admin' }, + ); + + expect(mockService.create).toHaveBeenCalledWith( + 'org_1', + { name: 'New Vendor', description: 'A test vendor' }, + 'usr_admin', + ); + expect(result).toEqual(created); + }); + + it('should create a vendor with all optional fields', async () => { + const created = { id: 'vnd_new', name: 'Full Vendor' }; + mockService.create.mockResolvedValue(created); + + const dto = { + name: 'Full Vendor', + description: 'Cloud provider', + category: 'cloud' as never, + status: 'not_assessed' as never, + website: 'https://example.com', + }; + + const result = await controller.create('org_1', dto, { + userId: 'usr_admin', + }); + + expect(mockService.create).toHaveBeenCalledWith( + 'org_1', + dto, + 'usr_admin', + ); + expect(result).toEqual(created); + }); + }); + + describe('triggerAssessment', () => { + it('should trigger assessment for a vendor', async () => { + const response = { runId: 'run_1', publicAccessToken: 'tok_1' }; + mockService.triggerAssessment.mockResolvedValue(response); + + const result = await controller.triggerAssessment('org_1', 'vnd_1', { + userId: 'usr_admin', + }); + + expect(mockService.triggerAssessment).toHaveBeenCalledWith( + 'vnd_1', + 'org_1', + 'usr_admin', + ); + expect(result).toEqual(response); + }); + }); +}); diff --git a/apps/api/src/admin-organizations/admin-vendors.controller.ts b/apps/api/src/admin-organizations/admin-vendors.controller.ts new file mode 100644 index 0000000000..56f97c9116 --- /dev/null +++ b/apps/api/src/admin-organizations/admin-vendors.controller.ts @@ -0,0 +1,112 @@ +import { + Controller, + Get, + Patch, + Post, + Param, + Body, + Req, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, + BadRequestException, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { VendorCategory, VendorStatus } from '@trycompai/db'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { VendorsService } from '../vendors/vendors.service'; +import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; +import { CreateAdminVendorDto } from './dto/create-admin-vendor.dto'; +import type { AdminRequest } from './platform-admin-auth-context'; + +interface UpdateVendorBody { + status?: string; + category?: string; +} + +@ApiTags('Admin - Vendors') +@Controller({ path: 'admin/organizations', version: '1' }) +@UseGuards(PlatformAdminGuard) +@UseInterceptors(AdminAuditLogInterceptor) +@Throttle({ default: { ttl: 60000, limit: 30 } }) +export class AdminVendorsController { + constructor(private readonly vendorsService: VendorsService) {} + + @Get(':orgId/vendors') + @ApiOperation({ summary: 'List all vendors for an organization (admin)' }) + async list(@Param('orgId') orgId: string) { + return this.vendorsService.findAllByOrganization(orgId); + } + + @Post(':orgId/vendors') + @ApiOperation({ summary: 'Create a vendor for an organization (admin)' }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async create( + @Param('orgId') orgId: string, + @Body() createDto: CreateAdminVendorDto, + @Req() req: AdminRequest, + ) { + return this.vendorsService.create(orgId, createDto, req.userId); + } + + @Patch(':orgId/vendors/:vendorId') + @ApiOperation({ summary: 'Update a vendor for an organization (admin)' }) + async update( + @Param('orgId') orgId: string, + @Param('vendorId') vendorId: string, + @Body() body: UpdateVendorBody, + ) { + const updateData: Record = {}; + + if (body.status !== undefined) { + if ( + !Object.values(VendorStatus).includes(body.status as VendorStatus) + ) { + throw new BadRequestException( + `Invalid status. Must be one of: ${Object.values(VendorStatus).join(', ')}`, + ); + } + updateData.status = body.status as VendorStatus; + } + + if (body.category !== undefined) { + if ( + !Object.values(VendorCategory).includes( + body.category as VendorCategory, + ) + ) { + throw new BadRequestException( + `Invalid category. Must be one of: ${Object.values(VendorCategory).join(', ')}`, + ); + } + updateData.category = body.category as VendorCategory; + } + + if (Object.keys(updateData).length === 0) { + throw new BadRequestException( + 'At least one field (status, category) is required', + ); + } + + return this.vendorsService.updateById(vendorId, orgId, updateData); + } + + @Post(':orgId/vendors/:vendorId/trigger-assessment') + @ApiOperation({ summary: 'Trigger vendor risk assessment (admin)' }) + @Throttle({ default: { ttl: 60000, limit: 5 } }) + async triggerAssessment( + @Param('orgId') orgId: string, + @Param('vendorId') vendorId: string, + @Req() req: AdminRequest, + ) { + return this.vendorsService.triggerAssessment(vendorId, orgId, req.userId); + } +} diff --git a/apps/api/src/admin-organizations/dto/create-admin-policy.dto.ts b/apps/api/src/admin-organizations/dto/create-admin-policy.dto.ts new file mode 100644 index 0000000000..61443ef8b7 --- /dev/null +++ b/apps/api/src/admin-organizations/dto/create-admin-policy.dto.ts @@ -0,0 +1,56 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsEnum } from 'class-validator'; +import { + PolicyStatus, + Frequency, + Departments, +} from '../../policies/dto/create-policy.dto'; + +export class CreateAdminPolicyDto { + @ApiProperty({ + description: 'Name of the policy', + example: 'Data Privacy Policy', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + description: 'Description of the policy', + example: 'Outlines data handling procedures', + required: false, + }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ + description: 'Status of the policy', + enum: PolicyStatus, + example: PolicyStatus.DRAFT, + required: false, + }) + @IsOptional() + @IsEnum(PolicyStatus) + status?: PolicyStatus; + + @ApiProperty({ + description: 'Review frequency', + enum: Frequency, + example: Frequency.YEARLY, + required: false, + }) + @IsOptional() + @IsEnum(Frequency) + frequency?: Frequency; + + @ApiProperty({ + description: 'Department this policy applies to', + enum: Departments, + example: Departments.IT, + required: false, + }) + @IsOptional() + @IsEnum(Departments) + department?: Departments; +} diff --git a/apps/api/src/admin-organizations/dto/create-admin-task.dto.ts b/apps/api/src/admin-organizations/dto/create-admin-task.dto.ts new file mode 100644 index 0000000000..88e929b5d2 --- /dev/null +++ b/apps/api/src/admin-organizations/dto/create-admin-task.dto.ts @@ -0,0 +1,80 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsIn } from 'class-validator'; + +const TASK_STATUSES = [ + 'todo', + 'in_progress', + 'in_review', + 'done', + 'not_relevant', + 'failed', +] as const; + +const TASK_FREQUENCIES = [ + 'daily', + 'weekly', + 'monthly', + 'quarterly', + 'yearly', +] as const; + +const DEPARTMENTS = [ + 'none', + 'admin', + 'gov', + 'hr', + 'it', + 'itsm', + 'qms', +] as const; + +export class CreateAdminTaskDto { + @ApiProperty({ + description: 'Title of the task', + example: 'Review access controls', + }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ + description: 'Description of the task', + example: 'Review and update access control policies quarterly', + }) + @IsString() + @IsNotEmpty() + description: string; + + @ApiProperty({ + description: 'Task status', + enum: TASK_STATUSES, + required: false, + }) + @IsOptional() + @IsIn([...TASK_STATUSES], { + message: `Status must be one of: ${TASK_STATUSES.join(', ')}`, + }) + status?: string; + + @ApiProperty({ + description: 'Task frequency', + enum: TASK_FREQUENCIES, + required: false, + }) + @IsOptional() + @IsIn([...TASK_FREQUENCIES], { + message: `Frequency must be one of: ${TASK_FREQUENCIES.join(', ')}`, + }) + frequency?: string; + + @ApiProperty({ + description: 'Department', + enum: DEPARTMENTS, + required: false, + }) + @IsOptional() + @IsIn([...DEPARTMENTS], { + message: `Department must be one of: ${DEPARTMENTS.join(', ')}`, + }) + department?: string; +} diff --git a/apps/api/src/admin-organizations/dto/create-admin-vendor.dto.ts b/apps/api/src/admin-organizations/dto/create-admin-vendor.dto.ts new file mode 100644 index 0000000000..517291480c --- /dev/null +++ b/apps/api/src/admin-organizations/dto/create-admin-vendor.dto.ts @@ -0,0 +1,56 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsOptional, + IsEnum, + IsUrl, +} from 'class-validator'; +import { Transform } from 'class-transformer'; +import { VendorCategory, VendorStatus } from '@trycompai/db'; + +export class CreateAdminVendorDto { + @ApiProperty({ + description: 'Vendor name', + example: 'CloudTech Solutions', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + description: 'Description of the vendor and services', + example: 'Cloud infrastructure provider for compute and storage', + }) + @IsString() + @IsNotEmpty() + description: string; + + @ApiProperty({ + description: 'Vendor category', + enum: VendorCategory, + required: false, + }) + @IsOptional() + @IsEnum(VendorCategory) + category?: VendorCategory; + + @ApiProperty({ + description: 'Assessment status', + enum: VendorStatus, + required: false, + }) + @IsOptional() + @IsEnum(VendorStatus) + status?: VendorStatus; + + @ApiProperty({ + description: 'Vendor website URL', + required: false, + example: 'https://example.com', + }) + @IsOptional() + @IsUrl() + @Transform(({ value }) => (value === '' ? undefined : value)) + website?: string; +} diff --git a/apps/api/src/admin-organizations/dto/invite-member.dto.ts b/apps/api/src/admin-organizations/dto/invite-member.dto.ts new file mode 100644 index 0000000000..f34440e257 --- /dev/null +++ b/apps/api/src/admin-organizations/dto/invite-member.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsIn, IsString } from 'class-validator'; +import { Transform } from 'class-transformer'; + +const ALLOWED_INVITE_ROLES = [ + 'admin', + 'auditor', + 'employee', + 'contractor', +] as const; + +export class InviteMemberDto { + @ApiProperty({ + description: 'Email address of the user to invite', + example: 'user@example.com', + }) + @IsEmail({}, { message: 'A valid email address is required' }) + @Transform(({ value }) => (typeof value === 'string' ? value.toLowerCase().trim() : value)) + email: string; + + @ApiProperty({ + description: 'Role to assign to the invited member', + enum: ALLOWED_INVITE_ROLES, + example: 'admin', + }) + @IsString() + @IsIn([...ALLOWED_INVITE_ROLES], { + message: `Role must be one of: ${ALLOWED_INVITE_ROLES.join(', ')}`, + }) + role: string; +} diff --git a/apps/api/src/admin-organizations/platform-admin-auth-context.ts b/apps/api/src/admin-organizations/platform-admin-auth-context.ts new file mode 100644 index 0000000000..bfbcc980a4 --- /dev/null +++ b/apps/api/src/admin-organizations/platform-admin-auth-context.ts @@ -0,0 +1,28 @@ +import type { AuthContext } from '../auth/types'; + +export interface AdminRequest { + userId: string; +} + +/** + * Build an AuthContext for platform admin operations that delegate to + * org-scoped services requiring an auth context. + * + * The context uses the org-level 'admin' role (not 'owner') so the + * platform admin sees the same data an org admin would. The + * `isPlatformAdmin` flag is set so services can distinguish this from + * a real org member if needed. + */ +export function buildPlatformAdminAuthContext( + userId: string, + organizationId: string, +): AuthContext { + return { + userId, + organizationId, + userRoles: ['admin'], + isPlatformAdmin: true, + isApiKey: false, + authType: 'session', + }; +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c06bd8ef9d..aa2050fa47 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -44,6 +44,7 @@ import { EmailModule } from './email/email.module'; import { SecretsModule } from './secrets/secrets.module'; import { SecurityPenetrationTestsModule } from './security-penetration-tests/security-penetration-tests.module'; import { StripeModule } from './stripe/stripe.module'; +import { AdminOrganizationsModule } from './admin-organizations/admin-organizations.module'; @Module({ imports: [ @@ -100,6 +101,7 @@ import { StripeModule } from './stripe/stripe.module'; SecretsModule, SecurityPenetrationTestsModule, StripeModule, + AdminOrganizationsModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/attachments/attachments.controller.spec.ts b/apps/api/src/attachments/attachments.controller.spec.ts index f77a0d39f9..7b2d134285 100644 --- a/apps/api/src/attachments/attachments.controller.spec.ts +++ b/apps/api/src/attachments/attachments.controller.spec.ts @@ -8,7 +8,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: {}, BUILT_IN_ROLE_PERMISSIONS: {}, })); diff --git a/apps/api/src/audit/audit-log.controller.spec.ts b/apps/api/src/audit/audit-log.controller.spec.ts index 15bd98ba7f..9d10c04d91 100644 --- a/apps/api/src/audit/audit-log.controller.spec.ts +++ b/apps/api/src/audit/audit-log.controller.spec.ts @@ -8,7 +8,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { app: ['read'], }, @@ -73,7 +73,7 @@ describe('AuditLogController', () => { where: { organizationId: 'org_1' }, include: { user: { - select: { id: true, name: true, email: true, image: true, isPlatformAdmin: true }, + select: { id: true, name: true, email: true, image: true, role: true }, }, member: true, organization: true, diff --git a/apps/api/src/audit/audit-log.controller.ts b/apps/api/src/audit/audit-log.controller.ts index 4db1274b2a..ea868c968f 100644 --- a/apps/api/src/audit/audit-log.controller.ts +++ b/apps/api/src/audit/audit-log.controller.ts @@ -54,7 +54,7 @@ export class AuditLogController { where, include: { user: { - select: { id: true, name: true, email: true, image: true, isPlatformAdmin: true }, + select: { id: true, name: true, email: true, image: true, role: true }, }, member: true, organization: true, diff --git a/apps/api/src/audit/audit-log.interceptor.ts b/apps/api/src/audit/audit-log.interceptor.ts index afed9ad5e2..09aeb3996e 100644 --- a/apps/api/src/audit/audit-log.interceptor.ts +++ b/apps/api/src/audit/audit-log.interceptor.ts @@ -70,7 +70,7 @@ export class AuditLogInterceptor implements NestInterceptor { return next.handle(); } - const { organizationId, userId, memberId } = request; + const { organizationId, userId, memberId, impersonatedBy } = request; if (!organizationId || !userId) { return next.handle(); } @@ -189,6 +189,7 @@ export class AuditLogInterceptor implements NestInterceptor { changes, commentCtx, descriptionOverride, + impersonatedBy, ).catch((err) => { this.logger.error('Failed to create audit log entry', err); }); @@ -253,6 +254,7 @@ export class AuditLogInterceptor implements NestInterceptor { changes: ChangesRecord | null, commentContext: AuditContextOverride | null, descriptionOverride: string | null, + impersonatedBy?: string, ): Promise { const entityType = commentContext?.entityType ?? RESOURCE_TO_ENTITY_TYPE[resource] ?? null; @@ -273,6 +275,9 @@ export class AuditLogInterceptor implements NestInterceptor { if (changes) { auditData.changes = changes; } + if (impersonatedBy) { + auditData.impersonatedBy = impersonatedBy; + } await db.auditLog.create({ data: { @@ -281,7 +286,9 @@ export class AuditLogInterceptor implements NestInterceptor { memberId: memberId ?? null, entityType, entityId, - description, + description: impersonatedBy + ? `[Impersonated] ${description}` + : description, data: auditData as Prisma.InputJsonValue, }, }); diff --git a/apps/api/src/auth/admin-rate-limit.middleware.spec.ts b/apps/api/src/auth/admin-rate-limit.middleware.spec.ts new file mode 100644 index 0000000000..af92fe73a9 --- /dev/null +++ b/apps/api/src/auth/admin-rate-limit.middleware.spec.ts @@ -0,0 +1,122 @@ +import type { Request, Response } from 'express'; + +const mockLimit = jest.fn(); + +const MockRatelimit = jest.fn().mockImplementation(() => ({ + limit: mockLimit, +})); +(MockRatelimit as unknown as Record).slidingWindow = jest + .fn() + .mockReturnValue('sliding-window-config'); + +jest.mock('@upstash/ratelimit', () => ({ + Ratelimit: MockRatelimit, +})); + +jest.mock('@upstash/redis', () => ({ + Redis: jest.fn(), +})); + +// Set env vars before importing the middleware +process.env.UPSTASH_REDIS_REST_URL = 'https://fake.upstash.io'; +process.env.UPSTASH_REDIS_REST_TOKEN = 'fake-token'; + +import { adminAuthRateLimiter } from './admin-rate-limit.middleware'; + +function buildReq(path: string, ip = '127.0.0.1'): Request { + return { path, ip, socket: { remoteAddress: ip } } as unknown as Request; +} + +function buildRes(): Response & { statusCode: number; body: unknown } { + const res = { + statusCode: 200, + body: undefined as unknown, + status(code: number) { + res.statusCode = code; + return res; + }, + json(data: unknown) { + res.body = data; + return res; + }, + }; + return res as unknown as Response & { statusCode: number; body: unknown }; +} + +describe('adminAuthRateLimiter', () => { + beforeEach(() => { + mockLimit.mockReset(); + mockLimit.mockResolvedValue({ success: true }); + }); + + it('passes through requests that are not admin auth routes', async () => { + const next = jest.fn(); + await adminAuthRateLimiter(buildReq('/api/auth/sign-in'), buildRes(), next); + expect(next).toHaveBeenCalledTimes(1); + expect(mockLimit).not.toHaveBeenCalled(); + }); + + it('passes through non-auth requests', async () => { + const next = jest.fn(); + await adminAuthRateLimiter(buildReq('/v1/policies'), buildRes(), next); + expect(next).toHaveBeenCalledTimes(1); + expect(mockLimit).not.toHaveBeenCalled(); + }); + + it('allows admin auth requests when rate limit succeeds', async () => { + const next = jest.fn(); + await adminAuthRateLimiter( + buildReq('/api/auth/admin/impersonate-user'), + buildRes(), + next, + ); + expect(next).toHaveBeenCalledTimes(1); + expect(mockLimit).toHaveBeenCalledWith('127.0.0.1'); + }); + + it('rejects requests when rate limit is exceeded', async () => { + mockLimit.mockResolvedValue({ success: false }); + + const next = jest.fn(); + const res = buildRes(); + await adminAuthRateLimiter( + buildReq('/api/auth/admin/set-role'), + res, + next, + ); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(429); + expect(res.body).toEqual({ + error: 'Too many requests to admin endpoints. Try again later.', + }); + }); + + it('uses IP from request for rate limit key', async () => { + const next = jest.fn(); + await adminAuthRateLimiter( + buildReq('/api/auth/admin/set-role', '10.0.0.1'), + buildRes(), + next, + ); + expect(mockLimit).toHaveBeenCalledWith('10.0.0.1'); + + await adminAuthRateLimiter( + buildReq('/api/auth/admin/set-role', '10.0.0.2'), + buildRes(), + next, + ); + expect(mockLimit).toHaveBeenCalledWith('10.0.0.2'); + }); + + it('allows request through when Redis is unreachable', async () => { + mockLimit.mockRejectedValue(new Error('Redis connection failed')); + + const next = jest.fn(); + await adminAuthRateLimiter( + buildReq('/api/auth/admin/set-role'), + buildRes(), + next, + ); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/auth/admin-rate-limit.middleware.ts b/apps/api/src/auth/admin-rate-limit.middleware.ts new file mode 100644 index 0000000000..18211fee7f --- /dev/null +++ b/apps/api/src/auth/admin-rate-limit.middleware.ts @@ -0,0 +1,62 @@ +import type { Request, Response, NextFunction } from 'express'; +import { Ratelimit } from '@upstash/ratelimit'; +import { Redis } from '@upstash/redis'; + +const MAX_REQUESTS = 10; +const WINDOW = '60 s'; + +const hasUpstashConfig = + !!process.env.UPSTASH_REDIS_REST_URL && + !!process.env.UPSTASH_REDIS_REST_TOKEN; + +const ratelimit = hasUpstashConfig + ? new Ratelimit({ + redis: new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, + }), + limiter: Ratelimit.slidingWindow(MAX_REQUESTS, WINDOW), + prefix: 'ratelimit:admin-auth', + }) + : null; + +/** + * Express middleware that rate-limits requests to /api/auth/admin/*. + * + * better-auth admin routes (impersonation, set-role, ban, etc.) are handled + * by better-auth's own request handler and never reach NestJS controllers, + * so the global ThrottlerGuard does not apply to them. This middleware fills + * that gap with a per-IP sliding window (10 req/min) backed by Upstash Redis + * so limits are shared across all ECS instances. + */ +export async function adminAuthRateLimiter( + req: Request, + res: Response, + next: NextFunction, +): Promise { + if (!req.path.startsWith('/api/auth/admin')) { + return next(); + } + + if (!ratelimit) { + return next(); + } + + const ip = req.ip || req.socket.remoteAddress || 'unknown'; + + try { + const { success } = await ratelimit.limit(ip); + + if (!success) { + res.status(429).json({ + error: 'Too many requests to admin endpoints. Try again later.', + }); + return; + } + } catch { + // If Redis is unreachable, allow the request through rather than + // blocking all admin operations. The WAF still provides baseline protection. + } + + return next(); +} diff --git a/apps/api/src/auth/api-key.service.spec.ts b/apps/api/src/auth/api-key.service.spec.ts index bd5b580183..518ab3aa65 100644 --- a/apps/api/src/auth/api-key.service.spec.ts +++ b/apps/api/src/auth/api-key.service.spec.ts @@ -1,4 +1,4 @@ -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { organization: ['read', 'update', 'delete'], member: ['create', 'read', 'update', 'delete'], diff --git a/apps/api/src/auth/api-key.service.ts b/apps/api/src/auth/api-key.service.ts index 9785ba33e0..876155364a 100644 --- a/apps/api/src/auth/api-key.service.ts +++ b/apps/api/src/auth/api-key.service.ts @@ -5,7 +5,7 @@ import { BadRequestException, } from '@nestjs/common'; import { db } from '@trycompai/db'; -import { statement } from '@comp/auth'; +import { statement } from '@trycompai/auth'; import { createHash, randomBytes } from 'node:crypto'; /** Result from validating an API key */ diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 6ebe3bc52a..bc1c57c15a 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -40,7 +40,7 @@ export class AuthController { email: true, name: true, image: true, - isPlatformAdmin: true, + role: true, }, }), db.member.findMany({ diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index e7c876313f..55c551d071 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -1,3 +1,4 @@ +import '../config/load-env'; import { MagicLinkEmail, OTPVerificationEmail } from '@trycompai/email'; import { triggerEmail } from '../email/trigger-email'; import { InviteEmail } from '../email/templates/invite-member'; @@ -5,13 +6,15 @@ import { db } from '@trycompai/db'; import { betterAuth } from 'better-auth'; import { prismaAdapter } from 'better-auth/adapters/prisma'; import { + admin, bearer, emailOTP, magicLink, multiSession, organization, } from 'better-auth/plugins'; -import { ac, allRoles } from '@comp/auth'; +import { ac, allRoles } from '@trycompai/auth'; +import { createAuthMiddleware } from 'better-auth/api'; const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 60; // 1 hour @@ -19,8 +22,7 @@ const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 60; // 1 hour * Determine the cookie domain based on environment. */ function getCookieDomain(): string | undefined { - const baseUrl = - process.env.BASE_URL || ''; + const baseUrl = process.env.BASE_URL || ''; if (baseUrl.includes('staging.trycomp.ai')) { return '.staging.trycomp.ai'; @@ -109,8 +111,7 @@ function validateSecurityConfig(): void { // Warn about development defaults in production if (process.env.NODE_ENV === 'production') { - const baseUrl = - process.env.BASE_URL || ''; + const baseUrl = process.env.BASE_URL || ''; if (baseUrl.includes('localhost')) { console.warn( 'SECURITY WARNING: BASE_URL contains "localhost" in production. ' + @@ -222,6 +223,77 @@ export const auth = betterAuth({ }, }, }, + hooks: { + after: createAuthMiddleware(async (ctx) => { + if (!ctx.path.startsWith('/admin/')) return; + + const session = ctx.context?.session; + const userId = session?.user?.id; + if (!userId) return; + + const descriptions: Record = { + '/admin/impersonate-user': 'Impersonated a user', + '/admin/stop-impersonating': 'Stopped impersonating a user', + '/admin/ban-user': 'Banned a user', + '/admin/unban-user': 'Unbanned a user', + '/admin/set-role': 'Changed a user role', + '/admin/set-user-password': 'Reset a user password', + '/admin/create-user': 'Created a user', + '/admin/update-user': 'Updated a user', + '/admin/remove-user': 'Removed a user', + '/admin/revoke-user-session': 'Revoked a user session', + '/admin/revoke-user-sessions': 'Revoked all user sessions', + }; + + const description = descriptions[ctx.path]; + if (!description) return; + + try { + let organizationId = (session.session as Record) + ?.activeOrganizationId as string | undefined; + + if (!organizationId) { + const userOrg = await db.organization.findFirst({ + where: { members: { some: { userId } } }, + orderBy: { createdAt: 'desc' }, + select: { id: true }, + }); + + if (!userOrg) { + console.error( + '[Auth] SECURITY: Admin action blocked — no organization could be resolved for admin user', + { userId, path: ctx.path }, + ); + throw new Error( + 'Admin action blocked: unable to resolve organization for audit trail', + ); + } + + organizationId = userOrg.id; + } + + await db.auditLog.create({ + data: { + userId, + memberId: null, + organizationId, + entityType: null, + entityId: null, + description: `[Platform Admin] ${description}`, + data: { + action: description, + method: 'POST', + path: ctx.path, + resource: 'admin', + permission: 'platform-admin', + }, + }, + }); + } catch (err) { + console.error('[Auth] Failed to write admin audit log:', err); + } + }), + }, // SECRET_KEY is validated at startup via validateSecurityConfig() secret: process.env.SECRET_KEY as string, plugins: [ @@ -304,6 +376,9 @@ export const auth = betterAuth({ }), multiSession(), bearer(), + admin({ + defaultRole: 'user', + }), ], socialProviders, user: { diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index 168f1032fb..f67690066b 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -44,10 +44,10 @@ export class HybridAuthGuard implements CanActivate { } // Try session-based authentication (bearer token or cookies) - const skipOrgCheck = this.reflector.getAllAndOverride(SKIP_ORG_CHECK_KEY, [ - context.getHandler(), - context.getClass(), - ]); + const skipOrgCheck = this.reflector.getAllAndOverride( + SKIP_ORG_CHECK_KEY, + [context.getHandler(), context.getClass()], + ); return this.handleSessionAuth(request, skipOrgCheck); } @@ -179,11 +179,6 @@ export class HybridAuthGuard implements CanActivate { id: true, role: true, department: true, - user: { - select: { - isPlatformAdmin: true, - }, - }, }, }); @@ -196,7 +191,6 @@ export class HybridAuthGuard implements CanActivate { userRoles = member.role ? member.role.split(',') : null; request.memberId = member.id; request.memberDepartment = member.department; - request.isPlatformAdmin = member.user?.isPlatformAdmin ?? false; } // Set request context for session auth @@ -207,7 +201,17 @@ export class HybridAuthGuard implements CanActivate { request.authType = 'session'; request.isApiKey = false; request.isServiceToken = false; - request.isPlatformAdmin = request.isPlatformAdmin ?? false; + // Resolve isPlatformAdmin from the User.role column (via better-auth session), + // not from the member relation. This ensures the flag is set regardless of + // org membership or skipOrgCheck. + request.isPlatformAdmin = + (user as { role?: string | null }).role === 'admin'; + + const rawImpersonatedBy = (sessionData as Record) + .impersonatedBy; + if (typeof rawImpersonatedBy === 'string' && rawImpersonatedBy) { + request.impersonatedBy = rawImpersonatedBy; + } return true; } catch (error) { diff --git a/apps/api/src/auth/permission.guard.ts b/apps/api/src/auth/permission.guard.ts index dc5247486e..bde84f7a49 100644 --- a/apps/api/src/auth/permission.guard.ts +++ b/apps/api/src/auth/permission.guard.ts @@ -6,7 +6,7 @@ import { Logger, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { RESTRICTED_ROLES, PRIVILEGED_ROLES } from '@comp/auth'; +import { RESTRICTED_ROLES, PRIVILEGED_ROLES } from '@trycompai/auth'; import { auth } from './auth.server'; import { resolveServiceByName } from './service-token.config'; import { AuthenticatedRequest } from './types'; @@ -47,11 +47,9 @@ export class PermissionGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { // Get required permissions from route metadata - const requiredPermissions = - this.reflector.getAllAndOverride(PERMISSIONS_KEY, [ - context.getHandler(), - context.getClass(), - ]); + const requiredPermissions = this.reflector.getAllAndOverride< + RequiredPermission[] + >(PERMISSIONS_KEY, [context.getHandler(), context.getClass()]); // No permissions required - allow access if (!requiredPermissions || requiredPermissions.length === 0) { @@ -77,9 +75,7 @@ export class PermissionGuard implements CanActivate { ); if (!hasAllPerms) { - throw new ForbiddenException( - 'API key lacks required permission scope', - ); + throw new ForbiddenException('API key lacks required permission scope'); } return true; } @@ -101,9 +97,7 @@ export class PermissionGuard implements CanActivate { this.logger.warn( `[PermissionGuard] Service "${request.serviceName}" denied: missing permission for ${requiredPermissions.map((p) => `${p.resource}:${p.actions.join(',')}`).join('; ')}`, ); - throw new ForbiddenException( - 'Service token lacks required permission', - ); + throw new ForbiddenException('Service token lacks required permission'); } return true; @@ -124,10 +118,7 @@ export class PermissionGuard implements CanActivate { } try { - const hasPermission = await this.checkPermission( - request, - permissionBody, - ); + const hasPermission = await this.checkPermission(request, permissionBody); if (!hasPermission) { this.logger.warn( @@ -141,7 +132,10 @@ export class PermissionGuard implements CanActivate { if (error instanceof ForbiddenException) { throw error; } - this.logger.error(`[PermissionGuard] Error checking permissions for ${request.method} ${request.url}:`, error); + this.logger.error( + `[PermissionGuard] Error checking permissions for ${request.method} ${request.url}:`, + error, + ); throw new ForbiddenException('Unable to verify permissions'); } } @@ -192,9 +186,7 @@ export class PermissionGuard implements CanActivate { // If user has any privileged role, they're not restricted const privileged: readonly string[] = PRIVILEGED_ROLES; const restricted: readonly string[] = RESTRICTED_ROLES; - const hasPrivilegedRole = roles.some((role) => - privileged.includes(role), - ); + const hasPrivilegedRole = roles.some((role) => privileged.includes(role)); if (hasPrivilegedRole) { return false; } diff --git a/apps/api/src/auth/platform-admin.guard.spec.ts b/apps/api/src/auth/platform-admin.guard.spec.ts new file mode 100644 index 0000000000..bf80815bdb --- /dev/null +++ b/apps/api/src/auth/platform-admin.guard.spec.ts @@ -0,0 +1,188 @@ +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { PlatformAdminGuard } from './platform-admin.guard'; + +const mockGetSession = jest.fn(); +const mockFindUnique = jest.fn(); + +jest.mock('./auth.server', () => ({ + auth: { + api: { + getSession: (...args: unknown[]) => mockGetSession(...args), + }, + }, +})); + +jest.mock('@trycompai/db', () => ({ + db: { + user: { + findUnique: (...args: unknown[]) => mockFindUnique(...args), + }, + }, +})); + +function buildContext(headers: Record = {}): ExecutionContext { + const request = { headers, userId: undefined, userEmail: undefined, isPlatformAdmin: undefined }; + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + } as unknown as ExecutionContext; +} + +describe('PlatformAdminGuard', () => { + let guard: PlatformAdminGuard; + + beforeEach(() => { + guard = new PlatformAdminGuard(); + jest.clearAllMocks(); + }); + + it('throws UnauthorizedException when no auth headers are present', async () => { + const ctx = buildContext({}); + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(ctx)).rejects.toThrow( + 'Platform admin routes require authentication', + ); + }); + + it('throws UnauthorizedException when session is invalid', async () => { + mockGetSession.mockResolvedValue(null); + const ctx = buildContext({ authorization: 'Bearer bad_token' }); + + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(ctx)).rejects.toThrow( + 'Invalid or expired session', + ); + }); + + it('throws UnauthorizedException when session has no user id', async () => { + mockGetSession.mockResolvedValue({ user: { id: null } }); + const ctx = buildContext({ cookie: 'session=abc' }); + + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when user is not found in DB', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'usr_1' } }); + mockFindUnique.mockResolvedValue(null); + const ctx = buildContext({ cookie: 'session=abc' }); + + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(ctx)).rejects.toThrow('User not found'); + }); + + it('throws ForbiddenException when user role is not admin', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'usr_1' } }); + mockFindUnique.mockResolvedValue({ + id: 'usr_1', + email: 'user@test.com', + role: 'user', + }); + const ctx = buildContext({ cookie: 'session=abc' }); + + await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException); + await expect(guard.canActivate(ctx)).rejects.toThrow( + 'Access denied: Platform admin privileges required', + ); + }); + + it('throws ForbiddenException when user role is null', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'usr_1' } }); + mockFindUnique.mockResolvedValue({ + id: 'usr_1', + email: 'user@test.com', + role: null, + }); + const ctx = buildContext({ cookie: 'session=abc' }); + + await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException); + }); + + it('returns true and sets request context for valid admin', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'usr_admin' } }); + mockFindUnique.mockResolvedValue({ + id: 'usr_admin', + email: 'admin@platform.com', + role: 'admin', + }); + + const request = { + headers: { authorization: 'Bearer valid_token' }, + userId: undefined as string | undefined, + userEmail: undefined as string | undefined, + isPlatformAdmin: undefined as boolean | undefined, + }; + const ctx = { + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; + + const result = await guard.canActivate(ctx); + + expect(result).toBe(true); + expect(request.userId).toBe('usr_admin'); + expect(request.userEmail).toBe('admin@platform.com'); + expect(request.isPlatformAdmin).toBe(true); + }); + + it('always queries the DB even if session contains role info', async () => { + mockGetSession.mockResolvedValue({ + user: { id: 'usr_1', role: 'admin' }, + }); + mockFindUnique.mockResolvedValue({ + id: 'usr_1', + email: 'user@test.com', + role: 'user', + }); + const ctx = buildContext({ cookie: 'session=abc' }); + + await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException); + expect(mockFindUnique).toHaveBeenCalledWith({ + where: { id: 'usr_1' }, + select: { id: true, email: true, role: true }, + }); + }); + + it('does not allow API key authentication', async () => { + const ctx = buildContext({ 'x-api-key': 'some_key' }); + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException); + }); + + it('does not allow service token authentication', async () => { + const ctx = buildContext({ 'x-service-token': 'some_token' }); + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException); + }); + + it('forwards authorization header to better-auth', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'usr_admin' } }); + mockFindUnique.mockResolvedValue({ + id: 'usr_admin', + email: 'admin@test.com', + role: 'admin', + }); + const ctx = buildContext({ authorization: 'Bearer token123' }); + + await guard.canActivate(ctx); + + const passedHeaders = mockGetSession.mock.calls[0][0].headers; + expect(passedHeaders.get('authorization')).toBe('Bearer token123'); + }); + + it('forwards cookie header to better-auth', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'usr_admin' } }); + mockFindUnique.mockResolvedValue({ + id: 'usr_admin', + email: 'admin@test.com', + role: 'admin', + }); + const ctx = buildContext({ cookie: 'session=xyz' }); + + await guard.canActivate(ctx); + + const passedHeaders = mockGetSession.mock.calls[0][0].headers; + expect(passedHeaders.get('cookie')).toBe('session=xyz'); + }); +}); diff --git a/apps/api/src/auth/platform-admin.guard.ts b/apps/api/src/auth/platform-admin.guard.ts index a4708e75f9..509b756c3b 100644 --- a/apps/api/src/auth/platform-admin.guard.ts +++ b/apps/api/src/auth/platform-admin.guard.ts @@ -48,13 +48,13 @@ export class PlatformAdminGuard implements CanActivate { throw new UnauthorizedException('Invalid or expired session'); } - // Fetch user from database to check isPlatformAdmin + // Verify admin role from the database (better-auth managed field) const user = await db.user.findUnique({ where: { id: session.user.id }, select: { id: true, email: true, - isPlatformAdmin: true, + role: true, }, }); @@ -62,7 +62,7 @@ export class PlatformAdminGuard implements CanActivate { throw new UnauthorizedException('User not found'); } - if (!user.isPlatformAdmin) { + if (user.role !== 'admin') { throw new ForbiddenException( 'Access denied: Platform admin privileges required', ); diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts index 7874ff475f..dbfbd5ac76 100644 --- a/apps/api/src/auth/types.ts +++ b/apps/api/src/auth/types.ts @@ -15,6 +15,7 @@ export interface AuthenticatedRequest extends Request { memberId?: string; // Member ID for assignment filtering (only available for session auth) memberDepartment?: Departments; // Member department for visibility filtering (only available for session auth) apiKeyScopes?: string[]; // Scopes for API key auth (empty = legacy full access) + impersonatedBy?: string; // User ID of the admin who initiated impersonation (only set during impersonation sessions) } export interface AuthContext { @@ -30,4 +31,5 @@ export interface AuthContext { memberId?: string; // Member ID for assignment filtering (only available for session auth) memberDepartment?: Departments; // Member department for visibility filtering (only available for session auth) apiKeyScopes?: string[]; // Scopes for API key auth (empty = legacy full access) + impersonatedBy?: string; // User ID of the admin who initiated impersonation (only set during impersonation sessions) } diff --git a/apps/api/src/cloud-security/cloud-security-query.service.ts b/apps/api/src/cloud-security/cloud-security-query.service.ts index 5075feea67..6a506d3335 100644 --- a/apps/api/src/cloud-security/cloud-security-query.service.ts +++ b/apps/api/src/cloud-security/cloud-security-query.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { db } from '@db'; -import { getManifest } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; const CLOUD_PROVIDER_CATEGORY = 'Cloud'; diff --git a/apps/api/src/cloud-security/cloud-security.service.ts b/apps/api/src/cloud-security/cloud-security.service.ts index e24adc29db..d9088e90b6 100644 --- a/apps/api/src/cloud-security/cloud-security.service.ts +++ b/apps/api/src/cloud-security/cloud-security.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { db } from '@db'; -import { getManifest } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; import { runs, tasks } from '@trigger.dev/sdk'; import { CredentialVaultService } from '../integration-platform/services/credential-vault.service'; import { OAuthCredentialsService } from '../integration-platform/services/oauth-credentials.service'; diff --git a/apps/api/src/comments/comment-mention-notifier.service.ts b/apps/api/src/comments/comment-mention-notifier.service.ts index 8f307df69d..d4ce80a875 100644 --- a/apps/api/src/comments/comment-mention-notifier.service.ts +++ b/apps/api/src/comments/comment-mention-notifier.service.ts @@ -247,7 +247,7 @@ export class CommentMentionNotifierService { deactivated: false, user: { id: { in: mentionedUserIds } }, OR: [ - { user: { isPlatformAdmin: false } }, + { user: { role: { not: 'admin' } } }, { role: { contains: 'owner' } }, ], }, diff --git a/apps/api/src/comments/comments.controller.spec.ts b/apps/api/src/comments/comments.controller.spec.ts index df16db3888..4f93c1dfb9 100644 --- a/apps/api/src/comments/comments.controller.spec.ts +++ b/apps/api/src/comments/comments.controller.spec.ts @@ -11,7 +11,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: {}, BUILT_IN_ROLE_PERMISSIONS: {}, })); diff --git a/apps/api/src/context/context.controller.spec.ts b/apps/api/src/context/context.controller.spec.ts index 6c48750e17..8ecc5ddd40 100644 --- a/apps/api/src/context/context.controller.spec.ts +++ b/apps/api/src/context/context.controller.spec.ts @@ -10,7 +10,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: {}, BUILT_IN_ROLE_PERMISSIONS: {}, })); diff --git a/apps/api/src/controls/controls.controller.spec.ts b/apps/api/src/controls/controls.controller.spec.ts index e82bd8229b..25b61809da 100644 --- a/apps/api/src/controls/controls.controller.spec.ts +++ b/apps/api/src/controls/controls.controller.spec.ts @@ -11,7 +11,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { control: ['create', 'read', 'update', 'delete'], }, diff --git a/apps/api/src/device-agent/device-agent.controller.spec.ts b/apps/api/src/device-agent/device-agent.controller.spec.ts index 7d083f1148..e7d70e1bc6 100644 --- a/apps/api/src/device-agent/device-agent.controller.spec.ts +++ b/apps/api/src/device-agent/device-agent.controller.spec.ts @@ -12,7 +12,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { app: ['create', 'read', 'update', 'delete'], }, diff --git a/apps/api/src/devices/devices.controller.spec.ts b/apps/api/src/devices/devices.controller.spec.ts index be69031100..579121c211 100644 --- a/apps/api/src/devices/devices.controller.spec.ts +++ b/apps/api/src/devices/devices.controller.spec.ts @@ -9,7 +9,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { app: ['read'], }, diff --git a/apps/api/src/evidence-forms/evidence-forms.controller.spec.ts b/apps/api/src/evidence-forms/evidence-forms.controller.spec.ts index fac27e5bf7..548bd3f2c1 100644 --- a/apps/api/src/evidence-forms/evidence-forms.controller.spec.ts +++ b/apps/api/src/evidence-forms/evidence-forms.controller.spec.ts @@ -9,7 +9,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { evidence: ['create', 'read', 'update', 'delete'], }, diff --git a/apps/api/src/evidence-forms/evidence-forms.definitions.ts b/apps/api/src/evidence-forms/evidence-forms.definitions.ts index 2b94ea7b2e..0d6d53a73f 100644 --- a/apps/api/src/evidence-forms/evidence-forms.definitions.ts +++ b/apps/api/src/evidence-forms/evidence-forms.definitions.ts @@ -1,4 +1,4 @@ -// Single source of truth: re-export from shared @comp/company package +// Single source of truth: re-export from shared @trycompai/company package export { evidenceFormTypeSchema, evidenceFormFileSchema, @@ -8,4 +8,4 @@ export { type EvidenceFormType, type EvidenceFormFieldDefinition, type EvidenceFormDefinition, -} from '@comp/company'; +} from '@trycompai/company'; diff --git a/apps/api/src/evidence-forms/evidence-forms.service.ts b/apps/api/src/evidence-forms/evidence-forms.service.ts index d9ed50f4cb..050110c410 100644 --- a/apps/api/src/evidence-forms/evidence-forms.service.ts +++ b/apps/api/src/evidence-forms/evidence-forms.service.ts @@ -4,7 +4,7 @@ import { db, EvidenceFormType as DbEvidenceFormType } from '@trycompai/db'; import { toDbEvidenceFormType, toExternalEvidenceFormType, -} from '@comp/company'; +} from '@trycompai/company'; import { BadRequestException, Injectable, diff --git a/apps/api/src/findings/finding-audit.service.ts b/apps/api/src/findings/finding-audit.service.ts index 19aa45966b..5f3405abaa 100644 --- a/apps/api/src/findings/finding-audit.service.ts +++ b/apps/api/src/findings/finding-audit.service.ts @@ -5,7 +5,7 @@ export interface FindingAuditParams { findingId: string; organizationId: string; userId: string; - memberId: string; + memberId: string | null; } @Injectable() diff --git a/apps/api/src/findings/finding-notifier.service.ts b/apps/api/src/findings/finding-notifier.service.ts index 7c5792f0fe..a7fff542bc 100644 --- a/apps/api/src/findings/finding-notifier.service.ts +++ b/apps/api/src/findings/finding-notifier.service.ts @@ -617,7 +617,7 @@ export class FindingNotifierService { organizationId, deactivated: false, OR: [ - { user: { isPlatformAdmin: false } }, + { user: { role: { not: 'admin' } } }, { role: { contains: 'owner' } }, ], }, diff --git a/apps/api/src/findings/findings.controller.ts b/apps/api/src/findings/findings.controller.ts index 874a8e36cf..79037c4910 100644 --- a/apps/api/src/findings/findings.controller.ts +++ b/apps/api/src/findings/findings.controller.ts @@ -473,9 +473,9 @@ export class FindingsController { const user = await db.user.findUnique({ where: { id: userId }, - select: { isPlatformAdmin: true }, + select: { role: true }, }); - return user?.isPlatformAdmin ?? false; + return user?.role === 'admin'; } } diff --git a/apps/api/src/findings/findings.service.ts b/apps/api/src/findings/findings.service.ts index 9e88c536ef..fd62476fb6 100644 --- a/apps/api/src/findings/findings.service.ts +++ b/apps/api/src/findings/findings.service.ts @@ -14,7 +14,7 @@ import { import { toDbEvidenceFormType, toExternalEvidenceFormType, -} from '@comp/company'; +} from '@trycompai/company'; import { CreateFindingDto } from './dto/create-finding.dto'; import { UpdateFindingDto } from './dto/update-finding.dto'; import { FindingAuditService } from './finding-audit.service'; @@ -37,6 +37,14 @@ export class FindingsService { }, }, }, + createdByAdmin: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, template: { select: { id: true, @@ -206,10 +214,11 @@ export class FindingsService { /** * Create a new finding (auditor or platform admin only) + * When memberId is null, createdByAdminId is used for platform admin attribution. */ async create( organizationId: string, - memberId: string, + memberId: string | null, userId: string, createDto: CreateFindingDto, ) { @@ -303,13 +312,13 @@ export class FindingsService { content: createDto.content, templateId: createDto.templateId, createdById: memberId, + createdByAdminId: memberId ? null : userId, organizationId, status: FindingStatus.open, }, include: this.findingInclude, }); - // Log to audit trail await this.findingAuditService.logFindingCreated({ findingId: finding.id, organizationId, @@ -323,10 +332,11 @@ export class FindingsService { type: createDto.type ?? FindingType.soc2, }); - // Send notifications (fire-and-forget) const actorName = finding.createdBy?.user?.name || finding.createdBy?.user?.email || + finding.createdByAdmin?.name || + finding.createdByAdmin?.email || 'Someone'; void this.findingNotifierService.notifyFindingCreated({ organizationId, @@ -365,7 +375,7 @@ export class FindingsService { userRoles: string[], isPlatformAdmin: boolean, userId: string, - memberId: string, + memberId: string | null, ) { // Verify finding exists and get current state for audit const finding = await this.findById(organizationId, findingId); @@ -517,10 +527,12 @@ export class FindingsService { this.logger.log( `Triggering 'ready_for_review' notification for finding ${findingId}`, ); - void this.findingNotifierService.notifyReadyForReview({ - ...notificationParams, - findingCreatorMemberId: finding.createdById, - }); + if (finding.createdById) { + void this.findingNotifierService.notifyReadyForReview({ + ...notificationParams, + findingCreatorMemberId: finding.createdById, + }); + } break; case FindingStatus.needs_revision: this.logger.log( diff --git a/apps/api/src/frameworks/frameworks-scores.helper.ts b/apps/api/src/frameworks/frameworks-scores.helper.ts index a5e9bc3c52..c7578602cf 100644 --- a/apps/api/src/frameworks/frameworks-scores.helper.ts +++ b/apps/api/src/frameworks/frameworks-scores.helper.ts @@ -3,7 +3,7 @@ import { meetingSubTypeValues, toDbEvidenceFormType, toExternalEvidenceFormType, -} from '@comp/company'; +} from '@trycompai/company'; import { db } from '@trycompai/db'; import { filterComplianceMembers } from '../utils/compliance-filters'; diff --git a/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts b/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts index c5929379d8..8a4e83aa40 100644 --- a/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts @@ -9,11 +9,15 @@ import { HttpStatus, Logger, UseGuards, + UseInterceptors, + Req, } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import { OAuthCredentialsService } from '../services/oauth-credentials.service'; import { PlatformCredentialRepository } from '../repositories/platform-credential.repository'; -import { getAllManifests, getManifest } from '@comp/integration-platform'; +import { getAllManifests, getManifest } from '@trycompai/integration-platform'; import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { PlatformAuditLogInterceptor } from '../interceptors/platform-audit-log.interceptor'; interface SavePlatformCredentialDto { providerSlug: string; @@ -25,6 +29,8 @@ interface SavePlatformCredentialDto { @Controller({ path: 'admin/integrations', version: '1' }) @UseGuards(PlatformAdminGuard) +@UseInterceptors(PlatformAuditLogInterceptor) +@Throttle({ default: { ttl: 60000, limit: 30 } }) export class AdminIntegrationsController { private readonly logger = new Logger(AdminIntegrationsController.name); @@ -33,16 +39,12 @@ export class AdminIntegrationsController { private readonly platformCredentialRepository: PlatformCredentialRepository, ) {} - /** - * List all integrations with their credential status - */ @Get() async listIntegrations() { const manifests = getAllManifests(); const platformCredentials = await this.platformCredentialRepository.findAll(); - // Create a map of configured credentials const configuredProviders = new Set( platformCredentials.map((c) => c.providerSlug), ); @@ -62,17 +64,14 @@ export class AdminIntegrationsController { capabilities: manifest.capabilities, isActive: manifest.isActive, docsUrl: manifest.docsUrl, - // Credential status hasCredentials: configuredProviders.has(manifest.id), credentialConfiguredAt: credential?.createdAt, credentialUpdatedAt: credential?.updatedAt, - // Encrypted credential data (decrypted client-side) - encryptedClientId: credential?.encryptedClientId, - encryptedClientSecret: credential?.encryptedClientSecret, + clientIdHint: credential?.clientIdHint, + clientSecretHint: credential?.clientSecretHint, existingCustomSettings: (credential as { customSettings?: Record } | undefined) ?.customSettings || undefined, - // OAuth-specific info ...(manifest.auth.type === 'oauth2' && { setupInstructions: manifest.auth.config.setupInstructions, createAppUrl: manifest.auth.config.createAppUrl, @@ -134,7 +133,7 @@ export class AdminIntegrationsController { @Post('credentials') async savePlatformCredentials( @Body() body: SavePlatformCredentialDto, - // TODO: Get userId from auth context + @Req() req: { userId: string }, ) { const { providerSlug, @@ -144,7 +143,6 @@ export class AdminIntegrationsController { customSettings, } = body; - // Validate provider exists const manifest = getManifest(providerSlug); if (!manifest) { throw new HttpException( @@ -153,7 +151,6 @@ export class AdminIntegrationsController { ); } - // Validate it's an OAuth provider if (manifest.auth.type !== 'oauth2') { throw new HttpException( `Provider ${providerSlug} does not use OAuth`, @@ -161,7 +158,6 @@ export class AdminIntegrationsController { ); } - // Validate required fields if (!clientId || !clientSecret) { throw new HttpException( 'clientId and clientSecret are required', @@ -175,19 +171,21 @@ export class AdminIntegrationsController { clientSecret, customScopes, customSettings, - // userId from auth context would go here + req.userId, ); - this.logger.log(`Platform credentials saved for ${providerSlug}`); + this.logger.log( + `Platform credentials saved for ${providerSlug} by ${req.userId}`, + ); return { success: true }; } - /** - * Delete platform credentials for an integration - */ @Delete('credentials/:providerSlug') - async deletePlatformCredentials(@Param('providerSlug') providerSlug: string) { + async deletePlatformCredentials( + @Param('providerSlug') providerSlug: string, + @Req() req: { userId: string }, + ) { const credential = await this.platformCredentialRepository.findByProviderSlug(providerSlug); @@ -200,7 +198,9 @@ export class AdminIntegrationsController { await this.oauthCredentialsService.deletePlatformCredentials(providerSlug); - this.logger.log(`Platform credentials deleted for ${providerSlug}`); + this.logger.log( + `Platform credentials deleted for ${providerSlug} by ${req.userId}`, + ); return { success: true }; } diff --git a/apps/api/src/integration-platform/controllers/checks.controller.ts b/apps/api/src/integration-platform/controllers/checks.controller.ts index fd0e596f53..da27103c06 100644 --- a/apps/api/src/integration-platform/controllers/checks.controller.ts +++ b/apps/api/src/integration-platform/controllers/checks.controller.ts @@ -18,7 +18,7 @@ import { getManifest, getAvailableChecks, runAllChecks, -} from '@comp/integration-platform'; +} from '@trycompai/integration-platform'; import { ConnectionRepository } from '../repositories/connection.repository'; import { ConnectionService } from '../services/connection.service'; import { CredentialVaultService } from '../services/credential-vault.service'; diff --git a/apps/api/src/integration-platform/controllers/connections.controller.spec.ts b/apps/api/src/integration-platform/controllers/connections.controller.spec.ts index 4f88334b23..a53dad789b 100644 --- a/apps/api/src/integration-platform/controllers/connections.controller.spec.ts +++ b/apps/api/src/integration-platform/controllers/connections.controller.spec.ts @@ -13,14 +13,14 @@ jest.mock('../../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { integration: ['create', 'read', 'update', 'delete'], }, BUILT_IN_ROLE_PERMISSIONS: {}, })); -jest.mock('@comp/integration-platform', () => ({ +jest.mock('@trycompai/integration-platform', () => ({ getManifest: jest.fn(), getAllManifests: jest.fn(), getActiveManifests: jest.fn(), @@ -31,7 +31,7 @@ import { getManifest, getAllManifests, getActiveManifests, -} from '@comp/integration-platform'; +} from '@trycompai/integration-platform'; const mockedGetManifest = getManifest as jest.MockedFunction< typeof getManifest diff --git a/apps/api/src/integration-platform/controllers/connections.controller.ts b/apps/api/src/integration-platform/controllers/connections.controller.ts index 539ff4cf6f..30df781e74 100644 --- a/apps/api/src/integration-platform/controllers/connections.controller.ts +++ b/apps/api/src/integration-platform/controllers/connections.controller.ts @@ -36,7 +36,7 @@ import { type OAuthConfig, type TaskTemplateId, type IntegrationCredentials, -} from '@comp/integration-platform'; +} from '@trycompai/integration-platform'; interface CreateConnectionDto { providerSlug: string; diff --git a/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts b/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts index 361d909dec..a7e1427bc2 100644 --- a/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts @@ -18,7 +18,7 @@ import { DynamicIntegrationRepository } from '../repositories/dynamic-integratio import { DynamicCheckRepository } from '../repositories/dynamic-check.repository'; import { ProviderRepository } from '../repositories/provider.repository'; import { DynamicManifestLoaderService } from '../services/dynamic-manifest-loader.service'; -import { validateIntegrationDefinition } from '@comp/integration-platform'; +import { validateIntegrationDefinition } from '@trycompai/integration-platform'; @Controller({ path: 'internal/dynamic-integrations', version: '1' }) @UseGuards(InternalTokenGuard) diff --git a/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts b/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts index 0b5efd6efd..0fc392f700 100644 --- a/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts +++ b/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts @@ -18,7 +18,7 @@ import { RequirePermission } from '../../auth/require-permission.decorator'; import { OrganizationId } from '../../auth/auth-context.decorator'; import { OAuthCredentialsService } from '../services/oauth-credentials.service'; import { OAuthAppRepository } from '../repositories/oauth-app.repository'; -import { getManifest } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; interface SaveOAuthAppDto { providerSlug: string; diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts b/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts index 8ca75078ee..9577a09a91 100644 --- a/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts +++ b/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts @@ -15,18 +15,18 @@ jest.mock('../../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { integration: ['create', 'read', 'update', 'delete'], }, BUILT_IN_ROLE_PERMISSIONS: {}, })); -jest.mock('@comp/integration-platform', () => ({ +jest.mock('@trycompai/integration-platform', () => ({ getManifest: jest.fn(), })); -import { getManifest } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; const mockedGetManifest = getManifest as jest.MockedFunction< typeof getManifest diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.ts b/apps/api/src/integration-platform/controllers/oauth.controller.ts index 262059d2dd..6c58a74466 100644 --- a/apps/api/src/integration-platform/controllers/oauth.controller.ts +++ b/apps/api/src/integration-platform/controllers/oauth.controller.ts @@ -24,7 +24,7 @@ import { CredentialVaultService } from '../services/credential-vault.service'; import { ConnectionService } from '../services/connection.service'; import { OAuthCredentialsService } from '../services/oauth-credentials.service'; import { AutoCheckRunnerService } from '../services/auto-check-runner.service'; -import { getManifest, type OAuthConfig } from '@comp/integration-platform'; +import { getManifest, type OAuthConfig } from '@trycompai/integration-platform'; interface StartOAuthDto { providerSlug: string; diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 3318c8a225..d050b99ba2 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -24,7 +24,7 @@ import { type RampUser, type RampUserStatus, type RampUsersResponse, -} from '@comp/integration-platform'; +} from '@trycompai/integration-platform'; interface GoogleWorkspaceUser { id: string; diff --git a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts index bdc277eeaf..3d7a6d3887 100644 --- a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts @@ -21,7 +21,7 @@ import { runAllChecks, type CheckRunResult, type OAuthConfig, -} from '@comp/integration-platform'; +} from '@trycompai/integration-platform'; import { ConnectionRepository } from '../repositories/connection.repository'; import { ProviderRepository } from '../repositories/provider.repository'; import { CheckRunRepository } from '../repositories/check-run.repository'; diff --git a/apps/api/src/integration-platform/controllers/variables.controller.spec.ts b/apps/api/src/integration-platform/controllers/variables.controller.spec.ts index 7139b779f9..5444b79b1e 100644 --- a/apps/api/src/integration-platform/controllers/variables.controller.spec.ts +++ b/apps/api/src/integration-platform/controllers/variables.controller.spec.ts @@ -12,18 +12,18 @@ jest.mock('../../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { integration: ['create', 'read', 'update', 'delete'], }, BUILT_IN_ROLE_PERMISSIONS: {}, })); -jest.mock('@comp/integration-platform', () => ({ +jest.mock('@trycompai/integration-platform', () => ({ getManifest: jest.fn(), })); -import { getManifest } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; const mockedGetManifest = getManifest as jest.MockedFunction< typeof getManifest diff --git a/apps/api/src/integration-platform/controllers/variables.controller.ts b/apps/api/src/integration-platform/controllers/variables.controller.ts index 52f0ea3e4f..bc0ea0d0ed 100644 --- a/apps/api/src/integration-platform/controllers/variables.controller.ts +++ b/apps/api/src/integration-platform/controllers/variables.controller.ts @@ -14,7 +14,7 @@ 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 { getManifest, type CheckVariable } from '@comp/integration-platform'; +import { getManifest, type CheckVariable } from '@trycompai/integration-platform'; import { ConnectionRepository } from '../repositories/connection.repository'; import { ConnectionService } from '../services/connection.service'; import { ProviderRepository } from '../repositories/provider.repository'; diff --git a/apps/api/src/integration-platform/controllers/webhook.controller.ts b/apps/api/src/integration-platform/controllers/webhook.controller.ts index 7c5b2367dd..653d44ee05 100644 --- a/apps/api/src/integration-platform/controllers/webhook.controller.ts +++ b/apps/api/src/integration-platform/controllers/webhook.controller.ts @@ -12,8 +12,8 @@ import { } from '@nestjs/common'; import { Request } from 'express'; import { createHmac, timingSafeEqual } from 'crypto'; -import { getManifest } from '@comp/integration-platform'; -import type { WebhookConfig } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; +import type { WebhookConfig } from '@trycompai/integration-platform'; import { ConnectionRepository } from '../repositories/connection.repository'; import { db, Prisma } from '@db'; diff --git a/apps/api/src/integration-platform/interceptors/platform-audit-log.interceptor.ts b/apps/api/src/integration-platform/interceptors/platform-audit-log.interceptor.ts new file mode 100644 index 0000000000..015fd6a1be --- /dev/null +++ b/apps/api/src/integration-platform/interceptors/platform-audit-log.interceptor.ts @@ -0,0 +1,127 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { Observable, tap } from 'rxjs'; +import { MUTATION_METHODS } from '../../audit/audit-log.constants'; + +@Injectable() +export class PlatformAuditLogInterceptor implements NestInterceptor { + private readonly logger = new Logger('PlatformAuditLog'); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const method: string = request.method; + + if (!MUTATION_METHODS.has(method)) { + return next.handle(); + } + + const userId: string | undefined = request.userId; + if (!userId) { + this.logger.warn( + `Platform audit log skipped for ${method} ${request.url}: missing userId`, + ); + return next.handle(); + } + + const providerSlug = this.extractProviderSlug(request); + const action = this.describeAction(method, providerSlug); + + return next.handle().pipe( + tap({ + next: () => { + void this.persistAuditEntry(userId, action, method, request.url, providerSlug, false); + }, + error: (err: Error) => { + void this.persistAuditEntry(userId, action, method, request.url, providerSlug, true, err.message); + }, + }), + ); + } + + private async persistAuditEntry( + userId: string, + action: string, + method: string, + path: string, + providerSlug: string | null, + failed: boolean, + errorMessage?: string, + ): Promise { + const logPayload = { + type: 'platform-admin-audit', + userId, + action, + method, + path, + providerSlug, + timestamp: new Date().toISOString(), + ...(failed && { failed: true, error: errorMessage }), + }; + + if (failed) { + this.logger.warn(JSON.stringify(logPayload)); + } else { + this.logger.log(JSON.stringify(logPayload)); + } + + try { + const userOrg = await db.organization.findFirst({ + where: { members: { some: { userId } } }, + orderBy: { createdAt: 'desc' }, + select: { id: true }, + }); + + await db.auditLog.create({ + data: { + userId, + memberId: null, + organizationId: userOrg?.id ?? 'platform', + entityType: 'integration', + entityId: providerSlug, + description: `[Platform Admin] ${action}${failed ? ' (failed)' : ''}`, + data: { + action, + method, + path, + resource: 'admin', + permission: 'platform-admin', + providerSlug, + ...(failed && { failed: true, error: errorMessage }), + }, + }, + }); + } catch (err) { + this.logger.error('Failed to persist platform audit log entry:', err); + } + } + + private extractProviderSlug(request: { + params?: Record; + body?: Record; + }): string | null { + return ( + request.params?.providerSlug ?? + (request.body?.providerSlug as string | undefined) ?? + null + ); + } + + private describeAction(method: string, providerSlug: string | null): string { + const target = providerSlug ? ` for '${providerSlug}'` : ''; + + switch (method) { + case 'POST': + return `Saved platform credentials${target}`; + case 'DELETE': + return `Deleted platform credentials${target}`; + default: + return `Modified platform credentials${target}`; + } + } +} diff --git a/apps/api/src/integration-platform/repositories/platform-credential.repository.ts b/apps/api/src/integration-platform/repositories/platform-credential.repository.ts index 922a9cae7d..2d5b718a0c 100644 --- a/apps/api/src/integration-platform/repositories/platform-credential.repository.ts +++ b/apps/api/src/integration-platform/repositories/platform-credential.repository.ts @@ -6,6 +6,8 @@ export interface CreatePlatformCredentialDto { providerSlug: string; encryptedClientId: Prisma.InputJsonValue; encryptedClientSecret: Prisma.InputJsonValue; + clientIdHint?: string; + clientSecretHint?: string; customScopes?: string[]; customSettings?: Prisma.InputJsonValue; createdById?: string; @@ -82,6 +84,8 @@ export class PlatformCredentialRepository { providerSlug: data.providerSlug, encryptedClientId: data.encryptedClientId, encryptedClientSecret: data.encryptedClientSecret, + clientIdHint: data.clientIdHint, + clientSecretHint: data.clientSecretHint, customScopes: data.customScopes || [], customSettings: data.customSettings, createdById: data.createdById, @@ -90,6 +94,8 @@ export class PlatformCredentialRepository { update: { encryptedClientId: data.encryptedClientId, encryptedClientSecret: data.encryptedClientSecret, + clientIdHint: data.clientIdHint, + clientSecretHint: data.clientSecretHint, customScopes: data.customScopes || [], customSettings: data.customSettings, updatedById: data.createdById, diff --git a/apps/api/src/integration-platform/services/auto-check-runner.service.ts b/apps/api/src/integration-platform/services/auto-check-runner.service.ts index 25c0974d92..f956011bb4 100644 --- a/apps/api/src/integration-platform/services/auto-check-runner.service.ts +++ b/apps/api/src/integration-platform/services/auto-check-runner.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { tasks } from '@trigger.dev/sdk'; -import { getManifest } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; import { ConnectionRepository } from '../repositories/connection.repository'; import { ProviderRepository } from '../repositories/provider.repository'; diff --git a/apps/api/src/integration-platform/services/connection.service.ts b/apps/api/src/integration-platform/services/connection.service.ts index 1599b91089..b854e9f26f 100644 --- a/apps/api/src/integration-platform/services/connection.service.ts +++ b/apps/api/src/integration-platform/services/connection.service.ts @@ -3,7 +3,7 @@ import { NotFoundException, ConflictException, } from '@nestjs/common'; -import { getManifest } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; import { ConnectionRepository } from '../repositories/connection.repository'; import { ProviderRepository } from '../repositories/provider.repository'; import { ConnectionAuthTeardownService } from './connection-auth-teardown.service'; diff --git a/apps/api/src/integration-platform/services/dynamic-manifest-loader.service.ts b/apps/api/src/integration-platform/services/dynamic-manifest-loader.service.ts index 60b51c5e27..b6f858655d 100644 --- a/apps/api/src/integration-platform/services/dynamic-manifest-loader.service.ts +++ b/apps/api/src/integration-platform/services/dynamic-manifest-loader.service.ts @@ -8,7 +8,7 @@ import { type IntegrationCapability, type FindingSeverity, type CheckVariable, -} from '@comp/integration-platform'; +} from '@trycompai/integration-platform'; import { DynamicIntegrationRepository, type DynamicIntegrationWithChecks } from '../repositories/dynamic-integration.repository'; import type { DynamicCheck } from '@prisma/client'; diff --git a/apps/api/src/integration-platform/services/oauth-credentials.service.ts b/apps/api/src/integration-platform/services/oauth-credentials.service.ts index c3c20c8454..2c584f01fb 100644 --- a/apps/api/src/integration-platform/services/oauth-credentials.service.ts +++ b/apps/api/src/integration-platform/services/oauth-credentials.service.ts @@ -5,7 +5,7 @@ import { CredentialVaultService, EncryptedData, } from './credential-vault.service'; -import { getManifest, type OAuthConfig } from '@comp/integration-platform'; +import { getManifest, type OAuthConfig } from '@trycompai/integration-platform'; import type { Prisma } from '@prisma/client'; export interface OAuthCredentials { @@ -161,6 +161,11 @@ export class OAuthCredentialsService { ); } + static maskSecret(value: string): string { + if (value.length <= 4) return '\u2022'.repeat(value.length); + return '\u2022'.repeat(value.length - 4) + value.slice(-4); + } + /** * Save platform-wide OAuth credentials (admin only) */ @@ -181,6 +186,8 @@ export class OAuthCredentialsService { providerSlug, encryptedClientId, encryptedClientSecret, + clientIdHint: OAuthCredentialsService.maskSecret(clientId), + clientSecretHint: OAuthCredentialsService.maskSecret(clientSecret), customScopes, customSettings: customSettings as Prisma.InputJsonValue | undefined, createdById: userId, diff --git a/apps/api/src/integration-platform/services/oauth-token-revocation.service.ts b/apps/api/src/integration-platform/services/oauth-token-revocation.service.ts index d7b0c569d8..593e595169 100644 --- a/apps/api/src/integration-platform/services/oauth-token-revocation.service.ts +++ b/apps/api/src/integration-platform/services/oauth-token-revocation.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { getManifest } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; import { OAuthCredentialsService } from './oauth-credentials.service'; type OAuthRevokeConfig = { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 299ebae6fa..0f19b1dbba 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -8,6 +8,7 @@ import * as express from 'express'; import helmet from 'helmet'; import path from 'path'; import { AppModule } from './app.module'; +import { adminAuthRateLimiter } from './auth/admin-rate-limit.middleware'; import { mkdirSync, writeFileSync, existsSync } from 'fs'; let app: INestApplication | null = null; @@ -42,7 +43,11 @@ async function bootstrap(): Promise { }), ); - // STEP 3: Configure body parser + // STEP 3: Rate-limit better-auth admin routes (impersonation, ban, set-role, etc.) + // These bypass NestJS controllers so the global ThrottlerGuard doesn't apply. + app.use(adminAuthRateLimiter); + + // STEP 4a: Configure body parser // NOTE: Attachment uploads are sent as base64 in JSON, so request payloads are // larger than the raw file size. Keep this above the user-facing max file size. // IMPORTANT: Skip body parsing for /api/auth routes — better-auth needs the raw @@ -61,7 +66,7 @@ async function bootstrap(): Promise { }); }); - // STEP 4: Enable global pipes and filters + // STEP 4b: Enable global pipes and filters app.useGlobalPipes( new ValidationPipe({ whitelist: true, diff --git a/apps/api/src/org-chart/org-chart.controller.spec.ts b/apps/api/src/org-chart/org-chart.controller.spec.ts index e7cc4b0a24..186052e792 100644 --- a/apps/api/src/org-chart/org-chart.controller.spec.ts +++ b/apps/api/src/org-chart/org-chart.controller.spec.ts @@ -8,7 +8,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { organization: ['create', 'read', 'update', 'delete'], }, diff --git a/apps/api/src/organization/organization.controller.spec.ts b/apps/api/src/organization/organization.controller.spec.ts index b07e065a84..8f89a27b33 100644 --- a/apps/api/src/organization/organization.controller.spec.ts +++ b/apps/api/src/organization/organization.controller.spec.ts @@ -15,7 +15,7 @@ jest.mock('../auth/auth.server', () => ({ }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { organization: ['read', 'update', 'delete'], member: ['create', 'read', 'update', 'delete'], diff --git a/apps/api/src/organization/organization.service.ts b/apps/api/src/organization/organization.service.ts index 946f48bf8e..25255acf2b 100644 --- a/apps/api/src/organization/organization.service.ts +++ b/apps/api/src/organization/organization.service.ts @@ -6,7 +6,7 @@ import { ForbiddenException, InternalServerErrorException, } from '@nestjs/common'; -import { allRoles } from '@comp/auth'; +import { allRoles } from '@trycompai/auth'; import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { db, Role } from '@trycompai/db'; diff --git a/apps/api/src/people/dto/people-responses.dto.ts b/apps/api/src/people/dto/people-responses.dto.ts index ae361734ff..86aee2e23b 100644 --- a/apps/api/src/people/dto/people-responses.dto.ts +++ b/apps/api/src/people/dto/people-responses.dto.ts @@ -53,10 +53,11 @@ export class UserResponseDto { lastLogin: Date | null; @ApiProperty({ - description: 'Whether the user is a platform admin (Comp AI team member)', - example: false, + description: 'Platform role of the user (managed by Better Auth admin plugin)', + example: 'user', + nullable: true, }) - isPlatformAdmin: boolean; + role: string | null; } export class PeopleResponseDto { diff --git a/apps/api/src/people/people-fleet.helper.ts b/apps/api/src/people/people-fleet.helper.ts index 33009f13b1..3b8b7066ca 100644 --- a/apps/api/src/people/people-fleet.helper.ts +++ b/apps/api/src/people/people-fleet.helper.ts @@ -104,7 +104,7 @@ export async function getAllEmployeeDevices( organizationId, deactivated: false, OR: [ - { user: { isPlatformAdmin: false } }, + { user: { role: { not: 'admin' } } }, { role: { contains: 'owner' } }, ], }, diff --git a/apps/api/src/people/people.controller.spec.ts b/apps/api/src/people/people.controller.spec.ts index 53f24eabd5..11f3769910 100644 --- a/apps/api/src/people/people.controller.spec.ts +++ b/apps/api/src/people/people.controller.spec.ts @@ -16,7 +16,7 @@ jest.mock('../auth/auth.server', () => ({ }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { organization: ['read', 'update', 'delete'], member: ['create', 'read', 'update', 'delete'], diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts index 57188685c3..53c4394f13 100644 --- a/apps/api/src/people/people.controller.ts +++ b/apps/api/src/people/people.controller.ts @@ -29,7 +29,7 @@ import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; import { RequirePermission } from '../auth/require-permission.decorator'; import type { AuthContext as AuthContextType } from '../auth/types'; -import { statement } from '@comp/auth'; +import { statement } from '@trycompai/auth'; import { CreatePeopleDto } from './dto/create-people.dto'; import { UpdatePeopleDto } from './dto/update-people.dto'; import { BulkCreatePeopleDto } from './dto/bulk-create-people.dto'; diff --git a/apps/api/src/people/people.service.spec.ts b/apps/api/src/people/people.service.spec.ts index 3d1f4271f9..ba105ddb0f 100644 --- a/apps/api/src/people/people.service.spec.ts +++ b/apps/api/src/people/people.service.spec.ts @@ -49,7 +49,7 @@ jest.mock('@trycompai/db', () => ({ }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ BUILT_IN_ROLE_PERMISSIONS: { owner: { organization: ['read', 'update', 'delete'], member: ['create', 'read', 'update', 'delete'] }, admin: { organization: ['read', 'update'], member: ['create', 'read', 'update', 'delete'] }, @@ -303,7 +303,7 @@ describe('PeopleService', () => { id: 'usr_1', name: 'Alice', email: 'alice@test.com', - isPlatformAdmin: false, + role: 'user', }, }; @@ -356,7 +356,7 @@ describe('PeopleService', () => { it('should throw ForbiddenException when deleting a platform admin', async () => { (db.member.findFirst as jest.Mock).mockResolvedValue({ ...mockMember, - user: { ...mockMember.user, isPlatformAdmin: true }, + user: { ...mockMember.user, role: 'admin' }, }); await expect( diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts index 8c69b27f2d..3c86e5f7b9 100644 --- a/apps/api/src/people/people.service.ts +++ b/apps/api/src/people/people.service.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import { db } from '@trycompai/db'; import { FleetService } from '../lib/fleet.service'; -import { BUILT_IN_ROLE_PERMISSIONS } from '@comp/auth'; +import { BUILT_IN_ROLE_PERMISSIONS } from '@trycompai/auth'; import type { PeopleResponseDto } from './dto/people-responses.dto'; import type { CreatePeopleDto } from './dto/create-people.dto'; import type { UpdatePeopleDto } from './dto/update-people.dto'; @@ -333,7 +333,7 @@ export class PeopleService { throw new ForbiddenException('Cannot remove the organization owner'); } - if (member.user.isPlatformAdmin) { + if (member.user.role === 'admin') { throw new ForbiddenException('Cannot remove a platform admin'); } diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index 23a20f487f..97e4563122 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -30,7 +30,7 @@ export class MemberQueries { createdAt: true, updatedAt: true, lastLogin: true, - isPlatformAdmin: true, + role: true, }, }, } as const; diff --git a/apps/api/src/policies/policies.controller.spec.ts b/apps/api/src/policies/policies.controller.spec.ts index 3203082313..e4f7653a6a 100644 --- a/apps/api/src/policies/policies.controller.spec.ts +++ b/apps/api/src/policies/policies.controller.spec.ts @@ -13,7 +13,7 @@ jest.mock('../auth/auth.server', () => ({ }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { policy: ['create', 'read', 'update', 'delete'], control: ['create', 'read', 'update', 'delete'], diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 4e9e2cbbae..69ef173d26 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -240,9 +240,9 @@ export class PoliciesService { if (createData.assigneeId) { const assignee = await db.member.findFirst({ where: { id: createData.assigneeId, organizationId }, - include: { user: { select: { isPlatformAdmin: true } } }, + include: { user: { select: { role: true } } }, }); - if (assignee?.user.isPlatformAdmin) { + if (assignee?.user.role === 'admin') { throw new BadRequestException('Cannot assign a platform admin as assignee'); } } @@ -983,9 +983,9 @@ export class PoliciesService { // Cannot assign a platform admin as approver const approverUser = await db.user.findUnique({ where: { id: approver.userId }, - select: { isPlatformAdmin: true }, + select: { role: true }, }); - if (approverUser?.isPlatformAdmin) { + if (approverUser?.role === 'admin') { throw new BadRequestException( 'Cannot assign a platform admin as approver', ); diff --git a/apps/api/src/questionnaire/questionnaire.controller.spec.ts b/apps/api/src/questionnaire/questionnaire.controller.spec.ts index 12774d1ddc..0422468af6 100644 --- a/apps/api/src/questionnaire/questionnaire.controller.spec.ts +++ b/apps/api/src/questionnaire/questionnaire.controller.spec.ts @@ -5,7 +5,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: {}, ac: {}, allRoles: {}, diff --git a/apps/api/src/risks/risks.controller.spec.ts b/apps/api/src/risks/risks.controller.spec.ts index b5da081e30..92120ee227 100644 --- a/apps/api/src/risks/risks.controller.spec.ts +++ b/apps/api/src/risks/risks.controller.spec.ts @@ -15,7 +15,7 @@ jest.mock('../auth/auth.server', () => ({ }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { risk: ['create', 'read', 'update', 'delete'], }, diff --git a/apps/api/src/risks/risks.service.ts b/apps/api/src/risks/risks.service.ts index 0fb6faf37d..cb049dbbe0 100644 --- a/apps/api/src/risks/risks.service.ts +++ b/apps/api/src/risks/risks.service.ts @@ -28,9 +28,9 @@ export class RisksService { private async validateAssigneeNotPlatformAdmin(assigneeId: string, organizationId: string) { const member = await db.member.findFirst({ where: { id: assigneeId, organizationId }, - include: { user: { select: { isPlatformAdmin: true } } }, + include: { user: { select: { role: true } } }, }); - if (member?.user.isPlatformAdmin) { + if (member?.user.role === 'admin') { throw new BadRequestException('Cannot assign a platform admin as assignee'); } } diff --git a/apps/api/src/roles/roles.controller.spec.ts b/apps/api/src/roles/roles.controller.spec.ts index dfb537b551..b1a8cb02a2 100644 --- a/apps/api/src/roles/roles.controller.spec.ts +++ b/apps/api/src/roles/roles.controller.spec.ts @@ -6,8 +6,8 @@ import { PermissionGuard } from '../auth/permission.guard'; import { RolesController } from './roles.controller'; -// Mock @comp/auth and auth.server to avoid importing better-auth ESM in Jest -jest.mock('@comp/auth', () => ({ +// Mock @trycompai/auth and auth.server to avoid importing better-auth ESM in Jest +jest.mock('@trycompai/auth', () => ({ statement: {}, allRoles: { owner: {}, admin: {}, auditor: {}, employee: {}, contractor: {} }, BUILT_IN_ROLE_PERMISSIONS: {}, diff --git a/apps/api/src/roles/roles.service.spec.ts b/apps/api/src/roles/roles.service.spec.ts index f17b195803..fe9165dd90 100644 --- a/apps/api/src/roles/roles.service.spec.ts +++ b/apps/api/src/roles/roles.service.spec.ts @@ -2,8 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { RolesService } from './roles.service'; -// Mock @comp/auth to avoid ESM import issues with better-auth in Jest -jest.mock('@comp/auth', () => { +// Mock @trycompai/auth to avoid ESM import issues with better-auth in Jest +jest.mock('@trycompai/auth', () => { const statement = { organization: ['read', 'update', 'delete'], member: ['create', 'read', 'update', 'delete'], diff --git a/apps/api/src/roles/roles.service.ts b/apps/api/src/roles/roles.service.ts index aab1a4a515..43919a5837 100644 --- a/apps/api/src/roles/roles.service.ts +++ b/apps/api/src/roles/roles.service.ts @@ -1,6 +1,6 @@ import { Injectable, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { db } from '@trycompai/db'; -import { statement, allRoles, BUILT_IN_ROLE_PERMISSIONS, BUILT_IN_ROLE_OBLIGATIONS, type RoleObligations } from '@comp/auth'; +import { statement, allRoles, BUILT_IN_ROLE_PERMISSIONS, BUILT_IN_ROLE_OBLIGATIONS, type RoleObligations } from '@trycompai/auth'; import type { CreateRoleDto } from './dto/create-role.dto'; import type { UpdateRoleDto } from './dto/update-role.dto'; diff --git a/apps/api/src/scripts/seed-dynamic-integration.ts b/apps/api/src/scripts/seed-dynamic-integration.ts index 17d82c228b..cdc8d8419b 100644 --- a/apps/api/src/scripts/seed-dynamic-integration.ts +++ b/apps/api/src/scripts/seed-dynamic-integration.ts @@ -16,7 +16,7 @@ import { readFileSync } from 'fs'; import { resolve } from 'path'; import { db } from '@db'; -import { validateIntegrationDefinition } from '@comp/integration-platform'; +import { validateIntegrationDefinition } from '@trycompai/integration-platform'; async function main() { const filePath = process.argv[2]; diff --git a/apps/api/src/secrets/secrets.controller.spec.ts b/apps/api/src/secrets/secrets.controller.spec.ts index 95e7ec50c0..58c0088b7d 100644 --- a/apps/api/src/secrets/secrets.controller.spec.ts +++ b/apps/api/src/secrets/secrets.controller.spec.ts @@ -10,7 +10,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: {}, BUILT_IN_ROLE_PERMISSIONS: {}, })); diff --git a/apps/api/src/soa/soa.controller.spec.ts b/apps/api/src/soa/soa.controller.spec.ts index fa40c3fc12..f2dd111cef 100644 --- a/apps/api/src/soa/soa.controller.spec.ts +++ b/apps/api/src/soa/soa.controller.spec.ts @@ -10,7 +10,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: {}, BUILT_IN_ROLE_PERMISSIONS: {}, })); diff --git a/apps/api/src/soa/soa.service.spec.ts b/apps/api/src/soa/soa.service.spec.ts index 8c448101f6..f3f3220fc8 100644 --- a/apps/api/src/soa/soa.service.spec.ts +++ b/apps/api/src/soa/soa.service.spec.ts @@ -218,7 +218,7 @@ describe('SOAService', () => { userId: 'user-1', role: 'employee', }); - (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ isPlatformAdmin: false }); + (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ role: 'user' }); await expect(service.submitForApproval(dto)).rejects.toThrow( ForbiddenException, ); @@ -230,7 +230,7 @@ describe('SOAService', () => { userId: 'user-1', role: 'admin', }); - (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ isPlatformAdmin: true }); + (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ role: 'admin' }); await expect(service.submitForApproval(dto)).rejects.toThrow( BadRequestException, ); @@ -242,7 +242,7 @@ describe('SOAService', () => { userId: 'user-1', role: 'admin', }); - (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ isPlatformAdmin: false }); + (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ role: 'user' }); (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null); await expect(service.submitForApproval(dto)).rejects.toThrow( NotFoundException, @@ -255,7 +255,7 @@ describe('SOAService', () => { userId: 'user-1', role: 'admin', }); - (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ isPlatformAdmin: false }); + (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ role: 'user' }); (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ id: 'doc-1', status: 'needs_review', @@ -271,7 +271,7 @@ describe('SOAService', () => { userId: 'user-1', role: 'owner', }); - (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ isPlatformAdmin: false }); + (mockDb.user.findUnique as jest.Mock).mockResolvedValue({ role: 'user' }); (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ id: 'doc-1', status: 'draft', diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index 575263ed44..8f4cef7c5e 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -394,9 +394,9 @@ export class SOAService { // Cannot assign a platform admin as approver const approverUser = await db.user.findUnique({ where: { id: approverMember.userId }, - select: { isPlatformAdmin: true }, + select: { role: true }, }); - if (approverUser?.isPlatformAdmin) { + if (approverUser?.role === 'admin') { throw new BadRequestException('Cannot assign a platform admin as approver'); } diff --git a/apps/api/src/task-management/task-item-assignment-notifier.service.ts b/apps/api/src/task-management/task-item-assignment-notifier.service.ts index 2489b696e1..4ee32165ad 100644 --- a/apps/api/src/task-management/task-item-assignment-notifier.service.ts +++ b/apps/api/src/task-management/task-item-assignment-notifier.service.ts @@ -67,7 +67,7 @@ export class TaskItemAssignmentNotifierService { id: true, name: true, email: true, - isPlatformAdmin: true, + role: true, }, }, }, @@ -94,7 +94,7 @@ export class TaskItemAssignmentNotifierService { // Skip notifications for platform admin members unless they are an owner const isOwner = assigneeMember.role?.split(',').map((r: string) => r.trim()).includes('owner'); - if (assigneeUser.isPlatformAdmin && !isOwner) { + if (assigneeUser.role === 'admin' && !isOwner) { this.logger.log( `Skipping assignment notification: assignee ${assigneeUser.email} is a platform admin (non-owner)`, ); diff --git a/apps/api/src/task-management/task-item-mention-notifier.service.ts b/apps/api/src/task-management/task-item-mention-notifier.service.ts index a8ea678f98..b45c6605f2 100644 --- a/apps/api/src/task-management/task-item-mention-notifier.service.ts +++ b/apps/api/src/task-management/task-item-mention-notifier.service.ts @@ -57,7 +57,7 @@ export class TaskItemMentionNotifierService { deactivated: false, user: { id: { in: mentionedUserIds } }, OR: [ - { user: { isPlatformAdmin: false } }, + { user: { role: { not: 'admin' } } }, { role: { contains: 'owner' } }, ], }, diff --git a/apps/api/src/task-management/task-management.service.ts b/apps/api/src/task-management/task-management.service.ts index 34d41ae623..1c992ee315 100644 --- a/apps/api/src/task-management/task-management.service.ts +++ b/apps/api/src/task-management/task-management.service.ts @@ -268,9 +268,9 @@ export class TaskManagementService { if (createTaskItemDto.assigneeId) { const assigneeMember = await db.member.findFirst({ where: { id: createTaskItemDto.assigneeId, organizationId }, - include: { user: { select: { isPlatformAdmin: true } } }, + include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.isPlatformAdmin) { + if (assigneeMember?.user.role === 'admin') { throw new BadRequestException('Cannot assign a platform admin as assignee'); } } @@ -483,9 +483,9 @@ export class TaskManagementService { if (updateTaskItemDto.assigneeId) { const assigneeMember = await db.member.findFirst({ where: { id: updateTaskItemDto.assigneeId, organizationId }, - include: { user: { select: { isPlatformAdmin: true } } }, + include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.isPlatformAdmin) { + if (assigneeMember?.user.role === 'admin') { throw new BadRequestException('Cannot assign a platform admin as assignee'); } } diff --git a/apps/api/src/tasks/task-notifier.service.ts b/apps/api/src/tasks/task-notifier.service.ts index dfc5aeb5f1..f6bb705f5a 100644 --- a/apps/api/src/tasks/task-notifier.service.ts +++ b/apps/api/src/tasks/task-notifier.service.ts @@ -69,7 +69,7 @@ export class TaskNotifierService { organizationId, deactivated: false, OR: [ - { user: { isPlatformAdmin: false } }, + { user: { role: { not: 'admin' } } }, { role: { contains: 'owner' } }, ], }, @@ -420,7 +420,7 @@ export class TaskNotifierService { organizationId, deactivated: false, OR: [ - { user: { isPlatformAdmin: false } }, + { user: { role: { not: 'admin' } } }, { role: { contains: 'owner' } }, ], }, @@ -1096,7 +1096,7 @@ export class TaskNotifierService { organizationId, deactivated: false, OR: [ - { user: { isPlatformAdmin: false } }, + { user: { role: { not: 'admin' } } }, { role: { contains: 'owner' } }, ], }, @@ -1303,7 +1303,7 @@ export class TaskNotifierService { organizationId, deactivated: false, OR: [ - { user: { isPlatformAdmin: false } }, + { user: { role: { not: 'admin' } } }, { role: { contains: 'owner' } }, ], }, diff --git a/apps/api/src/tasks/tasks.controller.spec.ts b/apps/api/src/tasks/tasks.controller.spec.ts index 270a9c9d4a..3335cddf0e 100644 --- a/apps/api/src/tasks/tasks.controller.spec.ts +++ b/apps/api/src/tasks/tasks.controller.spec.ts @@ -12,7 +12,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { task: ['create', 'read', 'update', 'delete'], evidence: ['create', 'read', 'delete'], diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index 86d5eff79a..9dcb28f517 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -204,7 +204,7 @@ export class TasksService { where, include: { user: { - select: { id: true, name: true, email: true, image: true, isPlatformAdmin: true }, + select: { id: true, name: true, email: true, image: true, role: true }, }, }, orderBy: { timestamp: 'desc' }, @@ -360,9 +360,9 @@ export class TasksService { if (assigneeId) { const assigneeMember = await db.member.findFirst({ where: { id: assigneeId, organizationId }, - include: { user: { select: { isPlatformAdmin: true } } }, + include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.isPlatformAdmin) { + if (assigneeMember?.user.role === 'admin') { throw new BadRequestException('Cannot assign a platform admin as assignee'); } } @@ -506,9 +506,9 @@ export class TasksService { if (updateData.assigneeId !== null) { const assigneeMember = await db.member.findFirst({ where: { id: updateData.assigneeId, organizationId }, - include: { user: { select: { isPlatformAdmin: true } } }, + include: { user: { select: { role: true } } }, }); - if (assigneeMember?.user.isPlatformAdmin) { + if (assigneeMember?.user.role === 'admin') { throw new BadRequestException('Cannot assign a platform admin as assignee'); } } @@ -845,7 +845,7 @@ export class TasksService { throw new BadRequestException('Approver not found or is deactivated'); } - if (approver.user.isPlatformAdmin) { + if (approver.user.role === 'admin') { throw new BadRequestException('Cannot assign a platform admin as approver'); } @@ -922,7 +922,7 @@ export class TasksService { throw new BadRequestException('Approver not found or is deactivated'); } - if (approver.user.isPlatformAdmin) { + if (approver.user.role === 'admin') { throw new BadRequestException('Cannot assign a platform admin as approver'); } diff --git a/apps/api/src/training/permissions-regression.spec.ts b/apps/api/src/training/permissions-regression.spec.ts index 2ea282c0dd..d8a0c77688 100644 --- a/apps/api/src/training/permissions-regression.spec.ts +++ b/apps/api/src/training/permissions-regression.spec.ts @@ -8,7 +8,7 @@ * can execute under Jest, while still testing the real role definitions. */ -// Mock better-auth ESM modules before importing @comp/auth +// Mock better-auth ESM modules before importing @trycompai/auth jest.mock('better-auth/plugins/access', () => ({ createAccessControl: (stmt: Record) => ({ newRole: (statements: Record) => ({ @@ -45,7 +45,7 @@ jest.mock('better-auth/plugins/organization/access', () => ({ import { BUILT_IN_ROLE_PERMISSIONS, BUILT_IN_ROLE_OBLIGATIONS, -} from '@comp/auth'; +} from '@trycompai/auth'; describe('Built-in role permissions — regression', () => { // ─── Owner ────────────────────────────────────────────────────────── diff --git a/apps/api/src/training/training.controller.spec.ts b/apps/api/src/training/training.controller.spec.ts index 38a86c30a5..a18b608865 100644 --- a/apps/api/src/training/training.controller.spec.ts +++ b/apps/api/src/training/training.controller.spec.ts @@ -9,7 +9,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: {}, BUILT_IN_ROLE_PERMISSIONS: {}, })); diff --git a/apps/api/src/trigger/cloud-security/cloud-security-schedule.ts b/apps/api/src/trigger/cloud-security/cloud-security-schedule.ts index 740cca20c1..484343c216 100644 --- a/apps/api/src/trigger/cloud-security/cloud-security-schedule.ts +++ b/apps/api/src/trigger/cloud-security/cloud-security-schedule.ts @@ -1,4 +1,4 @@ -import { getManifest } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; import { db } from '@db'; import { logger, schedules } from '@trigger.dev/sdk'; import { runCloudSecurityScan } from './run-cloud-security-scan'; diff --git a/apps/api/src/trigger/integration-platform/run-connection-checks.ts b/apps/api/src/trigger/integration-platform/run-connection-checks.ts index 600d0a22f5..e25f12b3ae 100644 --- a/apps/api/src/trigger/integration-platform/run-connection-checks.ts +++ b/apps/api/src/trigger/integration-platform/run-connection-checks.ts @@ -1,4 +1,4 @@ -import { getManifest, runAllChecks } from '@comp/integration-platform'; +import { getManifest, runAllChecks } from '@trycompai/integration-platform'; import { db } from '@db'; import { logger, task } from '@trigger.dev/sdk'; diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts index 4751b3ca72..c57f95b83a 100644 --- a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts +++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts @@ -1,4 +1,4 @@ -import { getManifest } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; import { db } from '@db'; import { logger, schedules } from '@trigger.dev/sdk'; import { runTaskIntegrationChecks } from './run-task-integration-checks'; diff --git a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts index 003b2e4446..08b9d7be1c 100644 --- a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts +++ b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts @@ -1,4 +1,4 @@ -import { getManifest, runAllChecks } from '@comp/integration-platform'; +import { getManifest, runAllChecks } from '@trycompai/integration-platform'; import { db } from '@db'; import { logger, task } from '@trigger.dev/sdk'; import { triggerEmail } from '../../email/trigger-email'; diff --git a/apps/api/src/trigger/integration-platform/sync-employees-schedule.ts b/apps/api/src/trigger/integration-platform/sync-employees-schedule.ts index 38cad1f69b..7e16b0a05d 100644 --- a/apps/api/src/trigger/integration-platform/sync-employees-schedule.ts +++ b/apps/api/src/trigger/integration-platform/sync-employees-schedule.ts @@ -1,4 +1,4 @@ -import { getManifest } from '@comp/integration-platform'; +import { getManifest } from '@trycompai/integration-platform'; import { db } from '@db'; import { logger, schedules } from '@trigger.dev/sdk'; diff --git a/apps/api/src/trust-portal/trust-access.controller.spec.ts b/apps/api/src/trust-portal/trust-access.controller.spec.ts index 782f551a5b..4e165fc0bc 100644 --- a/apps/api/src/trust-portal/trust-access.controller.spec.ts +++ b/apps/api/src/trust-portal/trust-access.controller.spec.ts @@ -9,7 +9,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { trust: ['create', 'read', 'update', 'delete'], }, diff --git a/apps/api/src/trust-portal/trust-portal.controller.spec.ts b/apps/api/src/trust-portal/trust-portal.controller.spec.ts index b0b0be543a..368c64edef 100644 --- a/apps/api/src/trust-portal/trust-portal.controller.spec.ts +++ b/apps/api/src/trust-portal/trust-portal.controller.spec.ts @@ -10,7 +10,7 @@ jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { trust: ['create', 'read', 'update', 'delete'], }, diff --git a/apps/api/src/utils/compliance-filters.ts b/apps/api/src/utils/compliance-filters.ts index a68a34b5bb..9baab13345 100644 --- a/apps/api/src/utils/compliance-filters.ts +++ b/apps/api/src/utils/compliance-filters.ts @@ -2,7 +2,7 @@ import { BUILT_IN_ROLE_OBLIGATIONS, type RoleObligations, allRoles, -} from '@comp/auth'; +} from '@trycompai/auth'; import { db } from '@trycompai/db'; /** @@ -26,7 +26,7 @@ function hasComplianceObligation( interface MemberWithRole { role: string; - user?: { isPlatformAdmin?: boolean } | null; + user?: { role?: string | null } | null; } /** @@ -72,7 +72,7 @@ export async function filterComplianceMembers( return memberRoles .filter(({ member, roleNames }) => { // Platform admins are excluded — they join customer orgs to debug - if (member.user?.isPlatformAdmin) return false; + if (member.user?.role === 'admin') return false; return hasComplianceObligation(roleNames, customObligationMap); }) .map(({ member }) => member); diff --git a/apps/api/src/vendors/vendors.controller.spec.ts b/apps/api/src/vendors/vendors.controller.spec.ts index 55cb4b409e..163fc92bac 100644 --- a/apps/api/src/vendors/vendors.controller.spec.ts +++ b/apps/api/src/vendors/vendors.controller.spec.ts @@ -12,7 +12,7 @@ jest.mock('../auth/auth.server', () => ({ }, })); -jest.mock('@comp/auth', () => ({ +jest.mock('@trycompai/auth', () => ({ statement: { vendor: ['create', 'read', 'update', 'delete'], }, diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts index 6281493e9f..d7831233c8 100644 --- a/apps/api/src/vendors/vendors.service.ts +++ b/apps/api/src/vendors/vendors.service.ts @@ -201,9 +201,9 @@ export class VendorsService { private async validateAssigneeNotPlatformAdmin(assigneeId: string, organizationId: string) { const member = await db.member.findFirst({ where: { id: assigneeId, organizationId }, - include: { user: { select: { isPlatformAdmin: true } } }, + include: { user: { select: { role: true } } }, }); - if (member?.user.isPlatformAdmin) { + if (member?.user.role === 'admin') { throw new BadRequestException('Cannot assign a platform admin as assignee'); } } diff --git a/apps/app/agents.md b/apps/app/agents.md index ab953de9c1..b13c5d170b 100644 --- a/apps/app/agents.md +++ b/apps/app/agents.md @@ -18,9 +18,9 @@ The `className` prop has been removed from all components to prevent style overr // ✅ ALWAYS - Use design system import { Button, Table, Badge, Tabs } from '@trycompai/design-system'; -// ❌ NEVER - Don't use @comp/ui when DS has the component -import { Button } from '@comp/ui/button'; -import { Table } from '@comp/ui/table'; +// ❌ NEVER - Don't use @trycompai/ui when DS has the component +import { Button } from '@trycompai/ui/button'; +import { Table } from '@trycompai/ui/table'; ``` ## Server vs Client Components diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index d5fffca64c..e3b0b1950e 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -44,11 +44,12 @@ const config: NextConfig = { : '', reactStrictMode: false, transpilePackages: [ + '@trycompai/auth', '@trycompai/db', '@prisma/client', '@trycompai/design-system', '@carbon/icons-react', - '@comp/company', + '@trycompai/company', ], images: { remotePatterns: [ diff --git a/apps/app/package.json b/apps/app/package.json index 13d62d8714..e2799c1119 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,5 +1,5 @@ { - "name": "@comp/app", + "name": "@trycompai/app", "version": "0.1.0", "type": "module", "dependencies": { @@ -19,9 +19,9 @@ "@browserbasehq/stagehand": "^3.0.5", "@calcom/atoms": "^1.0.102-framer", "@calcom/embed-react": "^1.5.3", - "@comp/auth": "workspace:*", - "@comp/company": "workspace:*", - "@comp/integration-platform": "workspace:*", + "@trycompai/auth": "workspace:*", + "@trycompai/company": "workspace:*", + "@trycompai/integration-platform": "workspace:*", "@date-fns/tz": "^1.2.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", diff --git a/apps/app/src/actions/policies/accept-requested-policy-changes.ts b/apps/app/src/actions/policies/accept-requested-policy-changes.ts index 72389b4b76..6f8822f153 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -107,7 +107,7 @@ export const acceptRequestedPolicyChangesAction = authActionClient organizationId: session.activeOrganizationId, isActive: true, deactivated: false, - user: { isPlatformAdmin: false }, + user: { role: { not: 'admin' } }, }, include: { user: true, diff --git a/apps/app/src/actions/safe-action.ts b/apps/app/src/actions/safe-action.ts index 1d92cddf10..a091c798e3 100644 --- a/apps/app/src/actions/safe-action.ts +++ b/apps/app/src/actions/safe-action.ts @@ -2,7 +2,7 @@ import { track } from '@/app/posthog'; import { env } from '@/env.mjs'; import { auth } from '@/utils/auth'; import { logger } from '@/utils/logger'; -import { client } from '@comp/kv'; +import { client } from '@trycompai/kv'; import { AuditLogEntityType, db } from '@db'; import { Ratelimit } from '@upstash/ratelimit'; import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from 'next-safe-action'; diff --git a/apps/app/src/app/(app)/[orgId]/admin/components/AdminSidebar.tsx b/apps/app/src/app/(app)/[orgId]/admin/components/AdminSidebar.tsx new file mode 100644 index 0000000000..b30cdf22af --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/components/AdminSidebar.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { AppShellNav, AppShellNavItem } from '@trycompai/design-system'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +interface AdminSidebarProps { + orgId: string; +} + +export function AdminSidebar({ orgId }: AdminSidebarProps) { + const pathname = usePathname() ?? ''; + + const items = [ + { id: 'organizations', label: 'Organizations', path: `/${orgId}/admin/organizations` }, + { id: 'integrations', label: 'Integrations', path: `/${orgId}/admin/integrations` }, + ]; + + const isPathActive = (path: string) => pathname.startsWith(path); + + return ( + + {items.map((item) => ( + + + {item.label} + + + ))} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/components/ImpersonationBanner.tsx b/apps/app/src/app/(app)/[orgId]/admin/components/ImpersonationBanner.tsx new file mode 100644 index 0000000000..5b52154a95 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/components/ImpersonationBanner.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { authClient, useSession } from '@/utils/auth-client'; +import { usePathname, useRouter } from 'next/navigation'; +import { useState } from 'react'; + +export function ImpersonationBanner() { + const { data: session } = useSession(); + const router = useRouter(); + const pathname = usePathname(); + const [stopping, setStopping] = useState(false); + + const rawImpersonatedBy = ( + session?.session as Record | undefined + )?.impersonatedBy; + const impersonatedBy = + typeof rawImpersonatedBy === 'string' ? rawImpersonatedBy : undefined; + + if (!impersonatedBy) return null; + + const orgId = pathname?.split('/')[1] ?? ''; + + const handleStop = async () => { + setStopping(true); + try { + await authClient.admin.stopImpersonating(); + (authClient.$store as { notify: (signal: string) => void }).notify( + '$sessionSignal', + ); + router.push(`/${orgId}/admin/organizations`); + router.refresh(); + } catch { + setStopping(false); + } + }; + + return ( +
+ + Impersonating {session?.user?.name ?? 'a user'}{' '} + ({session?.user?.email}) + + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/integrations/components/IntegrationCard.tsx b/apps/app/src/app/(app)/[orgId]/admin/integrations/components/IntegrationCard.tsx new file mode 100644 index 0000000000..24e6cff972 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/integrations/components/IntegrationCard.tsx @@ -0,0 +1,403 @@ +'use client'; + +import { api } from '@/lib/api-client'; +import { Badge, Button, Card, CardContent, Input, Label, Text } from '@trycompai/design-system'; +import { + CheckmarkFilled, + Key, + Launch, + Settings, + TrashCan, +} from '@trycompai/design-system/icons'; +import Image from 'next/image'; +import { useState } from 'react'; + +interface AdditionalOAuthSetting { + id: string; + label: string; + type: 'text' | 'password' | 'textarea' | 'select' | 'combobox'; + placeholder?: string; + helpText?: string; + required: boolean; + options?: { value: string; label: string }[]; + token?: string; +} + +export interface Integration { + id: string; + name: string; + description: string; + category: string; + logoUrl: string; + authType: string; + capabilities: string[]; + isActive: boolean; + docsUrl?: string; + hasCredentials: boolean; + credentialConfiguredAt?: string; + credentialUpdatedAt?: string; + clientIdHint?: string; + clientSecretHint?: string; + existingCustomSettings?: Record; + setupInstructions?: string; + createAppUrl?: string; + requiredScopes?: string[]; + authorizeUrl?: string; + additionalOAuthSettings?: AdditionalOAuthSetting[]; +} + +export function IntegrationCard({ + integration, + onRefresh, +}: { + integration: Integration; + onRefresh: () => void; +}) { + const [showConfig, setShowConfig] = useState(false); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [customSettingsValues, setCustomSettingsValues] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + + const additionalSettings = integration.additionalOAuthSettings || []; + + const handleSave = async () => { + if (!clientId || !clientSecret) return; + + const hasAllRequiredSettings = additionalSettings.every( + (setting) => !setting.required || customSettingsValues[setting.id], + ); + if (!hasAllRequiredSettings) return; + + setIsSaving(true); + setError(null); + + const response = await api.post('/v1/admin/integrations/credentials', { + providerSlug: integration.id, + clientId, + clientSecret, + customSettings: + Object.keys(customSettingsValues).length > 0 ? customSettingsValues : undefined, + }); + + if (response.error) { + setError(response.error); + } else { + setClientId(''); + setClientSecret(''); + setCustomSettingsValues({}); + setShowConfig(false); + onRefresh(); + } + + setIsSaving(false); + }; + + const handleDelete = async () => { + if (!confirm(`Delete credentials for ${integration.name}?`)) return; + + setIsDeleting(true); + setError(null); + + const response = await api.delete(`/v1/admin/integrations/credentials/${integration.id}`); + + if (response.error) { + setError(response.error); + } else { + onRefresh(); + } + + setIsDeleting(false); + }; + + return ( + + +
+
+
+ {integration.name} + {integration.hasCredentials && ( +
+ +
+ )} +
+
+
+

{integration.name}

+ {!integration.hasCredentials && ( + Not configured + )} +
+

+ {integration.description} +

+
+
+ +
+ {integration.category} + {integration.authType} + {integration.hasCredentials && integration.credentialUpdatedAt && ( + Updated {new Date(integration.credentialUpdatedAt).toLocaleDateString()} + )} +
+ + {integration.authType === 'oauth2' && ( + + )} +
+
+
+ ); +} + +function OAuthConfig({ + integration, + showConfig, + setShowConfig, + clientId, + setClientId, + clientSecret, + setClientSecret, + customSettingsValues, + setCustomSettingsValues, + isSaving, + isDeleting, + error, + additionalSettings, + handleSave, + handleDelete, +}: { + integration: Integration; + showConfig: boolean; + setShowConfig: (v: boolean) => void; + clientId: string; + setClientId: (v: string) => void; + clientSecret: string; + setClientSecret: (v: string) => void; + customSettingsValues: Record; + setCustomSettingsValues: (v: Record) => void; + isSaving: boolean; + isDeleting: boolean; + error: string | null; + additionalSettings: AdditionalOAuthSetting[]; + handleSave: () => Promise; + handleDelete: () => Promise; +}) { + return ( + <> +
+ + + {integration.hasCredentials && ( + + )} + + {integration.createAppUrl && ( + + + + )} +
+ + {showConfig && ( +
+ {error && ( +
{error}
+ )} + + {integration.hasCredentials && ( + + )} + + {integration.setupInstructions && ( +
+ + Setup Instructions + +
+                {integration.setupInstructions}
+              
+
+ )} + +
+ Callback URL: + + {process.env.NEXT_PUBLIC_API_URL || ''}/v1/integrations/oauth/callback + +
+ + {integration.requiredScopes && integration.requiredScopes.length > 0 && ( +
+ Required Scopes: + + {integration.requiredScopes.join(', ')} + +
+ )} + +
+
+ + setClientId(e.target.value)} + /> +
+
+ + setClientSecret(e.target.value)} + /> +
+ + {additionalSettings.length > 0 && ( + <> +
+ + ADDITIONAL OAUTH SETTINGS + +
+ {additionalSettings.map((setting) => ( +
+ + + setCustomSettingsValues({ + ...customSettingsValues, + [setting.id]: e.target.value, + }) + } + /> + {setting.helpText && ( +

{setting.helpText}

+ )} +
+ ))} + + )} +
+ + +
+ )} + + ); +} + +function CredentialsDisplay({ + clientIdHint, + clientSecretHint, + existingCustomSettings, +}: { + clientIdHint?: string; + clientSecretHint?: string; + existingCustomSettings?: Record; +}) { + if (!clientIdHint) { + return ( +
+ Credentials configured +
+ ); + } + + return ( +
+ CURRENT CREDENTIALS +
+
+ Client ID: + + {clientIdHint} + +
+
+ Secret: + + {clientSecretHint} + +
+ {existingCustomSettings && + Object.entries(existingCustomSettings).map(([key, value]) => ( +
+ {key}: + + {String(value)} + +
+ ))} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/integrations/page.tsx b/apps/app/src/app/(app)/[orgId]/admin/integrations/page.tsx new file mode 100644 index 0000000000..aeb41d4fb1 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/integrations/page.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { api } from '@/lib/api-client'; +import { + Button, + Card, + CardContent, + Input, + PageHeader, + PageLayout, + Stack, + Text, +} from '@trycompai/design-system'; +import { + InProgress, + Renew, +} from '@trycompai/design-system/icons'; +import { useState } from 'react'; +import useSWR from 'swr'; +import { IntegrationCard, type Integration } from './components/IntegrationCard'; + +export default function AdminIntegrationsPage() { + const [searchQuery, setSearchQuery] = useState(''); + + const { + data: integrations, + error, + isLoading, + mutate, + } = useSWR('admin-integrations', async () => { + const response = await api.get('/v1/admin/integrations'); + if (response.error) throw new Error(response.error); + return response.data || []; + }); + + const filteredIntegrations = integrations?.filter((i) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + i.name.toLowerCase().includes(query) || + i.description.toLowerCase().includes(query) || + i.category.toLowerCase().includes(query) + ); + }); + + const oauthIntegrations = filteredIntegrations?.filter((i) => i.authType === 'oauth2') || []; + const otherIntegrations = filteredIntegrations?.filter((i) => i.authType !== 'oauth2') || []; + + const configuredCount = integrations?.filter((i) => i.hasCredentials).length || 0; + const oauthPendingCount = integrations?.filter((i) => i.authType === 'oauth2' && !i.hasCredentials).length || 0; + + return ( + } + > + +
+ + + +
+ +
+
+ setSearchQuery(e.target.value)} + /> +
+ +
+ + {error && ( +
+ Failed to load integrations: {error.message} +
+ )} + + {isLoading && ( +
+
+
+ )} + + {!isLoading && integrations && ( + + {oauthIntegrations.length > 0 && ( + mutate()} + /> + )} + {otherIntegrations.length > 0 && ( + mutate()} + /> + )} + + )} +
+
+ ); +} + +function StatCard({ + label, + value, + variant, +}: { + label: string; + value: number; + variant?: 'success' | 'warning'; +}) { + const colorClass = + variant === 'success' + ? 'text-green-600 dark:text-green-400' + : variant === 'warning' + ? 'text-yellow-600 dark:text-yellow-400' + : ''; + + return ( + + +
+
{value}
+ {label} +
+
+
+ ); +} + +function IntegrationSection({ + title, + integrations, + onRefresh, +}: { + title: string; + integrations: Integration[]; + onRefresh: () => void; +}) { + return ( +
+ {title} +
+ {integrations.map((integration) => ( + + ))} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/layout.test.tsx b/apps/app/src/app/(app)/[orgId]/admin/layout.test.tsx new file mode 100644 index 0000000000..7278464481 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/layout.test.tsx @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockRedirect = vi.fn(); + +vi.mock('@/utils/auth', async () => { + const { mockAuth } = await import('@/test-utils/mocks/auth'); + return { auth: mockAuth }; +}); + +vi.mock('next/headers', () => ({ + headers: vi.fn().mockResolvedValue(new Headers()), +})); + +vi.mock('next/navigation', () => ({ + redirect: (...args: unknown[]) => { + mockRedirect(...args); + throw new Error('NEXT_REDIRECT'); + }, +})); + +import { setupAuthMocks, createMockUser, createMockSession } from '@/test-utils/mocks/auth'; + +const { default: AdminLayout } = await import('./layout'); + +describe('[orgId]/admin/layout - auth gate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('redirects to frameworks when user has no session', async () => { + setupAuthMocks({ session: null, user: null }); + + await expect( + AdminLayout({ + children: null, + params: Promise.resolve({ orgId: 'org_1' }), + }), + ).rejects.toThrow('NEXT_REDIRECT'); + + expect(mockRedirect).toHaveBeenCalledWith('/org_1/frameworks'); + }); + + it('redirects to frameworks when user role is not admin', async () => { + setupAuthMocks({ + session: createMockSession(), + user: createMockUser({ role: 'user' }), + }); + + await expect( + AdminLayout({ + children: null, + params: Promise.resolve({ orgId: 'org_1' }), + }), + ).rejects.toThrow('NEXT_REDIRECT'); + + expect(mockRedirect).toHaveBeenCalledWith('/org_1/frameworks'); + }); + + it('redirects to frameworks when user role is null', async () => { + setupAuthMocks({ + session: createMockSession(), + user: createMockUser({ role: null }), + }); + + await expect( + AdminLayout({ + children: null, + params: Promise.resolve({ orgId: 'org_1' }), + }), + ).rejects.toThrow('NEXT_REDIRECT'); + + expect(mockRedirect).toHaveBeenCalledWith('/org_1/frameworks'); + }); + + it('renders children when user is a platform admin', async () => { + setupAuthMocks({ + session: createMockSession(), + user: createMockUser({ role: 'admin' }), + }); + + const result = await AdminLayout({ + children: 'admin content', + params: Promise.resolve({ orgId: 'org_1' }), + }); + + expect(result).toBeTruthy(); + expect(mockRedirect).not.toHaveBeenCalled(); + }); + + it('does not leak data — redirect happens before any API calls for non-admins', async () => { + setupAuthMocks({ + session: createMockSession(), + user: createMockUser({ role: 'user' }), + }); + + await expect( + AdminLayout({ + children: null, + params: Promise.resolve({ orgId: 'org_1' }), + }), + ).rejects.toThrow('NEXT_REDIRECT'); + + expect(mockRedirect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/admin/layout.tsx b/apps/app/src/app/(app)/[orgId]/admin/layout.tsx new file mode 100644 index 0000000000..6ba1e21520 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/layout.tsx @@ -0,0 +1,22 @@ +import { auth } from '@/utils/auth'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; + +export default async function AdminLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}) { + const { orgId } = await params; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id || session.user.role !== 'admin') { + redirect(`/${orgId}/frameworks`); + } + + return <>{children}; +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx new file mode 100644 index 0000000000..06a0a1e4e7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/api-client', () => ({ + api: { + get: vi.fn().mockResolvedValue({ data: [] }), + post: vi.fn().mockResolvedValue({ data: {} }), + patch: vi.fn().mockResolvedValue({ data: {} }), + }, +})); + +vi.mock('@/utils/auth-client', () => ({ + authClient: { + admin: { + impersonateUser: vi.fn(), + stopImpersonating: vi.fn(), + }, + organization: { + setActive: vi.fn(), + }, + }, +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }), + usePathname: () => '/org_1/admin/organizations/org_2', +})); + +import { AdminOrgTabs, type AdminOrgDetail } from './AdminOrgTabs'; + +const mockOrg: AdminOrgDetail = { + id: 'org_2', + name: 'Test Org', + slug: 'test-org', + logo: null, + createdAt: '2026-01-01T00:00:00Z', + hasAccess: true, + onboardingCompleted: true, + website: 'https://test.com', + members: [ + { + id: 'mem_1', + role: 'owner', + createdAt: '2026-01-01T00:00:00Z', + user: { + id: 'usr_1', + name: 'Test Owner', + email: 'owner@test.com', + image: null, + }, + }, + ], +}; + +describe('AdminOrgTabs', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders all tab triggers', () => { + render(); + + expect(screen.getByRole('tab', { name: /overview/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /findings/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /tasks/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /vendors/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /context/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /evidence/i })).toBeInTheDocument(); + }); + + it('renders the page header with org name', () => { + render(); + expect(screen.getByText('Test Org')).toBeInTheDocument(); + }); + + it('shows active badge for active org', () => { + render(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('switches to findings tab on click', () => { + render(); + fireEvent.click(screen.getByRole('tab', { name: /findings/i })); + expect(screen.getByText(/loading findings/i)).toBeInTheDocument(); + }); + + it('switches to tasks tab on click', () => { + render(); + fireEvent.click(screen.getByRole('tab', { name: /tasks/i })); + expect(screen.getByText(/loading tasks/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx new file mode 100644 index 0000000000..6ab5aaa79f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useState } from 'react'; +import { api } from '@/lib/api-client'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Badge, + Button, + PageHeader, + PageHeaderDescription, + PageLayout, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@trycompai/design-system'; +import { Input } from '@trycompai/ui/input'; +import { Label } from '@trycompai/ui/label'; +import { OrganizationDetail } from './OrganizationDetail'; +import { MembersTab } from './MembersTab'; +import { FindingsTab } from './FindingsTab'; +import { TasksTab } from './TasksTab'; +import { VendorsTab } from './VendorsTab'; +import { ContextTab } from './ContextTab'; +import { EvidenceTab } from './EvidenceTab'; +import { PoliciesTab } from './PoliciesTab'; + +interface OrgMember { + id: string; + role: string; + createdAt: string; + user: { + id: string; + name: string; + email: string; + image: string | null; + }; +} + +export interface AdminOrgDetail { + id: string; + name: string; + slug: string; + logo: string | null; + createdAt: string; + hasAccess: boolean; + onboardingCompleted: boolean; + website: string | null; + members: OrgMember[]; +} + +export function AdminOrgTabs({ + org, + currentOrgId, +}: { + org: AdminOrgDetail; + currentOrgId: string; +}) { + const [activeTab, setActiveTab] = useState('overview'); + const [toggling, setToggling] = useState(false); + const [hasAccess, setHasAccess] = useState(org.hasAccess); + const [deactivateDialogOpen, setDeactivateDialogOpen] = useState(false); + const [confirmValue, setConfirmValue] = useState(''); + + const handleToggleAccess = async () => { + if (hasAccess) { + setConfirmValue(''); + setDeactivateDialogOpen(true); + return; + } + setToggling(true); + const res = await api.patch( + `/v1/admin/organizations/${org.id}/activate`, + ); + if (!res.error) setHasAccess(true); + setToggling(false); + }; + + const handleConfirmDeactivate = async () => { + setDeactivateDialogOpen(false); + setToggling(true); + const res = await api.patch( + `/v1/admin/organizations/${org.id}/deactivate`, + ); + if (!res.error) setHasAccess(false); + setToggling(false); + }; + + return ( + { if (v) setActiveTab(v); }}> + + + {hasAccess ? 'Active' : 'Inactive'} + + +
+ } + tabs={ + + Overview + Members + Policies + Findings + Tasks + Vendors + Context + Evidence + + } + > + {org.website && ( + + + {org.website} + + + )} + + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + { + setDeactivateDialogOpen(open); + if (!open) setConfirmValue(''); + }} + > + + + Deactivate organization + + This will immediately revoke access for all members of{' '} + {org.name}. They will not be able to log in or + use the platform until reactivated. + + +
+ + setConfirmValue(e.target.value)} + placeholder="deactivate" + /> +
+ + setConfirmValue('')}> + Cancel + + + Deactivate + + +
+
+ + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/ContextTab.test.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/ContextTab.test.tsx new file mode 100644 index 0000000000..122a0b2386 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/ContextTab.test.tsx @@ -0,0 +1,102 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPatch = vi.fn(); + +vi.mock('@/lib/api-client', () => ({ + api: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + patch: (...args: unknown[]) => mockPatch(...args), + }, +})); + +import { ContextTab } from './ContextTab'; + +const makeEntries = () => ({ + data: [ + { + id: 'ctx_1', + question: 'How do we handle auth?', + answer: 'We use session-based auth with cookies.', + tags: ['auth', 'security'], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + { + id: 'ctx_2', + question: 'Where is data stored?', + answer: 'PostgreSQL on AWS RDS.', + tags: ['database'], + createdAt: '2026-01-02T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + }, + ], + count: 2, +}); + +describe('ContextTab', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows loading state initially', () => { + mockGet.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText(/loading context/i)).toBeInTheDocument(); + }); + + it('renders context entries after loading', async () => { + mockGet.mockResolvedValue({ data: makeEntries() }); + render(); + + await waitFor(() => { + expect(screen.getByText(/context \(2\)/i)).toBeInTheDocument(); + }); + + expect(screen.getByText(/how do we handle auth/i)).toBeInTheDocument(); + expect(screen.getByText(/where is data stored/i)).toBeInTheDocument(); + }); + + it('shows empty state when no entries', async () => { + mockGet.mockResolvedValue({ data: { data: [], count: 0 } }); + render(); + + await waitFor(() => { + expect(screen.getByText(/no context entries/i)).toBeInTheDocument(); + }); + }); + + it('shows Add Context button', async () => { + mockGet.mockResolvedValue({ data: { data: [], count: 0 } }); + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add context/i })).toBeInTheDocument(); + }); + }); + + it('shows Edit buttons for each entry', async () => { + mockGet.mockResolvedValue({ data: makeEntries() }); + render(); + + await waitFor(() => { + const editButtons = screen.getAllByRole('button', { name: /edit/i }); + expect(editButtons).toHaveLength(2); + }); + }); + + it('calls correct API endpoint', async () => { + mockGet.mockResolvedValue({ data: { data: [], count: 0 } }); + render(); + + await waitFor(() => { + expect(mockGet).toHaveBeenCalledWith( + '/v1/admin/organizations/org_test/context', + ); + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/ContextTab.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/ContextTab.tsx new file mode 100644 index 0000000000..49698af392 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/ContextTab.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { api } from '@/lib/api-client'; +import { + Button, + Section, + Sheet, + SheetBody, + SheetContent, + SheetHeader, + SheetTitle, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Add, Edit } from '@trycompai/design-system/icons'; +import { Input } from '@trycompai/ui/input'; +import { Label } from '@trycompai/ui/label'; +import { Textarea } from '@trycompai/ui/textarea'; +import { useCallback, useEffect, useState } from 'react'; + +interface ContextEntry { + id: string; + question: string; + answer: string; + tags: string[]; + createdAt: string; + updatedAt: string; +} + +interface ContextResponse { + data: ContextEntry[]; + count: number; +} + +export function ContextTab({ orgId }: { orgId: string }) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [editingEntry, setEditingEntry] = useState(null); + const [showCreateForm, setShowCreateForm] = useState(false); + + const fetchContext = useCallback(async () => { + setLoading(true); + const res = await api.get( + `/v1/admin/organizations/${orgId}/context`, + ); + if (res.data) setEntries(res.data.data); + setLoading(false); + }, [orgId]); + + useEffect(() => { + void fetchContext(); + }, [fetchContext]); + + const handleSaved = () => { + setEditingEntry(null); + setShowCreateForm(false); + void fetchContext(); + }; + + if (loading) { + return ( +
+ Loading context... +
+ ); + } + + return ( + <> +
} + onClick={() => setShowCreateForm(true)} + > + Add Context + + } + > + {entries.length === 0 ? ( +
+ No context entries for this organization. +
+ ) : ( + + + + Question + Answer + Actions + + + + {[...entries].sort((a, b) => a.question.localeCompare(b.question)).map((entry) => ( + + +
+ + {entry.question} + +
+
+ +
+ + {entry.answer} + +
+
+ + + +
+ ))} +
+
+ )} +
+ + { + if (!open) { + setShowCreateForm(false); + setEditingEntry(null); + } + }} + > + + + + {editingEntry ? 'Edit Context' : 'Add Context'} + + + + + + + + + ); +} + +function ContextForm({ + orgId, + entry, + onSaved, +}: { + orgId: string; + entry: ContextEntry | null; + onSaved: () => void; +}) { + const [question, setQuestion] = useState(entry?.question ?? ''); + const [answer, setAnswer] = useState(entry?.answer ?? ''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!question.trim() || !answer.trim()) return; + + setSubmitting(true); + setError(null); + + const body = { question, answer }; + + const res = entry + ? await api.patch( + `/v1/admin/organizations/${orgId}/context/${entry.id}`, + body, + ) + : await api.post(`/v1/admin/organizations/${orgId}/context`, body); + + if (res.error) { + setError(res.error); + } else { + onSaved(); + } + setSubmitting(false); + }; + + return ( +
+ +
+ + setQuestion(e.target.value)} + placeholder="What is the question?" + /> +
+
+ +