mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-05 03:12:14 +00:00
feat(ui): send empty tenants to the add-provider wizard on first run
This commit is contained in:
+15
-14
@@ -59,6 +59,10 @@ export default async function RootLayout({
|
||||
// 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.
|
||||
@@ -69,29 +73,27 @@ export default async function RootLayout({
|
||||
// Fail-open: unknown scan state is treated as "has data" so the banner never blocks
|
||||
// progression on a fetch error.
|
||||
let hasCompletedScan = true;
|
||||
// Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open).
|
||||
let hasProviders: boolean | undefined = false;
|
||||
// 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 [providersData, scansByState] = await Promise.all([
|
||||
getProviders({ page: 1, pageSize: 1 }),
|
||||
getScansByState(),
|
||||
]);
|
||||
const scansByState = await getScansByState();
|
||||
hasCompletedScan = Array.isArray(scansByState?.data)
|
||||
? scansByState.data.some(
|
||||
(scan: { attributes?: { state?: string } }) =>
|
||||
scan.attributes?.state === SCAN_STATES.COMPLETED,
|
||||
)
|
||||
: true;
|
||||
hasProviders = Array.isArray(providersData?.data)
|
||||
? providersData.data.length > 0
|
||||
: undefined;
|
||||
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;
|
||||
|
||||
@@ -114,13 +116,12 @@ export default async function RootLayout({
|
||||
<Suspense>
|
||||
<NavigationProgress />
|
||||
</Suspense>
|
||||
{/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */}
|
||||
<StoreInitializer
|
||||
values={{ hasProviders: hasProviders ?? false, registryEligible }}
|
||||
/>
|
||||
{/* 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} />
|
||||
{cloudEnabled && (
|
||||
<>
|
||||
<OnboardingGate hasProviders={hasProviders} />
|
||||
{/* Single mount point so the watcher survives post-connect navigation. */}
|
||||
<OnboardingCheckpointWatcher tenantId={tenantId} />
|
||||
{/* Persistent banner shown only while a guided sequence is active. */}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
New tenants without providers land on the Add Provider wizard on first sign-in instead of a welcome modal
|
||||
@@ -1,18 +1,23 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { isFirstRunHandled } from "@/lib/onboarding/first-run-marker";
|
||||
import { addProviderTour } from "@/lib/tours/add-provider.tour";
|
||||
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
|
||||
|
||||
import { OnboardingGate } from "../onboarding-gate";
|
||||
|
||||
const pushMock = vi.fn();
|
||||
const replaceMock = vi.fn();
|
||||
const armMock = vi.fn();
|
||||
const pathnameMock = vi.fn();
|
||||
const permissionsMock = vi.fn();
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ permissions: permissionsMock() }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock, replace: vi.fn() }),
|
||||
useRouter: () => ({ push: vi.fn(), replace: replaceMock }),
|
||||
usePathname: () => pathnameMock(),
|
||||
}));
|
||||
|
||||
@@ -27,20 +32,28 @@ const addProviderTourId = {
|
||||
version: addProviderTour.version,
|
||||
};
|
||||
|
||||
const CLOUD_FIRST_RUN_HREF =
|
||||
"/providers?addProvider=true&addProviderSource=first_run&onboarding=add-provider";
|
||||
const OSS_FIRST_RUN_HREF =
|
||||
"/providers?addProvider=true&addProviderSource=first_run";
|
||||
|
||||
describe("OnboardingGate", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
pushMock.mockClear();
|
||||
replaceMock.mockClear();
|
||||
armMock.mockClear();
|
||||
pathnameMock.mockReturnValue("/");
|
||||
permissionsMock.mockReturnValue({ manage_providers: true });
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each(["/billing", "/billing/", "/billing/checkout"])(
|
||||
"defers onboarding on %s without resolving it",
|
||||
"defers the first run on %s without resolving it",
|
||||
(pathname) => {
|
||||
// Given
|
||||
pathnameMock.mockReturnValue(pathname);
|
||||
@@ -49,16 +62,13 @@ describe("OnboardingGate", () => {
|
||||
render(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /get started/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(localStorageAdapter.get(addProviderTourId)).toBeNull();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
expect(armMock).not.toHaveBeenCalled();
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
expect(isFirstRunHandled()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("offers onboarding after leaving billing without remounting the gate", async () => {
|
||||
it("sends the user to add a provider after leaving billing, without remounting the gate", async () => {
|
||||
// Given
|
||||
pathnameMock.mockReturnValue("/billing");
|
||||
const { rerender } = render(<OnboardingGate hasProviders={false} />);
|
||||
@@ -68,170 +78,127 @@ describe("OnboardingGate", () => {
|
||||
rerender(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /get started/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(localStorageAdapter.get(addProviderTourId)).toBeNull();
|
||||
expect(armMock).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(CLOUD_FIRST_RUN_HREF),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not suppress onboarding on a route that only shares the billing prefix", async () => {
|
||||
it("does not defer on a route that only shares the billing prefix", async () => {
|
||||
// Given
|
||||
pathnameMock.mockReturnValue("/billing-settings");
|
||||
pathnameMock.mockReturnValue("/billing-history");
|
||||
|
||||
// When
|
||||
render(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /get started/i }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
describe("when the user has no providers and no completion record", () => {
|
||||
it("shows the Welcome modal", async () => {
|
||||
describe("when a Cloud tenant has no providers and never went through the first run", () => {
|
||||
it("opens the add-provider wizard with its tour and arms the checkpoint", async () => {
|
||||
// Given / When
|
||||
render(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /get started/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the user already has providers", () => {
|
||||
it("does not show the Welcome modal", async () => {
|
||||
render(<OnboardingGate hasProviders={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /get started/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a completion record already exists in this browser", () => {
|
||||
it("does not show the Welcome modal", async () => {
|
||||
localStorageAdapter.set(addProviderTourId, {
|
||||
tourId: addProviderTour.id,
|
||||
version: addProviderTour.version,
|
||||
state: "dismissed",
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
render(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /get started/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the gate flow is dismissed but later sequence flows are incomplete", () => {
|
||||
it("does not show the Welcome modal for a later flow", async () => {
|
||||
// Later flows are only reachable via the checkpoint/sequence, never the gate.
|
||||
localStorageAdapter.set(addProviderTourId, {
|
||||
tourId: addProviderTour.id,
|
||||
version: addProviderTour.version,
|
||||
state: "dismissed",
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
render(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /get started/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when hasProviders is undefined (fail-open)", () => {
|
||||
it("does not show the Welcome modal", async () => {
|
||||
// `undefined` mirrors the tri-state layout forwards on a failed provider fetch.
|
||||
render(<OnboardingGate hasProviders={undefined} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /get started/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("can be mounted with the prop omitted entirely (fail-open)", async () => {
|
||||
render(<OnboardingGate />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /get started/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the user accepts the Welcome modal", () => {
|
||||
it("navigates to the flow route with the onboarding query param and writes no record", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OnboardingGate hasProviders={false} />);
|
||||
const getStarted = await screen.findByRole("button", {
|
||||
name: /get started/i,
|
||||
});
|
||||
|
||||
await user.click(getStarted);
|
||||
|
||||
expect(pushMock).toHaveBeenCalledWith(
|
||||
"/providers?onboarding=add-provider",
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(
|
||||
CLOUD_FIRST_RUN_HREF,
|
||||
),
|
||||
);
|
||||
expect(localStorageAdapter.get(addProviderTourId)).toBeNull();
|
||||
expect(armMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("arms the onboarding checkpoint", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("happens only once per browser", async () => {
|
||||
// Given
|
||||
const { unmount } = render(<OnboardingGate hasProviders={false} />);
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
|
||||
unmount();
|
||||
replaceMock.mockClear();
|
||||
|
||||
// When
|
||||
render(<OnboardingGate hasProviders={false} />);
|
||||
const getStarted = await screen.findByRole("button", {
|
||||
name: /get started/i,
|
||||
});
|
||||
|
||||
await user.click(getStarted);
|
||||
|
||||
expect(armMock).toHaveBeenCalledTimes(1);
|
||||
// Then
|
||||
expect(isFirstRunHandled()).toBe(true);
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the user dismisses the Welcome modal", () => {
|
||||
it("writes a dismissed record and stops showing the modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
describe("when a self-hosted deployment has no providers", () => {
|
||||
it("opens the add-provider wizard without the Cloud-only tour or checkpoint", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
|
||||
// When
|
||||
render(<OnboardingGate hasProviders={false} />);
|
||||
const skip = await screen.findByRole("button", {
|
||||
name: /skip for now/i,
|
||||
});
|
||||
|
||||
await user.click(skip);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /skip for now/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
const record = localStorageAdapter.get(addProviderTourId);
|
||||
expect(record).not.toBeNull();
|
||||
expect(record?.state).toBe("dismissed");
|
||||
});
|
||||
|
||||
it("does NOT arm the onboarding checkpoint", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OnboardingGate hasProviders={false} />);
|
||||
const skip = await screen.findByRole("button", {
|
||||
name: /skip for now/i,
|
||||
});
|
||||
|
||||
await user.click(skip);
|
||||
|
||||
// Skipping must never arm the checkpoint (user opted out).
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(OSS_FIRST_RUN_HREF),
|
||||
);
|
||||
expect(armMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the user cannot add providers", () => {
|
||||
it("leaves the user where they are, since an empty list may just be limited visibility", () => {
|
||||
// Given
|
||||
permissionsMock.mockReturnValue({ manage_providers: false });
|
||||
|
||||
// When
|
||||
render(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
// Then
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
expect(isFirstRunHandled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the tenant already has providers", () => {
|
||||
it("leaves the user where they are", () => {
|
||||
// Given / When
|
||||
render(<OnboardingGate hasProviders />);
|
||||
|
||||
// Then
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
expect(isFirstRunHandled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the add-provider tour was already resolved in this browser", () => {
|
||||
it("leaves the user where they are", () => {
|
||||
// Given
|
||||
localStorageAdapter.set(addProviderTourId, {
|
||||
tourId: addProviderTour.id,
|
||||
version: addProviderTour.version,
|
||||
state: "dismissed",
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// When
|
||||
render(<OnboardingGate hasProviders={false} />);
|
||||
|
||||
// Then
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
expect(armMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the provider count is unknown (fail-open)", () => {
|
||||
it("leaves the user where they are when the fetch failed", () => {
|
||||
// Given / When
|
||||
render(<OnboardingGate hasProviders={undefined} />);
|
||||
|
||||
// Then
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can be mounted with the prop omitted entirely", () => {
|
||||
// Given / When
|
||||
render(<OnboardingGate />);
|
||||
|
||||
// Then
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,24 @@ describe("OnboardingTrigger", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("starts at the step the page asks for, skipping the ones before it", async () => {
|
||||
// Given
|
||||
searchParamsValue = new URLSearchParams("onboarding=add-provider");
|
||||
|
||||
// When
|
||||
render(
|
||||
<OnboardingTrigger
|
||||
flow={addProviderFlow}
|
||||
startAtTarget="provider-type"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(startMock).toHaveBeenCalledExactlyOnceWith("provider-type"),
|
||||
);
|
||||
});
|
||||
|
||||
it("strips only the onboarding param and preserves other query params", async () => {
|
||||
searchParamsValue = new URLSearchParams(
|
||||
"scanId=scan-1&onboarding=add-provider&tab=completed",
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { OnboardingWelcomeModal } from "../onboarding-welcome-modal";
|
||||
|
||||
describe("OnboardingWelcomeModal", () => {
|
||||
describe("when open is true", () => {
|
||||
it("renders the flow title and description", () => {
|
||||
render(
|
||||
<OnboardingWelcomeModal
|
||||
open
|
||||
flowTitle="Add your first provider"
|
||||
flowDescription="Connect a cloud account so Prowler has something to scan."
|
||||
onAccept={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Add your first provider")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Connect a cloud account so Prowler has something to scan.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onAccept when the primary action is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<OnboardingWelcomeModal
|
||||
open
|
||||
flowTitle="Add your first provider"
|
||||
onAccept={onAccept}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /get started/i }));
|
||||
|
||||
expect(onAccept).toHaveBeenCalledTimes(1);
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onDismiss when the skip action is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<OnboardingWelcomeModal
|
||||
open
|
||||
flowTitle="Add your first provider"
|
||||
onAccept={onAccept}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /skip for now/i }));
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when open is false", () => {
|
||||
it("does not render the modal content", () => {
|
||||
render(
|
||||
<OnboardingWelcomeModal
|
||||
open={false}
|
||||
flowTitle="Add your first provider"
|
||||
onAccept={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /get started/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getOrderedFlows, shouldStartOnboarding } from "@/lib/onboarding";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import {
|
||||
getOrderedFlows,
|
||||
type OnboardingFlow,
|
||||
shouldStartOnboarding,
|
||||
} from "@/lib/onboarding";
|
||||
import {
|
||||
isFirstRunHandled,
|
||||
markFirstRunHandled,
|
||||
} from "@/lib/onboarding/first-run-marker";
|
||||
import { WIZARD_OPEN_SOURCE } from "@/lib/provider-funnel/provider-funnel-events";
|
||||
import { buildAddProviderHref } from "@/lib/providers-navigation";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
|
||||
import { TOUR_COMPLETION_STATES } from "@/lib/tours/tour-types";
|
||||
import { useTourCompletion } from "@/lib/tours/use-tour-completion";
|
||||
import { useOnboardingCheckpointStore } from "@/store/onboarding-checkpoint";
|
||||
|
||||
import { OnboardingWelcomeModal } from "./onboarding-welcome-modal";
|
||||
|
||||
interface OnboardingGateProps {
|
||||
// `undefined` = fetch failed/ambiguous; fail-open (never force the modal).
|
||||
// `undefined` = fetch failed/ambiguous; fail-open (never force the first run).
|
||||
hasProviders?: boolean;
|
||||
}
|
||||
|
||||
// Mandatory new-user gate. Mounted once in the layout; decision derived during render
|
||||
// via useSyncExternalStore — server renders nothing, no hydration mismatch.
|
||||
// New-tenant gate. Mounted once in the layout: an empty tenant is sent straight to
|
||||
// the add-provider wizard, once per browser. Renders nothing.
|
||||
export function OnboardingGate({ hasProviders }: OnboardingGateProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { permissions } = useAuth();
|
||||
// Billing must stay usable before onboarding; leaving it keeps the gate eligible.
|
||||
const isBillingRoute =
|
||||
pathname === "/billing" || pathname?.startsWith("/billing/");
|
||||
@@ -28,52 +37,47 @@ export function OnboardingGate({ hasProviders }: OnboardingGateProps) {
|
||||
// Gate forces only the first flow (`add-provider`); remaining flows come via checkpoint/replay.
|
||||
const flow = getOrderedFlows()[0] ?? null;
|
||||
|
||||
// Returns null on server/first render — gate stays closed until resolved client-side.
|
||||
// Returns null on server/first render; the redirect re-reads storage before acting.
|
||||
const completionRecord = useTourCompletion(flow?.tour ?? null);
|
||||
|
||||
// Session flag prevents the gate re-opening after accept/dismiss within this mount.
|
||||
const [resolvedThisSession, setResolvedThisSession] = useState(false);
|
||||
|
||||
const activeFlow =
|
||||
flow &&
|
||||
const shouldRedirect =
|
||||
flow !== null &&
|
||||
!isBillingRoute &&
|
||||
!resolvedThisSession &&
|
||||
shouldStartOnboarding({ hasProviders, completionRecord })
|
||||
? flow
|
||||
: null;
|
||||
shouldStartOnboarding({
|
||||
hasProviders,
|
||||
canManageProviders: permissions.manage_providers === true,
|
||||
completionRecord,
|
||||
});
|
||||
|
||||
if (!activeFlow) return null;
|
||||
if (!shouldRedirect) return null;
|
||||
|
||||
const handleAccept = () => {
|
||||
// Arm checkpoint only on explicit accept — skip must never arm it.
|
||||
return <FirstRunRedirect flow={flow} />;
|
||||
}
|
||||
|
||||
interface FirstRunRedirectProps {
|
||||
flow: OnboardingFlow;
|
||||
}
|
||||
|
||||
function FirstRunRedirect({ flow }: FirstRunRedirectProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useMountEffect(() => {
|
||||
// Hydration renders with an empty completion snapshot, so decide from storage here.
|
||||
const tourId = { id: flow.tour.id, version: flow.tour.version };
|
||||
if (isFirstRunHandled() || localStorageAdapter.get(tourId) !== null) return;
|
||||
|
||||
markFirstRunHandled();
|
||||
|
||||
const addProviderHref = buildAddProviderHref(WIZARD_OPEN_SOURCE.FIRST_RUN);
|
||||
if (!isCloud()) {
|
||||
router.replace(addProviderHref);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tours and the post-connect checkpoint are Cloud-only.
|
||||
useOnboardingCheckpointStore.getState().arm();
|
||||
setResolvedThisSession(true);
|
||||
// Routes may already carry a query string, so pick the right separator.
|
||||
const separator = activeFlow.route.includes("?") ? "&" : "?";
|
||||
router.push(`${activeFlow.route}${separator}onboarding=${activeFlow.id}`);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
// Persist dismissal so the gate silently skips on future visits.
|
||||
localStorageAdapter.set(
|
||||
{ id: activeFlow.tour.id, version: activeFlow.tour.version },
|
||||
{
|
||||
tourId: activeFlow.tour.id,
|
||||
version: activeFlow.tour.version,
|
||||
state: TOUR_COMPLETION_STATES.DISMISSED,
|
||||
completedAt: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
setResolvedThisSession(true);
|
||||
};
|
||||
router.replace(`${addProviderHref}&onboarding=${flow.id}`);
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingWelcomeModal
|
||||
open
|
||||
flowTitle={activeFlow.title}
|
||||
flowDescription={activeFlow.description}
|
||||
onAccept={handleAccept}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ interface OnboardingTriggerProps<TTarget extends string = string> {
|
||||
flow: OnboardingFlow; // force-started when the sequence names it or `?onboarding=<id>` matches
|
||||
stepHandlers?: { [K in TTarget]?: TourStepHandlers<TTarget> };
|
||||
configOverrides?: Partial<Config>;
|
||||
// Step to begin from when the page already did what the earlier steps ask for.
|
||||
startAtTarget?: TTarget;
|
||||
}
|
||||
|
||||
// Latched per-trigger: `key` mounts a fresh runner on each re-trigger; `mode` drives param-strip logic.
|
||||
@@ -50,6 +52,7 @@ export function OnboardingTrigger<TTarget extends string = string>({
|
||||
flow,
|
||||
stepHandlers,
|
||||
configOverrides,
|
||||
startAtTarget,
|
||||
}: OnboardingTriggerProps<TTarget>) {
|
||||
const searchParams = useSearchParams();
|
||||
const param = searchParams?.get(ONBOARDING_PARAM) ?? null; // null outside Suspense context
|
||||
@@ -108,6 +111,7 @@ export function OnboardingTrigger<TTarget extends string = string>({
|
||||
queryString={request.queryString}
|
||||
stepHandlers={stepHandlers}
|
||||
configOverrides={configOverrides}
|
||||
startAtTarget={startAtTarget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +122,7 @@ interface OnboardingTourRunnerProps<TTarget extends string> {
|
||||
queryString: string;
|
||||
stepHandlers?: { [K in TTarget]?: TourStepHandlers<TTarget> };
|
||||
configOverrides?: Partial<Config>;
|
||||
startAtTarget?: TTarget;
|
||||
}
|
||||
|
||||
function OnboardingTourRunner<TTarget extends string>({
|
||||
@@ -126,6 +131,7 @@ function OnboardingTourRunner<TTarget extends string>({
|
||||
queryString,
|
||||
stepHandlers,
|
||||
configOverrides,
|
||||
startAtTarget,
|
||||
}: OnboardingTourRunnerProps<TTarget>) {
|
||||
// onClosed is intentionally inert — the banner owns advance/exit for both modes.
|
||||
const { start } = useDriverTour(flow.tour, {
|
||||
@@ -144,7 +150,7 @@ function OnboardingTourRunner<TTarget extends string>({
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
start();
|
||||
start(startAtTarget);
|
||||
if (mode === "replay") {
|
||||
// Only strip when the param actually started this replay; a same-route
|
||||
// in-memory request leaves the URL untouched (no replaceState needed).
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { DialogFooter } from "@/components/shadcn/dialog";
|
||||
import { Modal } from "@/components/shadcn/modal/modal";
|
||||
|
||||
interface OnboardingWelcomeModalProps {
|
||||
open: boolean;
|
||||
flowTitle?: string;
|
||||
flowDescription?: string;
|
||||
onAccept: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingWelcomeModal({
|
||||
open,
|
||||
flowTitle,
|
||||
flowDescription,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: OnboardingWelcomeModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={flowTitle}
|
||||
description={flowDescription}
|
||||
size="lg"
|
||||
// Overlay/Escape/X counts as dismiss — gate persists the record once.
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onDismiss();
|
||||
}}
|
||||
>
|
||||
<DialogFooter>
|
||||
{/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */}
|
||||
<Button variant="outline" onClick={onDismiss}>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button onClick={onAccept}>Get started</Button>
|
||||
</DialogFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -19,11 +19,30 @@ posts to the API.
|
||||
| Per-route trigger | `ui/components/onboarding/onboarding-trigger.tsx` |
|
||||
| Ephemeral sequence slice | `ui/store/onboarding-sequence.ts` |
|
||||
| Checkpoint watcher + dialog | `ui/components/onboarding/onboarding-checkpoint-{watcher,dialog}.tsx` |
|
||||
| Mandatory new-user gate | `ui/components/onboarding/onboarding-gate.tsx` |
|
||||
| New-tenant gate (first-run redirect) | `ui/components/onboarding/onboarding-gate.tsx` |
|
||||
| First-run marker (once per browser) | `ui/lib/onboarding/first-run-marker.ts` |
|
||||
| Step outcome events (window) | `ui/lib/onboarding/onboarding-events.ts` |
|
||||
| Invite step before the checkpoint | `ui/components/onboarding/onboarding-invite-{step,dialog}.tsx` |
|
||||
| Manual replay list | `ui/components/ui/user-nav/user-nav.tsx` |
|
||||
|
||||
## First run
|
||||
|
||||
The gate is mounted in every deployment. When the tenant provably has no
|
||||
providers (`hasProviders === false`), the user holds `manage_providers` and
|
||||
neither the first-run marker (`prowler.onboarding.first-run`) nor an
|
||||
add-provider completion record exists, it replaces the route once with
|
||||
`/providers?addProvider=true&addProviderSource=first_run`, so the add-provider
|
||||
wizard is already open. Billing routes defer it; an unknown provider count or a
|
||||
user without the permission (an empty list may only mean limited visibility)
|
||||
never triggers it.
|
||||
|
||||
In Cloud the URL also carries `&onboarding=add-provider` and the checkpoint is
|
||||
armed. Because the wizard is already open, the providers page passes
|
||||
`startAtTarget="provider-type"` to its `<OnboardingTrigger />`, which skips the
|
||||
tour's welcome and "open the wizard" steps. A navbar replay with the wizard
|
||||
closed still starts from the first step. Self-hosted deployments get the
|
||||
redirect only: tours and the checkpoint stay Cloud-only.
|
||||
|
||||
## How the guided sequence works
|
||||
|
||||
1. The `(prowler)/layout.tsx` derives a tri-state `hasProviders` on every
|
||||
|
||||
@@ -18,14 +18,26 @@ describe("shouldStartOnboarding", () => {
|
||||
it("returns true for a zero-provider user with no completion record", () => {
|
||||
const result = shouldStartOnboarding({
|
||||
hasProviders: false,
|
||||
canManageProviders: true,
|
||||
completionRecord: null,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the user cannot add providers, even in an empty tenant", () => {
|
||||
// Limited-visibility users see zero providers without the tenant being empty.
|
||||
const result = shouldStartOnboarding({
|
||||
hasProviders: false,
|
||||
canManageProviders: false,
|
||||
completionRecord: null,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the user already has providers", () => {
|
||||
const result = shouldStartOnboarding({
|
||||
hasProviders: true,
|
||||
canManageProviders: true,
|
||||
completionRecord: null,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
@@ -34,6 +46,7 @@ describe("shouldStartOnboarding", () => {
|
||||
it("returns false when a dismissed record exists", () => {
|
||||
const result = shouldStartOnboarding({
|
||||
hasProviders: false,
|
||||
canManageProviders: true,
|
||||
completionRecord: recordWithState(TOUR_COMPLETION_STATES.DISMISSED),
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
@@ -42,6 +55,7 @@ describe("shouldStartOnboarding", () => {
|
||||
it("returns false when a completed record exists", () => {
|
||||
const result = shouldStartOnboarding({
|
||||
hasProviders: false,
|
||||
canManageProviders: true,
|
||||
completionRecord: recordWithState(TOUR_COMPLETION_STATES.COMPLETED),
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
@@ -50,6 +64,7 @@ describe("shouldStartOnboarding", () => {
|
||||
it("returns false when a skipped record exists", () => {
|
||||
const result = shouldStartOnboarding({
|
||||
hasProviders: false,
|
||||
canManageProviders: true,
|
||||
completionRecord: recordWithState(TOUR_COMPLETION_STATES.SKIPPED),
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
@@ -59,6 +74,7 @@ describe("shouldStartOnboarding", () => {
|
||||
// strict === false check rejects non-false values; don't force onboarding on unknown state
|
||||
const result = shouldStartOnboarding({
|
||||
hasProviders: undefined,
|
||||
canManageProviders: true,
|
||||
completionRecord: null,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
@@ -67,6 +83,7 @@ describe("shouldStartOnboarding", () => {
|
||||
it("fails open when hasProviders is null", () => {
|
||||
const result = shouldStartOnboarding({
|
||||
hasProviders: null as unknown as boolean,
|
||||
canManageProviders: true,
|
||||
completionRecord: null,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Durable "this browser already went through the first-run redirect" memory.
|
||||
// Self-hosted deployments run no tour, so no completion record would ever be
|
||||
// written there; without this marker an empty tenant would be redirected on
|
||||
// every page load.
|
||||
const FIRST_RUN_MARKER_KEY = "prowler.onboarding.first-run";
|
||||
|
||||
export function isFirstRunHandled(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
return window.localStorage.getItem(FIRST_RUN_MARKER_KEY) !== null;
|
||||
} catch {
|
||||
// Unreadable storage must not redirect forever: treat as handled.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function markFirstRunHandled(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(FIRST_RUN_MARKER_KEY, "true");
|
||||
} catch {
|
||||
// Non-fatal: a repeated redirect beats a thrown render.
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,19 @@ import type { TourCompletionRecord } from "@/lib/tours/tour-types";
|
||||
export interface GateDecisionInput {
|
||||
// `undefined` allowed; strict `=== false` check below fails open on ambiguous signals.
|
||||
hasProviders: boolean | undefined;
|
||||
// Limited-visibility users list zero providers in a tenant that is not empty.
|
||||
canManageProviders: boolean;
|
||||
completionRecord: TourCompletionRecord | null;
|
||||
}
|
||||
|
||||
// Only forces onboarding when providers are provably absent and no record exists.
|
||||
// Only forces onboarding when providers are provably absent, the user can add one
|
||||
// and no record exists.
|
||||
export function shouldStartOnboarding({
|
||||
hasProviders,
|
||||
canManageProviders,
|
||||
completionRecord,
|
||||
}: GateDecisionInput): boolean {
|
||||
const hasNoRecord =
|
||||
completionRecord === null || completionRecord === undefined;
|
||||
return hasProviders === false && hasNoRecord;
|
||||
return hasProviders === false && canManageProviders && hasNoRecord;
|
||||
}
|
||||
|
||||
@@ -195,4 +195,66 @@ describe("useDriverTour lifecycle", () => {
|
||||
// ...but no completion record was persisted, so the tour can reappear later.
|
||||
expect(store.get({ id: tour.id, version: tour.version })).toBeNull();
|
||||
});
|
||||
|
||||
describe("when asked to start at an anchored step", () => {
|
||||
const anchoredTour = {
|
||||
id: "anchored-tour",
|
||||
version: 1,
|
||||
coversFiles: [],
|
||||
steps: [
|
||||
{ title: "Welcome", description: "Intro" },
|
||||
{ target: "late", title: "Late anchor", description: "Inside a modal" },
|
||||
],
|
||||
} satisfies TourDefinition;
|
||||
|
||||
function AnchoredProbe({
|
||||
onResult,
|
||||
}: {
|
||||
onResult: (result: UseDriverTourResult) => void;
|
||||
}) {
|
||||
onResult(
|
||||
useDriverTour(anchoredTour, { autoOpen: false, store: createStore() }),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("skips the earlier steps once the anchor is in the DOM", async () => {
|
||||
// Given
|
||||
let latestResult: UseDriverTourResult | undefined;
|
||||
render(<AnchoredProbe onResult={(result) => (latestResult = result)} />);
|
||||
const anchor = document.createElement("div");
|
||||
anchor.setAttribute("data-tour-id", "anchored-tour-late");
|
||||
document.body.appendChild(anchor);
|
||||
|
||||
// When
|
||||
await act(async () => {
|
||||
latestResult?.start("late");
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(driverHarness.instances[0].drive).toHaveBeenCalledExactlyOnceWith(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("starts from the first step when the target is not part of the tour", async () => {
|
||||
// Given
|
||||
let latestResult: UseDriverTourResult | undefined;
|
||||
render(<AnchoredProbe onResult={(result) => (latestResult = result)} />);
|
||||
|
||||
// When
|
||||
await act(async () => {
|
||||
latestResult?.start("unknown");
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
driverHarness.instances[0].drive,
|
||||
).toHaveBeenCalledExactlyOnceWith();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,7 +102,8 @@ export interface UseDriverTourOptions<TTarget extends string = string> {
|
||||
}
|
||||
|
||||
export interface UseDriverTourResult {
|
||||
start: () => void;
|
||||
/** Optional step `target` to begin from, skipping the steps before it. */
|
||||
start: (startAtTarget?: string) => void;
|
||||
stop: () => void;
|
||||
/** True if a completion record exists for `(tour.id, tour.version)`. */
|
||||
hasCompleted: boolean;
|
||||
@@ -394,11 +395,29 @@ export function useDriverTour<TTarget extends string>(
|
||||
}, [autoOpen, enabled, hasCompleted, tourId, tourVersion]);
|
||||
|
||||
return {
|
||||
start: () => {
|
||||
start: (startAtTarget) => {
|
||||
const instance = driverRef.current;
|
||||
if (!instance) return;
|
||||
activeTourInstance = instance;
|
||||
instance.drive();
|
||||
|
||||
const startIndex = startAtTarget
|
||||
? tour.steps.findIndex((step) => step.target === startAtTarget)
|
||||
: -1;
|
||||
if (!startAtTarget || startIndex <= 0) {
|
||||
activeTourInstance = instance;
|
||||
instance.drive();
|
||||
return;
|
||||
}
|
||||
|
||||
// The anchor may mount right after the caller (e.g. a modal opening), so wait for it.
|
||||
waitForElement(getTourTargetSelector(tourId, startAtTarget))
|
||||
.then(() => {
|
||||
if (driverRef.current !== instance || instance.isActive()) return;
|
||||
activeTourInstance = instance;
|
||||
instance.drive(startIndex);
|
||||
})
|
||||
.catch(() => {
|
||||
// Anchor never appeared (e.g. the modal was dismissed); skip the tour.
|
||||
});
|
||||
},
|
||||
stop: () => driverRef.current?.destroy(),
|
||||
hasCompleted,
|
||||
|
||||
@@ -7,7 +7,11 @@ import { StoreInitializer } from "./store-initializer";
|
||||
describe("StoreInitializer", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useUIStore.setState({ hasProviders: false, registryEligible: false });
|
||||
useUIStore.setState({
|
||||
hasProviders: false,
|
||||
hasProvidersResolved: false,
|
||||
registryEligible: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Registry hidden when the server sends no eligibility decision", () => {
|
||||
@@ -33,4 +37,23 @@ describe("StoreInitializer", () => {
|
||||
expect(persisted.state?.hasProviders).toBe(true);
|
||||
expect(persisted.state).not.toHaveProperty("registryEligible");
|
||||
});
|
||||
|
||||
it("leaves the provider count unresolved when the server could not determine it", () => {
|
||||
// Given / When
|
||||
render(<StoreInitializer values={{ hasProviders: undefined }} />);
|
||||
|
||||
// Then
|
||||
expect(useUIStore.getState().hasProvidersResolved).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves a confirmed empty tenant without persisting the resolution", () => {
|
||||
// Given / When
|
||||
render(<StoreInitializer values={{ hasProviders: false }} />);
|
||||
|
||||
// Then
|
||||
expect(useUIStore.getState().hasProviders).toBe(false);
|
||||
expect(useUIStore.getState().hasProvidersResolved).toBe(true);
|
||||
const persisted = JSON.parse(localStorage.getItem("ui-store") ?? "{}");
|
||||
expect(persisted.state).not.toHaveProperty("hasProvidersResolved");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import { persist } from "zustand/middleware";
|
||||
interface UIStoreState {
|
||||
isSideMenuOpen: boolean;
|
||||
hasProviders: boolean;
|
||||
// True once the server reported a definitive provider count for this session.
|
||||
hasProvidersResolved: boolean;
|
||||
registryEligible: boolean;
|
||||
|
||||
openSideMenu: () => void;
|
||||
@@ -17,17 +19,19 @@ export const useUIStore = create<UIStoreState>()(
|
||||
(set) => ({
|
||||
isSideMenuOpen: false,
|
||||
hasProviders: false,
|
||||
hasProvidersResolved: false,
|
||||
registryEligible: false,
|
||||
openSideMenu: () => set({ isSideMenuOpen: true }),
|
||||
closeSideMenu: () => set({ isSideMenuOpen: false }),
|
||||
setHasProviders: (value: boolean) => set({ hasProviders: value }),
|
||||
setHasProviders: (value: boolean) =>
|
||||
set({ hasProviders: value, hasProvidersResolved: true }),
|
||||
setRegistryEligible: (value: boolean) => set({ registryEligible: value }),
|
||||
}),
|
||||
{
|
||||
name: "ui-store",
|
||||
// Registry eligibility is a per-request server decision; persisting it
|
||||
// would resurface a stale entry on the next session before the server
|
||||
// seed corrects it.
|
||||
// Registry eligibility and the provider-count resolution are per-request
|
||||
// server decisions; persisting them would resurface a stale entry on the
|
||||
// next session before the server seed corrects it.
|
||||
partialize: ({ isSideMenuOpen, hasProviders }) => ({
|
||||
isSideMenuOpen,
|
||||
hasProviders,
|
||||
|
||||
@@ -33,13 +33,14 @@
|
||||
### Expected Result
|
||||
|
||||
- Sign-up succeeds and redirects to Login.
|
||||
- User can log in successfully using the created credentials and reach the home page.
|
||||
- User can log in successfully using the created credentials.
|
||||
- Because the new tenant has no providers, the first run lands on `/providers` with the add-provider wizard already open (instead of the home page).
|
||||
|
||||
### Key verification points
|
||||
|
||||
- After submitting sign-up, the URL changes to `/sign-in`.
|
||||
- The newly created credentials can be used to sign in successfully.
|
||||
- After login, the user lands on the home (`/`) and main content is visible.
|
||||
- After login, the user lands on `/providers` and the "Adding A Provider" wizard is visible.
|
||||
|
||||
### Notes
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { SignUpPage } from "./sign-up-page";
|
||||
import { SignInPage } from "../sign-in-base/sign-in-base-page";
|
||||
import { ProvidersPage } from "../providers/providers-page";
|
||||
import { makeSuffix } from "../helpers";
|
||||
|
||||
test.describe("Sign Up Flow", () => {
|
||||
@@ -45,7 +46,12 @@ test.describe("Sign Up Flow", () => {
|
||||
email: uniqueEmail,
|
||||
password: password,
|
||||
});
|
||||
await signInPage.verifySuccessfulLogin();
|
||||
|
||||
// A brand-new tenant has no providers, so the first run lands on the
|
||||
// add-provider wizard instead of the Overview.
|
||||
const providersPage = new ProvidersPage(page);
|
||||
await expect(page).toHaveURL(/\/providers/);
|
||||
await providersPage.verifyWizardModalOpen();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user