diff --git a/apps/api/src/trust-portal/is-trust-portal-configured.spec.ts b/apps/api/src/trust-portal/is-trust-portal-configured.spec.ts new file mode 100644 index 0000000000..b0fff76e5a --- /dev/null +++ b/apps/api/src/trust-portal/is-trust-portal-configured.spec.ts @@ -0,0 +1,45 @@ +import { isTrustPortalConfigured } from './is-trust-portal-configured'; + +const DEFAULTS = { + domain: null, + contactEmail: null, + overviewContent: null, + favicon: null, + faqs: null, + frameworkFlags: [false, false, false], + documentCount: 0, + resourceCount: 0, + customLinkCount: 0, +}; + +describe('isTrustPortalConfigured', () => { + it('returns false for a fresh portal on all defaults', () => { + expect(isTrustPortalConfigured(DEFAULTS)).toBe(false); + }); + + it('returns false when faqs is an empty array', () => { + expect(isTrustPortalConfigured({ ...DEFAULTS, faqs: [] })).toBe(false); + }); + + it.each([ + ['domain', { domain: 'trust.acme.com' }], + ['contactEmail', { contactEmail: 'security@acme.com' }], + ['overviewContent', { overviewContent: 'We are secure.' }], + ['favicon', { favicon: 'org/favicon.png' }], + ['faqs', { faqs: [{ question: 'q', answer: 'a', order: 0 }] }], + ['a framework flag', { frameworkFlags: [false, true, false] }], + ['a document', { documentCount: 1 }], + ['a compliance resource (certificate)', { resourceCount: 1 }], + ['a custom link', { customLinkCount: 1 }], + ])('returns true when %s is set', (_label, override) => { + expect(isTrustPortalConfigured({ ...DEFAULTS, ...override })).toBe(true); + }); + + it('ignores non-array faqs values', () => { + expect(isTrustPortalConfigured({ ...DEFAULTS, faqs: 'not-an-array' })).toBe(false); + }); + + it('returns false when frameworkFlags is an empty array', () => { + expect(isTrustPortalConfigured({ ...DEFAULTS, frameworkFlags: [] })).toBe(false); + }); +}); diff --git a/apps/api/src/trust-portal/is-trust-portal-configured.ts b/apps/api/src/trust-portal/is-trust-portal-configured.ts new file mode 100644 index 0000000000..cd5c06ccb7 --- /dev/null +++ b/apps/api/src/trust-portal/is-trust-portal-configured.ts @@ -0,0 +1,42 @@ +export interface TrustPortalConfiguredInput { + domain?: string | null; + contactEmail?: string | null; + overviewContent?: string | null; + favicon?: string | null; + /** Organization.trustPortalFaqs — Json?, expected to be an array when set. */ + faqs?: unknown; + /** + * Raw Trust framework "enabled" boolean columns (soc2, soc2type1, soc2type2, + * soc3, iso27001, iso42001, nen7510, gdpr, hipaa, pci_dss, iso9001, pipeda, + * ccpa). Order is irrelevant — any `true` counts as configured. The caller is + * responsible for passing all of them; a dropped column silently weakens the + * signal. Distinct from `resourceCount` (uploaded certificate files). + */ + frameworkFlags: boolean[]; + documentCount: number; + resourceCount: number; + customLinkCount: number; +} + +/** + * A Trust Portal is "configured" once the org has done anything beyond the + * shared-domain defaults. Used to decide whether to nudge the customer to set + * it up. Computed from RAW values (the settings endpoint substitutes a Context + * Hub default for overviewContent — do not pass the substituted value here). + */ +export function isTrustPortalConfigured(input: TrustPortalConfiguredInput): boolean { + const hasFaqs = Array.isArray(input.faqs) && input.faqs.length > 0; + const hasFramework = input.frameworkFlags.some(Boolean); + + return Boolean( + input.domain || + input.contactEmail || + input.overviewContent || + input.favicon || + hasFaqs || + hasFramework || + input.documentCount > 0 || + input.resourceCount > 0 || + input.customLinkCount > 0, + ); +} diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index f15e1a0dca..e89cb6b6da 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -36,6 +36,7 @@ import { TrustDocumentUrlResponseDto, UploadTrustDocumentDto, } from './dto/trust-document.dto'; +import { isTrustPortalConfigured } from './is-trust-portal-configured'; interface VercelDomainVerification { type: string; @@ -1533,8 +1534,42 @@ export class TrustPortalService { defaultOverviewContent = missionContext?.answer ?? null; } + const [trustDocumentCount, trustResourceCount, trustCustomLinkCount] = + await Promise.all([ + db.trustDocument.count({ where: { organizationId } }), + db.trustResource.count({ where: { organizationId } }), + db.trustCustomLink.count({ where: { organizationId } }), + ]); + + const isConfigured = isTrustPortalConfigured({ + domain: trust.domain, + contactEmail: trust.contactEmail, + overviewContent: trust.overviewContent, // raw column, not the Context fallback + favicon: trust.favicon, + faqs: org.trustPortalFaqs, + frameworkFlags: [ + trust.soc2, // legacy column; folded into soc2type2 in the response but still a "configured" signal + trust.soc2type1, + trust.soc2type2, + trust.soc3, + trust.iso27001, + trust.iso42001, + trust.nen7510, + trust.gdpr, + trust.hipaa, + trust.pci_dss, + trust.iso9001, + trust.pipeda, + trust.ccpa, + ], + documentCount: trustDocumentCount, + resourceCount: trustResourceCount, + customLinkCount: trustCustomLinkCount, + }); + return { enabled: trust.status === 'published', + isConfigured, friendlyUrl: trust.friendlyUrl, domain: trust.domain ?? '', domainVerified: trust.domainVerified ?? false, diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesBanner.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesCard.tsx similarity index 92% rename from apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesBanner.tsx rename to apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesCard.tsx index 772aecdfa9..484bf1d1b3 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesBanner.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesCard.tsx @@ -15,7 +15,12 @@ import { ChevronUp, Upgrade } from '@trycompai/design-system/icons'; import { useParams, useRouter } from 'next/navigation'; import { useState } from 'react'; -export function FrameworkUpdatesBanner() { +/** + * The framework-updates card, with NO outer layout wrapper — the host (e.g. the + * Overview nudge stack) owns width/spacing. Returns null when there are no + * updates, so it's safe to mount unconditionally. + */ +export function FrameworkUpdatesCard() { const { data: statuses } = useFrameworkUpdateStatuses(); const { hasPermission } = usePermissions(); const router = useRouter(); @@ -29,7 +34,6 @@ export function FrameworkUpdatesBanner() { const count = statuses.length; return ( -
@@ -88,6 +92,5 @@ export function FrameworkUpdatesBanner() {
-
); } diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx deleted file mode 100644 index 5537fbdb9d..0000000000 --- a/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client'; - -import { useApiSWR } from '@/hooks/use-api-swr'; -import { WarningAlt, Close } from '@trycompai/design-system/icons'; -import Link from 'next/link'; -import { useParams } from 'next/navigation'; -import { useState } from 'react'; - -interface PendingMember { - memberId: string; - name: string; -} - -interface PendingResponse { - members: PendingMember[]; -} - -export function OffboardingBanner() { - const params = useParams<{ orgId: string }>(); - const { data, error } = useApiSWR( - '/v1/offboarding-checklist/pending', - ); - const members = data?.data?.members ?? []; - const [dismissed, setDismissed] = useState(false); - - if (error || dismissed || members.length === 0) return null; - - const link = members.length === 1 - ? `/${params.orgId}/people/${members[0].memberId}?tab=offboarding` - : `/${params.orgId}/people`; - - return ( -
-
- - - - {members.length} employee{members.length !== 1 ? 's' : ''} - {' '} - require{members.length === 1 ? 's' : ''} offboarding completion - -
-
- - View details - - -
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx index 1cdd083538..ddfe0746f3 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx @@ -4,7 +4,6 @@ import { FrameworkEditorFramework, Policy, Task } from '@db'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; import { ComplianceOverview } from './ComplianceOverview'; import { FrameworksOverview } from './FrameworksOverview'; -import { OffboardingBanner } from './OffboardingBanner'; import { ToDoOverview } from './ToDoOverview'; import { FrameworkInstanceWithComplianceScore } from './types'; @@ -71,7 +70,6 @@ export const Overview = ({ return (
-
0, + // The card keeps its own look and has no dismiss affordance, so ignore onDismiss. + render: () => , + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/NudgeCenter.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/NudgeCenter.tsx new file mode 100644 index 0000000000..cda2a0e3ff --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/NudgeCenter.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { ChevronDown, ChevronUp } from '@trycompai/design-system/icons'; +import type { ReactNode } from 'react'; + +const MAX_PEEK_LAYERS = 2; + +/** + * Groups multiple notification nudges. Collapsed, it renders the top nudge on a + * "pile" — a sliver of the cards behind it peeks out — with a toggle chip + * overlaid on the bottom edge to fan the whole stack open. Expanded, every + * nudge is shown in a vertical list. + */ +export function NudgeCenter({ + count, + expanded, + onToggle, + children, +}: { + count: number; + expanded: boolean; + onToggle: () => void; + children: ReactNode; +}) { + const peekLayers = Math.min(count - 1, MAX_PEEK_LAYERS); + + const toggle = (positionClass: string) => ( + + ); + + if (expanded) { + return ( +
+
{children}
+ {toggle('')} +
+ ); + } + + // Collapsed: top nudge on a pile, with the toggle chip overlaid on the + // bottom-center edge. Padding reserves room for the peeks + the chip overhang. + return ( +
+ {/* `isolate` keeps the stack's own stacking context so the peek layers + sit just behind the top card (not behind an ancestor background). */} +
+ {Array.from({ length: peekLayers }).map((_, i) => { + const depth = i + 1; + return ( +
+ ); + })} +
{children}
+ {toggle( + 'absolute bottom-0 left-1/2 z-20 -translate-x-1/2 translate-y-[65%]', + )} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/OffboardingNudge.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/OffboardingNudge.tsx new file mode 100644 index 0000000000..910cc070e3 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/OffboardingNudge.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useApiSWR } from '@/hooks/use-api-swr'; +import { Alert, AlertAction, AlertTitle, Button } from '@trycompai/design-system'; +import { ArrowRight, Close } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import type { NudgeState } from './types'; + +interface PendingMember { + memberId: string; + name: string; +} + +interface PendingResponse { + members: PendingMember[]; +} + +export function useOffboardingNudge(): NudgeState { + const { orgId } = useParams<{ orgId: string }>(); + const { data, error } = useApiSWR( + '/v1/offboarding-checklist/pending', + ); + const members = data?.data?.members ?? []; + + return { + id: 'offboarding', + priority: 10, + persistDismissal: false, + ready: data !== undefined || error !== undefined, + eligible: !error && members.length > 0, + render: (onDismiss) => ( + + ), + }; +} + +function OffboardingNudgeView({ + orgId, + members, + onDismiss, +}: { + orgId: string; + members: PendingMember[]; + onDismiss: () => void; +}) { + const link = + members.length === 1 + ? `/${orgId}/people/${members[0].memberId}?tab=offboarding` + : `/${orgId}/people`; + + return ( + + + + {`${members.length} employee${members.length !== 1 ? 's' : ''} require${ + members.length === 1 ? 's' : '' + } offboarding completion`} + + +
+ +
+ + + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.test.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.test.tsx new file mode 100644 index 0000000000..10d61f5005 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.test.tsx @@ -0,0 +1,200 @@ +import { render, screen } from '@testing-library/react'; +import { fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockHasPermission, setMockPermissions } from '@/test-utils/mocks/permissions'; + +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ permissions: {}, hasPermission: mockHasPermission }), +})); + +const mockUseApiSWR = vi.fn(); +vi.mock('@/hooks/use-api-swr', () => ({ + useApiSWR: () => mockUseApiSWR(), +})); + +const mockUseFrameworkUpdateStatuses = vi.fn(); +vi.mock('@/hooks/use-framework-update-statuses', () => ({ + useFrameworkUpdateStatuses: () => mockUseFrameworkUpdateStatuses(), +})); + +vi.mock('../components/FrameworkUpdatesCard', () => ({ + FrameworkUpdatesCard: () =>
framework updates available
, +})); + +vi.mock('next/navigation', () => ({ + useParams: () => ({ orgId: 'org_123' }), + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('next/link', () => ({ + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); + +vi.mock('@trycompai/design-system', () => ({ + Alert: ({ children }: any) =>
{children}
, + AlertAction: ({ children }: any) =>
{children}
, + AlertTitle: ({ children }: any) =>
{children}
, + AlertDescription: ({ children }: any) =>
{children}
, + Button: ({ children }: any) => {children}, +})); + +vi.mock('@trycompai/design-system/icons', () => ({ + Close: () => x, + WarningAlt: () => !, + ChevronDown: () => v, + ChevronUp: () => ^, + ArrowRight: () => , +})); + +import { OverviewNudges } from './OverviewNudges'; + +const TRUST_PERMS = { trust: ['read', 'update'] }; + +function setOffboarding(members: { memberId: string; name: string }[]) { + mockUseApiSWR.mockReturnValue({ data: { data: { members } }, error: undefined }); +} + +function setFrameworkUpdates(items: { frameworkInstanceId: string }[]) { + mockUseFrameworkUpdateStatuses.mockReturnValue({ data: items, error: undefined }); +} + +describe('OverviewNudges', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + setOffboarding([]); // default: no offboarding + setFrameworkUpdates([]); // default: no framework updates + setMockPermissions(TRUST_PERMS); + }); + + const server = (over?: Partial<{ isTrustNdaEnabled: boolean; isConfigured: boolean }>) => ({ + trust: { isTrustNdaEnabled: true, isConfigured: false, ...over }, + }); + + it('shows the trust nudge when enabled, not configured, and user can update', () => { + render(); + expect(screen.getByText('Showcase your security posture')).toBeInTheDocument(); + }); + + it('hides the trust nudge when already configured', () => { + render(); + expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + }); + + it('hides the trust nudge when the feature flag is off', () => { + render(); + expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + }); + + it('hides the trust nudge without trust:update', () => { + setMockPermissions({ trust: ['read'] }); + render(); + expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + }); + + it('collapses to the top nudge with a stack control when several apply', () => { + setOffboarding([{ memberId: 'm1', name: 'Jo' }]); + render(); + // Offboarding (priority 10) is shown; trust waits behind the stack. + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + // The user is told more are waiting. + expect(screen.getByText('2 notices')).toBeInTheDocument(); + }); + + it('expands the stack to reveal every waiting nudge, then collapses', () => { + setOffboarding([{ memberId: 'm1', name: 'Jo' }]); + render(); + + fireEvent.click(screen.getByText('2 notices')); + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + expect(screen.getByText('Showcase your security posture')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Show less')); + expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + expect(screen.getByText('2 notices')).toBeInTheDocument(); + }); + + it('shows no stack control when only one nudge applies', () => { + render(); + expect(screen.getByText('Showcase your security posture')).toBeInTheDocument(); + expect(screen.queryByText(/\d+ notices/)).not.toBeInTheDocument(); + }); + + it('dismissing the trust nudge hides it and persists', () => { + const { unmount } = render(); + fireEvent.click(screen.getByLabelText('Dismiss')); + expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + unmount(); + render(); + expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + }); + + it('dismissing offboarding hides it for the session without persisting', () => { + setOffboarding([{ memberId: 'm1', name: 'Jo' }]); + // isConfigured: true so the trust nudge does not appear after offboarding is dismissed + const { unmount } = render( + , + ); + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Dismiss')); + expect(screen.queryByText(/offboarding completion/)).not.toBeInTheDocument(); + expect( + window.localStorage.getItem('overview-nudge-dismissed:offboarding:org_123'), + ).toBeNull(); + + // Not persisted → reappears on remount. + unmount(); + render(); + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + }); + + it('renders nothing while offboarding is loading and trust is ineligible', () => { + mockUseApiSWR.mockReturnValue({ data: undefined, error: undefined }); // SWR loading + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows the framework updates nudge when it is the only one eligible', () => { + setFrameworkUpdates([{ frameworkInstanceId: 'fi_1' }]); + // isConfigured: true → trust off; no offboarding → framework is the only nudge. + render(); + expect(screen.getByText('framework updates available')).toBeInTheDocument(); + expect(screen.queryByText(/\d+ notices/)).not.toBeInTheDocument(); + }); + + it('orders framework updates last in the stack (offboarding, trust, framework)', () => { + setOffboarding([{ memberId: 'm1', name: 'Jo' }]); + setFrameworkUpdates([{ frameworkInstanceId: 'fi_1' }]); + render(); + + // Collapsed: only offboarding (priority 10) on top; the other two wait. + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + expect(screen.queryByText('framework updates available')).not.toBeInTheDocument(); + expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + expect(screen.getByText('3 notices')).toBeInTheDocument(); + + // Expanded: all three shown, framework updates rendered after the trust nudge. + fireEvent.click(screen.getByText('3 notices')); + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + const trustEl = screen.getByText('Showcase your security posture'); + const frameworkEl = screen.getByText('framework updates available'); + expect( + trustEl.compareDocumentPosition(frameworkEl) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('excludes framework updates from the count while its data is loading', () => { + setOffboarding([{ memberId: 'm1', name: 'Jo' }]); + mockUseFrameworkUpdateStatuses.mockReturnValue({ data: undefined, error: undefined }); + render(); + // offboarding + trust = 2; framework not ready, so it doesn't inflate the count. + expect(screen.getByText('2 notices')).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.tsx new file mode 100644 index 0000000000..d3eab5128a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useFrameworkUpdatesNudge } from './FrameworkUpdatesNudge'; +import { NudgeCenter } from './NudgeCenter'; +import { useOffboardingNudge } from './OffboardingNudge'; +import { useTrustPortalSetupNudge } from './TrustPortalSetupNudge'; +import type { NudgeState, ServerNudgeData } from './types'; + +const dismissKey = (id: string, orgId: string) => + `overview-nudge-dismissed:${id}:${orgId}`; + +export function OverviewNudges({ + orgId, + server, +}: { + orgId: string; + server: ServerNudgeData; +}) { + // Hooks called unconditionally, in stable priority order. + const offboarding = useOffboardingNudge(); + const frameworkUpdates = useFrameworkUpdatesNudge(); + const trust = useTrustPortalSetupNudge({ orgId, server }); + const candidates = [offboarding, frameworkUpdates, trust]; + + // Stable across renders unless a persistable nudge is added/removed. + const persistableIds = candidates + .filter((c) => c.persistDismissal) + .map((c) => c.id) + .join(','); + + const [dismissed, setDismissed] = useState>(new Set()); + const [expanded, setExpanded] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + const next = new Set(); + for (const id of persistableIds.split(',').filter(Boolean)) { + if (window.localStorage.getItem(dismissKey(id, orgId)) === '1') { + next.add(id); + } + } + setDismissed(next); + }, [orgId, persistableIds]); + + const visible = candidates + .filter((c) => c.ready && c.eligible && !dismissed.has(c.id)) + .sort((a, b) => a.priority - b.priority); + + // Collapse the tray whenever there's no longer more than one to fan out. + useEffect(() => { + if (visible.length <= 1 && expanded) setExpanded(false); + }, [visible.length, expanded]); + + if (!mounted || visible.length === 0) return null; + + const dismiss = (nudge: NudgeState) => () => { + if (nudge.persistDismissal) { + window.localStorage.setItem(dismissKey(nudge.id, orgId), '1'); + } + setDismissed((prev) => new Set(prev).add(nudge.id)); + }; + + const body = + visible.length === 1 ? ( + visible[0].render(dismiss(visible[0])) + ) : ( + setExpanded((prev) => !prev)} + > + {(expanded ? visible : visible.slice(0, 1)).map((nudge) => ( +
{nudge.render(dismiss(nudge))}
+ ))} +
+ ); + + // Match the page's centered content width so nudges align with everything else. + return
{body}
; +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx new file mode 100644 index 0000000000..ac4edb4f2f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { usePermissions } from '@/hooks/use-permissions'; +import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, + Button, +} from '@trycompai/design-system'; +import { ArrowRight, Close } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import type { NudgeState, ServerNudgeData } from './types'; + +export function useTrustPortalSetupNudge({ + orgId, + server, +}: { + orgId: string; + server: ServerNudgeData; +}): NudgeState { + const { hasPermission } = usePermissions(); + const canSetup = hasPermission('trust', 'update'); + const { isTrustNdaEnabled, isConfigured } = server.trust; + + return { + id: 'trust-portal-setup', + priority: 20, + persistDismissal: true, + ready: true, // server data already resolved + eligible: isTrustNdaEnabled && !isConfigured && canSetup, + render: (onDismiss) => ( + + ), + }; +} + +function TrustPortalSetupNudgeView({ + orgId, + onDismiss, +}: { + orgId: string; + onDismiss: () => void; +}) { + return ( + + + Showcase your security posture + + + + Show prospects and vendors your compliance progress — enable the + frameworks you’re working on and your published policies appear + automatically. + + +
+ +
+ + + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/types.ts b/apps/app/src/app/(app)/[orgId]/overview/nudges/types.ts new file mode 100644 index 0000000000..f3d4c92c3d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/types.ts @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react'; + +/** Server-resolved data the Overview page passes into the nudge host. */ +export interface ServerNudgeData { + trust: { + isTrustNdaEnabled: boolean; + isConfigured: boolean; + }; +} + +/** + * One candidate nudge. The host picks the lowest-`priority` candidate that is + * `ready && eligible && !dismissed` and renders it — at most one at a time. + */ +export interface NudgeState { + id: string; + /** Lower number wins (shown first). */ + priority: number; + /** true → dismissal persists in localStorage; false → session-only. */ + persistDismissal: boolean; + /** false while underlying data is still loading. */ + ready: boolean; + /** Has something to show AND the user is allowed to act on it. */ + eligible: boolean; + /** Renders the nudge UI; called once for the single visible nudge. */ + render: (onDismiss: () => void) => ReactNode; +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/page.tsx b/apps/app/src/app/(app)/[orgId]/overview/page.tsx index 221477b79d..4a2dc4c64b 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/page.tsx @@ -1,9 +1,12 @@ +import { getFeatureFlags } from '@/app/posthog'; import { serverApi } from '@/lib/api-server'; +import { auth } from '@/utils/auth'; import type { FrameworkEditorFramework, Policy, Task } from '@db'; import { PageHeader, PageLayout } from '@trycompai/design-system'; -import { FrameworkUpdatesBanner } from './components/FrameworkUpdatesBanner'; +import { headers } from 'next/headers'; import { Overview } from './components/Overview'; import { OverviewTabs } from './components/OverviewTabs'; +import { OverviewNudges } from './nudges/OverviewNudges'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; export async function generateMetadata() { @@ -34,16 +37,32 @@ interface ScoresResponse { export default async function OverviewPage({ params }: { params: Promise<{ orgId: string }> }) { const { orgId: organizationId } = await params; - const [scoresRes, frameworksRes, availableRes] = await Promise.all([ + const requestHeaders = await headers(); + const session = await auth.api.getSession({ headers: requestHeaders }); + + const [scoresRes, frameworksRes, availableRes, settingsRes] = await Promise.all([ serverApi.get('/v1/frameworks/scores'), serverApi.get<{ data: FrameworkWithScore[] }>('/v1/frameworks?includeControls=true&includeScores=true'), serverApi.get<{ data: FrameworkEditorFramework[] }>('/v1/frameworks/available'), + serverApi.get<{ isConfigured?: boolean }>('/v1/trust-portal/settings'), ]); const scores = scoresRes.data; const frameworksData = frameworksRes.data?.data ?? []; const allFrameworks = availableRes.data?.data ?? []; + let isTrustNdaEnabled = false; + if (session?.user?.id) { + const flags = await getFeatureFlags(session.user.id, { + groups: { organization: organizationId }, + }); + isTrustNdaEnabled = + flags['is-trust-nda-enabled'] === true || flags['is-trust-nda-enabled'] === 'true'; + } + + // Fail closed: if we can't determine state, don't nudge. + const isTrustConfigured = settingsRes.data?.isConfigured ?? true; + const frameworksWithControls = frameworksData.map( ({ complianceScore: _score, ...fw }: FrameworkWithScore) => fw, ); @@ -54,7 +73,10 @@ export default async function OverviewPage({ params }: { params: Promise<{ orgId return ( <> - + } />}> ({ + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); + +vi.mock('@trycompai/design-system', () => ({ + Alert: ({ children }: any) =>
{children}
, + AlertTitle: ({ children }: any) =>
{children}
, + AlertDescription: ({ children }: any) =>
{children}
, +})); + +import { TrustPortalGettingStarted } from './TrustPortalGettingStarted'; + +describe('TrustPortalGettingStarted', () => { + it('renders the live shared portal URL', () => { + render(); + expect(screen.getByText(/trust.inc\/org_123/)).toBeInTheDocument(); + }); + + it('renders the getting-started heading', () => { + render(); + expect( + screen.getByText(/finish setting up your trust portal/i), + ).toBeInTheDocument(); + }); + + it('renders the setup steps', () => { + render(); + expect(screen.getByText(/frameworks you/i)).toBeInTheDocument(); + expect(screen.getByText(/published policies show automatically/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.tsx new file mode 100644 index 0000000000..f8e5fcd65f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.tsx @@ -0,0 +1,34 @@ +import { Alert, AlertDescription, AlertTitle } from '@trycompai/design-system'; +import Link from 'next/link'; + +const STEPS = [ + 'Enable the frameworks you’re working toward to show prospects and vendors your compliance progress — no certificate needed yet.', + 'Your published policies show automatically — drafts and in-progress updates stay private.', + 'Add a custom domain and contact email to make it your own.', +]; + +export function TrustPortalGettingStarted({ portalUrl }: { portalUrl: string }) { + return ( + + + Finish setting up your Trust Portal + + + + Your Trust Portal is at{' '} + + {portalUrl} + + . Put it to work so prospects and vendors can see where you stand: + + + {/* variant="info" renders an icon, so Alert is a 2-col grid; place the + list in the text column like the title/description slots above. */} +
    + {STEPS.map((step) => ( +
  • {step}
  • + ))} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/trust/page.tsx b/apps/app/src/app/(app)/[orgId]/trust/page.tsx index b980b263c3..2dac763620 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/page.tsx @@ -3,6 +3,7 @@ import { Button, PageHeader, PageLayout } from '@trycompai/design-system'; import { Launch } from '@trycompai/design-system/icons'; import type { Metadata } from 'next'; import Link from 'next/link'; +import { TrustPortalGettingStarted } from './components/TrustPortalGettingStarted'; import { TrustPortalSwitch } from './portal-settings/components/TrustPortalSwitch'; export default async function TrustPage({ @@ -26,6 +27,7 @@ export default async function TrustPage({ ]); const settings = settingsRes.data as any; + const isTrustConfigured = settings?.isConfigured ?? true; const customLinks = Array.isArray(customLinksRes.data) ? customLinksRes.data : []; @@ -96,6 +98,7 @@ export default async function TrustPage({ /> } > + {!isTrustConfigured && }