Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 36 additions & 34 deletions apps/app/src/app/(app)/[orgId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -192,25 +192,27 @@ export default async function Layout({
initialToken={publicAccessToken || undefined}
>
<OrganizationIdentifier orgId={organization.id} orgName={organization.name} />
<AppShellWrapper
organization={organization}
organizations={organizations}
logoUrls={logoUrls}
onboarding={onboarding}
isCollapsed={isCollapsed}
isQuestionnaireEnabled={isQuestionnaireEnabled}
isTrustNdaEnabled={isTrustNdaEnabled}
isWebAutomationsEnabled={isWebAutomationsEnabled}
isSecurityEnabled={isSecurityEnabled}
hasAuditorRole={hasAuditorRole}
isOnlyAuditor={isOnlyAuditor}
canAccessAuditorView={auditorViewVisible}
permissions={permissions}
user={user}
isAdmin={isUserAdmin}
>
{children}
</AppShellWrapper>
<ServerFeatureFlagsProvider flags={featureFlags}>
<AppShellWrapper
organization={organization}
organizations={organizations}
logoUrls={logoUrls}
onboarding={onboarding}
isCollapsed={isCollapsed}
isQuestionnaireEnabled={isQuestionnaireEnabled}
isTrustNdaEnabled={isTrustNdaEnabled}
isWebAutomationsEnabled={isWebAutomationsEnabled}
isSecurityEnabled={isSecurityEnabled}
hasAuditorRole={hasAuditorRole}
isOnlyAuditor={isOnlyAuditor}
canAccessAuditorView={auditorViewVisible}
permissions={permissions}
user={user}
isAdmin={isUserAdmin}
>
{children}
</AppShellWrapper>
</ServerFeatureFlagsProvider>
<HotKeys />
</TriggerTokenProvider>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => (
<ServerFeatureFlagsProvider flags={{ 'is-timeline-enabled': true }}>
{children}
</ServerFeatureFlagsProvider>
),
});

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 }) => (
<ServerFeatureFlagsProvider flags={{ 'is-timeline-enabled': 'variant-a' }}>
{children}
</ServerFeatureFlagsProvider>
),
});

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 }) => (
<ServerFeatureFlagsProvider flags={{ 'is-timeline-enabled': false }}>
{children}
</ServerFeatureFlagsProvider>
),
});

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 }) => (
<ServerFeatureFlagsProvider flags={{ 'is-timeline-enabled': true }}>
{children}
</ServerFeatureFlagsProvider>
),
});

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(
<ServerFeatureFlagsProvider flags={{ 'is-timeline-enabled': true }}>
<OverviewTabs />
</ServerFeatureFlagsProvider>,
);

expect(screen.getByText('Timeline')).toBeInTheDocument();
});

it('hides the Timeline tab when the flag is off everywhere', () => {
useFeatureFlagEnabledMock.mockReturnValue(undefined);

render(
<ServerFeatureFlagsProvider flags={{}}>
<OverviewTabs />
</ServerFeatureFlagsProvider>,
);

expect(screen.queryByText('Timeline')).not.toBeInTheDocument();
});

it('shows the Timeline tab from the live client flag without server flags', () => {
useFeatureFlagEnabledMock.mockReturnValue(true);

render(<OverviewTabs />);

expect(screen.getByText('Timeline')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import { createContext, useContext } from 'react';

export type ServerFeatureFlags = Record<string, string | boolean>;

const ServerFeatureFlagsContext = createContext<ServerFeatureFlags | null>(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 (
<ServerFeatureFlagsContext.Provider value={flags}>
{children}
</ServerFeatureFlagsContext.Provider>
);
}

export function useServerFeatureFlags(): ServerFeatureFlags | null {
return useContext(ServerFeatureFlagsContext);
}
25 changes: 20 additions & 5 deletions packages/analytics/src/hooks/use-feature-flag.ts
Original file line number Diff line number Diff line change
@@ -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)
);
}
1 change: 1 addition & 0 deletions packages/analytics/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading