mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(ui): add PostHog-backed in-app feedback survey (#12116)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
This commit is contained in:
co-authored by
Pablo F.G
parent
1bb3fc9bda
commit
7f1cdb82ae
+1
-1
@@ -87,7 +87,7 @@ ENV HOSTNAME="0.0.0.0"
|
||||
# NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work:
|
||||
# UI_SENTRY_ENABLED + UI_SENTRY_DSN (+ optional UI_SENTRY_ENVIRONMENT)
|
||||
# UI_GOOGLE_TAG_MANAGER_ENABLED + UI_GOOGLE_TAG_MANAGER_ID
|
||||
# UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (no consumer yet)
|
||||
# UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (feedback survey)
|
||||
# - reserved: REO_DEV_CLIENT_ID (no consumer yet)
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
|
||||
@@ -17,6 +17,7 @@ import { NavigationProgress } from "@/components/shadcn/navigation-progress";
|
||||
import { Toaster } from "@/components/shadcn/toast";
|
||||
import { TaskPollingWatcher } from "@/components/shared/task-polling-watcher";
|
||||
import { GlobalSidePanel } from "@/components/side-panel";
|
||||
import { FeedbackSurvey } from "@/components/survey/feedback-survey";
|
||||
import { fontMono, fontSans } from "@/config/fonts";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
@@ -52,8 +53,8 @@ export default async function RootLayout({
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Onboarding is Cloud-only; skip its fetches and orchestrators in OSS.
|
||||
const onboardingEnabled = isCloud();
|
||||
// Skip Cloud-only onboarding fetches and orchestrators in OSS.
|
||||
const cloudEnabled = isCloud();
|
||||
|
||||
// Fail-open: unknown scan state is treated as "has data" so the banner never blocks
|
||||
// progression on a fetch error.
|
||||
@@ -61,7 +62,7 @@ export default async function RootLayout({
|
||||
// Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open).
|
||||
let hasProviders: boolean | undefined = false;
|
||||
|
||||
if (onboardingEnabled) {
|
||||
if (cloudEnabled) {
|
||||
const [providersData, scansByState] = await Promise.all([
|
||||
getProviders({ page: 1, pageSize: 1 }),
|
||||
getScansByState(),
|
||||
@@ -98,7 +99,7 @@ export default async function RootLayout({
|
||||
</Suspense>
|
||||
{/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */}
|
||||
<StoreInitializer values={{ hasProviders: hasProviders ?? false }} />
|
||||
{onboardingEnabled && (
|
||||
{cloudEnabled && (
|
||||
<>
|
||||
<OnboardingGate hasProviders={hasProviders} />
|
||||
{/* Single mount point so the watcher survives post-connect navigation. */}
|
||||
@@ -108,6 +109,7 @@ export default async function RootLayout({
|
||||
</>
|
||||
)}
|
||||
<MainLayout>{children}</MainLayout>
|
||||
{cloudEnabled && <FeedbackSurvey />}
|
||||
{/* Always mounted: it hosts the detail (finding/resource) views in
|
||||
every deployment; the AI tab inside is cloud-gated on its own. */}
|
||||
<GlobalSidePanel />
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
In Prowler Cloud, authenticated users can send product feedback through a persistent widget backed by a PostHog headless survey, rendered with native Prowler components and editable from the PostHog dashboard
|
||||
@@ -0,0 +1,448 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Survey } from "posthog-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type SurveyCallback = (surveys: Survey[]) => void;
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isCloud: vi.fn(),
|
||||
useRuntimeConfig: vi.fn(),
|
||||
init: vi.fn(),
|
||||
onSurveysLoaded: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
moduleLoaded: vi.fn(),
|
||||
// Mirrors the singleton's `__loaded` flag: true when a PostHog instance
|
||||
// already exists (as on Prowler Cloud, initialized in app/providers.tsx).
|
||||
loaded: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({ isCloud: mocks.isCloud }));
|
||||
vi.mock("@/hooks/use-runtime-config", () => ({
|
||||
useRuntimeConfig: mocks.useRuntimeConfig,
|
||||
}));
|
||||
vi.mock("posthog-js", () => {
|
||||
mocks.moduleLoaded();
|
||||
return {
|
||||
default: {
|
||||
get __loaded() {
|
||||
return mocks.loaded;
|
||||
},
|
||||
init: mocks.init,
|
||||
onSurveysLoaded: mocks.onSurveysLoaded,
|
||||
capture: mocks.capture,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const POSTHOG_KEY = "phc_key";
|
||||
|
||||
const SURVEY_FIXTURE = {
|
||||
id: "survey-123",
|
||||
name: "Prowler Feedback",
|
||||
type: "api",
|
||||
questions: [
|
||||
{
|
||||
id: "q-1",
|
||||
type: "open",
|
||||
question: "What could we do better?",
|
||||
description:
|
||||
"Is there anything we could do to make your experience better?",
|
||||
},
|
||||
],
|
||||
appearance: {
|
||||
placeholder: "Type your answer here",
|
||||
submitButtonText: "Submit answer",
|
||||
thankYouMessageHeader: "Thanks for the feedback!",
|
||||
thankYouMessageDescription: "The Prowler team reads every response.",
|
||||
},
|
||||
} as unknown as Survey;
|
||||
|
||||
// Feed the given surveys to the component through the posthog callback the way
|
||||
// the real SDK does once the definitions have loaded.
|
||||
const provideSurveys = (surveys: Survey[]): void => {
|
||||
mocks.onSurveysLoaded.mockImplementation((callback: SurveyCallback) => {
|
||||
callback(surveys);
|
||||
return () => {};
|
||||
});
|
||||
};
|
||||
|
||||
const renderSurvey = async () => {
|
||||
const { FeedbackSurvey } = await import("./feedback-survey");
|
||||
return render(<FeedbackSurvey />);
|
||||
};
|
||||
|
||||
describe("FeedbackSurvey", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
mocks.loaded = false;
|
||||
mocks.isCloud.mockReturnValue(true);
|
||||
mocks.useRuntimeConfig.mockReturnValue({
|
||||
cloudEnabled: true,
|
||||
posthogEnabled: true,
|
||||
posthogKey: POSTHOG_KEY,
|
||||
posthogHost: "https://eu.posthog.com",
|
||||
});
|
||||
provideSurveys([SURVEY_FIXTURE]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("renders the feedback trigger when Cloud and the survey is available", async () => {
|
||||
// When
|
||||
await renderSurvey();
|
||||
|
||||
// Then
|
||||
const trigger = await screen.findByRole("button", {
|
||||
name: "Give feedback",
|
||||
});
|
||||
expect(trigger).toBeVisible();
|
||||
expect(trigger).toHaveTextContent("Feedback");
|
||||
});
|
||||
|
||||
it("ignores a same-name non-API survey and selects the API survey", async () => {
|
||||
// Given - PostHog can return multiple survey types with the same name.
|
||||
const popoverSurvey = {
|
||||
...SURVEY_FIXTURE,
|
||||
id: "survey-popover",
|
||||
type: "popover",
|
||||
questions: [
|
||||
{
|
||||
id: "q-popover",
|
||||
type: "open",
|
||||
question: "This popover must not render",
|
||||
},
|
||||
],
|
||||
} as unknown as Survey;
|
||||
provideSurveys([popoverSurvey, SURVEY_FIXTURE]);
|
||||
const user = userEvent.setup();
|
||||
|
||||
// When
|
||||
await renderSurvey();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Give feedback" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "What could we do better?" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "This popover must not render" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a visible feedback form with the fetched question, description, placeholder, and submit copy", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
await renderSurvey();
|
||||
const trigger = await screen.findByRole("button", {
|
||||
name: "Give feedback",
|
||||
});
|
||||
|
||||
// Then - the form is not present until the user opens it
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "What could we do better?" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.click(trigger);
|
||||
|
||||
// Then - the user actually sees the survey copy from the definition
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "What could we do better?" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Is there anything we could do to make your experience better?",
|
||||
),
|
||||
).toBeVisible();
|
||||
const input = screen.getByPlaceholderText("Type your answer here");
|
||||
expect(input).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Submit answer" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("captures 'survey shown' when the form opens", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
await renderSurvey();
|
||||
const trigger = await screen.findByRole("button", {
|
||||
name: "Give feedback",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(trigger);
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(mocks.capture).toHaveBeenCalledWith("survey shown", {
|
||||
$survey_id: "survey-123",
|
||||
$survey_name: "Prowler Feedback",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("captures 'survey sent' with modern and legacy response keys and then thanks the user", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
await renderSurvey();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Give feedback" }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Type your answer here"),
|
||||
"Add dark mode",
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Submit answer" }));
|
||||
|
||||
// Then - the capture carries the survey identity, per-question payload, and
|
||||
// BOTH the modern per-question key and the legacy first-question key.
|
||||
await waitFor(() =>
|
||||
expect(mocks.capture).toHaveBeenCalledWith("survey sent", {
|
||||
$survey_id: "survey-123",
|
||||
$survey_name: "Prowler Feedback",
|
||||
$survey_questions: [
|
||||
{
|
||||
id: "q-1",
|
||||
question: "What could we do better?",
|
||||
response: "Add dark mode",
|
||||
},
|
||||
],
|
||||
"$survey_response_q-1": "Add dark mode",
|
||||
$survey_response: "Add dark mode",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then - the user sees the thank-you copy from the definition
|
||||
expect(await screen.findByText("Thanks for the feedback!")).toBeVisible();
|
||||
expect(
|
||||
screen.getByText("The Prowler team reads every response."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("captures 'survey dismissed' when the form is closed without submitting", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
await renderSurvey();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Give feedback" }),
|
||||
);
|
||||
await screen.findByRole("heading", { name: "What could we do better?" });
|
||||
|
||||
// When - the user closes the popover without answering
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(mocks.capture).toHaveBeenCalledWith("survey dismissed", {
|
||||
$survey_id: "survey-123",
|
||||
$survey_name: "Prowler Feedback",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not dismiss after a successful submit when the form is closed", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
await renderSurvey();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Give feedback" }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Type your answer here"),
|
||||
"Great tool",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Submit answer" }));
|
||||
await screen.findByText("Thanks for the feedback!");
|
||||
|
||||
// When - the user closes the thank-you popover
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
// Then - a submitted survey is never also reported as dismissed
|
||||
await waitFor(() =>
|
||||
expect(mocks.capture).toHaveBeenCalledWith(
|
||||
"survey sent",
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
expect(mocks.capture).not.toHaveBeenCalledWith(
|
||||
"survey dismissed",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("initializes PostHog once with the runtime config, then loads surveys, when Cloud with a key and host and no existing instance", async () => {
|
||||
// Given - no PostHog instance exists yet (self-hosted opt-in path). Init is
|
||||
// deferred to this component effect (post-hydration) to avoid a hydration
|
||||
// mismatch, using the runtime public config island.
|
||||
mocks.loaded = false;
|
||||
|
||||
// When
|
||||
await renderSurvey();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(mocks.init).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.init.mock.calls[0][0]).toBe(POSTHOG_KEY);
|
||||
expect(mocks.init.mock.calls[0][1]).toMatchObject({
|
||||
api_host: "https://eu.posthog.com",
|
||||
ui_host: "https://eu.posthog.com",
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
});
|
||||
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("consumes the already-initialized Cloud instance without re-initializing", async () => {
|
||||
// Given - on Prowler Cloud the singleton is already initialized
|
||||
// (app/providers.tsx), so __loaded is true.
|
||||
mocks.loaded = true;
|
||||
|
||||
// When
|
||||
await renderSurvey();
|
||||
|
||||
// Then - it reuses Cloud's instance: no re-init, but surveys still load and
|
||||
// the trigger renders.
|
||||
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
|
||||
expect(mocks.init).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Give feedback" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("does not initialize or load surveys when the runtime config is empty", async () => {
|
||||
// Given - the runtime config island has not provided PostHog settings
|
||||
mocks.loaded = false;
|
||||
mocks.useRuntimeConfig.mockReturnValue({
|
||||
cloudEnabled: false,
|
||||
posthogEnabled: false,
|
||||
posthogKey: null,
|
||||
posthogHost: null,
|
||||
});
|
||||
|
||||
// When
|
||||
const view = await renderSurvey();
|
||||
|
||||
// Then - no init, no survey fetch, nothing rendered
|
||||
expect(mocks.init).not.toHaveBeenCalled();
|
||||
expect(mocks.onSurveysLoaded).not.toHaveBeenCalled();
|
||||
expect(mocks.moduleLoaded).not.toHaveBeenCalled();
|
||||
expect(view.container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing and touches no PostHog surface when PostHog is disabled, even with a key and host present", async () => {
|
||||
// Given - the integration is off with a stale key/host still present
|
||||
mocks.loaded = false;
|
||||
mocks.useRuntimeConfig.mockReturnValue({
|
||||
cloudEnabled: true,
|
||||
posthogEnabled: false,
|
||||
posthogKey: POSTHOG_KEY,
|
||||
posthogHost: "https://eu.posthog.com",
|
||||
});
|
||||
|
||||
// When
|
||||
const view = await renderSurvey();
|
||||
|
||||
// Then
|
||||
expect(view.container).toBeEmptyDOMElement();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Give feedback" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mocks.init).not.toHaveBeenCalled();
|
||||
expect(mocks.onSurveysLoaded).not.toHaveBeenCalled();
|
||||
expect(mocks.capture).not.toHaveBeenCalled();
|
||||
expect(mocks.moduleLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders nothing and touches no PostHog surface when not Cloud, even with a survey and key present", async () => {
|
||||
// Given - OSS: isCloud() is the primary guard, even with a key and instance
|
||||
mocks.isCloud.mockReturnValue(false);
|
||||
mocks.loaded = true;
|
||||
provideSurveys([SURVEY_FIXTURE]);
|
||||
|
||||
// When
|
||||
const view = await renderSurvey();
|
||||
|
||||
// Then - no init, no trigger, no survey fetch, no capture, regardless of config
|
||||
expect(view.container).toBeEmptyDOMElement();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Give feedback" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mocks.init).not.toHaveBeenCalled();
|
||||
expect(mocks.onSurveysLoaded).not.toHaveBeenCalled();
|
||||
expect(mocks.capture).not.toHaveBeenCalled();
|
||||
expect(mocks.moduleLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders nothing when Cloud but the named survey is not available", async () => {
|
||||
// Given
|
||||
provideSurveys([]);
|
||||
|
||||
// When
|
||||
const view = await renderSurvey();
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
|
||||
expect(view.container).toBeEmptyDOMElement();
|
||||
expect(mocks.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders nothing and never crashes when the matched survey has no questions", async () => {
|
||||
// Given - a definition with an empty questions array (regression: submit
|
||||
// used to read questions[0].question unguarded and could crash).
|
||||
const questionlessSurvey = {
|
||||
...SURVEY_FIXTURE,
|
||||
questions: [],
|
||||
} as unknown as Survey;
|
||||
provideSurveys([questionlessSurvey]);
|
||||
|
||||
// When
|
||||
const view = await renderSurvey();
|
||||
|
||||
// Then - no trigger renders, so the form (and its question access) is
|
||||
// unreachable: nothing to interact with, nothing throws.
|
||||
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
|
||||
expect(view.container).toBeEmptyDOMElement();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Give feedback" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mocks.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders nothing when the first question is not an open-text question", async () => {
|
||||
// Given - an editor changed the question type in the PostHog dashboard to a
|
||||
// non-open kind; the free-text form only matches `open`.
|
||||
const ratingSurvey = {
|
||||
...SURVEY_FIXTURE,
|
||||
questions: [
|
||||
{
|
||||
id: "q-1",
|
||||
type: "rating",
|
||||
question: "How would you rate Prowler?",
|
||||
display: "number",
|
||||
scale: 5,
|
||||
lowerBoundLabel: "Bad",
|
||||
upperBoundLabel: "Great",
|
||||
},
|
||||
],
|
||||
} as unknown as Survey;
|
||||
provideSurveys([ratingSurvey]);
|
||||
|
||||
// When
|
||||
const view = await renderSurvey();
|
||||
|
||||
// Then - feature safely off: no trigger, no malformed response path
|
||||
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
|
||||
expect(view.container).toBeEmptyDOMElement();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Give feedback" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mocks.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { lazy, Suspense } from "react";
|
||||
|
||||
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
const RuntimeFeedbackSurvey = lazy(() => import("./runtime-feedback-survey"));
|
||||
|
||||
export function FeedbackSurvey() {
|
||||
const { posthogEnabled, posthogKey, posthogHost } = useRuntimeConfig();
|
||||
|
||||
if (!isCloud() || !posthogEnabled || !posthogKey || !posthogHost) return null;
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<RuntimeFeedbackSurvey
|
||||
key={`${posthogKey}:${posthogHost}`}
|
||||
posthogKey={posthogKey}
|
||||
posthogHost={posthogHost}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
import posthogClient from "posthog-js";
|
||||
import type { Survey } from "posthog-js";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/shadcn/popover";
|
||||
import { Textarea } from "@/components/shadcn/textarea/textarea";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
|
||||
const SURVEY_NAME = "Prowler Feedback";
|
||||
|
||||
const SURVEY_EVENT = {
|
||||
SHOWN: "survey shown",
|
||||
SENT: "survey sent",
|
||||
DISMISSED: "survey dismissed",
|
||||
} as const;
|
||||
|
||||
interface RuntimeFeedbackSurveyProps {
|
||||
posthogKey: string;
|
||||
posthogHost: string;
|
||||
}
|
||||
|
||||
export default function RuntimeFeedbackSurvey({
|
||||
posthogKey,
|
||||
posthogHost,
|
||||
}: RuntimeFeedbackSurveyProps) {
|
||||
const [survey, setSurvey] = useState<Survey | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [response, setResponse] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
useMountEffect(() => {
|
||||
if (!posthogClient.__loaded) {
|
||||
posthogClient.init(posthogKey, {
|
||||
api_host: posthogHost,
|
||||
ui_host: posthogHost,
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
});
|
||||
}
|
||||
|
||||
return posthogClient.onSurveysLoaded((surveys) => {
|
||||
setSurvey(
|
||||
surveys.find(
|
||||
(item) => item.name === SURVEY_NAME && item.type === "api",
|
||||
) ?? null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const question = survey?.questions?.[0];
|
||||
if (!survey || question?.type !== "open") return null;
|
||||
|
||||
const questionId = question.id ?? "";
|
||||
const appearance = survey.appearance;
|
||||
const trimmedResponse = response.trim();
|
||||
const identity = { $survey_id: survey.id, $survey_name: survey.name };
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
setSubmitted(false);
|
||||
setResponse("");
|
||||
posthogClient.capture(SURVEY_EVENT.SHOWN, identity);
|
||||
return;
|
||||
}
|
||||
if (!submitted) posthogClient.capture(SURVEY_EVENT.DISMISSED, identity);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!trimmedResponse) return;
|
||||
posthogClient.capture(SURVEY_EVENT.SENT, {
|
||||
...identity,
|
||||
$survey_questions: [
|
||||
{
|
||||
id: questionId,
|
||||
question: question.question,
|
||||
response: trimmedResponse,
|
||||
},
|
||||
],
|
||||
[`$survey_response_${questionId}`]: trimmedResponse,
|
||||
$survey_response: trimmedResponse,
|
||||
});
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Give feedback"
|
||||
size="xl"
|
||||
className="group fixed right-6 bottom-20 z-50 transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] motion-reduce:transform-none motion-reduce:transition-none"
|
||||
>
|
||||
<MessageSquareText
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-200 group-hover:scale-110 group-hover:-rotate-6 motion-reduce:transform-none motion-reduce:transition-none"
|
||||
/>
|
||||
<span>Feedback</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="top"
|
||||
className="w-[min(92vw,26rem)] p-5"
|
||||
>
|
||||
{submitted ? (
|
||||
<div role="status" aria-live="polite" className="flex flex-col gap-1">
|
||||
<h2 className="text-text-neutral-primary text-base font-semibold">
|
||||
{appearance?.thankYouMessageHeader ?? "Thanks for the feedback!"}
|
||||
</h2>
|
||||
{appearance?.thankYouMessageDescription ? (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{appearance.thankYouMessageDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-text-neutral-primary text-base font-semibold">
|
||||
{question.question}
|
||||
</h2>
|
||||
{question.description ? (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{question.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={question.question}
|
||||
placeholder={appearance?.placeholder ?? ""}
|
||||
value={response}
|
||||
onChange={(event) => setResponse(event.target.value)}
|
||||
textareaSize="lg"
|
||||
className="min-h-32"
|
||||
/>
|
||||
<Button type="submit" disabled={!trimmedResponse}>
|
||||
{appearance?.submitButtonText ?? "Submit"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -519,6 +519,14 @@
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "posthog-js",
|
||||
"from": "1.260.1",
|
||||
"to": "1.407.2",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-07-27T08:35:41.397Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "react",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
const POSTHOG_CSP_SOURCE = "https://*.posthog.com";
|
||||
|
||||
interface CspOptions {
|
||||
cloudEnabled: boolean;
|
||||
posthogEnabled: boolean;
|
||||
posthogKey: string | null;
|
||||
posthogHost: string | null;
|
||||
}
|
||||
|
||||
export function getCspHeader({
|
||||
cloudEnabled,
|
||||
posthogEnabled,
|
||||
posthogKey,
|
||||
posthogHost,
|
||||
}: CspOptions): string {
|
||||
const allowPosthog =
|
||||
cloudEnabled && posthogEnabled && Boolean(posthogKey && posthogHost);
|
||||
const posthogSource = allowPosthog ? ` ${POSTHOG_CSP_SOURCE}` : "";
|
||||
|
||||
return `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com${posthogSource};
|
||||
connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io${posthogSource};
|
||||
img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${posthogSource};
|
||||
font-src 'self';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
frame-src 'self' https://js.stripe.com https://www.googletagmanager.com${posthogSource};
|
||||
frame-ancestors 'none';
|
||||
`.replace(/\n/g, "");
|
||||
}
|
||||
@@ -45,6 +45,29 @@ describe("getRuntimeConfigClient", () => {
|
||||
// Keys not present in the island fall back to null.
|
||||
expect(config.googleTagManagerId).toBeNull();
|
||||
expect(config.posthogKey).toBeNull();
|
||||
// Booleans fall back to false, not undefined.
|
||||
expect(config.posthogEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("carries the PostHog enable flag through the island", async () => {
|
||||
// Given
|
||||
writeIsland(
|
||||
JSON.stringify({
|
||||
posthogEnabled: true,
|
||||
posthogKey: "phc_key",
|
||||
posthogHost: "https://eu.i.posthog.com",
|
||||
}),
|
||||
);
|
||||
const { getRuntimeConfigClient } = await import(
|
||||
"./get-runtime-config.client"
|
||||
);
|
||||
|
||||
// When
|
||||
const config = getRuntimeConfigClient();
|
||||
|
||||
// Then
|
||||
expect(config.posthogEnabled).toBe(true);
|
||||
expect(config.posthogKey).toBe("phc_key");
|
||||
});
|
||||
|
||||
it("falls back to an all-null config when the island is absent", async () => {
|
||||
@@ -115,6 +138,7 @@ describe("getRuntimeConfigClient", () => {
|
||||
"cloudBillingEnabled",
|
||||
"cloudEnabled",
|
||||
"googleTagManagerId",
|
||||
"posthogEnabled",
|
||||
"posthogHost",
|
||||
"posthogKey",
|
||||
"reoDevClientId",
|
||||
|
||||
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
assertGatedIntegrations,
|
||||
GATED_INTEGRATIONS,
|
||||
isGatedIntegrationEnabled,
|
||||
readGatedEnv,
|
||||
warnGatedIntegrationsMisconfig,
|
||||
} from "./integrations";
|
||||
@@ -213,6 +215,64 @@ describe("assertGatedIntegrations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGatedIntegrationEnabled", () => {
|
||||
it("is false when neither the enable flag nor any legacy name is set", () => {
|
||||
// Given no PostHog env at all
|
||||
|
||||
// When / Then
|
||||
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when the enable flag is 'true'", () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_POSTHOG_ENABLED", "true");
|
||||
vi.stubEnv("UI_POSTHOG_KEY", "phc_key");
|
||||
vi.stubEnv("UI_POSTHOG_HOST", "https://eu.i.posthog.com");
|
||||
|
||||
// When / Then
|
||||
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(true);
|
||||
});
|
||||
|
||||
it("is true for a complete legacy config without the enable flag", () => {
|
||||
// Given - legacy presence activates without the flag
|
||||
vi.stubEnv("POSTHOG_KEY", "phc_key");
|
||||
vi.stubEnv("POSTHOG_HOST", "https://eu.i.posthog.com");
|
||||
|
||||
// When / Then
|
||||
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a partial legacy config without the enable flag", () => {
|
||||
// Given - incomplete legacy set (host missing)
|
||||
vi.stubEnv("POSTHOG_KEY", "phc_key");
|
||||
|
||||
// When / Then
|
||||
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the enable flag is explicitly 'false'", () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_POSTHOG_ENABLED", "false");
|
||||
vi.stubEnv("UI_POSTHOG_KEY", "phc_key");
|
||||
|
||||
// When / Then
|
||||
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves each integration independently", () => {
|
||||
// Given - only Sentry is enabled
|
||||
vi.stubEnv("UI_SENTRY_ENABLED", "true");
|
||||
vi.stubEnv("UI_SENTRY_DSN", "https://dsn.example");
|
||||
|
||||
// When / Then
|
||||
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.sentry)).toBe(true);
|
||||
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(false);
|
||||
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.googleTagManager)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("warnGatedIntegrationsMisconfig", () => {
|
||||
it("warns when a config value is set but its enable flag is not 'true'", () => {
|
||||
// Given
|
||||
|
||||
+11
-1
@@ -17,7 +17,7 @@ interface IntegrationEnvVar {
|
||||
legacy?: keyof NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
interface GatedIntegration {
|
||||
export interface GatedIntegration {
|
||||
name: string;
|
||||
enableKey: keyof NodeJS.ProcessEnv;
|
||||
required: ReadonlyArray<IntegrationEnvVar>;
|
||||
@@ -82,6 +82,16 @@ function hasCompleteLegacyConfig(integration: GatedIntegration): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// True when an integration is active through either path: the enable flag is
|
||||
// "true", or a complete legacy config is present.
|
||||
export function isGatedIntegrationEnabled(
|
||||
integration: GatedIntegration,
|
||||
): boolean {
|
||||
return (
|
||||
readBoolEnv(integration.enableKey) || hasCompleteLegacyConfig(integration)
|
||||
);
|
||||
}
|
||||
|
||||
// True when any required legacy name is set, i.e. the deployment is attempting
|
||||
// legacy activation (possibly half-configured).
|
||||
function hasAnyLegacyConfig(integration: GatedIntegration): boolean {
|
||||
|
||||
@@ -6,8 +6,9 @@ export interface RuntimePublicConfig {
|
||||
googleTagManagerId: string | null;
|
||||
apiBaseUrl: string | null;
|
||||
apiDocsUrl: string | null;
|
||||
posthogKey: string | null; // reserved
|
||||
posthogHost: string | null; // reserved
|
||||
posthogEnabled: boolean;
|
||||
posthogKey: string | null;
|
||||
posthogHost: string | null;
|
||||
reoDevClientId: string | null; // reserved
|
||||
cloudEnabled: boolean;
|
||||
cloudBillingEnabled: boolean;
|
||||
@@ -24,6 +25,7 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
|
||||
googleTagManagerId: null,
|
||||
apiBaseUrl: null,
|
||||
apiDocsUrl: null,
|
||||
posthogEnabled: false,
|
||||
posthogKey: null,
|
||||
posthogHost: null,
|
||||
reoDevClientId: null,
|
||||
@@ -42,6 +44,7 @@ const pickConfig = (
|
||||
googleTagManagerId: parsed.googleTagManagerId ?? null,
|
||||
apiBaseUrl: parsed.apiBaseUrl ?? null,
|
||||
apiDocsUrl: parsed.apiDocsUrl ?? null,
|
||||
posthogEnabled: parsed.posthogEnabled ?? false,
|
||||
posthogKey: parsed.posthogKey ?? null,
|
||||
posthogHost: parsed.posthogHost ?? null,
|
||||
reoDevClientId: parsed.reoDevClientId ?? null,
|
||||
|
||||
@@ -2,7 +2,11 @@ import "server-only";
|
||||
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { readGatedEnv } from "@/lib/integrations";
|
||||
import {
|
||||
GATED_INTEGRATIONS,
|
||||
isGatedIntegrationEnabled,
|
||||
readGatedEnv,
|
||||
} from "@/lib/integrations";
|
||||
import { type RuntimePublicConfig } from "@/lib/runtime-config.shared";
|
||||
import { readBoolEnv, readEnv } from "@/lib/runtime-env";
|
||||
|
||||
@@ -33,6 +37,7 @@ export async function getRuntimePublicConfig(): Promise<RuntimePublicConfig> {
|
||||
),
|
||||
apiBaseUrl: readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"),
|
||||
apiDocsUrl: readEnv("UI_API_DOCS_URL", "NEXT_PUBLIC_API_DOCS_URL"),
|
||||
posthogEnabled: isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog),
|
||||
posthogKey: readGatedEnv(
|
||||
"UI_POSTHOG_ENABLED",
|
||||
"UI_POSTHOG_KEY",
|
||||
|
||||
@@ -6,22 +6,6 @@ const { withSentryConfig } = require("@sentry/nextjs");
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
// HTTP Security Headers
|
||||
// 'unsafe-eval' is configured under `script-src` because it is required by NextJS for development mode.
|
||||
//
|
||||
// CSP is static; the JSON config island is inert (no nonce needed). A runtime
|
||||
// Sentry DSN must be in `connect-src` below — `*.sentry.io` covers Sentry Cloud,
|
||||
// but a self-hosted/region host is blocked until per-request CSP (middleware) lands.
|
||||
const cspHeader = `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com;
|
||||
connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io;
|
||||
img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com;
|
||||
font-src 'self';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
frame-src 'self' https://js.stripe.com https://www.googletagmanager.com;
|
||||
frame-ancestors 'none';
|
||||
`;
|
||||
|
||||
const nextConfig = {
|
||||
poweredByHeader: false,
|
||||
// Use standalone only in production deployments, not for CI/testing
|
||||
@@ -37,10 +21,6 @@ const nextConfig = {
|
||||
},
|
||||
async headers() {
|
||||
const headers = [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: cspHeader.replace(/\n/g, ""),
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getCspHeader } from "@/lib/csp";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const config = require("./next.config.js") as {
|
||||
headers: () => Promise<
|
||||
Array<{ headers: Array<{ key: string; value: string }> }>
|
||||
>;
|
||||
};
|
||||
|
||||
const POSTHOG_WILDCARD = "https://*.posthog.com";
|
||||
const ENABLED_POSTHOG_CONFIG = {
|
||||
cloudEnabled: true,
|
||||
posthogEnabled: true,
|
||||
posthogKey: "phc_key",
|
||||
posthogHost: "https://eu.posthog.com",
|
||||
};
|
||||
|
||||
const BASELINE_CSP = {
|
||||
"default-src": ["'self'"],
|
||||
"script-src": [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
"'unsafe-eval'",
|
||||
"https://js.stripe.com",
|
||||
"https://www.googletagmanager.com",
|
||||
"https://browser.sentry-cdn.com",
|
||||
],
|
||||
"connect-src": [
|
||||
"'self'",
|
||||
"https://api.iconify.design",
|
||||
"https://api.simplesvg.com",
|
||||
"https://api.unisvg.com",
|
||||
"https://js.stripe.com",
|
||||
"https://www.googletagmanager.com",
|
||||
"https://*.sentry.io",
|
||||
"https://*.ingest.sentry.io",
|
||||
],
|
||||
"img-src": [
|
||||
"'self'",
|
||||
"https://www.google-analytics.com",
|
||||
"https://www.googletagmanager.com",
|
||||
],
|
||||
"font-src": ["'self'"],
|
||||
"style-src": ["'self'", "'unsafe-inline'"],
|
||||
"frame-src": [
|
||||
"'self'",
|
||||
"https://js.stripe.com",
|
||||
"https://www.googletagmanager.com",
|
||||
],
|
||||
"frame-ancestors": ["'none'"],
|
||||
} as const;
|
||||
|
||||
const getStaticCsp = async () => {
|
||||
const rules = await config.headers();
|
||||
return rules[0]?.headers.find(({ key }) => key === "Content-Security-Policy");
|
||||
};
|
||||
|
||||
const parseCsp = (value: string) => {
|
||||
return Object.fromEntries(
|
||||
value
|
||||
.split(";")
|
||||
.map((entry) => entry.trim().split(/\s+/))
|
||||
.filter(([name]) => name)
|
||||
.map(([name, ...sources]) => [name, sources]),
|
||||
) as Record<string, string[]>;
|
||||
};
|
||||
|
||||
describe("PostHog Content Security Policy", () => {
|
||||
it("does not configure CSP through static Next headers", async () => {
|
||||
// When
|
||||
const staticCsp = await getStaticCsp();
|
||||
|
||||
// Then
|
||||
expect(staticCsp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits PostHog permissions from the baseline request CSP", () => {
|
||||
// When
|
||||
const csp = parseCsp(
|
||||
getCspHeader({
|
||||
cloudEnabled: false,
|
||||
posthogEnabled: false,
|
||||
posthogKey: null,
|
||||
posthogHost: null,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(csp).toEqual(BASELINE_CSP);
|
||||
expect(Object.values(csp).flat()).not.toContain(POSTHOG_WILDCARD);
|
||||
});
|
||||
|
||||
it("adds PostHog permissions only for a fully enabled Cloud request", () => {
|
||||
// When
|
||||
const csp = parseCsp(getCspHeader(ENABLED_POSTHOG_CONFIG));
|
||||
|
||||
// Then
|
||||
expect(csp["script-src"]).toContain(POSTHOG_WILDCARD);
|
||||
expect(csp["connect-src"]).toContain(POSTHOG_WILDCARD);
|
||||
expect(csp["img-src"]).toContain(POSTHOG_WILDCARD);
|
||||
expect(csp["frame-src"]).toContain(POSTHOG_WILDCARD);
|
||||
expect(csp["font-src"]).not.toContain(POSTHOG_WILDCARD);
|
||||
expect(csp["default-src"]).not.toContain(POSTHOG_WILDCARD);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Cloud is disabled", { cloudEnabled: false }],
|
||||
["PostHog is disabled", { posthogEnabled: false }],
|
||||
["the key is missing", { posthogKey: null }],
|
||||
["the host is missing", { posthogHost: null }],
|
||||
])("omits PostHog permissions when %s", (_case, override) => {
|
||||
// Given
|
||||
const config = { ...ENABLED_POSTHOG_CONFIG, ...override };
|
||||
|
||||
// When
|
||||
const csp = parseCsp(getCspHeader(config));
|
||||
|
||||
// Then
|
||||
expect(Object.values(csp).flat()).not.toContain(POSTHOG_WILDCARD);
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,7 @@
|
||||
"next": "16.2.11",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-themes": "0.2.1",
|
||||
"posthog-js": "1.407.2",
|
||||
"react": "19.2.7",
|
||||
"react-day-picker": "9.13.0",
|
||||
"react-dom": "19.2.7",
|
||||
|
||||
Generated
+72
@@ -237,6 +237,9 @@ importers:
|
||||
next-themes:
|
||||
specifier: 0.2.1
|
||||
version: 0.2.1(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
posthog-js:
|
||||
specifier: 1.407.2
|
||||
version: 1.407.2(preact-render-to-string@6.5.11(preact@10.24.3))
|
||||
react:
|
||||
specifier: 19.2.7
|
||||
version: 19.2.7
|
||||
@@ -1896,6 +1899,15 @@ packages:
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@posthog/browser-common@0.2.1':
|
||||
resolution: {integrity: sha512-FTVPsRw6GBKWvFwN26TNIQNNYPR9FsUj+B+CKhk/ht63IRzn4yYFtFOjcmW+bfXvNHuRADs6APbZ6Haz1kpoAw==}
|
||||
|
||||
'@posthog/core@1.45.1':
|
||||
resolution: {integrity: sha512-tLtvzomavb2PPWdGYKsusyIzIeL2Px47v348Smibkay7sMy/83TyPk+Ptsp2NdeOgJsbuwSxWkR2+XA0aSCAaA==}
|
||||
|
||||
'@posthog/types@1.398.0':
|
||||
resolution: {integrity: sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg==}
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
@@ -4158,6 +4170,9 @@ packages:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
core-js@3.49.0:
|
||||
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||
|
||||
cors@2.8.5:
|
||||
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -4790,6 +4805,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.4.9:
|
||||
resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -6131,6 +6149,9 @@ packages:
|
||||
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
posthog-js@1.407.2:
|
||||
resolution: {integrity: sha512-5deTvXopn+NJWEmCUw8Ix/ms3cv2+60WJ73Vgbvu2wSkZY/FIj2uqAgCnq7IewGpGC+tH7CAbPYFzH6hw8eW1w==}
|
||||
|
||||
preact-render-to-string@6.5.11:
|
||||
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
|
||||
peerDependencies:
|
||||
@@ -6139,6 +6160,14 @@ packages:
|
||||
preact@10.24.3:
|
||||
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
|
||||
|
||||
preact@10.29.7:
|
||||
resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==}
|
||||
peerDependencies:
|
||||
preact-render-to-string: '>=5'
|
||||
peerDependenciesMeta:
|
||||
preact-render-to-string:
|
||||
optional: true
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -6246,6 +6275,9 @@ packages:
|
||||
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
query-selector-shadow-dom@1.0.1:
|
||||
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -7154,6 +7186,9 @@ packages:
|
||||
web-namespaces@2.0.1:
|
||||
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
||||
|
||||
web-vitals@5.3.0:
|
||||
resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -8976,6 +9011,17 @@ snapshots:
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@posthog/browser-common@0.2.1':
|
||||
dependencies:
|
||||
'@posthog/core': 1.45.1
|
||||
'@posthog/types': 1.398.0
|
||||
|
||||
'@posthog/core@1.45.1':
|
||||
dependencies:
|
||||
'@posthog/types': 1.398.0
|
||||
|
||||
'@posthog/types@1.398.0': {}
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.2': {}
|
||||
@@ -11363,6 +11409,8 @@ snapshots:
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
core-js@3.49.0: {}
|
||||
|
||||
cors@2.8.5:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
@@ -12175,6 +12223,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
fflate@0.4.9: {}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
@@ -13824,12 +13874,30 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
posthog-js@1.407.2(preact-render-to-string@6.5.11(preact@10.24.3)):
|
||||
dependencies:
|
||||
'@posthog/browser-common': 0.2.1
|
||||
'@posthog/core': 1.45.1
|
||||
'@posthog/types': 1.398.0
|
||||
core-js: 3.49.0
|
||||
dompurify: 3.4.11
|
||||
fflate: 0.4.9
|
||||
preact: 10.29.7(preact-render-to-string@6.5.11(preact@10.24.3))
|
||||
query-selector-shadow-dom: 1.0.1
|
||||
web-vitals: 5.3.0
|
||||
transitivePeerDependencies:
|
||||
- preact-render-to-string
|
||||
|
||||
preact-render-to-string@6.5.11(preact@10.24.3):
|
||||
dependencies:
|
||||
preact: 10.24.3
|
||||
|
||||
preact@10.24.3: {}
|
||||
|
||||
preact@10.29.7(preact-render-to-string@6.5.11(preact@10.24.3)):
|
||||
optionalDependencies:
|
||||
preact-render-to-string: 6.5.11(preact@10.24.3)
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier-plugin-packagejson@2.5.22(prettier@3.6.2):
|
||||
@@ -13873,6 +13941,8 @@ snapshots:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
query-selector-shadow-dom@1.0.1: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
@@ -14970,6 +15040,8 @@ snapshots:
|
||||
|
||||
web-namespaces@2.0.1: {}
|
||||
|
||||
web-vitals@5.3.0: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
@@ -100,6 +100,9 @@ allowBuilds:
|
||||
unrs-resolver: true
|
||||
# msw: Copies mockServiceWorker.js into the directories listed in package.json's `msw.workerDirectory` (here: `public/`) so the runtime worker stays in sync with the installed msw version. Pure file copy — no native binary, no network access. Required for vitest browser tests to intercept fetches via the service worker.
|
||||
msw: true
|
||||
# core-js: transitive dep of posthog-js. Its postinstall only prints a funding
|
||||
# banner — no native binary is needed for the feedback survey. Deny the script.
|
||||
core-js: false
|
||||
|
||||
# --- Level 3: Trust Policy + Exotic Subdeps ---
|
||||
# Fail when a package's trust evidence is downgraded (e.g., new publisher).
|
||||
|
||||
+35
-5
@@ -2,6 +2,12 @@ import { NextResponse } from "next/server";
|
||||
import type { NextAuthRequest } from "next-auth";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
import { getCspHeader } from "@/lib/csp";
|
||||
import {
|
||||
GATED_INTEGRATIONS,
|
||||
isGatedIntegrationEnabled,
|
||||
readGatedEnv,
|
||||
} from "@/lib/integrations";
|
||||
import { readEnv } from "@/lib/runtime-env";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
@@ -19,6 +25,30 @@ const isPublicRoute = (pathname: string): boolean => {
|
||||
return publicRoutes.some((route) => pathname.startsWith(route));
|
||||
};
|
||||
|
||||
const withSecurityHeaders = (response: NextResponse): NextResponse => {
|
||||
response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
getCspHeader({
|
||||
cloudEnabled: isCloud(),
|
||||
posthogEnabled: isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog),
|
||||
posthogKey: readGatedEnv(
|
||||
"UI_POSTHOG_ENABLED",
|
||||
"UI_POSTHOG_KEY",
|
||||
"POSTHOG_KEY",
|
||||
),
|
||||
posthogHost: readGatedEnv(
|
||||
"UI_POSTHOG_ENABLED",
|
||||
"UI_POSTHOG_HOST",
|
||||
"POSTHOG_HOST",
|
||||
),
|
||||
}),
|
||||
);
|
||||
return response;
|
||||
};
|
||||
|
||||
const redirect = (url: URL): NextResponse =>
|
||||
withSecurityHeaders(NextResponse.redirect(url));
|
||||
|
||||
// NextAuth's auth() wrapper - renamed from middleware to proxy
|
||||
export default auth((req: NextAuthRequest) => {
|
||||
const { pathname } = req.nextUrl;
|
||||
@@ -33,13 +63,13 @@ export default auth((req: NextAuthRequest) => {
|
||||
const signInUrl = new URL("/sign-in", req.url);
|
||||
signInUrl.searchParams.set("error", sessionError);
|
||||
signInUrl.searchParams.set("callbackUrl", pathname + req.nextUrl.search);
|
||||
return NextResponse.redirect(signInUrl);
|
||||
return redirect(signInUrl);
|
||||
}
|
||||
|
||||
if (!user && !isPublicRoute(pathname)) {
|
||||
const signInUrl = new URL("/sign-in", req.url);
|
||||
signInUrl.searchParams.set("callbackUrl", pathname + req.nextUrl.search);
|
||||
return NextResponse.redirect(signInUrl);
|
||||
return redirect(signInUrl);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -48,7 +78,7 @@ export default auth((req: NextAuthRequest) => {
|
||||
!cloudBillingEnabled ||
|
||||
user?.permissions?.manage_billing !== true)
|
||||
) {
|
||||
return NextResponse.redirect(new URL("/profile", req.url));
|
||||
return redirect(new URL("/profile", req.url));
|
||||
}
|
||||
|
||||
if (user?.permissions) {
|
||||
@@ -58,11 +88,11 @@ export default auth((req: NextAuthRequest) => {
|
||||
pathname.startsWith("/integrations") &&
|
||||
!permissions.manage_integrations
|
||||
) {
|
||||
return NextResponse.redirect(new URL("/profile", req.url));
|
||||
return redirect(new URL("/profile", req.url));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
return withSecurityHeaders(NextResponse.next());
|
||||
});
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const RUNTIME_CONFIG_KEYS = [
|
||||
"googleTagManagerId",
|
||||
"apiBaseUrl",
|
||||
"apiDocsUrl",
|
||||
"posthogEnabled",
|
||||
"posthogKey",
|
||||
"posthogHost",
|
||||
"reoDevClientId",
|
||||
|
||||
Reference in New Issue
Block a user