Files
prowler/ui/app/(prowler)/layout.tsx
T

145 lines
5.4 KiB
TypeScript

import "@/styles/globals.css";
import * as Sentry from "@sentry/nextjs";
import { Metadata, Viewport } from "next";
import { ReactNode, Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { getScansByState } from "@/actions/scans/scans";
import { auth } from "@/auth.config";
import MainLayout from "@/components/layout/main-layout/main-layout";
import {
OnboardingCheckpointWatcher,
OnboardingGate,
OnboardingSequenceBanner,
} from "@/components/onboarding";
import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
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 { REGISTRY_ACCESS } from "@/lib/registry/access";
import { evaluateRegistryAccess } from "@/lib/registry/access.server";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import { StoreInitializer } from "@/store/ui/store-initializer";
import { SCAN_STATES } from "@/types/attack-paths";
import { Providers } from "../providers";
export const metadata: Metadata = {
title: {
default: siteConfig.name,
template: `%s - ${siteConfig.name}`,
},
description: siteConfig.description,
icons: {
icon: "/favicon.ico",
},
other: {
...Sentry.getTraceData(),
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
};
export default async function RootLayout({
children,
}: {
children: ReactNode;
}) {
// Skip Cloud-only onboarding fetches and orchestrators in OSS.
const cloudEnabled = isCloud();
// Every deployment needs the provider count: it drives the first-run redirect
// and the sidebar's Add Provider action.
const providersPromise = getProviders({ page: 1, pageSize: 1 });
// One-time server-side Registry gate per request: only an ELIGIBLE answer
// shows the sidebar entry; UNKNOWN and INELIGIBLE both hide it. Started
// here so it resolves in parallel with the Cloud onboarding fetches.
const registryAccessPromise = auth().then((session) =>
evaluateRegistryAccess(session?.accessToken),
);
// Fail-open: unknown scan state is treated as "has data" so the banner never blocks
// progression on a fetch error.
let hasCompletedScan = true;
// Scopes the onboarding steps' local markers, so resolving them for one
// tenant does not silence them for another.
let tenantId: string | null = null;
if (cloudEnabled) {
const scansByState = await getScansByState();
hasCompletedScan = Array.isArray(scansByState?.data)
? scansByState.data.some(
(scan: { attributes?: { state?: string } }) =>
scan.attributes?.state === SCAN_STATES.COMPLETED,
)
: true;
tenantId = (await auth())?.tenantId ?? null;
}
const providersData = await providersPromise;
// Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open).
const hasProviders: boolean | undefined = Array.isArray(providersData?.data)
? providersData.data.length > 0
: undefined;
const registryEligible =
(await registryAccessPromise).status === REGISTRY_ACCESS.ELIGIBLE;
return (
<html suppressHydrationWarning lang="en">
<head>
<RuntimePublicConfig />
</head>
<body
suppressHydrationWarning
className={cn(
"bg-bg-neutral-primary min-h-screen font-sans antialiased",
fontSans.variable,
fontMono.variable,
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
{/* Suspense contains the useSearchParams() CSR bailout so statically
prerendered pages don't fail the build (matches the auth layout). */}
<Suspense>
<NavigationProgress />
</Suspense>
{/* Tri-state for both: an unknown count leaves the store unresolved and the gate closed. */}
<StoreInitializer values={{ hasProviders, registryEligible }} />
{/* Every deployment: an empty tenant lands on the add-provider wizard once. */}
<OnboardingGate hasProviders={hasProviders} tenantId={tenantId} />
{cloudEnabled && (
<>
{/* Single mount point so the watcher survives post-connect navigation. */}
<OnboardingCheckpointWatcher tenantId={tenantId} />
{/* Persistent banner shown only while a guided sequence is active. */}
<OnboardingSequenceBanner hasCompletedScan={hasCompletedScan} />
</>
)}
<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 />
{/* Resumes persisted background-task polling (e.g. cross-provider
PDF generation) so completion toasts survive hard reloads. */}
<TaskPollingWatcher />
<Toaster />
</Providers>
</body>
</html>
);
}