From f852262020a54b5357448aad7e9124d59c9f7ad3 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Tue, 3 Mar 2026 21:29:21 -0500 Subject: [PATCH 1/8] refactor(ui): improve nav icons + remove overview drag-and-drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Icons (AppShellWrapper — rail): - Compliance: CertificateCheck → Badge (certification badge) - Trust: CloudAuditing → Globe (public/global trust center) - Security: Security → ManageProtection (protection management) Icons (AppSidebar — compliance sidebar): - Auditor View: TaskComplete → DocumentSigned (auditors sign off) - Controls: Security → SettingsAdjust (adjustable params, no dupe) - Documents: Catalog → FolderDetails (folder of docs) - People: Group → UserMultiple (standard multi-user) - Risks: Warning → Scale (weighing risk) - Vendors: ShoppingBag → Partnership (business partners) - Questionnaire: Document → DocumentTasks (task-filled form) - Integrations: Integration → Connect (connecting systems) - Cloud Tests: Chemistry → TestTool (test tooling) Overview: - Replace with a plain 2-column grid - Delete DraggableCards.tsx, SortableCard.tsx, useCardOrder.ts Co-Authored-By: Claude Sonnet 4.6 --- .../[orgId]/components/AppShellWrapper.tsx | 8 +- .../(app)/[orgId]/components/AppSidebar.tsx | 36 ++--- .../frameworks/components/DraggableCards.tsx | 132 ------------------ .../frameworks/components/Overview.tsx | 5 +- .../frameworks/components/SortableCard.tsx | 47 ------- .../[orgId]/frameworks/hooks/useCardOrder.ts | 30 ---- 6 files changed, 24 insertions(+), 234 deletions(-) delete mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/components/DraggableCards.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/components/SortableCard.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/hooks/useCardOrder.ts diff --git a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx index a16043dc43..f9dc5f5fe3 100644 --- a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx @@ -7,7 +7,7 @@ import { NotificationBell } from '@/components/notifications/notification-bell'; import { OrganizationSwitcher } from '@/components/organization-switcher'; import { SidebarProvider, useSidebar } from '@/context/sidebar-context'; import { authClient } from '@/utils/auth-client'; -import { CertificateCheck, CloudAuditing, Logout, Security, Settings } from '@carbon/icons-react'; +import { Badge, Globe, Logout, ManageProtection, Settings } from '@carbon/icons-react'; import { DropdownMenu, DropdownMenuContent, @@ -240,14 +240,14 @@ function AppShellWrapperContent({ } + icon={} label="Compliance" /> {isTrustNdaEnabled && ( } + icon={} label="Trust" /> )} @@ -255,7 +255,7 @@ function AppShellWrapperContent({ } + icon={} label="Security" /> ) : null} diff --git a/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx index f92cfeb30c..67a62c8a5b 100644 --- a/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx @@ -1,18 +1,18 @@ 'use client'; import { - Catalog, - Chemistry, + Connect, Dashboard, - Document, - Group, - Integration, + DocumentSigned, + DocumentTasks, + FolderDetails, ListChecked, Policy, - Security, - ShoppingBag, - TaskComplete, - Warning, + Partnership, + Scale, + SettingsAdjust, + TestTool, + UserMultiple, } from '@carbon/icons-react'; import type { Organization } from '@db'; import { AppShellNav, AppShellNavItem } from '@trycompai/design-system'; @@ -53,14 +53,14 @@ export function AppSidebar({ id: 'auditor', path: `/${organization.id}/auditor`, name: 'Auditor View', - icon: , + icon: , hidden: !hasAuditorRole, }, { id: 'controls', path: `/${organization.id}/controls`, name: 'Controls', - icon: , + icon: , hidden: !organization.advancedModeEnabled, }, { @@ -79,45 +79,45 @@ export function AppSidebar({ id: 'documents', path: `/${organization.id}/documents`, name: 'Documents', - icon: , + icon: , }, { id: 'people', path: `/${organization.id}/people/all`, name: 'People', - icon: , + icon: , }, { id: 'risk', path: `/${organization.id}/risk`, name: 'Risks', - icon: , + icon: , }, { id: 'vendors', path: `/${organization.id}/vendors`, name: 'Vendors', - icon: , + icon: , }, { id: 'questionnaire', path: `/${organization.id}/questionnaire`, name: 'Questionnaire', - icon: , + icon: , hidden: !isQuestionnaireEnabled, }, { id: 'integrations', path: `/${organization.id}/integrations`, name: 'Integrations', - icon: , + icon: , hidden: isOnlyAuditor, }, { id: 'tests', path: `/${organization.id}/cloud-tests`, name: 'Cloud Tests', - icon: , + icon: , }, ]; diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/DraggableCards.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/DraggableCards.tsx deleted file mode 100644 index 60916819ec..0000000000 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/DraggableCards.tsx +++ /dev/null @@ -1,132 +0,0 @@ -'use client'; - -import { - DndContext, - DragEndEvent, - DragOverlay, - DragStartEvent, - PointerSensor, - closestCenter, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { SortableContext, arrayMove, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { useEffect, useState } from 'react'; -import { useCardOrder } from '../hooks/useCardOrder'; -import { SortableCard } from './SortableCard'; - -interface DraggableCardsProps { - children: React.ReactNode[]; - onReorder?: (newOrder: number[]) => void; -} - -export function DraggableCards({ children, onReorder }: DraggableCardsProps) { - const [items, setItems] = useState(children); - const [activeId, setActiveId] = useState(null); - const [mounted, setMounted] = useState(false); - const { order, updateOrder } = useCardOrder(children.map((_, index) => index)); - - // Ensure component is mounted before rendering drag-and-drop functionality - useEffect(() => { - setMounted(true); - }, []); - - // Reorder items when order changes from localStorage - useEffect(() => { - if (order.length === children.length) { - const reorderedItems = order.map((index) => children[index]); - setItems(reorderedItems); - } - }, [order, children]); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - ); - - const handleDragStart = (event: DragStartEvent) => { - setActiveId(event.active.id as string); - }; - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = items.findIndex((_, index) => `card-${index}` === active.id); - const newIndex = items.findIndex((_, index) => `card-${index}` === over.id); - - const newItems = arrayMove(items, oldIndex, newIndex); - setItems(newItems); - - // Update the stored order - map each position to the original card index - const newOrder = newItems.map((item, newPosition) => { - // Find which original card (from children) this item represents - const originalIndex = children.findIndex((child) => child === item); - return originalIndex; - }); - - console.log('Drag reorder:', { - oldIndex, - newIndex, - newItems: newItems.map((_, i) => `card-${i}`), - newOrder, - childrenLength: children.length, - }); - - updateOrder(newOrder); - - // Call the onReorder callback with the new order - if (onReorder) { - onReorder(newOrder); - } - } - - setActiveId(null); - }; - - // Don't render drag-and-drop functionality until mounted to prevent hydration mismatch - if (!mounted) { - return ( -
- {children.map((child, index) => ( -
- {child} -
- ))} -
- ); - } - - return ( - - `card-${index}`)} - strategy={verticalListSortingStrategy} - > -
- {items.map((child, index) => ( - -
{child}
-
- ))} -
-
- - - {activeId ? ( -
- {items.find((_, index) => `card-${index}` === activeId)} -
- ) : null} -
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx index 9c69702e13..47bf84f5d5 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx @@ -3,7 +3,6 @@ import { Finding, FrameworkEditorFramework, Policy, Task } from '@db'; import { FrameworkInstanceWithControls } from '../types'; import { ComplianceOverview } from './ComplianceOverview'; -import { DraggableCards } from './DraggableCards'; import { FindingsOverview } from './FindingsOverview'; import { FrameworksOverview } from './FrameworksOverview'; import { ToDoOverview } from './ToDoOverview'; @@ -84,7 +83,7 @@ export const Overview = ({ }); return ( - +
- +
); }; diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/SortableCard.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/SortableCard.tsx deleted file mode 100644 index ad40b4a141..0000000000 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/SortableCard.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client'; - -import { useSortable } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { GripVertical } from 'lucide-react'; - -interface SortableCardProps { - id: string; - children: React.ReactNode; -} - -export function SortableCard({ id, children }: SortableCardProps) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( -
- {/* Drag Handle */} -
- -
- - {/* Card Content */} -
- {children} -
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/hooks/useCardOrder.ts b/apps/app/src/app/(app)/[orgId]/frameworks/hooks/useCardOrder.ts deleted file mode 100644 index b8283374e8..0000000000 --- a/apps/app/src/app/(app)/[orgId]/frameworks/hooks/useCardOrder.ts +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; - -const STORAGE_KEY = 'frameworks-cards-order'; - -export function useCardOrder(defaultOrder: number[]) { - const [order, setOrder] = useState(() => { - if (typeof window === 'undefined') return defaultOrder; - - try { - const stored = localStorage.getItem(STORAGE_KEY); - return stored ? JSON.parse(stored) : defaultOrder; - } catch { - return defaultOrder; - } - }); - - useEffect(() => { - if (typeof window !== 'undefined') { - localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); - } - }, [order]); - - const updateOrder = (newOrder: number[]) => { - setOrder(newOrder); - }; - - return { order, updateOrder }; -} From 9d9c947cc6caae74a0f7234d700fcb74f7e810f9 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Tue, 3 Mar 2026 21:32:48 -0500 Subject: [PATCH 2/8] revert(ui): restore original compliance sidebar icons Co-Authored-By: Claude Sonnet 4.6 --- .../(app)/[orgId]/components/AppSidebar.tsx | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx index 67a62c8a5b..f92cfeb30c 100644 --- a/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx @@ -1,18 +1,18 @@ 'use client'; import { - Connect, + Catalog, + Chemistry, Dashboard, - DocumentSigned, - DocumentTasks, - FolderDetails, + Document, + Group, + Integration, ListChecked, Policy, - Partnership, - Scale, - SettingsAdjust, - TestTool, - UserMultiple, + Security, + ShoppingBag, + TaskComplete, + Warning, } from '@carbon/icons-react'; import type { Organization } from '@db'; import { AppShellNav, AppShellNavItem } from '@trycompai/design-system'; @@ -53,14 +53,14 @@ export function AppSidebar({ id: 'auditor', path: `/${organization.id}/auditor`, name: 'Auditor View', - icon: , + icon: , hidden: !hasAuditorRole, }, { id: 'controls', path: `/${organization.id}/controls`, name: 'Controls', - icon: , + icon: , hidden: !organization.advancedModeEnabled, }, { @@ -79,45 +79,45 @@ export function AppSidebar({ id: 'documents', path: `/${organization.id}/documents`, name: 'Documents', - icon: , + icon: , }, { id: 'people', path: `/${organization.id}/people/all`, name: 'People', - icon: , + icon: , }, { id: 'risk', path: `/${organization.id}/risk`, name: 'Risks', - icon: , + icon: , }, { id: 'vendors', path: `/${organization.id}/vendors`, name: 'Vendors', - icon: , + icon: , }, { id: 'questionnaire', path: `/${organization.id}/questionnaire`, name: 'Questionnaire', - icon: , + icon: , hidden: !isQuestionnaireEnabled, }, { id: 'integrations', path: `/${organization.id}/integrations`, name: 'Integrations', - icon: , + icon: , hidden: isOnlyAuditor, }, { id: 'tests', path: `/${organization.id}/cloud-tests`, name: 'Cloud Tests', - icon: , + icon: , }, ]; From 3d282abb428397091e9016ecedd1186e1a39add0 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Tue, 3 Mar 2026 21:41:40 -0500 Subject: [PATCH 3/8] fix(billing): look up run by providerRunId not internal id The id returned by the create API is the provider run ID stored in providerRunId, not the internal ptr... primary key. Co-Authored-By: Claude Sonnet 4.6 --- .../[orgId]/security/penetration-tests/actions/billing.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts index 0ca4c81742..d415e677c0 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts @@ -206,8 +206,9 @@ export async function checkAndChargePentestBilling(orgId: string, runId: string) await requireOrgMember(orgId); // Verify the run exists and belongs to this org to prevent arbitrary runId abuse. + // runId here is the provider run ID (stored in providerRunId), not the internal ptr... key. const run = await db.securityPenetrationTestRun.findUnique({ - where: { id: runId }, + where: { providerRunId: runId }, select: { organizationId: true }, }); if (!run || run.organizationId !== orgId) { From 1b0273875463f9059bbc504e9df157fa5b732f46 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Wed, 4 Mar 2026 14:38:07 -0500 Subject: [PATCH 4/8] feat(pentest): gate penetration tests page behind subscription Show a locked marketing state when the org has no active pentest subscription. Includes a reusable ModuleGate component in @comp/ui with dark chrome-frame preview, and an animated pen-test demo that cycles through agents and reveals findings with severity badges. Co-Authored-By: Claude Opus 4.6 --- .../components/pentest-preview-animation.tsx | 159 ++++++++++++++++++ .../security/penetration-tests/page.tsx | 8 +- .../penetration-tests-page-client.test.tsx | 65 ++++--- .../penetration-tests-page-client.tsx | 67 +++++++- packages/ui/src/components/index.ts | 1 + packages/ui/src/components/module-gate.tsx | 78 +++++++++ 6 files changed, 357 insertions(+), 21 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx create mode 100644 packages/ui/src/components/module-gate.tsx diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx new file mode 100644 index 0000000000..87c1a1df35 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AlertTriangle, CheckCircle2, Loader2, Shield, ShieldAlert } from 'lucide-react'; + +interface Finding { + severity: 'critical' | 'high' | 'medium' | 'low'; + title: string; + location: string; +} + +const findings: Finding[] = [ + { severity: 'critical', title: 'SQL Injection in /api/users', location: 'POST /api/users?search=' }, + { severity: 'high', title: 'Stored XSS in comments', location: 'POST /api/comments' }, + { severity: 'high', title: 'Broken access control', location: 'GET /api/admin/settings' }, + { severity: 'medium', title: 'Missing rate limiting', location: 'POST /api/auth/login' }, + { severity: 'medium', title: 'Insecure CORS policy', location: 'Origin: *' }, + { severity: 'low', title: 'Missing security headers', location: 'X-Frame-Options' }, +]; + +const agents = [ + 'Reconnaissance', + 'Authentication testing', + 'Injection testing', + 'Access control audit', + 'Configuration review', +]; + +const severityColors = { + critical: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400', + high: 'bg-orange-100 text-orange-700 dark:bg-orange-950/40 dark:text-orange-400', + medium: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950/40 dark:text-yellow-400', + low: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400', +}; + +export function PentestPreviewAnimation() { + const [progress, setProgress] = useState(0); + const [currentAgent, setCurrentAgent] = useState(0); + const [visibleFindings, setVisibleFindings] = useState(0); + const [phase, setPhase] = useState<'scanning' | 'complete'>('scanning'); + + useEffect(() => { + const totalDuration = 8000; + const interval = 50; + let elapsed = 0; + + const timer = setInterval(() => { + elapsed += interval; + const t = elapsed / totalDuration; + + if (t >= 1) { + setPhase('complete'); + setProgress(100); + setVisibleFindings(findings.length); + setCurrentAgent(agents.length - 1); + + // Reset after a pause + setTimeout(() => { + elapsed = 0; + setPhase('scanning'); + setProgress(0); + setVisibleFindings(0); + setCurrentAgent(0); + }, 3000); + return; + } + + // Progress with easing + const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + setProgress(Math.round(eased * 100)); + + // Cycle through agents + setCurrentAgent(Math.min(Math.floor(t * agents.length), agents.length - 1)); + + // Reveal findings progressively + setVisibleFindings(Math.floor(t * (findings.length + 1))); + }, interval); + + return () => clearInterval(timer); + }, []); + + const isComplete = phase === 'complete'; + + return ( +
+ {/* Header */} +
+
+ {isComplete ? ( + + ) : ( + + )} + app.acme.com + {isComplete ? ( + + Complete + + ) : ( + + Running + + )} +
+ {progress}% +
+ + {/* Progress bar */} +
+
+
+ + {/* Current agent */} + {!isComplete && ( +
+ + {agents[currentAgent]}… + {currentAgent + 1}/{agents.length} agents +
+ )} + + {isComplete && ( +
+ + Scan complete — {findings.length} findings + 5/5 agents +
+ )} + + {/* Findings */} +
+ {findings.slice(0, visibleFindings).map((finding, i) => ( +
+
+ +
+

{finding.title}

+

{finding.location}

+
+
+ + {finding.severity} + +
+ ))} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx index c51f4ad16a..8741c752c5 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx @@ -32,7 +32,13 @@ export default async function PenetrationTestsPage({ redirect('/'); } - return ; + const subscription = await db.pentestSubscription.findUnique({ + where: { organizationId: orgId }, + }); + + const hasActiveSubscription = subscription?.status === 'active'; + + return ; } export async function generateMetadata(): Promise { diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx index 7206308c0f..8db96797ae 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx @@ -107,6 +107,25 @@ vi.mock('@comp/ui/badge', () => ({ Badge: ({ children }: { children: ReactNode }) => {children}, })); +vi.mock('@comp/ui/module-gate', () => ({ + ModuleGate: ({ title, description, action, features }: { title: string; description?: string; action?: ReactNode; features?: string[] }) => ( +
+

{title}

+ {description &&

{description}

} + {features?.map((f: string) => {f})} + {action} +
+ ), +})); + +vi.mock('./components/pentest-preview-animation', () => ({ + PentestPreviewAnimation: () =>
, +})); + +vi.mock('./actions/billing', () => ({ + subscribeToPentestPlan: vi.fn(), +})); + vi.mock('@trycompai/design-system', () => ({ Button: ({ asChild, children, ...props }: ComponentProps<'button'> & { asChild?: boolean }) => { if (asChild && isValidElement(children)) { @@ -196,8 +215,16 @@ describe('PenetrationTestsPageClient', () => { } as ReturnType); }); + it('renders locked state when subscription is not active', () => { + render(); + + expect(screen.getByText('Find vulnerabilities before attackers do')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Get started/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Create Report' })).not.toBeInTheDocument(); + }); + it('renders an empty state and call-to-action when no reports exist', () => { - render(); + render(); expect(screen.getAllByText('No reports yet')).toHaveLength(2); expect(screen.getByRole('button', { name: 'Create your first report' })).toBeInTheDocument(); @@ -211,7 +238,7 @@ describe('PenetrationTestsPageClient', () => { resetError: vi.fn(), }); - const { getByText } = render(); + const { getByText } = render(); fireEvent.click(getByText('Create your first report')); @@ -229,7 +256,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [reportRows[1]], }); - render(); + render(); expect(screen.getByText('1 completed report')).toBeInTheDocument(); }); @@ -244,7 +271,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [reportRows[1], { ...reportRows[1], id: 'run_completed_2' }], }); - render(); + render(); expect(screen.getByText('2 reports in progress')).toBeInTheDocument(); }); @@ -259,7 +286,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [{ ...reportRows[1], id: 'run_completed_2' }, reportRows[1]], }); - render(); + render(); expect(screen.getByText('2 completed reports')).toBeInTheDocument(); }); @@ -292,7 +319,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('In progress (0/2)')).toBeInTheDocument(); }); @@ -307,7 +334,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [reportRows[1]], }); - render(); + render(); expect(screen.getByText('https://running.example.com')).toBeInTheDocument(); expect(screen.getByText('https://completed.example.com')).toBeInTheDocument(); @@ -343,7 +370,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('https://no-repo.example.com')).toBeInTheDocument(); expect(screen.getByText('—')).toBeInTheDocument(); @@ -359,7 +386,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - const { container } = render(); + const { container } = render(); expect(container.querySelector('.animate-spin')).toBeTruthy(); }); @@ -392,7 +419,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('In progress (1/2)')).toBeInTheDocument(); }); @@ -425,14 +452,14 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('In progress')).toBeInTheDocument(); expect(screen.queryByText('(n/a/n/a)')).toBeNull(); }); it('creates a report and navigates to the report detail page', async () => { - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -463,7 +490,7 @@ describe('PenetrationTestsPageClient', () => { }); it('requires target URL before submitting report request', async () => { - render(); + render(); const submitForm = screen.getByText('Start penetration test').closest('form'); await act(async () => { @@ -477,7 +504,7 @@ describe('PenetrationTestsPageClient', () => { }); it('creates a report without repository URL when only target is provided', async () => { - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -503,7 +530,7 @@ describe('PenetrationTestsPageClient', () => { it('surfaces errors when run creation fails', async () => { createReportMock.mockRejectedValue(new Error('No active pentest subscription.')); - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -531,7 +558,7 @@ describe('PenetrationTestsPageClient', () => { it('surfaces a generic error message when run creation fails with non-error value', async () => { createReportMock.mockRejectedValue('service-down'); - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -557,7 +584,7 @@ describe('PenetrationTestsPageClient', () => { }); it('shows a Connect GitHub button when GitHub is not connected', async () => { - render(); + render(); await act(async () => { fireEvent.click(screen.getByText('Create Report')); @@ -579,7 +606,7 @@ describe('PenetrationTestsPageClient', () => { isLoading: false, } as ReturnType); - render(); + render(); await act(async () => { fireEvent.click(screen.getByText('Create Report')); @@ -590,7 +617,7 @@ describe('PenetrationTestsPageClient', () => { }); it('starts GitHub OAuth when Connect GitHub button is clicked', async () => { - render(); + render(); await act(async () => { fireEvent.click(screen.getByText('Create Report')); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx index 87420edd05..dc41f934f5 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx @@ -27,7 +27,9 @@ import { TableHeader, TableRow, } from '@comp/ui/table'; +import { ModuleGate } from '@comp/ui/module-gate'; import { AlertCircle, Loader2 } from 'lucide-react'; +import { PentestPreviewAnimation } from './components/pentest-preview-animation'; import { useRouter } from 'next/navigation'; import { FormEvent, useState } from 'react'; import { toast } from 'sonner'; @@ -42,9 +44,11 @@ import { useIntegrationMutations, } from '@/hooks/use-integration-platform'; import { Button, PageHeader, PageLayout } from '@trycompai/design-system'; +import { subscribeToPentestPlan } from './actions/billing'; interface PenetrationTestsPageClientProps { orgId: string; + hasActiveSubscription: boolean; } const hasProtocol = (value: string): boolean => /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value); @@ -65,9 +69,10 @@ const normalizeTargetUrl = (value: string): string | null => { } }; -export function PenetrationTestsPageClient({ orgId }: PenetrationTestsPageClientProps) { +export function PenetrationTestsPageClient({ orgId, hasActiveSubscription }: PenetrationTestsPageClientProps) { const router = useRouter(); + const [isSubscribing, setIsSubscribing] = useState(false); const [showNewRunDialog, setShowNewRunDialog] = useState(false); const [targetUrl, setTargetUrl] = useState(''); const [repoUrl, setRepoUrl] = useState(''); @@ -129,6 +134,66 @@ export function PenetrationTestsPageClient({ orgId }: PenetrationTestsPageClient } }; + const handleSubscribe = async () => { + setIsSubscribing(true); + try { + const result = await subscribeToPentestPlan(orgId); + if (result.url) { + window.location.href = result.url; + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to start checkout'); + setIsSubscribing(false); + } + }; + + if (!hasActiveSubscription) { + return ( + + + Run penetration tests with Maced and review generated reports. + + + + {isSubscribing ? ( + <> + + Redirecting… + + ) : ( + 'Get started — $99/mo' + )} + + } + secondaryAction={ + + } + preview={} + /> + + ); + } + return ( +
+

+ {label} +

+

{title}

+

{description}

+
+ + {features && features.length > 0 && ( +
    + {features.map((item) => ( +
  • + + {item} +
  • + ))} +
+ )} + +
+ {action} + {secondaryAction} +
+ + {preview && ( +
+ {/* Dark chrome bar */} +
+ + + +
+ {/* Content area */} +
+ {preview} +
+
+ )} +
+ ); +} From ec41dab178f44a5e75a639710784e0d778351254 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Wed, 4 Mar 2026 16:23:49 -0500 Subject: [PATCH 5/8] feat(pentest): add billing enforcement, usage tracking, and billing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add preauthorize billing check before run creation (prevents unbilled runs) - Add dynamic pricing from Stripe (subscription + overage prices) - Add usage tracking in pentest page header and create dialog - Add overage confirmation dialog when included runs are exhausted - Add billing page with subscription table, usage details, and payment method - Add role-based auth (admin/owner only) for billing server actions - Add duplicate subscription guard in subscribeToPentestPlan - Add webhook unit tests (10 cases covering all event types) - Fix period boundary queries (lte → lt) to prevent double-counting - Fix payment method lookup to check subscription PM before customer PM - Fix PaymentIntent config (off_session instead of conflicting options) Co-Authored-By: Claude Opus 4.6 --- .../penetration-tests/actions/billing.ts | 236 ++++++++++--- .../hooks/use-penetration-tests.test.tsx | 41 ++- .../hooks/use-penetration-tests.ts | 11 +- .../security/penetration-tests/page.test.tsx | 14 + .../security/penetration-tests/page.tsx | 32 +- .../penetration-tests-page-client.test.tsx | 170 +++++++-- .../penetration-tests-page-client.tsx | 116 +++++-- .../(app)/[orgId]/settings/billing/page.tsx | 327 +++++++++++++----- .../api/webhooks/stripe-pentest/route.test.ts | 261 ++++++++++++++ .../prisma/schema/pentest-subscription.prisma | 2 +- packages/docs/openapi.json | 175 +++++++++- 11 files changed, 1172 insertions(+), 213 deletions(-) create mode 100644 apps/app/src/app/api/webhooks/stripe-pentest/route.test.ts diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts index d415e677c0..db0189fd12 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts @@ -6,7 +6,7 @@ import { stripe } from '@/lib/stripe'; import { db } from '@db'; import { headers } from 'next/headers'; -async function requireOrgMember(orgId: string): Promise { +async function requireOrgAdmin(orgId: string): Promise { const response = await auth.api.getSession({ headers: await headers() }); if (!response?.session) { throw new Error('Unauthorized'); @@ -14,6 +14,25 @@ async function requireOrgMember(orgId: string): Promise { if (response.session.activeOrganizationId !== orgId) { throw new Error('Unauthorized'); } + + const userId = (response as { user?: { id?: string } }).user?.id; + if (!userId) { + throw new Error('Unauthorized'); + } + + // Verify user is an admin or owner — billing actions should not be available to regular members + const member = await db.member.findFirst({ + where: { + userId, + organizationId: orgId, + deactivated: false, + role: { in: ['admin', 'owner'] }, + }, + }); + + if (!member) { + throw new Error('Billing actions require admin or owner role.'); + } } async function getOrgBillingUrl(orgId: string): Promise { @@ -27,13 +46,21 @@ async function getOrgBillingUrl(orgId: string): Promise { export async function subscribeToPentestPlan( orgId: string, ): Promise<{ url: string }> { - await requireOrgMember(orgId); + await requireOrgAdmin(orgId); const returnBaseUrl = await getOrgBillingUrl(orgId); if (!stripe) { throw new Error('Stripe is not configured.'); } + // Guard against creating duplicate subscriptions + const existingSub = await db.pentestSubscription.findUnique({ + where: { organizationId: orgId }, + }); + if (existingSub?.status === 'active') { + throw new Error('Organization already has an active pentest subscription.'); + } + const priceId = env.STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID; if (!priceId) { throw new Error('STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID is not configured.'); @@ -93,7 +120,7 @@ export async function handleSubscriptionSuccess( orgId: string, sessionId: string, ): Promise { - await requireOrgMember(orgId); + await requireOrgAdmin(orgId); if (!stripe) { throw new Error('Stripe is not configured.'); @@ -179,7 +206,7 @@ export async function handleSubscriptionSuccess( export async function createBillingPortalSession( orgId: string, ): Promise<{ url: string }> { - await requireOrgMember(orgId); + await requireOrgAdmin(orgId); const returnUrl = await getOrgBillingUrl(orgId); if (!stripe) { @@ -202,18 +229,29 @@ export async function createBillingPortalSession( return { url: portalSession.url }; } -export async function checkAndChargePentestBilling(orgId: string, runId: string): Promise { - await requireOrgMember(orgId); +export interface PreauthorizeResult { + authorized: boolean; + isOverage: boolean; + error?: string; +} - // Verify the run exists and belongs to this org to prevent arbitrary runId abuse. - // runId here is the provider run ID (stored in providerRunId), not the internal ptr... key. - const run = await db.securityPenetrationTestRun.findUnique({ - where: { providerRunId: runId }, - select: { organizationId: true }, - }); - if (!run || run.organizationId !== orgId) { - throw new Error('Run not found.'); - } +export interface PentestPricing { + subscriptionPrice: string; // e.g. "$99/mo" + overagePrice: string; // e.g. "$199" +} + +export interface PentestUsage { + includedRuns: number; + usedRuns: number; + remainingRuns: number; + currentPeriodEnd: string; +} + +export async function preauthorizePentestRun( + orgId: string, + nonce: string, +): Promise { + await requireOrgAdmin(orgId); const subscription = await db.pentestSubscription.findUnique({ where: { organizationId: orgId }, @@ -221,13 +259,11 @@ export async function checkAndChargePentestBilling(orgId: string, runId: string) }); if (!subscription) { - throw new Error( - `No active pentest subscription. Subscribe at /settings/billing.`, - ); + return { authorized: false, isOverage: false, error: 'No active pentest subscription. Subscribe at /settings/billing.' }; } if (subscription.status !== 'active') { - throw new Error('Pentest subscription is not active.'); + return { authorized: false, isOverage: false, error: 'Pentest subscription is not active.' }; } const runsThisPeriod = await db.securityPenetrationTestRun.count({ @@ -235,70 +271,160 @@ export async function checkAndChargePentestBilling(orgId: string, runId: string) organizationId: orgId, createdAt: { gte: subscription.currentPeriodStart, - lte: subscription.currentPeriodEnd, + lt: subscription.currentPeriodEnd, }, }, }); - if (runsThisPeriod <= subscription.includedRunsPerPeriod) { - return; + if (runsThisPeriod < subscription.includedRunsPerPeriod) { + return { authorized: true, isOverage: false }; } + // Over limit — charge overage if (!stripe) { - throw new Error('Stripe is not configured.'); + return { authorized: false, isOverage: true, error: 'Stripe is not configured.' }; } const overagePriceId = env.STRIPE_PENTEST_OVERAGE_PRICE_ID; if (!overagePriceId) { - throw new Error('STRIPE_PENTEST_OVERAGE_PRICE_ID is not configured.'); + return { authorized: false, isOverage: true, error: 'STRIPE_PENTEST_OVERAGE_PRICE_ID is not configured.' }; } const price = await stripe.prices.retrieve(overagePriceId); const amount = price.unit_amount; if (!amount) { - throw new Error('Overage price has no unit amount.'); + return { authorized: false, isOverage: true, error: 'Overage price has no unit amount.' }; } const stripeCustomerId = subscription.organizationBilling.stripeCustomerId; - const customer = await stripe.customers.retrieve(stripeCustomerId, { - expand: ['invoice_settings.default_payment_method'], + // Try the subscription's default payment method first (Checkout often sets it here), + // then fall back to the customer's invoice_settings.default_payment_method. + const stripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId, { + expand: ['default_payment_method'], }); - if (customer.deleted) { - throw new Error('Stripe customer not found.'); + let paymentMethodId: string | undefined; + + const subPm = stripeSub.default_payment_method; + if (subPm) { + paymentMethodId = typeof subPm === 'string' ? subPm : subPm.id; + } + + if (!paymentMethodId) { + const customer = await stripe.customers.retrieve(stripeCustomerId, { + expand: ['invoice_settings.default_payment_method'], + }); + + if (customer.deleted) { + return { authorized: false, isOverage: true, error: 'Stripe customer not found.' }; + } + + const custPm = customer.invoice_settings?.default_payment_method; + if (custPm) { + paymentMethodId = typeof custPm === 'string' ? custPm : custPm.id; + } } - const defaultPaymentMethod = customer.invoice_settings?.default_payment_method; - if (!defaultPaymentMethod) { - throw new Error('No payment method on file. Update billing at /settings/billing.'); + if (!paymentMethodId) { + return { authorized: false, isOverage: true, error: 'No payment method on file. Update billing at /settings/billing.' }; } - const paymentMethodId = - typeof defaultPaymentMethod === 'string' - ? defaultPaymentMethod - : defaultPaymentMethod.id; - - // Idempotency key scoped to the specific run ID so concurrent creates - // never share a key and each overage run is charged exactly once. - const idempotencyKey = `pentest-overage-${orgId}-${runId}`; - - const paymentIntent = await stripe.paymentIntents.create( - { - customer: stripeCustomerId, - amount, - currency: 'usd', - payment_method: paymentMethodId, - confirm: true, - automatic_payment_methods: { - enabled: true, - allow_redirects: 'never', + const idempotencyKey = `pentest-overage-${orgId}-${nonce}`; + + try { + const paymentIntent = await stripe.paymentIntents.create( + { + customer: stripeCustomerId, + amount, + currency: 'usd', + payment_method: paymentMethodId, + confirm: true, + off_session: true, + }, + { idempotencyKey }, + ); + + if (paymentIntent.status !== 'succeeded') { + return { authorized: false, isOverage: true, error: 'Overage payment failed. Check billing.' }; + } + } catch { + return { authorized: false, isOverage: true, error: 'Overage payment failed. Check billing.' }; + } + + return { authorized: true, isOverage: true }; +} + +export async function getPentestUsage(orgId: string): Promise { + await requireOrgAdmin(orgId); + + const subscription = await db.pentestSubscription.findUnique({ + where: { organizationId: orgId }, + }); + + if (!subscription || subscription.status !== 'active') { + return null; + } + + const usedRuns = await db.securityPenetrationTestRun.count({ + where: { + organizationId: orgId, + createdAt: { + gte: subscription.currentPeriodStart, + lt: subscription.currentPeriodEnd, }, }, - { idempotencyKey }, - ); + }); + + const includedRuns = subscription.includedRunsPerPeriod; + const remainingRuns = Math.max(0, includedRuns - usedRuns); + + return { + includedRuns, + usedRuns, + remainingRuns, + currentPeriodEnd: subscription.currentPeriodEnd.toISOString(), + }; +} + +function formatStripePrice(unitAmount: number | null, currency: string, interval?: string | null): string { + const amount = (unitAmount ?? 0) / 100; + const formatted = new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); + if (interval) { + const shortInterval = interval === 'month' ? 'mo' : interval === 'year' ? 'yr' : interval; + return `${formatted}/${shortInterval}`; + } + return formatted; +} + +export async function getPentestPricing(): Promise { + const fallback: PentestPricing = { subscriptionPrice: '$99/mo', overagePrice: '$199' }; + + if (!stripe) return fallback; + + const subPriceId = env.STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID; + const overagePriceId = env.STRIPE_PENTEST_OVERAGE_PRICE_ID; - if (paymentIntent.status !== 'succeeded') { - throw new Error('Overage payment failed. Check billing.'); + try { + const [subPrice, overagePrice] = await Promise.all([ + subPriceId ? stripe.prices.retrieve(subPriceId) : null, + overagePriceId ? stripe.prices.retrieve(overagePriceId) : null, + ]); + + return { + subscriptionPrice: subPrice + ? formatStripePrice(subPrice.unit_amount, subPrice.currency, subPrice.recurring?.interval) + : fallback.subscriptionPrice, + overagePrice: overagePrice + ? formatStripePrice(overagePrice.unit_amount, overagePrice.currency) + : fallback.overagePrice, + }; + } catch { + return fallback; } } diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx index d780148f2f..c1cce972e9 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx @@ -11,7 +11,7 @@ import { } from './use-penetration-tests'; vi.mock('../actions/billing', () => ({ - checkAndChargePentestBilling: vi.fn().mockResolvedValue(undefined), + preauthorizePentestRun: vi.fn().mockResolvedValue({ authorized: true, isOverage: false }), })); vi.mock('@/utils/jwt-manager', () => ({ @@ -239,15 +239,13 @@ describe('use-penetration-tests hooks', () => { expect(requestBody.repoUrl).toBeUndefined(); }); - it('billing action failure surfaces the error after run creation', async () => { - fetchMock.mockResolvedValueOnce( - createJsonResponse({ id: 'run_billed', status: 'provisioning' }), - ); - - const { checkAndChargePentestBilling } = await import('../actions/billing'); - vi.mocked(checkAndChargePentestBilling).mockRejectedValueOnce( - new Error('No active pentest subscription.'), - ); + it('preauthorization rejection prevents run creation', async () => { + const { preauthorizePentestRun } = await import('../actions/billing'); + vi.mocked(preauthorizePentestRun).mockResolvedValueOnce({ + authorized: false, + isOverage: false, + error: 'No active pentest subscription.', + }); const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); @@ -259,10 +257,31 @@ describe('use-penetration-tests hooks', () => { ).rejects.toThrow('No active pentest subscription.'); }); - expect(fetchMock).toHaveBeenCalledOnce(); + // The API should never be called when preauthorization fails + expect(fetchMock).not.toHaveBeenCalled(); expect(result.current.error).toBe('No active pentest subscription.'); }); + it('preauthorization failure from thrown error surfaces the error', async () => { + const { preauthorizePentestRun } = await import('../actions/billing'); + vi.mocked(preauthorizePentestRun).mockRejectedValueOnce( + new Error('Billing authorization failed.'), + ); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport({ + targetUrl: 'https://app.example.com', + }), + ).rejects.toThrow('Billing authorization failed.'); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current.error).toBe('Billing authorization failed.'); + }); + it('surfaces json provider error objects from create response', async () => { fetchMock.mockResolvedValueOnce( new Response( diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts index 2f727a4c57..6517b8570f 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts @@ -12,7 +12,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useSWRConfig } from 'swr'; import useSWR from 'swr'; import { isReportInProgress, sortReportsByUpdatedAtDesc } from '../lib'; -import { checkAndChargePentestBilling } from '../actions/billing'; +import { preauthorizePentestRun } from '../actions/billing'; const reportListEndpoint = '/v1/security-penetration-tests'; const githubReposEndpoint = '/v1/security-penetration-tests/github/repos'; @@ -243,6 +243,13 @@ export function useCreatePenetrationTest( setIsCreating(true); setError(null); try { + // Preauthorize billing before creating the run + const nonce = crypto.randomUUID(); + const authResult = await preauthorizePentestRun(organizationId, nonce); + if (!authResult.authorized) { + throw new Error(authResult.error ?? 'Billing authorization failed.'); + } + const response = await api.post<{ id?: string; status?: PentestReportStatus; @@ -269,8 +276,6 @@ export function useCreatePenetrationTest( throw new Error('Could not resolve report ID from create response.'); } - await checkAndChargePentestBilling(organizationId, reportId); - const data: CreatePenetrationTestResponse = { id: reportId, status: response.data?.status, diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.test.tsx index 97ca9aba37..05bcfc04f5 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.test.tsx @@ -7,6 +7,8 @@ import PenetrationTestsPage, { generateMetadata } from './page'; const authGetSessionMock = vi.fn(); const dbFindFirstMock = vi.fn(); +const dbPentestSubFindUniqueMock = vi.fn(); +const dbRunCountMock = vi.fn(); const headersMock = vi.fn(); const childMock = vi.fn(); @@ -23,6 +25,12 @@ vi.mock('@db', () => ({ member: { findFirst: (...args: unknown[]) => dbFindFirstMock(...args), }, + pentestSubscription: { + findUnique: (...args: unknown[]) => dbPentestSubFindUniqueMock(...args), + }, + securityPenetrationTestRun: { + count: (...args: unknown[]) => dbRunCountMock(...args), + }, }, })); @@ -34,6 +42,10 @@ vi.mock('next/navigation', () => ({ redirect: vi.fn(), })); +vi.mock('./actions/billing', () => ({ + getPentestPricing: vi.fn().mockResolvedValue({ subscriptionPrice: '$99/mo', overagePrice: '$49' }), +})); + vi.mock('./penetration-tests-page-client', () => ({ PenetrationTestsPageClient: ({ orgId }: { orgId: string }) => { childMock(orgId); @@ -47,6 +59,8 @@ describe('Penetration Tests page', () => { headersMock.mockReturnValue(new Headers()); authGetSessionMock.mockResolvedValue({ user: { id: 'user_1' } }); dbFindFirstMock.mockResolvedValue({ id: 'member_1' }); + dbPentestSubFindUniqueMock.mockResolvedValue(null); + dbRunCountMock.mockResolvedValue(0); vi.mocked(redirect).mockImplementation(() => { const error = new Error('NEXT_REDIRECT'); (error as Error & { digest: string }).digest = 'NEXT_REDIRECT'; diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx index 8741c752c5..1fccf5a6ee 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx @@ -4,6 +4,7 @@ import { headers } from 'next/headers'; import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; +import { getPentestPricing } from './actions/billing'; import { PenetrationTestsPageClient } from './penetration-tests-page-client'; export default async function PenetrationTestsPage({ @@ -38,7 +39,36 @@ export default async function PenetrationTestsPage({ const hasActiveSubscription = subscription?.status === 'active'; - return ; + let usage: { + includedRuns: number; + usedRuns: number; + remainingRuns: number; + currentPeriodEnd: string; + } | null = null; + + if (hasActiveSubscription && subscription) { + const usedRuns = await db.securityPenetrationTestRun.count({ + where: { + organizationId: orgId, + createdAt: { + gte: subscription.currentPeriodStart, + lt: subscription.currentPeriodEnd, + }, + }, + }); + + const includedRuns = subscription.includedRunsPerPeriod; + usage = { + includedRuns, + usedRuns, + remainingRuns: Math.max(0, includedRuns - usedRuns), + currentPeriodEnd: subscription.currentPeriodEnd.toISOString(), + }; + } + + const pricing = await getPentestPricing(); + + return ; } export async function generateMetadata(): Promise { diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx index 8db96797ae..1f46fe7a40 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx @@ -216,7 +216,7 @@ describe('PenetrationTestsPageClient', () => { }); it('renders locked state when subscription is not active', () => { - render(); + render(); expect(screen.getByText('Find vulnerabilities before attackers do')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Get started/ })).toBeInTheDocument(); @@ -224,7 +224,7 @@ describe('PenetrationTestsPageClient', () => { }); it('renders an empty state and call-to-action when no reports exist', () => { - render(); + render(); expect(screen.getAllByText('No reports yet')).toHaveLength(2); expect(screen.getByRole('button', { name: 'Create your first report' })).toBeInTheDocument(); @@ -238,7 +238,7 @@ describe('PenetrationTestsPageClient', () => { resetError: vi.fn(), }); - const { getByText } = render(); + const { getByText } = render(); fireEvent.click(getByText('Create your first report')); @@ -256,7 +256,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [reportRows[1]], }); - render(); + render(); expect(screen.getByText('1 completed report')).toBeInTheDocument(); }); @@ -271,7 +271,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [reportRows[1], { ...reportRows[1], id: 'run_completed_2' }], }); - render(); + render(); expect(screen.getByText('2 reports in progress')).toBeInTheDocument(); }); @@ -286,7 +286,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [{ ...reportRows[1], id: 'run_completed_2' }, reportRows[1]], }); - render(); + render(); expect(screen.getByText('2 completed reports')).toBeInTheDocument(); }); @@ -319,7 +319,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('In progress (0/2)')).toBeInTheDocument(); }); @@ -334,7 +334,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [reportRows[1]], }); - render(); + render(); expect(screen.getByText('https://running.example.com')).toBeInTheDocument(); expect(screen.getByText('https://completed.example.com')).toBeInTheDocument(); @@ -370,7 +370,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('https://no-repo.example.com')).toBeInTheDocument(); expect(screen.getByText('—')).toBeInTheDocument(); @@ -386,7 +386,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - const { container } = render(); + const { container } = render(); expect(container.querySelector('.animate-spin')).toBeTruthy(); }); @@ -419,7 +419,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('In progress (1/2)')).toBeInTheDocument(); }); @@ -452,14 +452,14 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('In progress')).toBeInTheDocument(); expect(screen.queryByText('(n/a/n/a)')).toBeNull(); }); it('creates a report and navigates to the report detail page', async () => { - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -490,7 +490,7 @@ describe('PenetrationTestsPageClient', () => { }); it('requires target URL before submitting report request', async () => { - render(); + render(); const submitForm = screen.getByText('Start penetration test').closest('form'); await act(async () => { @@ -504,7 +504,7 @@ describe('PenetrationTestsPageClient', () => { }); it('creates a report without repository URL when only target is provided', async () => { - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -530,7 +530,7 @@ describe('PenetrationTestsPageClient', () => { it('surfaces errors when run creation fails', async () => { createReportMock.mockRejectedValue(new Error('No active pentest subscription.')); - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -558,7 +558,7 @@ describe('PenetrationTestsPageClient', () => { it('surfaces a generic error message when run creation fails with non-error value', async () => { createReportMock.mockRejectedValue('service-down'); - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -584,7 +584,7 @@ describe('PenetrationTestsPageClient', () => { }); it('shows a Connect GitHub button when GitHub is not connected', async () => { - render(); + render(); await act(async () => { fireEvent.click(screen.getByText('Create Report')); @@ -606,7 +606,7 @@ describe('PenetrationTestsPageClient', () => { isLoading: false, } as ReturnType); - render(); + render(); await act(async () => { fireEvent.click(screen.getByText('Create Report')); @@ -617,7 +617,7 @@ describe('PenetrationTestsPageClient', () => { }); it('starts GitHub OAuth when Connect GitHub button is clicked', async () => { - render(); + render(); await act(async () => { fireEvent.click(screen.getByText('Create Report')); @@ -629,4 +629,134 @@ describe('PenetrationTestsPageClient', () => { expect(startOAuthMock).toHaveBeenCalledWith('github', expect.any(String)); }); + + it('displays usage indicator when usage data is provided', () => { + render( + , + ); + + expect(screen.getByText('2/3 runs used this period')).toBeInTheDocument(); + }); + + it('shows dynamic dialog description based on remaining runs', async () => { + render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByText('Create Report')); + }); + + expect(screen.getByText('You have 2 of 3 included runs remaining this period.')).toBeInTheDocument(); + }); + + it('shows overage warning in dialog when no remaining runs', async () => { + render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByText('Create Report')); + }); + + expect(screen.getByText('You have used all included runs. This run will be charged at $49.')).toBeInTheDocument(); + }); + + it('shows overage confirmation dialog when submitting with zero remaining runs', async () => { + const { getByLabelText } = render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByText('Create Report')); + }); + + await act(async () => { + fireEvent.change(getByLabelText('Target URL'), { target: { value: 'https://example.com' } }); + fireEvent.click(screen.getByText('Start penetration test')); + }); + + expect(screen.getByText('Overage charge')).toBeInTheDocument(); + expect(screen.getByText(/This run will be charged at \$49\. Continue\?/)).toBeInTheDocument(); + expect(createReportMock).not.toHaveBeenCalled(); + }); + + it('creates report after confirming overage dialog', async () => { + const { getByLabelText } = render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByText('Create Report')); + }); + + await act(async () => { + fireEvent.change(getByLabelText('Target URL'), { target: { value: 'https://example.com' } }); + fireEvent.click(screen.getByText('Start penetration test')); + }); + + await act(async () => { + fireEvent.click(screen.getByText('Confirm & start')); + }); + + await waitFor(() => { + expect(createReportMock).toHaveBeenCalledWith({ + targetUrl: 'https://example.com', + repoUrl: undefined, + }); + }); + }); + + it('does not create report when overage dialog is cancelled', async () => { + const { getByLabelText } = render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByText('Create Report')); + }); + + await act(async () => { + fireEvent.change(getByLabelText('Target URL'), { target: { value: 'https://example.com' } }); + fireEvent.click(screen.getByText('Start penetration test')); + }); + + // Find the Cancel button in the overage dialog (there are multiple Cancel buttons) + const cancelButtons = screen.getAllByText('Cancel'); + await act(async () => { + fireEvent.click(cancelButtons[cancelButtons.length - 1]); + }); + + expect(createReportMock).not.toHaveBeenCalled(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx index dc41f934f5..e4d2850a3d 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx @@ -45,10 +45,20 @@ import { } from '@/hooks/use-integration-platform'; import { Button, PageHeader, PageLayout } from '@trycompai/design-system'; import { subscribeToPentestPlan } from './actions/billing'; +import type { PentestPricing } from './actions/billing'; + +interface PentestUsage { + includedRuns: number; + usedRuns: number; + remainingRuns: number; + currentPeriodEnd: string; +} interface PenetrationTestsPageClientProps { orgId: string; hasActiveSubscription: boolean; + usage: PentestUsage | null; + pricing: PentestPricing; } const hasProtocol = (value: string): boolean => /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value); @@ -69,11 +79,13 @@ const normalizeTargetUrl = (value: string): string | null => { } }; -export function PenetrationTestsPageClient({ orgId, hasActiveSubscription }: PenetrationTestsPageClientProps) { +export function PenetrationTestsPageClient({ orgId, hasActiveSubscription, usage, pricing }: PenetrationTestsPageClientProps) { const router = useRouter(); const [isSubscribing, setIsSubscribing] = useState(false); const [showNewRunDialog, setShowNewRunDialog] = useState(false); + const [showOverageConfirm, setShowOverageConfirm] = useState(false); + const [pendingPayload, setPendingPayload] = useState<{ targetUrl: string; repoUrl?: string } | null>(null); const [targetUrl, setTargetUrl] = useState(''); const [repoUrl, setRepoUrl] = useState(''); const [isConnectingGithub, setIsConnectingGithub] = useState(false); @@ -105,6 +117,22 @@ export function PenetrationTestsPageClient({ orgId, hasActiveSubscription }: Pen } }; + const executeCreateReport = async (payload: { targetUrl: string; repoUrl?: string }) => { + try { + const response = await createReport(payload); + + setTargetUrl(''); + setRepoUrl(''); + setShowNewRunDialog(false); + setShowOverageConfirm(false); + setPendingPayload(null); + toast.success('Penetration test queued successfully.'); + router.push(`/${orgId}/security/penetration-tests/${response.id}`); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Could not queue a new report'); + } + }; + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); const trimmedTargetUrl = targetUrl.trim(); @@ -118,20 +146,30 @@ export function PenetrationTestsPageClient({ orgId, hasActiveSubscription }: Pen return; } - try { - const response = await createReport({ - targetUrl: normalizedTargetUrl, - repoUrl: repoUrl.trim() || undefined, - }); + const payload = { + targetUrl: normalizedTargetUrl, + repoUrl: repoUrl.trim() || undefined, + }; - setTargetUrl(''); - setRepoUrl(''); + // If no remaining runs, show overage confirmation first + if (usage && usage.remainingRuns === 0) { + setPendingPayload(payload); setShowNewRunDialog(false); - toast.success('Penetration test queued successfully.'); - router.push(`/${orgId}/security/penetration-tests/${response.id}`); - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Could not queue a new report'); + setShowOverageConfirm(true); + return; } + + await executeCreateReport(payload); + }; + + const handleConfirmOverage = async () => { + if (!pendingPayload) return; + await executeCreateReport(pendingPayload); + }; + + const handleCancelOverage = () => { + setShowOverageConfirm(false); + setPendingPayload(null); }; const handleSubscribe = async () => { @@ -159,7 +197,7 @@ export function PenetrationTestsPageClient({ orgId, hasActiveSubscription }: Pen title="Find vulnerabilities before attackers do" description="Automated pen-tests against your apps and infrastructure with detailed reports and remediation guidance." features={[ - '3 pen-test runs included per month', + '1 pen-test run included per month', 'OWASP Top 10 and infrastructure coverage', 'Actionable remediation steps in every report', ]} @@ -175,7 +213,7 @@ export function PenetrationTestsPageClient({ orgId, hasActiveSubscription }: Pen Redirecting… ) : ( - 'Get started — $99/mo' + `Get started — ${pricing.subscriptionPrice}` )} } @@ -199,16 +237,17 @@ export function PenetrationTestsPageClient({ orgId, hasActiveSubscription }: Pen setShowNewRunDialog(true)}>Create Report +
+ {usage && ( + + {usage.usedRuns}/{usage.includedRuns} runs used this period + + )} + +
} > - Run penetration tests with Maced and review generated reports.{' '} - - Manage subscription - + Run penetration tests with Maced and review generated reports.
@@ -216,8 +255,11 @@ export function PenetrationTestsPageClient({ orgId, hasActiveSubscription }: Pen Queue a penetration test - Your subscription includes 3 penetration test runs per month. Additional runs are - charged as overage immediately. + {usage && usage.remainingRuns > 0 + ? `You have ${usage.remainingRuns} of ${usage.includedRuns} included runs remaining this period.` + : usage && usage.remainingRuns === 0 + ? `You have used all included runs. This run will be charged at ${pricing.overagePrice}.` + : 'Your subscription includes 1 penetration test run per month. Additional runs are charged as overage.'}
@@ -310,6 +352,32 @@ export function PenetrationTestsPageClient({ orgId, hasActiveSubscription }: Pen
+ { if (!open) handleCancelOverage(); }}> + + + Overage charge + + You have used all {usage?.includedRuns ?? 1} included runs this period. This run will be charged at {pricing.overagePrice}. Continue? + + + + + + + + + Your reports ({reports.length}) diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx index ef9aa71ca4..dc64e77990 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx @@ -1,7 +1,19 @@ import { auth } from '@/utils/auth'; import { db } from '@db'; +import { Alert, AlertDescription } from '@comp/ui/alert'; +import { Badge } from '@comp/ui/badge'; import { Button } from '@trycompai/design-system'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; +import { Separator } from '@comp/ui/separator'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@comp/ui/table'; +import { InfoIcon } from 'lucide-react'; import { headers } from 'next/headers'; import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; @@ -44,9 +56,6 @@ export default async function BillingPage({ params, searchParams }: BillingPageP let errorMessage: string | null = null; if (success === 'true' && session_id) { - // The webhook (checkout.session.completed) is the primary activation path. - // handleSubscriptionSuccess is a same-request fallback for the case where - // the webhook hasn't fired yet when the user lands back on this page. try { await handleSubscriptionSuccess(orgId, session_id); successMessage = 'Subscription activated! You can now create penetration test runs.'; @@ -70,39 +79,240 @@ export default async function BillingPage({ params, searchParams }: BillingPageP organizationId: orgId, createdAt: { gte: subscription.currentPeriodStart, - lte: subscription.currentPeriodEnd, + lt: subscription.currentPeriodEnd, }, }, }) : null; + const formatDate = (date: Date) => + date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + + const pentestIsActive = subscription?.status === 'active'; + const pentestIsCancelled = subscription?.status === 'cancelled'; + const pentestIsPastDue = subscription?.status === 'past_due'; + return ( -
+
{successMessage && ( -
- {successMessage} -
+ + {successMessage} + )} {errorMessage && ( -
- {errorMessage} -
+ + {errorMessage} + )} + {/* Subscriptions */} - Payment & Billing + Subscriptions - Manage your payment method for all app subscriptions. + Manage your active products and add-ons. - - {billing ? ( -
-

- Stripe customer connected. -

+ + + + + Product + Status + Usage + Renewal + + + + + {/* Compliance */} + + +
+ Compliance + Managed by account team +
+
+ + Active + + Custom + Custom + +
+ + {/* Security — Penetration Testing */} + + +
+ Penetration Testing + Security add-on +
+
+ + {pentestIsActive ? ( + Active + ) : pentestIsCancelled ? ( + Cancelled + ) : pentestIsPastDue ? ( + Past due + ) : ( + Not subscribed + )} + + + {pentestIsActive && runsThisPeriod !== null ? ( + + {runsThisPeriod} + /{subscription.includedRunsPerPeriod} runs + + ) : ( + + )} + + + {pentestIsActive ? ( + formatDate(subscription.currentPeriodEnd) + ) : pentestIsCancelled && subscription ? ( + + Ends {formatDate(subscription.currentPeriodEnd)} + + ) : ( + + )} + + + {pentestIsActive && billing ? ( + { + 'use server'; + const { url } = await createBillingPortalSession(orgId); + redirect(url); + }} + > + + + ) : pentestIsPastDue && billing ? ( +
{ + 'use server'; + const { url } = await createBillingPortalSession(orgId); + redirect(url); + }} + > + + + ) : ( +
{ + 'use server'; + const { url } = await subscribeToPentestPlan(orgId); + redirect(url); + }} + > + + + )} +
+
+
+
+
+ + + {/* Penetration Testing — expanded details (only when active) */} + {pentestIsActive && subscription && ( + + +
+
+ Penetration Testing + + Current billing period: {formatDate(subscription.currentPeriodStart)} — {formatDate(subscription.currentPeriodEnd)} + +
+ Active +
+
+ +
+
+

Included runs

+

{subscription.includedRunsPerPeriod}

+

per billing period

+
+
+

Used this period

+

{runsThisPeriod ?? 0}

+

+ {runsThisPeriod !== null && runsThisPeriod > subscription.includedRunsPerPeriod + ? `${runsThisPeriod - subscription.includedRunsPerPeriod} overage` + : `${Math.max(0, subscription.includedRunsPerPeriod - (runsThisPeriod ?? 0))} remaining`} +

+
+
+

Member since

+

+ {subscription.createdAt.toLocaleDateString(undefined, { month: 'short', year: 'numeric' })} +

+

+ {formatDate(subscription.createdAt)} +

+
+
+ + {runsThisPeriod !== null && ( + <> + +
+
+

Period usage

+ + {runsThisPeriod} of {subscription.includedRunsPerPeriod} runs + +
+
+
subscription.includedRunsPerPeriod + ? 'bg-destructive' + : runsThisPeriod === subscription.includedRunsPerPeriod + ? 'bg-orange-500' + : 'bg-primary' + }`} + style={{ width: `${Math.min(100, (runsThisPeriod / subscription.includedRunsPerPeriod) * 100)}%` }} + /> +
+ {runsThisPeriod > subscription.includedRunsPerPeriod && ( +

+ {runsThisPeriod - subscription.includedRunsPerPeriod} overage run{runsThisPeriod - subscription.includedRunsPerPeriod !== 1 ? 's' : ''} billed this period +

+ )} +
+ + )} + + + )} + + {/* Payment method — only show when there's an active self-serve subscription */} + {billing && pentestIsActive && ( + + +
+
+ Payment method + + Used for self-serve subscriptions like Penetration Testing. + +
{ 'use server'; @@ -110,76 +320,23 @@ export default async function BillingPage({ params, searchParams }: BillingPageP redirect(url); }} > -
- ) : ( -

- Payment method will be set up when you subscribe to an app below. -

- )} - -
+ + + )} - - - Penetration Testing - - $99/month — Includes 3 penetration test runs per period. Additional runs charged as - overage at $49/run. - - - - {subscription && subscription.status === 'active' ? ( -
-
- Status - {subscription.status} -
-
- Included runs / period - {subscription.includedRunsPerPeriod} -
- {runsThisPeriod !== null && ( -
- Runs used this period - - {runsThisPeriod} / {subscription.includedRunsPerPeriod} - -
- )} -
- Period ends - - {subscription.currentPeriodEnd.toLocaleDateString()} - -
-
- ) : subscription && subscription.status === 'cancelled' ? ( -

- Your subscription has been cancelled. Subscribe below to resume. -

- ) : ( -

- No active subscription. Subscribe to start running penetration tests. -

- )} - - {(!subscription || subscription.status === 'cancelled') && ( -
{ - 'use server'; - const { url } = await subscribeToPentestPlan(orgId); - redirect(url); - }} - > - -
- )} -
-
+ {/* Account-managed billing note */} +
+ +

+ Compliance billing is managed by your account team. For invoices, payment changes, or + billing questions related to Compliance, contact your account manager. +

+
); } diff --git a/apps/app/src/app/api/webhooks/stripe-pentest/route.test.ts b/apps/app/src/app/api/webhooks/stripe-pentest/route.test.ts new file mode 100644 index 0000000000..b9b4baab43 --- /dev/null +++ b/apps/app/src/app/api/webhooks/stripe-pentest/route.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { POST } from './route'; +import type Stripe from 'stripe'; + +// ─── Mocks ──────────────────────────────────────────────── + +const constructEventMock = vi.fn(); +const subscriptionsRetrieveMock = vi.fn(); + +vi.mock('@/lib/stripe', () => ({ + stripe: { + webhooks: { constructEvent: (...args: unknown[]) => constructEventMock(...args) }, + subscriptions: { retrieve: (...args: unknown[]) => subscriptionsRetrieveMock(...args) }, + }, +})); + +vi.mock('@/env.mjs', () => ({ + env: { STRIPE_PENTEST_WEBHOOK_SECRET: 'whsec_test_secret' }, +})); + +const dbBillingFindFirstMock = vi.fn(); +const dbPentestSubUpsertMock = vi.fn(); +const dbPentestSubUpdateManyMock = vi.fn(); + +vi.mock('@db', () => ({ + db: { + organizationBilling: { + findFirst: (...args: unknown[]) => dbBillingFindFirstMock(...args), + }, + pentestSubscription: { + upsert: (...args: unknown[]) => dbPentestSubUpsertMock(...args), + updateMany: (...args: unknown[]) => dbPentestSubUpdateManyMock(...args), + }, + }, +})); + +const headersMock = vi.fn(); +vi.mock('next/headers', () => ({ + headers: () => headersMock(), +})); + +// ─── Helpers ────────────────────────────────────────────── + +function makeRequest(body: string): Request { + return new Request('http://localhost/api/webhooks/stripe-pentest', { + method: 'POST', + body, + }); +} + +function makeStripeEvent(type: string, data: unknown): Stripe.Event { + return { + id: 'evt_test', + type, + data: { object: data }, + object: 'event', + api_version: '2026-01-28.clover', + created: Date.now() / 1000, + livemode: false, + pending_webhooks: 0, + request: null, + } as unknown as Stripe.Event; +} + +const PERIOD_START = 1709568000; // Mar 4 2024 +const PERIOD_END = 1712246400; // Apr 4 2024 + +function makeSubscriptionItem(priceId = 'price_test') { + return { + price: { id: priceId }, + current_period_start: PERIOD_START, + current_period_end: PERIOD_END, + }; +} + +// ─── Tests ──────────────────────────────────────────────── + +describe('Stripe Pentest Webhook', () => { + beforeEach(() => { + vi.clearAllMocks(); + headersMock.mockReturnValue(new Headers({ 'stripe-signature': 'sig_test' })); + }); + + it('returns 400 when stripe-signature header is missing', async () => { + headersMock.mockReturnValue(new Headers()); + const res = await POST(makeRequest('{}')); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('Missing stripe-signature header'); + }); + + it('returns 400 when signature verification fails', async () => { + constructEventMock.mockImplementation(() => { + throw new Error('Invalid signature'); + }); + const res = await POST(makeRequest('{}')); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('Invalid signature'); + }); + + // ── checkout.session.completed ────────────────────────── + + describe('checkout.session.completed', () => { + it('creates pentest subscription on successful checkout', async () => { + const event = makeStripeEvent('checkout.session.completed', { + mode: 'subscription', + customer: 'cus_test', + subscription: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbBillingFindFirstMock.mockResolvedValue({ + id: 'billing_1', + organizationId: 'org_1', + }); + subscriptionsRetrieveMock.mockResolvedValue({ + status: 'active', + items: { data: [makeSubscriptionItem()] }, + }); + dbPentestSubUpsertMock.mockResolvedValue({}); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbBillingFindFirstMock).toHaveBeenCalledWith({ + where: { stripeCustomerId: 'cus_test' }, + }); + expect(subscriptionsRetrieveMock).toHaveBeenCalledWith('sub_test'); + expect(dbPentestSubUpsertMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: { organizationId: 'org_1' }, + create: expect.objectContaining({ + organizationId: 'org_1', + organizationBillingId: 'billing_1', + stripeSubscriptionId: 'sub_test', + stripePriceId: 'price_test', + status: 'active', + }), + }), + ); + }); + + it('ignores non-subscription checkout sessions', async () => { + const event = makeStripeEvent('checkout.session.completed', { + mode: 'payment', + customer: 'cus_test', + }); + constructEventMock.mockReturnValue(event); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + expect(dbBillingFindFirstMock).not.toHaveBeenCalled(); + }); + + it('ignores checkout for unknown customer', async () => { + const event = makeStripeEvent('checkout.session.completed', { + mode: 'subscription', + customer: 'cus_unknown', + subscription: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbBillingFindFirstMock.mockResolvedValue(null); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + expect(dbPentestSubUpsertMock).not.toHaveBeenCalled(); + }); + }); + + // ── customer.subscription.updated ─────────────────────── + + describe('customer.subscription.updated', () => { + it('updates subscription status and period dates', async () => { + const event = makeStripeEvent('customer.subscription.updated', { + id: 'sub_test', + status: 'active', + items: { data: [makeSubscriptionItem()] }, + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockResolvedValue({ count: 1 }); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbPentestSubUpdateManyMock).toHaveBeenCalledWith({ + where: { stripeSubscriptionId: 'sub_test' }, + data: { + status: 'active', + currentPeriodStart: new Date(PERIOD_START * 1000), + currentPeriodEnd: new Date(PERIOD_END * 1000), + }, + }); + }); + + it('stores past_due status from Stripe', async () => { + const event = makeStripeEvent('customer.subscription.updated', { + id: 'sub_test', + status: 'past_due', + items: { data: [makeSubscriptionItem()] }, + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockResolvedValue({ count: 1 }); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbPentestSubUpdateManyMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'past_due' }), + }), + ); + }); + }); + + // ── customer.subscription.deleted ─────────────────────── + + describe('customer.subscription.deleted', () => { + it('sets status to cancelled', async () => { + const event = makeStripeEvent('customer.subscription.deleted', { + id: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockResolvedValue({ count: 1 }); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbPentestSubUpdateManyMock).toHaveBeenCalledWith({ + where: { stripeSubscriptionId: 'sub_test' }, + data: { status: 'cancelled' }, + }); + }); + }); + + // ── unknown events ────────────────────────────────────── + + it('ignores unknown event types', async () => { + const event = makeStripeEvent('invoice.paid', { id: 'inv_test' }); + constructEventMock.mockReturnValue(event); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + expect(dbPentestSubUpsertMock).not.toHaveBeenCalled(); + expect(dbPentestSubUpdateManyMock).not.toHaveBeenCalled(); + }); + + // ── error handling ────────────────────────────────────── + + it('returns 500 when handler throws', async () => { + const event = makeStripeEvent('customer.subscription.deleted', { + id: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockRejectedValue(new Error('DB error')); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe('Webhook handler failed'); + }); +}); diff --git a/packages/db/prisma/schema/pentest-subscription.prisma b/packages/db/prisma/schema/pentest-subscription.prisma index 791632998b..44d44bfd04 100644 --- a/packages/db/prisma/schema/pentest-subscription.prisma +++ b/packages/db/prisma/schema/pentest-subscription.prisma @@ -6,7 +6,7 @@ model PentestSubscription { stripePriceId String @map("stripe_price_id") stripeOveragePriceId String? @map("stripe_overage_price_id") status String @default("active") // active | cancelled | past_due - includedRunsPerPeriod Int @default(3) @map("included_runs_per_period") + includedRunsPerPeriod Int @default(1) @map("included_runs_per_period") currentPeriodStart DateTime @map("current_period_start") currentPeriodEnd DateTime @map("current_period_end") createdAt DateTime @default(now()) @map("created_at") diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index ba1cb02c54..52240bee5a 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -8288,6 +8288,44 @@ ] } }, + "/v1/internal/tasks/notify-bulk-automation-failures": { + "post": { + "operationId": "InternalTaskNotificationController_notifyBulkAutomationFailures_v1", + "parameters": [ + { + "name": "X-Internal-Token", + "in": "header", + "description": "Internal service token (required in production)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotifyBulkAutomationFailuresDto" + } + } + } + }, + "responses": { + "200": { + "description": "Notifications sent" + }, + "500": { + "description": "Notification delivery failed" + } + }, + "summary": "Send consolidated automation failure digest (email + in-app) for an org (internal)", + "tags": [ + "Internal - Tasks" + ] + } + }, "/v1/tasks/{taskId}/automations": { "get": { "description": "Retrieve all automations for a specific task", @@ -16227,6 +16265,51 @@ "tags": [ "Evidence Forms" ] + }, + "delete": { + "description": "Remove an evidence form submission for the active organization. Requires owner, admin, or auditor role.", + "operationId": "EvidenceFormsController_deleteSubmission_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "formType", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "submissionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Delete a submission", + "tags": [ + "Evidence Forms" + ] } }, "/v1/evidence-forms/{formType}/submissions": { @@ -16497,6 +16580,37 @@ ] } }, + "/v1/security-penetration-tests/github/repos": { + "get": { + "description": "Returns GitHub repositories accessible with the connected GitHub integration.", + "operationId": "SecurityPenetrationTestsController_listGithubRepos_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Repository list returned" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "List accessible GitHub repositories", + "tags": [ + "Security Penetration Tests" + ] + } + }, "/v1/security-penetration-tests/{id}": { "get": { "description": "Returns a penetration test run with progress metadata.", @@ -16658,7 +16772,7 @@ }, "/v1/security-penetration-tests/webhook": { "post": { - "description": "Receives callback payloads from the penetration test provider when a report is updated. Per-run webhook token validation is enforced when handshake state exists.", + "description": "Receives callback payloads from the penetration test provider when a run is updated. Per-run webhook token validation is enforced when handshake state exists.", "operationId": "SecurityPenetrationTestsController_handleWebhook_v1", "parameters": [ { @@ -16677,13 +16791,6 @@ "description": "Per-job webhook token used for handshake validation when callbacks are sent to Comp.", "schema": {} }, - { - "name": "orgId", - "required": false, - "in": "query", - "description": "Organization context for webhook processing when X-Organization-Id is not provided.", - "schema": {} - }, { "name": "X-Webhook-Token", "in": "header", @@ -19059,6 +19166,53 @@ "taskStatusChanged" ] }, + "FailedTaskDto": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "description": "Task ID" + }, + "taskTitle": { + "type": "string", + "description": "Task title" + }, + "failedCount": { + "type": "number", + "description": "Number of failed automations for this task" + }, + "totalCount": { + "type": "number", + "description": "Total number of automations for this task" + } + }, + "required": [ + "taskId", + "taskTitle", + "failedCount", + "totalCount" + ] + }, + "NotifyBulkAutomationFailuresDto": { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "description": "Organization ID" + }, + "tasks": { + "description": "Array of failed tasks with counts", + "type": "array", + "items": { + "$ref": "#/components/schemas/FailedTaskDto" + } + } + }, + "required": [ + "organizationId", + "tasks" + ] + }, "UpdateAutomationDto": { "type": "object", "properties": { @@ -20691,11 +20845,6 @@ "type": "string", "description": "Workspace identifier used by the pentest engine" }, - "mockCheckout": { - "type": "boolean", - "description": "Set false to reject non-mocked checkout flows for strict behavior", - "default": true - }, "webhookUrl": { "type": "string", "description": "Optional webhook URL to notify when report generation completes" From 1b06c7a2df585da012a0cab27fe097140123e3be Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Wed, 4 Mar 2026 16:32:00 -0500 Subject: [PATCH 6/8] fix(pentest): address PR review feedback - Fix animation interval spawning duplicate reset timers (added pausing flag) - Use stable nonce ref to prevent duplicate overage charges on retry - Remove dead getPentestUsage function and PentestUsage interface Co-Authored-By: Claude Opus 4.6 --- .../penetration-tests/actions/billing.ts | 39 ------------------- .../components/pentest-preview-animation.tsx | 8 +++- .../hooks/use-penetration-tests.ts | 10 +++-- 3 files changed, 14 insertions(+), 43 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts index db0189fd12..3ca39a0a87 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts @@ -240,13 +240,6 @@ export interface PentestPricing { overagePrice: string; // e.g. "$199" } -export interface PentestUsage { - includedRuns: number; - usedRuns: number; - remainingRuns: number; - currentPeriodEnd: string; -} - export async function preauthorizePentestRun( orgId: string, nonce: string, @@ -355,38 +348,6 @@ export async function preauthorizePentestRun( return { authorized: true, isOverage: true }; } -export async function getPentestUsage(orgId: string): Promise { - await requireOrgAdmin(orgId); - - const subscription = await db.pentestSubscription.findUnique({ - where: { organizationId: orgId }, - }); - - if (!subscription || subscription.status !== 'active') { - return null; - } - - const usedRuns = await db.securityPenetrationTestRun.count({ - where: { - organizationId: orgId, - createdAt: { - gte: subscription.currentPeriodStart, - lt: subscription.currentPeriodEnd, - }, - }, - }); - - const includedRuns = subscription.includedRunsPerPeriod; - const remainingRuns = Math.max(0, includedRuns - usedRuns); - - return { - includedRuns, - usedRuns, - remainingRuns, - currentPeriodEnd: subscription.currentPeriodEnd.toISOString(), - }; -} - function formatStripePrice(unitAmount: number | null, currency: string, interval?: string | null): string { const amount = (unitAmount ?? 0) / 100; const formatted = new Intl.NumberFormat('en-US', { diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx index 87c1a1df35..893748b3c3 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx @@ -44,7 +44,11 @@ export function PentestPreviewAnimation() { const interval = 50; let elapsed = 0; + let pausing = false; + const timer = setInterval(() => { + if (pausing) return; + elapsed += interval; const t = elapsed / totalDuration; @@ -54,9 +58,11 @@ export function PentestPreviewAnimation() { setVisibleFindings(findings.length); setCurrentAgent(agents.length - 1); - // Reset after a pause + // Pause, then reset once + pausing = true; setTimeout(() => { elapsed = 0; + pausing = false; setPhase('scanning'); setProgress(0); setVisibleFindings(0); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts index 6517b8570f..79140cf349 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts @@ -8,7 +8,7 @@ import type { PentestReportStatus, PentestRun, } from '@/lib/security/penetration-tests-client'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useSWRConfig } from 'swr'; import useSWR from 'swr'; import { isReportInProgress, sortReportsByUpdatedAtDesc } from '../lib'; @@ -237,6 +237,9 @@ export function useCreatePenetrationTest( const { mutate } = useSWRConfig(); const [isCreating, setIsCreating] = useState(false); const [error, setError] = useState(null); + // Stable nonce per target URL — prevents duplicate overage charges if the + // API call fails after billing succeeds and the user retries. + const nonceRef = useRef(crypto.randomUUID()); const createReport = useCallback( async (payload: CreatePayload): Promise => { @@ -244,8 +247,7 @@ export function useCreatePenetrationTest( setError(null); try { // Preauthorize billing before creating the run - const nonce = crypto.randomUUID(); - const authResult = await preauthorizePentestRun(organizationId, nonce); + const authResult = await preauthorizePentestRun(organizationId, nonceRef.current); if (!authResult.authorized) { throw new Error(authResult.error ?? 'Billing authorization failed.'); } @@ -320,6 +322,8 @@ export function useCreatePenetrationTest( } void mutate(reportListKey(organizationId)); void mutate(reportKey(organizationId, reportId)); + // Rotate nonce so the next submission gets a fresh idempotency key + nonceRef.current = crypto.randomUUID(); return data; } catch (reportError) { const message = From aad064240a5283e3f592ff855f2c0ef778ad8df1 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 3 Apr 2026 15:32:10 -0400 Subject: [PATCH 7/8] docs: rewrite README with cleaner structure and updated positioning Restructure to match current product messaging ("the agentic compliance platform"), remove duplicate sections, add table of contents, monorepo layout, and streamlined quick start guide. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 446 ++++++++++++++++-------------------------------------- 1 file changed, 132 insertions(+), 314 deletions(-) diff --git a/README.md b/README.md index 55a4cbfdaa..55852df2e6 100644 --- a/README.md +++ b/README.md @@ -1,361 +1,192 @@ - -

- - Logo +

+ + Comp AI logo +

Comp AI

+

The agentic compliance platform.

+

Get SOC 2 and ISO 27001 audit-ready in record time, backed by enterprise-grade cybersecurity.

+ +

+ Product Hunt + GitHub Stars + License + Commits per month +

-

Comp AI

- -

- The open-source compliance platform. -
- Learn more » -
-
- Discord - · - Website - · - Documentation - · +

+ Website · + Docs · + Discord · + Roadmap · Issues - · - Roadmap

-

- -

- Product Hunt - Github Stars - License - Commits-per-month - -

- -## About - -### AI that handles compliance for you in hours. - -Comp AI is the fastest way to get compliant with frameworks like SOC 2, ISO 27001, HIPAA and GDPR. Comp AI automates evidence collection, policy management, and control implementation while keeping you in control of your data and infrastructure. - -## Recognition - -#### [ProductHunt](https://www.producthunt.com/posts/comp-ai) - -Comp AI - The open source Vanta & Drata alternative | Product Hunt - -#### [Vercel](https://vercel.com/) - - - Vercel OSS Program - - -### Built With - -- [Next.js](https://nextjs.org/?ref=trycomp.ai) -- [Trigger.dev](https://trigger.dev/?ref=trycomp.ai) -- [Prisma](https://prisma.io/?ref=trycomp.ai) -- [Tailwind CSS](https://tailwindcss.com/?ref=trycomp.ai) -- [Upstash](https://upstash.com/?ref=trycomp.ai) -- [Vercel](https://vercel.com/?ref=trycomp.ai) +
-## Contact us +## Overview -Contact our founders at hello@trycomp.ai to learn more about how we can help you achieve compliance. +Comp AI automates compliance end-to-end: AI agents collect evidence from 500+ integrations, generate policies from your business context, and continuously monitor your security posture — all from a single, open-source platform. -## Stay Up-to-Date +- `apps/app` — main web app (Next.js 16, port `3000`) +- `apps/api` — backend API (NestJS, port `3001`) +- `apps/portal` — customer portal (Next.js 16, port `3002`) +- `apps/docs` — documentation site -Get access to the cloud hosted version of [Comp AI](https://trycomp.ai). +## Contents -## Getting Started +- [Quick start](#quick-start) +- [Monorepo layout](#monorepo-layout) +- [Run commands](#run-commands) +- [Environment setup](#environment-setup) +- [Database](#database) +- [Package publishing](#package-publishing) +- [Contributing](#contributing) +- [License](#license) -To get a local copy up and running, please follow these simple steps. +## Quick start ### Prerequisites -Here is what you need to be able to run Comp AI. +- Node.js `>=20` +- Bun `>=1.1.36` +- Docker (for Postgres) -- Node.js (Version: >=20.x) -- Bun (Version: >=1.1.36) -- Postgres (Version: >=15.x) +### Bootstrap -## Development - -To get the project working locally with all integrations, follow these extended development steps - -### Setup - -## Add environment variables and fill them out with your credentials +```bash +git clone https://github.com/trycompai/comp.git +cd comp +bun install -```sh +# Copy env files cp apps/app/.env.example apps/app/.env cp apps/portal/.env.example apps/portal/.env cp packages/db/.env.example packages/db/.env -``` - -## Get code running locally - -1. Clone the repo -```sh -git clone https://github.com/trycompai/comp.git -``` +# Start database +cd packages/db +bun run docker:up +bun run db:migrate +cd ../.. -2. Navigate to the project directory +# Generate Prisma clients +bun run db:generate -```sh -cd comp +# Start all apps +bun run dev ``` -3. Install dependencies using Bun +### Local endpoints -```sh -bun install -``` +- App: `http://localhost:3000` +- API: `http://localhost:3001` +- Portal: `http://localhost:3002` -4. Get Database Running +## Monorepo layout -```sh -cd packages/db -bun run docker:up # Spin up docker container -bun run db:migrate # Run migrations -``` +```text +apps/ + app/ Next.js main application + api/ NestJS backend API + portal/ Next.js customer portal + docs/ Documentation site + device-agent/ Electron desktop agent -5. Generate Prisma Types for each app - -```sh -cd apps/app -bun run db:generate -cd ../portal -bun run db:generate -cd ../api -bun run db:generate +packages/ + db/ Prisma schema, client, migrations + ui/ Shared component library + email/ Email templates (React Email) + kv/ Key-value store (Upstash Redis) + analytics/ Analytics utilities + auth/ Authentication (Better Auth) + integrations/ Third-party integrations + integration-platform/ Integration platform core + utils/ Shared utilities ``` -6. Run all apps in parallel from the root directory +## Run commands -```sh +```bash +# Development (all apps) bun run dev -``` ---- +# Generate Prisma clients (required after schema changes or pulling) +bun run db:generate -### Environment Setup +# Build all +bun run build -Create the following `.env` files and fill them out with your credentials +# Lint and type check +bun run lint +bun run check-types -- `comp/apps/app/.env` -- `comp/apps/portal/.env` -- `comp/packages/db/.env` +# Tests +bun run test +``` -You can copy from the `.env.example` files: +## Environment setup -### Linux / macOS +Create `.env` files from the examples: -```sh +```bash cp apps/app/.env.example apps/app/.env cp apps/portal/.env.example apps/portal/.env cp packages/db/.env.example packages/db/.env ``` -### Windows (Command Prompt) - -```cmd -copy apps\app\.env.example apps\app\.env -copy apps\portal\.env.example apps\portal\.env -copy packages\db\.env.example packages\db\.env -``` - -### Windows (PowerShell) - -```powershell -Copy-Item apps\app\.env.example -Destination apps\app\.env -Copy-Item apps\portal\.env.example -Destination apps\portal\.env -Copy-Item packages\db\.env.example -Destination packages\db\.env -``` - -Additionally, ensure the following required environment variables are added to `.env` in `comp/apps/app/.env`: +Required variables for `apps/app/.env`: ```env -AUTH_SECRET="" # Use `openssl rand -base64 32` to generate -DATABASE_URL="postgresql://user:password@host:port/database" -RESEND_API_KEY="" # Resend (https://resend.com/api-keys) - Resend Dashboard -> API Keys +AUTH_SECRET="" # openssl rand -base64 32 +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/comp" +RESEND_API_KEY="" # From https://resend.com/api-keys NEXT_PUBLIC_PORTAL_URL="http://localhost:3002" -REVALIDATION_SECRET="" # Use `openssl rand -base64 32` to generate +REVALIDATION_SECRET="" # openssl rand -base64 32 ``` -> ✅ Make sure you have all of these variables in your `.env` file. -> If you're copying from `.env.example`, it might be missing the last two (`NEXT_PUBLIC_PORTAL_URL` and `REVALIDATION_SECRET`), so be sure to add them manually. - -Some environment variables may not load correctly from `.env` — in such cases, **hard-code** the values directly in the relevant files (see Hardcoding section below). - ---- - -### Cloud & Auth Configuration +### Third-party services -#### 1. Trigger.dev +- **Google OAuth** — [Cloud Console](https://console.cloud.google.com/auth/clients). Add redirect URIs for `localhost:3000` and `localhost:3002`. +- **Trigger.dev** — [cloud.trigger.dev](https://cloud.trigger.dev). Create a project and set the project ID in `apps/app/trigger.config.ts`. +- **Upstash Redis** — [console.upstash.com](https://console.upstash.com). Create a Redis database and add the URL/token to `.env`. -- Create an account on [https://cloud.trigger.dev](https://cloud.trigger.dev) -- Create a project and copy the Project ID -- In `comp/apps/app/trigger.config.ts`, set: - ```ts - project: 'proj_****az***ywb**ob*'; - ``` +## Database -#### 2. Google OAuth - -- Go to [Google Cloud OAuth Console](https://console.cloud.google.com/auth/clients) -- Create an OAuth client: - - Type: Web Application - - Name: `comp_app` # You can choose a different name if you prefer! -- Add these **Authorized Redirect URIs**: - - ``` - http://localhost - http://localhost:3000 - http://localhost:3002 - http://localhost:3000/api/auth/callback/google - http://localhost:3002/api/auth/callback/google - http://localhost:3000/auth - http://localhost:3002/auth - ``` - -- After creating the app, copy the `GOOGLE_ID` and `GOOGLE_SECRET` - - Add them to your `.env` files - - If that doesn’t work, hard-code them in: - ``` - comp/apps/portal/src/app/lib/auth.ts - ``` - -#### 3. Redis (Upstash) - -- Go to [https://console.upstash.com](https://console.upstash.com) -- Create a Redis database -- Copy the **Redis URL** and **TOKEN** -- Add them to your `.env` file, or hard-code them if the environment variables are not being recognized in: - ``` - comp/packages/kv/src/index.ts - ``` - ---- - -### Database Setup - -Start and initialize the PostgreSQL database using Docker: - -1. Start the database: - - ```sh - cd packages/db - bun docker:up - ``` - -2. Default credentials: - - Database name: `comp` - - Username: `postgres` - - Password: `postgres` - -3. To change the default password: - - ```sql - ALTER USER postgres WITH PASSWORD 'new_password'; - ``` - -4. If you encounter the following error: - - ``` - HINT: No function matches the given name and argument types... - ``` - - Run the fix: - - ```sh - psql "postgresql://postgres:@localhost:5432/comp" -f ./packages/db/prisma/functionDefinition.sql - ``` - - Expected output: `CREATE FUNCTION` - - > 💡 `comp` is the database name. Make sure to use the correct **port** and **database name** for your setup. - -5. Apply schema and seed: - -```sh - # Generate Prisma client - bun db:generate - - # Push the schema to the database - bun db:push - - # Optional: Seed the database with initial data - bun db:seed -``` - -Other useful database commands: - -```sh -# Open Prisma Studio to view/edit data -bun db:studio - -# Run database migrations -bun db:migrate - -# Stop the database container -bun docker:down - -# Remove the database container and volume -bun docker:clean -``` - ---- +```bash +cd packages/db -### Start Development +# Start Postgres (Docker) +bun run docker:up -Once everything is configured: +# Run migrations +bun run db:migrate -```sh -bun run dev -``` +# Generate Prisma client +bun run db:generate -Or use the Turbo repo script: +# Push schema (no migration) +bun run db:push -```sh -turbo dev -``` +# Seed data +bun run db:seed -> 💡 Make sure you have Turbo installed. If not, you can install it using Bun: +# Open Prisma Studio +bun run db:studio -```sh -bun add -g turbo +# Stop / remove database +bun run docker:down +bun run docker:clean ``` -🎉 Yay! You now have a working local instance of Comp AI! 🚀 - -## Deployment - -### Docker +Default credentials: `postgres:postgres@localhost:5432/comp` -Steps to deploy Comp AI on Docker are coming soon. +## Package publishing -### Vercel +Published to npm via semantic-release on merges to `release`: -Steps to deploy Comp AI on Vercel are coming soon. - -## 📦 Package Publishing - -This repository uses semantic-release to automatically publish packages to npm when merging to the `release` branch. The following packages are published: - -- `@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 - -1. **NPM Token**: Add your npm token as `NPM_TOKEN` in GitHub repository secrets -2. **Release Branch**: Create and merge PRs into the `release` branch to trigger publishing -3. **Versioning**: Uses conventional commits for automatic version bumping - -### Usage +- `@trycompai/db` — Database utilities +- `@trycompai/email` — Email templates +- `@trycompai/kv` — Key-value store +- `@trycompai/ui` — Component library ```bash # Install a published package @@ -363,37 +194,24 @@ npm install @trycompai/ui # Use in your project import { Button } from '@trycompai/ui/button' -import { client } from '@trycompai/kv' ``` -### Development - -```bash -# Build all packages -bun run build +## Recognition -# Build specific package -bun run -F @trycompai/ui build +Comp AI — #1 Product of the Day -# Test packages locally -bun run release:packages --dry-run -``` +Vercel OSS Program -## Contributors +## Contributing -## Repo Activity - -![Alt](https://repobeats.axiom.co/api/embed/1371c2fe20e274ff1e0e8d4ca225455dea609cb9.svg 'Repobeats analytics image') - - +![Repo activity](https://repobeats.axiom.co/api/embed/1371c2fe20e274ff1e0e8d4ca225455dea609cb9.svg 'Repobeats analytics image') ## License -Comp AI, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition"]). +Comp AI, Inc. is a commercial open source company. The core technology (99%) is licensed under [AGPLv3](https://opensource.org/license/agpl-v3). Enterprise features under `/ee` require a commercial license. See [LICENSE](https://github.com/trycompai/comp/blob/main/LICENSE) for details. -> [!TIP] -> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/trycompai/comp/discussions) if you feel like something is wrong. +Open a [discussion](https://github.com/trycompai/comp/discussions) if you have questions about what's open vs commercial. From ee6d513caaf076362638324db355a66ede5664de Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Fri, 3 Apr 2026 17:15:51 -0400 Subject: [PATCH 8/8] fix: replace react-wrap-balancer with text-balance, fix prisma config, update theme colors - Remove react-wrap-balancer dependency, use Tailwind text-balance utility - Add dotenv import to packages/db prisma.config.ts so migrations work - Update primary color and add proper dark mode theme Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/PostPaymentOnboarding.tsx | 6 +-- packages/db/prisma.config.ts | 1 + packages/ui/src/globals.css | 48 +++++++++---------- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index 8f3968fc0b..50f2d2645b 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -9,7 +9,7 @@ import type { Organization } from '@db'; import { AnimatePresence, motion } from 'framer-motion'; import { AlertCircle, Loader2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import Balancer from 'react-wrap-balancer'; + import { usePostPaymentOnboarding } from '../hooks/usePostPaymentOnboarding'; interface PostPaymentOnboardingProps { @@ -157,12 +157,12 @@ export function PostPaymentOnboarding({

- {step?.question || ''} + {step?.question || ''}

- Our AI will personalize the platform based on your answers. + Our AI will personalize the platform based on your answers.

diff --git a/packages/db/prisma.config.ts b/packages/db/prisma.config.ts index 6e980e4651..b2bb85e4f8 100644 --- a/packages/db/prisma.config.ts +++ b/packages/db/prisma.config.ts @@ -1,3 +1,4 @@ +import "dotenv/config"; import { defineConfig } from "prisma/config"; export default defineConfig({ diff --git a/packages/ui/src/globals.css b/packages/ui/src/globals.css index 4c53e96623..3653680caf 100644 --- a/packages/ui/src/globals.css +++ b/packages/ui/src/globals.css @@ -9,7 +9,7 @@ --card-foreground: 0 0% 3.9%; --popover: 0 0% 100%; --popover-foreground: 0 0% 3.9%; - --primary: 165 100% 15%; + --primary: 162 100% 24%; --primary-foreground: 0 0% 98%; --secondary: 0 0% 96.1%; --secondary-foreground: 0 0% 9%; @@ -23,17 +23,17 @@ --warning-foreground: 26 83% 14%; --border: 0 0% 89.8%; --input: 0 0% 89.8%; - --ring: 165 100% 15%; + --ring: 162 100% 24%; --radius: 0.5rem; - --chart-primary: 165 100% 15%; + --chart-primary: 162 100% 24%; --chart-positive: 163 64% 21%; /* Green: use for published, or reviewed = the items are in a good state */ --chart-neutral: 45 89% 53%; /* Yellow: use for draft(s) and actions that need reviewing soon */ --chart-warning: 220 14% 53%; /* Gray: deleted, archived - states that have been closed */ --chart-destructive: 0 85% 60%; /* Red: use for needs review, something that needs action immediately */ --chart-other: 196 80% 45%; /* Blue: use for other states */ - --chart-closed: #004c3a; + --chart-closed: #007A55; --chart-pending: #0ba5e9; --chart-open: #ffc007; --chart-archived: #64748b; @@ -41,37 +41,37 @@ } .dark { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 165 100% 15%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; + --background: 0 0% 7%; + --foreground: 0 0% 98%; + --card: 0 0% 12%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 12%; + --popover-foreground: 0 0% 98%; + --primary: 155 100% 43%; + --primary-foreground: 166 100% 9%; + --secondary: 0 0% 15%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 15%; + --muted-foreground: 0 0% 64%; + --accent: 0 0% 20%; + --accent-foreground: 0 0% 98%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; --warning: 45 93% 47%; --warning-foreground: 26 83% 14%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 165 100% 15%; + --border: 0 0% 18%; + --input: 0 0% 18%; + --ring: 155 100% 43%; --radius: 0.5rem; - --chart-primary: 165 100% 15%; - --chart-positive: 163 64% 21%; /* Green: use for published, or reviewed = the items are in a good state */ + --chart-primary: 155 100% 43%; + --chart-positive: 155 100% 43%; /* Green: use for published, or reviewed = the items are in a good state */ --chart-neutral: 45 89% 53%; /* Yellow: use for draft(s) and actions that need reviewing soon */ --chart-warning: 220 14% 53%; /* Gray: deleted, archived - states that have been closed */ --chart-destructive: 0 85% 60%; /* Red: use for needs review, something that needs action immediately */ --chart-other: 196 80% 45%; /* Blue: use for other states */ - --chart-closed: #004c3a; + --chart-closed: #00DB80; --chart-pending: #0ba5e9; --chart-open: #ffc007; --chart-archived: #64748b;