feat(GRO-2359): route Authentik new-SSO users into OOBE (web)
The post-auth handler in CustomerPortal previously rendered the
"Portal access not configured" card when the SSO bridge returned 404
(no client row for the user's email). That trapped first-time SSO
users on a dead-end screen with no path to portal creation.
This change routes the 404 to a new OOBE component (src/portal/OOBE.tsx)
that drives portal creation:
* Mounts inline inside CustomerPortal so the post-auth flow stays
inside the portal render tree (no App-level router needed).
* Also reachable as a direct deep-link via the new /onboarding route
in App.tsx (for grooming admins or recovery flows).
* Submits to a new POST /api/portal/clients-from-auth endpoint in
groombook-api (companion commit) that creates a fresh client row
bound to the Better Auth email. 409 means the email already has a
portal record — the OOBE shows a portal-selection message.
* Uses the canonical shared signOut() from lib/auth-client (GRO-2358
invariant) for the Sign out button.
* Full window.location.href reload on submit success to reset the
bridge's cached state and land the user in their portal.
The no-access card itself is preserved for the deep-link deleted-portal
case (a customer whose portal was disabled/deleted), signalled via
?noAccess=deleted-portal on a portal sub-route. The OOBE handles the
first-time-creation case; the no-access card handles the "had a portal
but lost it" case.
Test coverage:
* "routes to /onboarding when session-from-auth returns 404 (GRO-2359)"
— proves the post-auth 404 mounts the OOBE inline, not the legacy
no-access card.
* 6 new OOBE tests: render from direct link, name prefill, form
submission, 409 portal-selection, required-name validation, shared
signOut(), redirect on no-session.
* P1 no-access tests reworked to use ?noAccess=deleted-portal so the
GRO-2358 signOut invariant is still verified on the only surviving
path to the no-access card.
UAT_PLAYBOOK §5.25.5–6e rewritten to cover the OOBE flow (form submit,
409, deep-link mount, deleted-portal no-access card).
Paired with the api PR on feature/2357-p2-portal-clients-from-auth.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
(cherry picked from commit 250c7a5ac9)
This commit is contained in:
+332
-27
@@ -64,6 +64,22 @@ const AUDIT_LOGS: ImpersonationAuditLog[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Shared test fixtures ───────────────────────────────────────────────────
|
||||
|
||||
// `brandingResponse` is the mock /api/branding payload used by every test
|
||||
// in this file. Hoisted to module scope so the SSO bridge and the OOBE
|
||||
// describe blocks can both reach it without redefining the same body.
|
||||
const brandingResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response;
|
||||
|
||||
// ─── ImpersonationBanner ────────────────────────────────────────────────────
|
||||
|
||||
describe("ImpersonationBanner", () => {
|
||||
@@ -352,17 +368,6 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
signOutSpy.mockClear();
|
||||
});
|
||||
|
||||
const brandingResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response;
|
||||
|
||||
it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => {
|
||||
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
@@ -408,14 +413,21 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a friendly fallback when session-from-auth returns 404 (no client record)", async () => {
|
||||
it("routes to /onboarding when session-from-auth returns 404 (GRO-2359)", async () => {
|
||||
// GRO-2359 replaces the P1 no-access fallback for the new-user path.
|
||||
// The post-auth handler must now navigate to /onboarding so the OOBE
|
||||
// component can drive portal creation. The no-access card itself is
|
||||
// reserved for the deep-link deleted-portal case (see the next two
|
||||
// tests, which exercise ?noAccess=deleted-portal).
|
||||
global.fetch = vi.fn((input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
|
||||
json: async () => ({
|
||||
user: { email: "stranger@example.com", name: "Stranger", role: "customer" },
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/session-from-auth") {
|
||||
@@ -428,6 +440,9 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
// MemoryRouter is required for the React Router context used by
|
||||
// useNavigate inside CustomerPortal. We pass `initialEntries=["/"]`
|
||||
// and let the post-auth handler navigate the router to /onboarding.
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
@@ -435,12 +450,13 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// The bridge 404 must NOT render the legacy no-access card. The OOBE
|
||||
// form is the new-user surface.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/set up your portal/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/not linked to a customer record/i)).toBeInTheDocument();
|
||||
// Sign-out escape hatch is present so the user is not stuck in a loop
|
||||
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls the shared signOut() handler and navigates to /login from the no-access screen (GRO-2358)", async () => {
|
||||
@@ -456,6 +472,11 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// GRO-2359: the post-auth bridge 404 now routes to /onboarding (OOBE)
|
||||
// on the new-user path. The no-access card itself is reserved for the
|
||||
// deep-link deleted-portal case, which is signalled via
|
||||
// ?noAccess=deleted-portal. A server-side "client disabled" check
|
||||
// (future GRO) is the natural trigger.
|
||||
global.fetch = vi.fn((input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
@@ -465,11 +486,15 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
|
||||
} as Response);
|
||||
}
|
||||
// The bridge must NOT succeed (so portalSessionId stays null) and must
|
||||
// NOT be 404 (which would route to /onboarding). A 500 models a
|
||||
// server-side portal-disabled check; the no-access card is mounted
|
||||
// because of the URL param, not because of the bridge.
|
||||
if (url === "/api/portal/session-from-auth") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: "No client record found for this user" }),
|
||||
status: 500,
|
||||
json: async () => ({ error: "Portal disabled" }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
@@ -477,7 +502,7 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<MemoryRouter initialEntries={["/?noAccess=deleted-portal"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -487,8 +512,8 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
});
|
||||
|
||||
// Pre-condition: the shared signOut() must NOT have been called yet — the
|
||||
// no-access screen is mounted because the bridge failed, not because the
|
||||
// user clicked anything.
|
||||
// no-access screen is mounted because of the deleted-portal signal, not
|
||||
// because the user clicked anything.
|
||||
expect(signOutSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Drive the click. The handler is the SAME `signOut()` exported from
|
||||
@@ -515,8 +540,12 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
// AC requires verifying the SAME logout handler is reachable from at
|
||||
// least one other authenticated surface — here a deep link to a portal
|
||||
// sub-route (e.g. /appointments) for a user with a Better Auth session
|
||||
// but no client record. The no-access screen is the only authenticated
|
||||
// surface without a route guard, so the handler must fire identically.
|
||||
// whose portal was deleted. The no-access screen is the only
|
||||
// authenticated surface without a route guard, so the handler must
|
||||
// fire identically.
|
||||
//
|
||||
// GRO-2359: the bridge 404 now routes to /onboarding (OOBE) on the
|
||||
// new-user path; ?noAccess=deleted-portal is the surviving trigger.
|
||||
signOutSpy.mockClear();
|
||||
|
||||
const originalLocation = window.location;
|
||||
@@ -535,11 +564,15 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
|
||||
} as Response);
|
||||
}
|
||||
// The bridge must NOT succeed (so portalSessionId stays null) and must
|
||||
// NOT be 404 (which would route to /onboarding). A 500 models a
|
||||
// server-side portal-disabled check; the no-access card is mounted
|
||||
// because of the URL param, not because of the bridge.
|
||||
if (url === "/api/portal/session-from-auth") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: "No client record found for this user" }),
|
||||
status: 500,
|
||||
json: async () => ({ error: "Portal disabled" }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
@@ -547,7 +580,7 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/appointments"]}>
|
||||
<MemoryRouter initialEntries={["/appointments?noAccess=deleted-portal"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -753,3 +786,275 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OOBE portal-creation flow (GRO-2359)", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
// The OOBE is mounted both from the post-auth callback (CustomerPortal
|
||||
// navigates to /onboarding on bridge 404) and from a direct deep-link.
|
||||
// This set of tests exercises the direct-link mount, the form submit, and
|
||||
// the shared signOut() handler. The post-auth routing is covered by the
|
||||
// "routes to /onboarding when session-from-auth returns 404" test above.
|
||||
|
||||
function setupOOBEAuthMock(opts: { role?: string } = {}) {
|
||||
const role = opts.role ?? "customer";
|
||||
return vi.fn((input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user: { email: "new-sso@example.com", name: "New SSO", role },
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
it("renders the OOBE form when navigated to /onboarding directly (GRO-2359)", async () => {
|
||||
global.fetch = setupOOBEAuthMock();
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: /set up your portal/i })).toBeInTheDocument();
|
||||
});
|
||||
// All three primary form fields are present.
|
||||
expect(screen.getByLabelText(/your name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/phone/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/address/i)).toBeInTheDocument();
|
||||
// Submit and shared signOut are both present.
|
||||
expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prefills the name field from the Better Auth session (GRO-2359)", async () => {
|
||||
global.fetch = setupOOBEAuthMock();
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
||||
});
|
||||
});
|
||||
|
||||
it("calls POST /api/portal/clients-from-auth and navigates to / on success (GRO-2359)", async () => {
|
||||
const fetchMock = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user: { email: "new-sso@example.com", name: "New SSO", role: "customer" },
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({
|
||||
id: "new-client-id",
|
||||
name: "New SSO",
|
||||
email: "new-sso@example.com",
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
||||
});
|
||||
|
||||
// Fill phone + address and submit.
|
||||
fireEvent.change(screen.getByLabelText(/phone/i), {
|
||||
target: { value: "555-1234" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/address/i), {
|
||||
target: { value: "1 Main St" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
||||
|
||||
// The endpoint must have been called with the form values, normalised
|
||||
// (phone/address trimmed). We don't assert navigation here because the
|
||||
// MemoryRouter would need a history prop to assert a URL change — the
|
||||
// internal `navigate("/")` call is the contract.
|
||||
await waitFor(() => {
|
||||
const calls = vi.mocked(fetchMock).mock.calls;
|
||||
const onboardCall = calls.find(([u]) =>
|
||||
typeof u === "string" && (u as string).endsWith("/api/portal/clients-from-auth"),
|
||||
);
|
||||
expect(onboardCall).toBeDefined();
|
||||
const body = JSON.parse(((onboardCall?.[1] as RequestInit | undefined)?.body as string) ?? "{}");
|
||||
expect(body).toEqual({
|
||||
name: "New SSO",
|
||||
phone: "555-1234",
|
||||
address: "1 Main St",
|
||||
notes: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the portal-selection message when the API returns 409 (GRO-2359)", async () => {
|
||||
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user: { email: "new-sso@example.com", name: "New SSO", role: "customer" },
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ error: "A customer record with this email already exists" }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/already exists/i)).toBeInTheDocument();
|
||||
});
|
||||
// The submit button is re-enabled after the error so the user can retry.
|
||||
expect(screen.getByRole("button", { name: /Create my portal/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("requires the name field before submitting (GRO-2359)", async () => {
|
||||
// Use a session WITHOUT a name so the OOBE starts with an empty form.
|
||||
global.fetch = vi.fn((input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: { email: "noname@example.com", role: "customer" } }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/your name/i)).toHaveValue("");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
||||
|
||||
// The name-required error is shown; no API call was made.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/tell us your name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the shared signOut() handler on the OOBE Sign out button (GRO-2359)", async () => {
|
||||
signOutSpy.mockClear();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
global.fetch = setupOOBEAuthMock();
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Sign out/i }));
|
||||
|
||||
// Same canonical handler as AdminLayout and the no-access card, per
|
||||
// GRO-2358 — never a raw fetch("/api/auth/sign-out").
|
||||
await waitFor(() => {
|
||||
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/login");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||
});
|
||||
|
||||
it("redirects to /login when no Better Auth session is present (GRO-2359)", async () => {
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
global.fetch = vi.fn((input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({ ok: false, status: 401, json: async () => ({}) } as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/login");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user