mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 07:14:20 +00:00
fix(ui): stop the add-provider modal from shifting while it loads (#13179)
This commit is contained in:
1 parent
75c9a6bf51
commit
dcd3d5a0fb
22 files changed
+799
-151
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
|
||||
import { ComplianceDownloadContainer } from "./compliance-download-container";
|
||||
|
||||
// Like <main>: a marked size container, so sm: follows its width.
|
||||
function renderInPage(width: number) {
|
||||
return render(
|
||||
<div data-responsive-container className="@container" style={{ width }}>
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
scanId="scan-1"
|
||||
complianceId="compliance-1"
|
||||
reportType="threatscore"
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
// sr-only text keeps a 1px box, so its width tells whether it takes room.
|
||||
function labelWidth(screen: Awaited<ReturnType<typeof renderInPage>>) {
|
||||
return screen
|
||||
.getByText("CSV", { exact: true })
|
||||
.element()
|
||||
.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
describe("compact compliance downloads", () => {
|
||||
it("shows only the icons on a narrow page", async () => {
|
||||
// When
|
||||
const screen = await renderInPage(400);
|
||||
|
||||
// Then
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole("button", { name: "Download compliance CSV report" }),
|
||||
)
|
||||
.toBeVisible();
|
||||
expect(labelWidth(screen)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("names each format beside its icon from sm up", async () => {
|
||||
// When
|
||||
const screen = await renderInPage(800);
|
||||
|
||||
// Then
|
||||
expect(labelWidth(screen)).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ const runtimeConfig: RuntimePublicConfig = {
|
||||
reoDevClientId: null,
|
||||
cloudEnabled: false,
|
||||
cloudBillingEnabled: false,
|
||||
registryEnabled: false,
|
||||
selfRegistrationEnabled: true,
|
||||
stripePublishableKey: null,
|
||||
stripePublishableKeyV2: null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
import { FC, ReactNode, useState } from "react";
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Badge, SearchInput } from "@/components/shadcn";
|
||||
import { FormMessage } from "@/components/shadcn/form";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -26,6 +27,7 @@ const PROVIDERS = Object.entries(PROVIDER_TYPE_DATA).map(
|
||||
);
|
||||
|
||||
const PROVIDER_TAB = { ALL: "all", REGISTRY: "registry" } as const;
|
||||
const REGISTRY_SKELETON_ROWS = 3;
|
||||
type ProviderTab = (typeof PROVIDER_TAB)[keyof typeof PROVIDER_TAB];
|
||||
|
||||
interface RadioGroupProviderProps {
|
||||
@@ -36,6 +38,10 @@ interface RadioGroupProviderProps {
|
||||
* Local (OSS) and flag-off deployments, where the source tabs are hidden.
|
||||
*/
|
||||
registryAvailable?: boolean;
|
||||
/** Discovery in flight: the Registry tab shows placeholder rows. */
|
||||
registryLoading?: boolean;
|
||||
/** Shown in the Registry tab instead of its list (upgrade, setup, retry). */
|
||||
registryNotice?: ReactNode;
|
||||
registryOptions?: RegistryProviderOption[];
|
||||
isInvalid: boolean;
|
||||
errorMessage?: string;
|
||||
@@ -46,6 +52,8 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
|
||||
isInvalid,
|
||||
errorMessage,
|
||||
registryAvailable = false,
|
||||
registryLoading = false,
|
||||
registryNotice,
|
||||
registryOptions = [],
|
||||
}) => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -161,6 +169,19 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const registryPlaceholder = registryLoading ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading Registry providers"
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
{Array.from({ length: REGISTRY_SKELETON_ROWS }, (_, index) => (
|
||||
<Skeleton key={index} className="h-[72px] w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
registryNotice
|
||||
);
|
||||
const validationMessage = errorMessage && (
|
||||
<FormMessage className="text-text-error-primary">
|
||||
{errorMessage}
|
||||
@@ -188,7 +209,11 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
|
||||
<TabsTrigger value={PROVIDER_TAB.REGISTRY}>Registry</TabsTrigger>
|
||||
</TabsList>
|
||||
{searchInput}
|
||||
<TabsContent value={activeTab}>{providerList}</TabsContent>
|
||||
<TabsContent value={activeTab}>
|
||||
{activeTab === PROVIDER_TAB.REGISTRY && registryPlaceholder
|
||||
? registryPlaceholder
|
||||
: providerList}
|
||||
</TabsContent>
|
||||
{validationMessage}
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
|
||||
import { LighthouseConnectHelp } from "./lighthouse-connect-help";
|
||||
|
||||
vi.mock(import("@/lib/shared/env"), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
isCloud: () => true,
|
||||
}));
|
||||
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||
getLighthouseV2Configurations: () =>
|
||||
Promise.resolve({ data: [{ id: "config-1", connected: true }] }),
|
||||
}));
|
||||
|
||||
const DESCRIPTION =
|
||||
"Lighthouse AI walks you through it with Prowler's docs, right beside this form.";
|
||||
|
||||
// Like the modal frame: a marked size container, so lg: follows its width.
|
||||
function renderInWizardFrame(width: number) {
|
||||
return render(
|
||||
<div data-responsive-container className="@container" style={{ width }}>
|
||||
<LighthouseConnectHelp
|
||||
docsLink="https://goto.prowler.com/provider-aws"
|
||||
docsDestination="Provider"
|
||||
providerType={null}
|
||||
mode="add"
|
||||
stepName="connect"
|
||||
stepLabel="Link a Provider"
|
||||
isOrganizationsFlow={false}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Lighthouse help in the wizard header", () => {
|
||||
it("keeps to a single line while the wizard stacks its steps", async () => {
|
||||
// When
|
||||
const screen = await renderInWizardFrame(720);
|
||||
|
||||
// Then
|
||||
const callout = screen.getByRole("region", {
|
||||
name: "Help from Lighthouse AI",
|
||||
});
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Ask Lighthouse AI" }))
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByText("Not sure how to connect your cloud?"))
|
||||
.toBeVisible();
|
||||
await expect.element(screen.getByText(DESCRIPTION)).not.toBeVisible();
|
||||
expect(
|
||||
callout.element().getBoundingClientRect().height,
|
||||
).toBeLessThanOrEqual(72);
|
||||
});
|
||||
|
||||
it("explains what Lighthouse AI does beside a wide wizard", async () => {
|
||||
// When
|
||||
const screen = await renderInWizardFrame(1200);
|
||||
|
||||
// Then
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Ask Lighthouse AI" }))
|
||||
.toBeVisible();
|
||||
await expect.element(screen.getByText(DESCRIPTION)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ProviderFunnelDetail,
|
||||
} from "@/lib/provider-funnel/provider-funnel-events";
|
||||
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
|
||||
import { useLighthouseLlmStore } from "@/store/lighthouse-llm/store";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
|
||||
@@ -73,6 +74,17 @@ function connectedConfiguration(connected: boolean) {
|
||||
return { data: [{ id: "config-1", connected }] };
|
||||
}
|
||||
|
||||
// An LLM check the test settles by hand.
|
||||
function pendingCheck() {
|
||||
let settle: (
|
||||
result: ReturnType<typeof connectedConfiguration>,
|
||||
) => void = () => {};
|
||||
const promise = new Promise((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
return { promise, settle };
|
||||
}
|
||||
|
||||
describe("LighthouseConnectHelp", () => {
|
||||
beforeEach(() => {
|
||||
getConfigurationsMock.mockResolvedValue(connectedConfiguration(true));
|
||||
@@ -82,6 +94,7 @@ describe("LighthouseConnectHelp", () => {
|
||||
});
|
||||
useProviderWizardStore.getState().reset();
|
||||
useLighthouseContextStore.setState({ contributions: {} });
|
||||
useLighthouseLlmStore.setState({ hasConnectedLlm: null, latestCheck: 0 });
|
||||
});
|
||||
|
||||
it("invites the user to ask Lighthouse AI about the provider they picked", async () => {
|
||||
@@ -99,6 +112,142 @@ describe("LighthouseConnectHelp", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("holds the callout's place while it checks for a connected LLM", async () => {
|
||||
// Given
|
||||
let settleCheck: (
|
||||
result: ReturnType<typeof connectedConfiguration>,
|
||||
) => void = () => {};
|
||||
getConfigurationsMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
settleCheck = resolve;
|
||||
}),
|
||||
);
|
||||
const funnelEvents = recordFunnelEvents();
|
||||
|
||||
// When
|
||||
renderHelp("aws");
|
||||
|
||||
// Then: a placeholder, never the docs line the callout would replace
|
||||
expect(
|
||||
screen.getByRole("status", { name: "Loading Lighthouse AI help" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /AWS documentation/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await act(async () => settleCheck(connectedConfiguration(true)));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(funnelEvents.stop()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows the last answer at once when the wizard opens again, then refreshes it", async () => {
|
||||
// Given: an earlier opening found a connected LLM
|
||||
const firstOpening = renderHelp("aws");
|
||||
await screen.findByRole("button", { name: "Ask Lighthouse AI" });
|
||||
firstOpening.unmount();
|
||||
let settleCheck: (
|
||||
result: ReturnType<typeof connectedConfiguration>,
|
||||
) => void = () => {};
|
||||
getConfigurationsMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
settleCheck = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
renderHelp("aws");
|
||||
|
||||
// Then: no placeholder while the fresh check runs
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When: the LLM was disconnected meanwhile
|
||||
await act(async () => settleCheck(connectedConfiguration(false)));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /AWS documentation/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("ignores an older check that answers after a newer opening", async () => {
|
||||
// Given: the wizard closes while its check runs, then opens again
|
||||
const olderCheck = pendingCheck();
|
||||
const newerCheck = pendingCheck();
|
||||
getConfigurationsMock
|
||||
.mockReturnValueOnce(olderCheck.promise)
|
||||
.mockReturnValueOnce(newerCheck.promise);
|
||||
const firstOpening = renderHelp("aws");
|
||||
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledOnce());
|
||||
firstOpening.unmount();
|
||||
renderHelp("aws");
|
||||
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
// When: the newer check answers first, then the older one
|
||||
await act(async () => newerCheck.settle(connectedConfiguration(false)));
|
||||
await act(async () => olderCheck.settle(connectedConfiguration(true)));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /AWS documentation/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps the remembered answer when a later check fails", async () => {
|
||||
// Given: an earlier opening found a connected LLM
|
||||
const firstOpening = renderHelp("aws");
|
||||
await screen.findByRole("button", { name: "Ask Lighthouse AI" });
|
||||
firstOpening.unmount();
|
||||
getConfigurationsMock.mockRejectedValueOnce(new Error("network"));
|
||||
|
||||
// When
|
||||
renderHelp("aws");
|
||||
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledTimes(2));
|
||||
await act(() => Promise.resolve());
|
||||
|
||||
// Then: a failed refresh does not swap the callout for the docs line
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("falls back to the documentation link when the check fails", async () => {
|
||||
// Given
|
||||
getConfigurationsMock.mockRejectedValue(new Error("network"));
|
||||
|
||||
// When
|
||||
renderHelp("aws");
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("link", { name: /AWS documentation/i }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps only the documentation link while Lighthouse AI is not set up", async () => {
|
||||
// Given
|
||||
getConfigurationsMock.mockResolvedValue(connectedConfiguration(false));
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LighthouseCalloutIcon,
|
||||
LighthouseCalloutTitle,
|
||||
} from "@/components/shadcn/lighthouse-callout";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { getVisibleSidePanelTabs } from "@/components/side-panel/side-panel-tabs";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
PROVIDER_FUNNEL_STEP,
|
||||
} from "@/lib/provider-funnel/provider-funnel-events";
|
||||
import { SIDE_PANEL_PUSH_MEDIA_QUERY } from "@/lib/ui-layout";
|
||||
import { useLighthouseLlmStore } from "@/store/lighthouse-llm/store";
|
||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import { LIGHTHOUSE_SKILL_ID } from "@/types/lighthouse-skills";
|
||||
@@ -40,6 +42,13 @@ const CONNECTION_GUIDE_SKILL = getSkillById(
|
||||
LIGHTHOUSE_SKILL_ID.PROVIDER_CONNECTION_GUIDE,
|
||||
);
|
||||
|
||||
const HELP_STATE = {
|
||||
DOCS: "docs",
|
||||
CHECKING: "checking",
|
||||
LIGHTHOUSE: "lighthouse",
|
||||
} as const;
|
||||
type HelpState = (typeof HELP_STATE)[keyof typeof HELP_STATE];
|
||||
|
||||
interface LighthouseConnectHelpProps {
|
||||
docsLink: string;
|
||||
docsDestination: string;
|
||||
@@ -61,7 +70,7 @@ export function LighthouseConnectHelp({
|
||||
stepLabel,
|
||||
isOrganizationsFlow,
|
||||
}: LighthouseConnectHelpProps) {
|
||||
const isLighthouseReady = useIsLighthouseReady();
|
||||
const helpState = useHelpState();
|
||||
const isGuiding = useSidePanelStore(
|
||||
(state) => state.isOpen && state.selectedTab === SIDE_PANEL_TAB.AI_CHAT,
|
||||
);
|
||||
@@ -73,7 +82,7 @@ export function LighthouseConnectHelp({
|
||||
(state) => state.awsConnectDraft?.method,
|
||||
);
|
||||
|
||||
if (!isLighthouseReady || !CONNECTION_GUIDE_SKILL) {
|
||||
if (helpState === HELP_STATE.DOCS || !CONNECTION_GUIDE_SKILL) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
|
||||
<Info className="size-4 shrink-0" />
|
||||
@@ -120,17 +129,13 @@ export function LighthouseConnectHelp({
|
||||
? getProviderDisplayName(providerType)
|
||||
: null;
|
||||
const copy = getHelpCopy({ isGuiding, providerName });
|
||||
|
||||
return (
|
||||
<LighthouseCallout
|
||||
aria-label="Help from Lighthouse AI"
|
||||
className="animate-in fade-in-0 slide-in-from-top-1 mt-2 duration-300"
|
||||
>
|
||||
<HelpShownSignal providerType={providerType} wizardStep={stepName} />
|
||||
const calloutContent = (
|
||||
<>
|
||||
<LighthouseCalloutIcon />
|
||||
<LighthouseCalloutBody>
|
||||
<LighthouseCalloutTitle>{copy.title}</LighthouseCalloutTitle>
|
||||
<LighthouseCalloutDescription>
|
||||
{/* A stacked wizard keeps the list in view: title and actions only. */}
|
||||
<LighthouseCalloutDescription className="hidden lg:block">
|
||||
{copy.description}
|
||||
</LighthouseCalloutDescription>
|
||||
</LighthouseCalloutBody>
|
||||
@@ -145,6 +150,32 @@ export function LighthouseConnectHelp({
|
||||
{copy.action}
|
||||
</LighthouseCalloutAction>
|
||||
</LighthouseCalloutActions>
|
||||
</>
|
||||
);
|
||||
|
||||
if (helpState === HELP_STATE.CHECKING) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Loading Lighthouse AI help"
|
||||
className="relative mt-2"
|
||||
>
|
||||
{/* Invisible copy sizes the placeholder exactly like the callout. */}
|
||||
<LighthouseCallout aria-hidden inert className="invisible">
|
||||
{calloutContent}
|
||||
</LighthouseCallout>
|
||||
<Skeleton className="absolute inset-0 rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LighthouseCallout
|
||||
aria-label="Help from Lighthouse AI"
|
||||
className="animate-in fade-in-0 slide-in-from-top-1 mt-2 duration-300"
|
||||
>
|
||||
<HelpShownSignal providerType={providerType} wizardStep={stepName} />
|
||||
{calloutContent}
|
||||
{/* Follow-ups typed in the panel carry the wizard's current state. */}
|
||||
<LighthouseContextContributor
|
||||
contributorId="provider-wizard"
|
||||
@@ -196,34 +227,47 @@ function getHelpCopy({ isGuiding, providerName }: HelpCopyInput) {
|
||||
|
||||
// Lighthouse AI can only help beside the wizard when the panel hosts the chat
|
||||
// (cloud), the panel pushes instead of covering it, and an LLM is connected.
|
||||
function useIsLighthouseReady(): boolean {
|
||||
function useHelpState(): HelpState {
|
||||
const canHostChat = getVisibleSidePanelTabs().some(
|
||||
(tab) => tab.id === SIDE_PANEL_TAB.AI_CHAT,
|
||||
);
|
||||
const isPushViewport = useMediaQuery(SIDE_PANEL_PUSH_MEDIA_QUERY);
|
||||
// Local state needed: whether an LLM is connected is fetched once per open.
|
||||
const [hasConnectedLlm, setHasConnectedLlm] = useState(false);
|
||||
// Kept across openings; every opening still refreshes it.
|
||||
const hasConnectedLlm = useLighthouseLlmStore(
|
||||
(state) => state.hasConnectedLlm,
|
||||
);
|
||||
const startCheck = useLighthouseLlmStore((state) => state.startCheck);
|
||||
const settleCheck = useLighthouseLlmStore((state) => state.settleCheck);
|
||||
// Local state needed: with nothing remembered, a failed check shows the docs.
|
||||
const [checkFailed, setCheckFailed] = useState(false);
|
||||
|
||||
useMountEffect(() => {
|
||||
if (!canHostChat) return;
|
||||
let isMounted = true;
|
||||
const check = startCheck();
|
||||
// Lazy: the Lighthouse actions only load where the chat can exist (cloud).
|
||||
import("@/app/(prowler)/lighthouse/_actions")
|
||||
.then(({ getLighthouseV2Configurations }) =>
|
||||
getLighthouseV2Configurations(),
|
||||
)
|
||||
.then((result) => {
|
||||
if (!isMounted || "error" in result) return;
|
||||
setHasConnectedLlm(
|
||||
result.data.some((configuration) => configuration.connected),
|
||||
);
|
||||
})
|
||||
// Optional help: a failed check just leaves the docs link in place.
|
||||
.catch(() => {});
|
||||
.then((result) =>
|
||||
"error" in result
|
||||
? null
|
||||
: result.data.some((configuration) => configuration.connected),
|
||||
)
|
||||
// Optional help: a failed check keeps the last answer, else the docs.
|
||||
.catch(() => null)
|
||||
.then((connected) => {
|
||||
if (connected !== null) settleCheck(check, connected);
|
||||
else if (isMounted) setCheckFailed(true);
|
||||
});
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
});
|
||||
|
||||
return canHostChat && isPushViewport && hasConnectedLlm;
|
||||
if (!canHostChat || !isPushViewport) return HELP_STATE.DOCS;
|
||||
if (hasConnectedLlm === null)
|
||||
return checkFailed ? HELP_STATE.DOCS : HELP_STATE.CHECKING;
|
||||
return hasConnectedLlm ? HELP_STATE.LIGHTHOUSE : HELP_STATE.DOCS;
|
||||
}
|
||||
@@ -154,8 +154,9 @@ async function enterAccountDetails() {
|
||||
|
||||
describe("provider wizard account creation", () => {
|
||||
beforeEach(() => {
|
||||
// Registry discovery only runs in Cloud.
|
||||
// Registry discovery only runs in Cloud with Registry enabled.
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
|
||||
useProviderWizardStore.getState().reset();
|
||||
resetToasts();
|
||||
getInstalledRegistryProviderOptions.mockResolvedValue({
|
||||
@@ -525,44 +526,45 @@ describe("provider wizard account creation", () => {
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ProviderWizardModal open onOpenChange={vi.fn()} />);
|
||||
await screen.findByText("Registry providers could not be loaded");
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Amazon Web Services/ }),
|
||||
await screen.findByRole("option", { name: /Amazon Web Services/ }),
|
||||
).toBeVisible();
|
||||
|
||||
// Then: an unanswered discovery cannot vouch for Registry availability.
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: "Registry" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("tab", { name: "Registry" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Retry Registry providers" }),
|
||||
await screen.findByRole("button", { name: "Retry Registry providers" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("option", { name: "Acme Cloud Registry" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("tab", { name: "Registry" })).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText("Registry providers could not be loaded"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the Registry tab with a retry when eligible discovery fails", async () => {
|
||||
it("shows the retry inside the Registry tab when eligible discovery fails", async () => {
|
||||
// Given: Cloud with Registry enabled, but the catalog read failed.
|
||||
getInstalledRegistryProviderOptions.mockResolvedValueOnce({
|
||||
status: "error",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<ProviderWizardModal open onOpenChange={vi.fn()} />);
|
||||
await screen.findByText("Registry providers could not be loaded");
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("tab", { name: "Registry" }));
|
||||
await user.click(await screen.findByRole("tab", { name: "Registry" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("No Registry providers available.")).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText("Registry providers could not be loaded"),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Retry Registry providers" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText("No Registry providers available."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
|
||||
import { WizardStepper } from "./wizard-stepper";
|
||||
|
||||
// Like the modal frame: a marked size container, so lg: follows its width.
|
||||
function renderInWizardFrame(width: number) {
|
||||
return render(
|
||||
<div data-responsive-container className="@container" style={{ width }}>
|
||||
<WizardStepper currentStep={1} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
function boxOf(element: Element) {
|
||||
const { width, height } = element.getBoundingClientRect();
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
describe("wizard stepper", () => {
|
||||
it("fits on one row naming only the current step while the wizard stacks", async () => {
|
||||
// When
|
||||
const screen = await renderInWizardFrame(720);
|
||||
|
||||
// Then
|
||||
const progress = screen
|
||||
.getByRole("navigation", { name: "Wizard progress" })
|
||||
.element();
|
||||
expect(boxOf(progress).height).toBeLessThanOrEqual(40);
|
||||
await expect
|
||||
.element(screen.getByText("Authenticate Credentials"))
|
||||
.toBeVisible();
|
||||
// Other steps keep their names for assistive tech but take no room.
|
||||
await expect.element(
|
||||
screen.getByRole("navigation", { name: "Wizard progress" }),
|
||||
).toMatchAriaInlineSnapshot(`
|
||||
- navigation "Wizard progress":
|
||||
- text: Link a Provider Authenticate Credentials Validate Connection Launch Scan
|
||||
`);
|
||||
const otherStep = screen.getByText("Link a Provider").element();
|
||||
expect(otherStep.checkVisibility()).toBe(true);
|
||||
// The block holding the name is clipped to 1px; the text inside overflows it.
|
||||
expect(boxOf(otherStep.closest("div")!).width).toBeLessThanOrEqual(1);
|
||||
await expect
|
||||
.element(
|
||||
screen.getByText(
|
||||
"Authorize a secure connection between Prowler and your provider.",
|
||||
),
|
||||
)
|
||||
.not.toBeVisible();
|
||||
expect(
|
||||
screen
|
||||
.getByText("Authenticate Credentials")
|
||||
.element()
|
||||
.closest("[aria-current]"),
|
||||
).toHaveAttribute("aria-current", "step");
|
||||
});
|
||||
|
||||
it("lists every step with its description beside a wide wizard", async () => {
|
||||
// When
|
||||
const screen = await renderInWizardFrame(1200);
|
||||
|
||||
// Then
|
||||
await expect
|
||||
.element(
|
||||
screen.getByText(
|
||||
"Enter the provider details you would like to add in Prowler.",
|
||||
),
|
||||
)
|
||||
.toBeVisible();
|
||||
await expect.element(screen.getByText("Link a Provider")).toBeVisible();
|
||||
expect(
|
||||
boxOf(
|
||||
screen.getByRole("navigation", { name: "Wizard progress" }).element(),
|
||||
).height,
|
||||
).toBeGreaterThan(300);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CircleCheckBig, FolderGit2, KeyRound, Rocket } from "lucide-react";
|
||||
import { ReactElement } from "react";
|
||||
import { Fragment, ReactElement } from "react";
|
||||
|
||||
import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -67,6 +67,13 @@ export function getActiveStepIndex(
|
||||
return Math.max(0, Math.min(currentStep + stepOffset, stepCount - 1));
|
||||
}
|
||||
|
||||
const CONNECTOR_DIRECTION = {
|
||||
VERTICAL: "to bottom",
|
||||
HORIZONTAL: "to right",
|
||||
} as const;
|
||||
type ConnectorDirection =
|
||||
(typeof CONNECTOR_DIRECTION)[keyof typeof CONNECTOR_DIRECTION];
|
||||
|
||||
export function WizardStepper({
|
||||
currentStep,
|
||||
stepOffset = 0,
|
||||
@@ -78,42 +85,71 @@ export function WizardStepper({
|
||||
steps.length,
|
||||
);
|
||||
|
||||
// Below lg the wizard stacks: one row of steps, naming only the current one.
|
||||
return (
|
||||
<nav aria-label="Wizard progress" className="flex flex-col gap-0">
|
||||
<nav
|
||||
aria-label="Wizard progress"
|
||||
className="flex items-center gap-2 lg:flex-col lg:items-stretch lg:gap-0"
|
||||
>
|
||||
{steps.map((step, index) => {
|
||||
const isComplete = index < activeVisualStep;
|
||||
const isActive = index === activeVisualStep;
|
||||
const isInactive = index > activeVisualStep;
|
||||
const hasNext = index < steps.length - 1;
|
||||
|
||||
return (
|
||||
<div key={step.label} className="flex items-start gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<StepCircle
|
||||
isComplete={isComplete}
|
||||
isActive={isActive}
|
||||
icon={step.icon}
|
||||
/>
|
||||
{index < steps.length - 1 && (
|
||||
<StepConnector isComplete={isComplete} />
|
||||
<Fragment key={step.label}>
|
||||
<div
|
||||
aria-current={isActive ? "step" : undefined}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 lg:items-start lg:gap-3",
|
||||
!isActive && "shrink-0",
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<StepCircle
|
||||
isComplete={isComplete}
|
||||
isActive={isActive}
|
||||
icon={step.icon}
|
||||
/>
|
||||
{hasNext && (
|
||||
<StepConnector
|
||||
isComplete={isComplete}
|
||||
className="hidden h-14 w-px lg:block"
|
||||
direction={CONNECTOR_DIRECTION.VERTICAL}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 pt-[10px]">
|
||||
<span
|
||||
<div
|
||||
className={cn(
|
||||
"text-lg leading-7 font-normal",
|
||||
isActive && "text-text-neutral-primary",
|
||||
isComplete && "text-text-neutral-primary",
|
||||
isInactive && "text-text-neutral-tertiary",
|
||||
"min-w-0 flex-col gap-1 lg:flex lg:pt-[10px]",
|
||||
isActive ? "flex" : "sr-only lg:not-sr-only",
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
<p className="text-text-neutral-secondary text-xs leading-5">
|
||||
{step.description}
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm font-normal lg:overflow-visible lg:text-lg lg:leading-7 lg:whitespace-normal",
|
||||
isActive && "text-text-neutral-primary",
|
||||
isComplete && "text-text-neutral-primary",
|
||||
isInactive && "text-text-neutral-tertiary",
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
<p className="text-text-neutral-secondary hidden text-xs leading-5 lg:block">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasNext && (
|
||||
<StepConnector
|
||||
isComplete={isComplete}
|
||||
className="h-px min-w-3 flex-1 lg:hidden"
|
||||
direction={CONNECTOR_DIRECTION.HORIZONTAL}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@@ -126,41 +162,58 @@ interface StepCircleProps {
|
||||
icon: IconComponent;
|
||||
}
|
||||
|
||||
const CIRCLE_SIZE = "size-8 lg:size-[44px]";
|
||||
|
||||
function StepCircle({ isComplete, isActive, icon: Icon }: StepCircleProps) {
|
||||
if (isComplete) {
|
||||
return (
|
||||
<div className="bg-button-primary-press flex size-[44px] shrink-0 items-center justify-center rounded-full">
|
||||
<CircleCheckBig className="text-bg-neutral-primary size-6" />
|
||||
<div
|
||||
className={`bg-button-primary-press flex shrink-0 items-center justify-center rounded-full ${CIRCLE_SIZE}`}
|
||||
>
|
||||
<CircleCheckBig className="text-bg-neutral-primary size-4 lg:size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
return (
|
||||
<div className="border-border-input-primary-pressed bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
|
||||
<div
|
||||
className={`border-border-input-primary-pressed bg-bg-neutral-secondary flex shrink-0 items-center justify-center rounded-full border ${CIRCLE_SIZE}`}
|
||||
>
|
||||
<StepIcon icon={Icon} className="text-border-input-primary-pressed" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
|
||||
<div
|
||||
className={`border-border-neutral-secondary bg-bg-neutral-secondary flex shrink-0 items-center justify-center rounded-full border ${CIRCLE_SIZE}`}
|
||||
>
|
||||
<StepIcon icon={Icon} className="text-text-neutral-tertiary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepConnector({ isComplete }: { isComplete: boolean }) {
|
||||
interface StepConnectorProps {
|
||||
isComplete: boolean;
|
||||
className: string;
|
||||
direction: ConnectorDirection;
|
||||
}
|
||||
|
||||
function StepConnector({
|
||||
isComplete,
|
||||
className,
|
||||
direction,
|
||||
}: StepConnectorProps) {
|
||||
if (isComplete) {
|
||||
return <div className="bg-border-input-primary-pressed h-14 w-px" />;
|
||||
return <div className={cn("bg-border-input-primary-pressed", className)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-14 w-px"
|
||||
className={className}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(to bottom, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)",
|
||||
backgroundImage: `repeating-linear-gradient(${direction}, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -173,10 +226,17 @@ function StepIcon({
|
||||
icon: IconComponent;
|
||||
className: string;
|
||||
}) {
|
||||
// Decorative like lucide's; size classes beat the SVG's width/height attributes.
|
||||
if (isCustomSvgIcon(Icon)) {
|
||||
return <Icon size={24} className={className} />;
|
||||
return (
|
||||
<Icon
|
||||
aria-hidden
|
||||
size={24}
|
||||
className={cn("size-4 lg:size-6", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Icon className={cn("size-6", className)} />;
|
||||
return <Icon className={cn("size-4 lg:size-6", className)} />;
|
||||
}
|
||||
|
||||
function isCustomSvgIcon(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -90,15 +90,16 @@ describe("provider account aliases", () => {
|
||||
});
|
||||
|
||||
describe("Registry provider source tabs", () => {
|
||||
it("explains Registry setup without claiming a connection failure", async () => {
|
||||
it("explains Registry setup in the Registry tab without claiming a connection failure", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
const user = userEvent.setup();
|
||||
getInstalledRegistryProviderOptions.mockResolvedValue({
|
||||
status: "onboarding",
|
||||
});
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
|
||||
// When
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
await user.click(screen.getByRole("tab", { name: "Registry" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
@@ -110,28 +111,42 @@ describe("Registry provider source tabs", () => {
|
||||
expect(
|
||||
screen.queryByText("Registry providers could not be loaded"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When / Then: the built-in list never gets a banner pushed above it.
|
||||
await user.click(screen.getByRole("tab", { name: "All providers" }));
|
||||
expect(
|
||||
screen.queryByText("Registry is not connected yet"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the Registry paid plan upgrade while keeping built-in providers usable", async () => {
|
||||
it("offers the Registry paid plan upgrade in the Registry tab while keeping built-in providers usable", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
getInstalledRegistryProviderOptions.mockResolvedValue({
|
||||
status: "subscription_required",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
useCloudUpgradeStore.getState().closeCloudUpgrade();
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
|
||||
// When
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
await user.click(screen.getByRole("tab", { name: "Registry" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Unlock Registry" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(useCloudUpgradeStore.getState().activeFeature).toBe("registry");
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("tab", { name: "All providers" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Amazon Web Services/ }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Unlock Registry" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Registry providers could not be loaded"),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -144,12 +159,64 @@ describe("Registry provider source tabs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("shows both source tabs before discovery settles and loads Registry providers in place", async () => {
|
||||
// Given: Registry discovery is still in flight.
|
||||
const user = userEvent.setup();
|
||||
let settleDiscovery: (result: {
|
||||
status: "ready";
|
||||
options: { type: string; label: string }[];
|
||||
}) => void = () => {};
|
||||
getInstalledRegistryProviderOptions.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
settleDiscovery = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
|
||||
// Then: the tabs are there from the first render.
|
||||
expect(screen.getByRole("tab", { name: "All providers" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Registry" })).toBeVisible();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("tab", { name: "Registry" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("status", { name: "Loading Registry providers" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText("No Registry providers available."),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await act(async () => {
|
||||
settleDiscovery({
|
||||
status: "ready",
|
||||
options: [{ type: "acme", label: "Acme Cloud" }],
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Acme Cloud Registry/ }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("status", { name: "Loading Registry providers" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never mentions Registry outside Cloud, even when discovery would fail", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
@@ -171,7 +238,22 @@ describe("Registry provider source tabs", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Registry tab when discovery denies access (Local or flag off)", async () => {
|
||||
it("hides the source tabs without asking discovery when the deployment has Registry off", () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "false");
|
||||
|
||||
// When
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
|
||||
// Then
|
||||
expect(getInstalledRegistryProviderOptions).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Amazon Web Services/ }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("hides the source tabs when discovery denies the user access", async () => {
|
||||
// Given
|
||||
getInstalledRegistryProviderOptions.mockResolvedValue({
|
||||
status: "access_denied",
|
||||
@@ -195,44 +277,21 @@ describe("Registry provider source tabs", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Registry tab once discovery confirms the deployment offers it", async () => {
|
||||
// Given: Cloud or Private Cloud with Registry enabled and no artifacts yet.
|
||||
getInstalledRegistryProviderOptions.mockResolvedValue({
|
||||
status: "ready",
|
||||
options: [],
|
||||
});
|
||||
|
||||
// When
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("tab", { name: "Registry" })).toBeVisible();
|
||||
expect(screen.getByRole("tab", { name: "All providers" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps Registry hidden and offers a retry when access is unknown", async () => {
|
||||
it("offers a retry in the Registry tab when access is unknown", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
getInstalledRegistryProviderOptions
|
||||
.mockResolvedValueOnce({ status: "unknown" })
|
||||
.mockResolvedValueOnce({ status: "ready", options: [] });
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
|
||||
// When
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
await user.click(screen.getByRole("tab", { name: "Registry" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("Registry providers could not be loaded"),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: "Registry" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Retry Registry providers" }),
|
||||
).toBeVisible();
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
@@ -240,7 +299,9 @@ describe("Registry provider source tabs", () => {
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("tab", { name: "Registry" })).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText("No Registry providers available."),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText("Registry providers could not be loaded"),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -259,6 +320,7 @@ describe("Registry provider source tabs", () => {
|
||||
}),
|
||||
);
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
await user.click(screen.getByRole("tab", { name: "Registry" }));
|
||||
const retry = await screen.findByRole("button", {
|
||||
name: "Retry Registry providers",
|
||||
});
|
||||
@@ -296,6 +358,7 @@ describe("Registry provider source tabs", () => {
|
||||
)
|
||||
.mockResolvedValueOnce({ status: "error" });
|
||||
render(<ConnectAccountForm onSuccess={vi.fn()} />);
|
||||
await user.click(screen.getByRole("tab", { name: "Registry" }));
|
||||
const retry = await screen.findByRole("button", {
|
||||
name: "Retry Registry providers",
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
|
||||
import { Form } from "@/components/shadcn/form";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { REGISTRY_PROVIDER_DISCOVERY } from "@/lib/registry/provider-options";
|
||||
import { isRegistryEnabled } from "@/lib/shared/env";
|
||||
import {
|
||||
createAddProviderFormSchema,
|
||||
AddProviderFormValues,
|
||||
@@ -229,9 +230,10 @@ export const ConnectAccountForm = ({
|
||||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.READY
|
||||
? registryDiscovery.options
|
||||
: [];
|
||||
// The deployment flag is known up front, so the tabs render before discovery.
|
||||
const registryAvailable =
|
||||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.READY ||
|
||||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.ERROR;
|
||||
isRegistryEnabled() &&
|
||||
registryDiscovery?.status !== REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED;
|
||||
const registryError =
|
||||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.ERROR ||
|
||||
registryDiscovery?.status === REGISTRY_PROVIDER_DISCOVERY.UNKNOWN;
|
||||
@@ -440,34 +442,41 @@ export const ConnectAccountForm = ({
|
||||
{/* Step 1: Provider selection */}
|
||||
{prevStep === 1 && (
|
||||
<div>
|
||||
{registryDiscovery?.status ===
|
||||
REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED && (
|
||||
<RegistryUpgradePrompt />
|
||||
)}
|
||||
{registryDiscovery?.status ===
|
||||
REGISTRY_PROVIDER_DISCOVERY.ONBOARDING && <RegistrySetupPrompt />}
|
||||
{registryError && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Registry providers could not be loaded</AlertTitle>
|
||||
<AlertDescription>
|
||||
Built-in providers are available. Check the Registry
|
||||
connection and try again.
|
||||
{/* aria-disabled, not disabled: the pressed button keeps focus. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-disabled={isRetryingDiscovery}
|
||||
onClick={retryDiscovery}
|
||||
>
|
||||
{isRetryingDiscovery
|
||||
? "Retrying…"
|
||||
: "Retry Registry providers"}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<RadioGroupProvider
|
||||
registryAvailable={registryAvailable}
|
||||
registryLoading={registryAvailable && registryDiscovery === null}
|
||||
registryNotice={
|
||||
registryDiscovery?.status ===
|
||||
REGISTRY_PROVIDER_DISCOVERY.SUBSCRIPTION_REQUIRED ? (
|
||||
<RegistryUpgradePrompt />
|
||||
) : registryDiscovery?.status ===
|
||||
REGISTRY_PROVIDER_DISCOVERY.ONBOARDING ? (
|
||||
<RegistrySetupPrompt />
|
||||
) : (
|
||||
registryError && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
Registry providers could not be loaded
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Built-in providers are available. Check the Registry
|
||||
connection and try again.
|
||||
{/* aria-disabled, not disabled: the pressed button keeps focus. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-disabled={isRetryingDiscovery}
|
||||
onClick={retryDiscovery}
|
||||
>
|
||||
{isRetryingDiscovery
|
||||
? "Retrying…"
|
||||
: "Retry Registry providers"}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
)
|
||||
}
|
||||
registryOptions={registryOptions}
|
||||
control={form.control}
|
||||
isInvalid={!!form.formState.errors.providerType}
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("useRegistryProviderDiscovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
|
||||
});
|
||||
|
||||
it("ignores a stale discovery that settles after a newer one", async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
REGISTRY_PROVIDER_DISCOVERY,
|
||||
type RegistryProviderDiscoveryResult,
|
||||
} from "@/lib/registry/provider-options";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { isRegistryEnabled } from "@/lib/shared/env";
|
||||
|
||||
export function useRegistryProviderDiscovery() {
|
||||
// Local state needed: discovery and retries resolve asynchronously.
|
||||
@@ -20,7 +20,7 @@ export function useRegistryProviderDiscovery() {
|
||||
const latestRequest = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCloud()) return;
|
||||
if (!isRegistryEnabled()) return;
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
const request = ++latestRequest.current;
|
||||
|
||||
@@ -145,6 +145,7 @@ describe("getRuntimeConfigClient", () => {
|
||||
"posthogIngestionHost",
|
||||
"posthogKey",
|
||||
"posthogUiHost",
|
||||
"registryEnabled",
|
||||
"reoDevClientId",
|
||||
"selfRegistrationEnabled",
|
||||
"sentryDsn",
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface RuntimePublicConfig {
|
||||
reoDevClientId: string | null; // reserved
|
||||
cloudEnabled: boolean;
|
||||
cloudBillingEnabled: boolean;
|
||||
registryEnabled: boolean;
|
||||
selfRegistrationEnabled: boolean;
|
||||
stripePublishableKey: string | null; // reserved
|
||||
stripePublishableKeyV2: string | null; // reserved
|
||||
@@ -34,6 +35,7 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
|
||||
reoDevClientId: null,
|
||||
cloudEnabled: false,
|
||||
cloudBillingEnabled: false,
|
||||
registryEnabled: false,
|
||||
selfRegistrationEnabled: true,
|
||||
stripePublishableKey: null,
|
||||
stripePublishableKeyV2: null,
|
||||
@@ -55,6 +57,7 @@ const pickConfig = (
|
||||
reoDevClientId: parsed.reoDevClientId ?? null,
|
||||
cloudEnabled: parsed.cloudEnabled ?? false,
|
||||
cloudBillingEnabled: parsed.cloudBillingEnabled ?? false,
|
||||
registryEnabled: parsed.registryEnabled ?? false,
|
||||
selfRegistrationEnabled: parsed.selfRegistrationEnabled ?? true,
|
||||
stripePublishableKey: parsed.stripePublishableKey ?? null,
|
||||
stripePublishableKeyV2: parsed.stripePublishableKeyV2 ?? null,
|
||||
|
||||
@@ -96,3 +96,27 @@ describe("getRuntimePublicConfig self-registration flag", () => {
|
||||
expect(config.selfRegistrationEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimePublicConfig Registry flag", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('is enabled when UI_REGISTRY_ENABLED is "true"', async () => {
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
|
||||
|
||||
const { getRuntimePublicConfig } = await importFresh();
|
||||
const config = await getRuntimePublicConfig();
|
||||
|
||||
expect(config.registryEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("is disabled when UI_REGISTRY_ENABLED is unset", async () => {
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", undefined);
|
||||
|
||||
const { getRuntimePublicConfig } = await importFresh();
|
||||
const config = await getRuntimePublicConfig();
|
||||
|
||||
expect(config.registryEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,8 @@ export async function getRuntimePublicConfig(): Promise<RuntimePublicConfig> {
|
||||
posthogUiHost: readGatedEnv("UI_POSTHOG_ENABLED", "UI_POSTHOG_UI_HOST"),
|
||||
reoDevClientId: readEnv("REO_DEV_CLIENT_ID"),
|
||||
cloudEnabled: readBoolEnv("UI_CLOUD_ENABLED"),
|
||||
// Deployment gate only: per-user Registry access is still checked server-side.
|
||||
registryEnabled: readBoolEnv("UI_REGISTRY_ENABLED"),
|
||||
// Off only when explicitly "false": invited users can still register.
|
||||
selfRegistrationEnabled: readOptOutEnv("UI_SELF_REGISTRATION_ENABLED"),
|
||||
// Install-level selector "legacy" | "metronome" | "false"; the client only
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { RUNTIME_CONFIG_SCRIPT_ID } from "@/lib/runtime-config.shared";
|
||||
|
||||
import { isCloud, isSelfRegistrationEnabled } from "./env";
|
||||
import { isCloud, isRegistryEnabled, isSelfRegistrationEnabled } from "./env";
|
||||
|
||||
const writeIsland = (content: Record<string, unknown> | string) => {
|
||||
const el = document.createElement("script");
|
||||
@@ -95,3 +95,38 @@ describe("isSelfRegistrationEnabled", () => {
|
||||
expect(isSelfRegistrationEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRegistryEnabled", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
document.head.innerHTML = "";
|
||||
});
|
||||
|
||||
it('returns true in Prowler Cloud when UI_REGISTRY_ENABLED is "true"', () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
|
||||
expect(isRegistryEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false in Prowler Cloud when UI_REGISTRY_ENABLED is unset", () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
expect(isRegistryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false outside Prowler Cloud even when UI_REGISTRY_ENABLED is "true"', () => {
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
|
||||
expect(isRegistryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the island flags over the env vars", () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
|
||||
writeIsland({ cloudEnabled: true, registryEnabled: false });
|
||||
expect(isRegistryEnabled()).toBe(false);
|
||||
|
||||
document.head.innerHTML = "";
|
||||
vi.stubEnv("UI_REGISTRY_ENABLED", "false");
|
||||
writeIsland({ cloudEnabled: true, registryEnabled: true });
|
||||
expect(isRegistryEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,21 @@ export function isCloud(): boolean {
|
||||
return readBoolEnv("UI_CLOUD_ENABLED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this Cloud deployment offers Registry providers (`UI_REGISTRY_ENABLED`).
|
||||
*
|
||||
* Read from the island in the browser, then the env, like `isCloud()`. It only
|
||||
* gates the deployment: per-user Registry access is still decided server-side.
|
||||
*/
|
||||
export function isRegistryEnabled(): boolean {
|
||||
if (!isCloud()) return false;
|
||||
|
||||
const islandConfig = readRuntimeConfigIsland();
|
||||
if (islandConfig) return islandConfig.registryEnabled;
|
||||
|
||||
return readBoolEnv("UI_REGISTRY_ENABLED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether visitors can create an account without an invitation.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface LighthouseLlmStoreState {
|
||||
// Last answer this page load (null: never checked), shown at once on reopen.
|
||||
hasConnectedLlm: boolean | null;
|
||||
// Id of the newest check: an older check that answers late is ignored.
|
||||
latestCheck: number;
|
||||
startCheck: () => number;
|
||||
settleCheck: (check: number, hasConnectedLlm: boolean) => void;
|
||||
}
|
||||
|
||||
export const useLighthouseLlmStore = create<LighthouseLlmStoreState>(
|
||||
(set, get) => ({
|
||||
hasConnectedLlm: null,
|
||||
latestCheck: 0,
|
||||
startCheck: () => {
|
||||
const check = get().latestCheck + 1;
|
||||
set({ latestCheck: check });
|
||||
return check;
|
||||
},
|
||||
settleCheck: (check, hasConnectedLlm) => {
|
||||
if (check === get().latestCheck) set({ hasConnectedLlm });
|
||||
},
|
||||
}),
|
||||
);
|
||||
+1
-12
@@ -717,18 +717,7 @@
|
||||
|
||||
/* ===== UTILITY LAYER ===== */
|
||||
@layer utilities {
|
||||
/* Screen reader only - visually hidden but accessible */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
/* No .sr-only here: Tailwind ships it, and a copy would beat lg:not-sr-only. */
|
||||
|
||||
/* Hide scrollbar */
|
||||
.no-scrollbar {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const RUNTIME_CONFIG_KEYS = [
|
||||
"reoDevClientId",
|
||||
"cloudBillingEnabled",
|
||||
"cloudEnabled",
|
||||
"registryEnabled",
|
||||
"selfRegistrationEnabled",
|
||||
"stripePublishableKey",
|
||||
"stripePublishableKeyV2",
|
||||
|
||||
Reference in new issue
Block a user