diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index d85c2e2314..b284e75bea 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -15,7 +15,7 @@ import type { OrganizationFromMe } from '@/types'; import { auth } from '@/utils/auth'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@/lib/s3-presigner'; -import { OrganizationIdentifier } from '@trycompai/analytics'; +import { OrganizationIdentifier, ServerFeatureFlagsProvider } from '@trycompai/analytics'; import { db, Role } from '@db/server'; import dynamic from 'next/dynamic'; import { cookies, headers } from 'next/headers'; @@ -146,21 +146,21 @@ export default async function Layout({ // Check feature flags for menu items. Security (penetration tests) is // always enabled now — the nav rail entry is gated solely by the // `pentest:read` permission downstream, matching `security/layout.tsx`. - let isQuestionnaireEnabled = false; - let isTrustNdaEnabled = false; - let isWebAutomationsEnabled = false; + // The full map is also provided to the client via ServerFeatureFlagsProvider + // so `useFeatureFlag` keeps working when posthog-js is blocked client-side. + const featureFlags = session?.user?.id + ? await getFeatureFlags(session.user.id, { + groups: { organization: organization.id }, + }) + : {}; + const isQuestionnaireEnabled = featureFlags['ai-vendor-questionnaire'] === true; + const isTrustNdaEnabled = + featureFlags['is-trust-nda-enabled'] === true || + featureFlags['is-trust-nda-enabled'] === 'true'; + const isWebAutomationsEnabled = + featureFlags['is-web-automations-enabled'] === true || + featureFlags['is-web-automations-enabled'] === 'true'; const isSecurityEnabled = true; - if (session?.user?.id) { - const flags = await getFeatureFlags(session.user.id, { - groups: { organization: organization.id }, - }); - isQuestionnaireEnabled = flags['ai-vendor-questionnaire'] === true; - isTrustNdaEnabled = - flags['is-trust-nda-enabled'] === true || flags['is-trust-nda-enabled'] === 'true'; - isWebAutomationsEnabled = - flags['is-web-automations-enabled'] === true || - flags['is-web-automations-enabled'] === 'true'; - } // Check auditor role const hasAuditorRole = roles.includes(Role.auditor); @@ -192,25 +192,27 @@ export default async function Layout({ initialToken={publicAccessToken || undefined} > - - {children} - + + + {children} + + ); diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/OverviewTabs.test.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/OverviewTabs.test.tsx new file mode 100644 index 0000000000..0114a7739d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/components/OverviewTabs.test.tsx @@ -0,0 +1,142 @@ +import { render, renderHook, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// The live posthog-js value is controlled per-test. `undefined` simulates a +// client whose /ingest/flags request is blocked (ad blocker, privacy browser, +// corporate proxy) — flags never load, so the hook never resolves. +const { useFeatureFlagEnabledMock } = vi.hoisted(() => ({ + useFeatureFlagEnabledMock: vi.fn<(flag: string) => boolean | undefined>(), +})); + +vi.mock('posthog-js/react', () => ({ + useFeatureFlagEnabled: (flag: string) => useFeatureFlagEnabledMock(flag), + usePostHog: () => null, + PostHogProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock('next/navigation', () => ({ + useParams: () => ({ orgId: 'org_test123' }), + usePathname: () => '/org_test123/overview', +})); + +vi.mock('@/hooks/use-findings-api', () => ({ + useOrganizationFindings: () => ({ data: undefined }), +})); + +vi.mock('@db', () => ({ + FindingStatus: { open: 'open' }, +})); + +import { ServerFeatureFlagsProvider, useFeatureFlag } from '@trycompai/analytics'; +import { OverviewTabs } from './OverviewTabs'; + +describe('useFeatureFlag server fallback', () => { + it('returns false when flags never load and no server flags are provided', () => { + useFeatureFlagEnabledMock.mockReturnValue(undefined); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled')); + + expect(result.current).toBe(false); + }); + + it('falls back to the server-evaluated value when flags never load', () => { + useFeatureFlagEnabledMock.mockReturnValue(undefined); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(true); + }); + + it('treats multivariate (string) server values as enabled', () => { + useFeatureFlagEnabledMock.mockReturnValue(undefined); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(true); + }); + + it('stays false when both live and server values are disabled', () => { + useFeatureFlagEnabledMock.mockReturnValue(false); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(false); + }); + + it('prefers an enabled server value over a stale persisted live=false', () => { + // posthog-js serves flags persisted from an older session even when the + // network is blocked — those can predate the admin toggle. The fresher + // server-side evaluation must win for enable rollouts. + useFeatureFlagEnabledMock.mockReturnValue(false); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(true); + }); + + it('returns true from the live value alone, without a provider', () => { + useFeatureFlagEnabledMock.mockReturnValue(true); + + const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled')); + + expect(result.current).toBe(true); + }); +}); + +describe('OverviewTabs timeline gating', () => { + it('shows the Timeline tab via server flags when the client cannot load flags', () => { + useFeatureFlagEnabledMock.mockReturnValue(undefined); + + render( + + + , + ); + + expect(screen.getByText('Timeline')).toBeInTheDocument(); + }); + + it('hides the Timeline tab when the flag is off everywhere', () => { + useFeatureFlagEnabledMock.mockReturnValue(undefined); + + render( + + + , + ); + + expect(screen.queryByText('Timeline')).not.toBeInTheDocument(); + }); + + it('shows the Timeline tab from the live client flag without server flags', () => { + useFeatureFlagEnabledMock.mockReturnValue(true); + + render(); + + expect(screen.getByText('Timeline')).toBeInTheDocument(); + }); +}); diff --git a/packages/analytics/src/components/server-feature-flags-provider.tsx b/packages/analytics/src/components/server-feature-flags-provider.tsx new file mode 100644 index 0000000000..f203628ce9 --- /dev/null +++ b/packages/analytics/src/components/server-feature-flags-provider.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export type ServerFeatureFlags = Record; + +const ServerFeatureFlagsContext = createContext(null); + +/** + * Provides feature flags evaluated server-side (posthog-node) as a fallback + * for `useFeatureFlag`. The client-side posthog-js flags request is routinely + * blocked by ad blockers / privacy browsers / corporate proxies — without this + * fallback, flag-gated UI silently never renders for those users. + */ +export function ServerFeatureFlagsProvider({ + flags, + children, +}: { + flags: ServerFeatureFlags; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useServerFeatureFlags(): ServerFeatureFlags | null { + return useContext(ServerFeatureFlagsContext); +} diff --git a/packages/analytics/src/hooks/use-feature-flag.ts b/packages/analytics/src/hooks/use-feature-flag.ts index 6f6e29e714..2f8fe7a6c1 100644 --- a/packages/analytics/src/hooks/use-feature-flag.ts +++ b/packages/analytics/src/hooks/use-feature-flag.ts @@ -1,14 +1,29 @@ 'use client'; import { useFeatureFlagEnabled } from 'posthog-js/react'; +import { useServerFeatureFlags } from '../components/server-feature-flags-provider'; /** * Returns whether a feature flag is enabled for the current user/group. - * Thin wrapper around posthog-js's reactive hook. Returns false until flags - * finish loading — callers should treat the flag as the source of truth, and - * create + toggle the flag via the admin UI (or PostHog) to enable features - * locally. + * + * The flag is on when EITHER source says so: + * - the live posthog-js value (fresh while the browser can reach PostHog), or + * - flags evaluated server-side and passed down via ServerFeatureFlagsProvider. + * + * The OR matters: when the client's flags request is blocked (ad blockers, + * privacy browsers, corporate proxies) the live value is `undefined` forever — + * or worse, a stale `false` persisted from an old session — and only the + * server-evaluated value can turn the feature on. Mirrors PostHog "enabled" + * semantics: `true` or any non-empty variant string counts as enabled. */ export function useFeatureFlag(flagKey: string): boolean { - return useFeatureFlagEnabled(flagKey) === true; + const liveValue = useFeatureFlagEnabled(flagKey); + const serverFlags = useServerFeatureFlags(); + const serverValue = serverFlags?.[flagKey]; + + return ( + liveValue === true || + serverValue === true || + (typeof serverValue === 'string' && serverValue.length > 0) + ); } diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index 45754873df..cc0ab9862d 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -27,4 +27,5 @@ export const Analytics = { export * from './components/page-view'; export * from './components/provider'; export * from './components/organization-identifier'; +export * from './components/server-feature-flags-provider'; export * from './hooks/use-feature-flag';