Promote uat → main (PROD): GRO-2359 OOBE portal-creation routing (web) (#79)
Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
This commit was merged in pull request #79.
This commit is contained in:
@@ -13,6 +13,7 @@ import { Communication } from "./sections/Communication.js";
|
||||
import { AccountSettings } from "./sections/AccountSettings.js";
|
||||
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||
import { OOBE } from "./OOBE.js";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
import { signOut } from "../lib/auth-client.js";
|
||||
@@ -53,6 +54,13 @@ export function CustomerPortal() {
|
||||
// (e.g. authenticated user with no matching client row). Rendered in place
|
||||
// of the portal chrome instead of bouncing back to /login.
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
// GRO-2359 — the SSO bridge 404 (no client row for the user's email)
|
||||
// routes the user into the OOBE. We mount the OOBE inline rather than
|
||||
// navigating to /onboarding so the post-auth flow stays inside the
|
||||
// CustomerPortal render tree (test-isolated, no App-level router needed
|
||||
// for the integration to work). The /onboarding route in App.tsx is
|
||||
// still the mount point for direct deep-links to the same component.
|
||||
const [showOOBE, setShowOOBE] = useState(false);
|
||||
const { branding } = useBranding();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -63,6 +71,18 @@ export function CustomerPortal() {
|
||||
initDone.current = true;
|
||||
|
||||
const sessionId = searchParams.get("sessionId");
|
||||
// GRO-2359: a deep-link to a portal sub-route with ?noAccess=deleted-portal
|
||||
// is the only path that still shows the no-access card. The post-auth
|
||||
// 404-from-bridge path now navigates to /onboarding (OOBE) so the new
|
||||
// user can create a portal. The deleted-portal case is set explicitly
|
||||
// (e.g. a groomer who disabled a client) and uses the same no-access
|
||||
// UI with the shared signOut() — that was the GRO-2358 invariant.
|
||||
const noAccess = searchParams.get("noAccess");
|
||||
if (noAccess === "deleted-portal") {
|
||||
setAuthError(
|
||||
"Your portal access has been removed. Please contact your groomer if you think this is a mistake.",
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
setIsImpersonating(true);
|
||||
@@ -153,11 +173,13 @@ export function CustomerPortal() {
|
||||
setPortalSessionId(data.sessionId);
|
||||
setClientName(data.clientName);
|
||||
} else if (bridgeResp.status === 404) {
|
||||
// Authenticated but no matching client row — show a friendly message
|
||||
// instead of bouncing back to /login (which would loop indefinitely).
|
||||
setAuthError(
|
||||
"Your account is not linked to a customer record. Please contact your groomer to set up portal access."
|
||||
);
|
||||
// Authenticated but no matching client row — mount the OOBE
|
||||
// (GRO-2359) so the user can create their portal record instead
|
||||
// of landing on the no-access card. The no-access card itself is
|
||||
// still reachable for the deleted-portal case (see GRO-2358) via
|
||||
// the ?noAccess=deleted-portal deep-link, but is no longer in
|
||||
// the new-user path.
|
||||
setShowOOBE(true);
|
||||
}
|
||||
// 401/other: fall through; App.tsx render guard will redirect to /login.
|
||||
} catch {
|
||||
@@ -280,6 +302,15 @@ export function CustomerPortal() {
|
||||
// session state. Dev users are verified via localStorage and the dev-session flow.
|
||||
// SSO customers are recognised by portalSessionId (set by the Better Auth bridge).
|
||||
if (!session && !portalSessionId) {
|
||||
// GRO-2359 — new-user path: mount the OOBE inline so the SSO bridge's
|
||||
// 404 hands the user a portal-creation form instead of the no-access
|
||||
// card. onCompleted triggers a full page reload to /, which re-runs
|
||||
// the bridge (now with a matching client row) and lands the user in
|
||||
// the portal. A full reload (not React Router navigate) is the
|
||||
// safest reset of the bridge's cached state.
|
||||
if (showOOBE) {
|
||||
return <OOBE onCompleted={() => { window.location.href = "/"; }} />;
|
||||
}
|
||||
if (authError) {
|
||||
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LogOut, Shield, Sparkles } from "lucide-react";
|
||||
import { signOut } from "../lib/auth-client.js";
|
||||
|
||||
/**
|
||||
* OOBE (Out-of-Box Experience) for a first-time Authentik SSO user whose
|
||||
* email does not match any existing `clients` row.
|
||||
*
|
||||
* The post-auth handler in `CustomerPortal.tsx` redirects to this component
|
||||
* when `POST /api/portal/session-from-auth` returns 404. From here the new
|
||||
* user can either:
|
||||
* (a) Create a fresh customer record bound to their SSO email, or
|
||||
* (b) Sign out (the no-access screen is no longer in the new-user path).
|
||||
*
|
||||
* After successful creation, the OOBE navigates to `/` so the portal's
|
||||
* existing SSO bridge re-runs and lands the user in their portal with a
|
||||
* real `X-Impersonation-Session-Id` header. No new client state is
|
||||
* required — the bridge re-resolves the session and the rest of the
|
||||
* portal is unchanged.
|
||||
*
|
||||
* GRO-2359 — root-cause fix for "new SSO user lands on Portal access not
|
||||
* configured" (companion to GRO-2358, which restored logout on that screen).
|
||||
*/
|
||||
|
||||
type OOBEFormState = {
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
type OOBEStatus = "loading" | "ready" | "submitting" | "error";
|
||||
|
||||
const EMPTY_FORM: OOBEFormState = {
|
||||
name: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
type OOBEProps = {
|
||||
/**
|
||||
* Override the post-success destination. Defaults to `/` so the SSO bridge
|
||||
* re-runs. Test suites pass a custom destination to keep assertions
|
||||
* deterministic without a real portal session.
|
||||
*/
|
||||
onCompleted?: () => void;
|
||||
};
|
||||
|
||||
export function OOBE({ onCompleted }: OOBEProps = {}) {
|
||||
const [status, setStatus] = useState<OOBEStatus>("loading");
|
||||
const [form, setForm] = useState<OOBEFormState>(EMPTY_FORM);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionEmail, setSessionEmail] = useState<string | null>(null);
|
||||
|
||||
// Resolve the Better Auth session on mount. The OOBE is gated to
|
||||
// authenticated users — if no session exists the API will reject the
|
||||
// creation request, so we redirect to /login early. We prefill `name`
|
||||
// from the Better Auth `user.name` if the SSO provider returned one.
|
||||
//
|
||||
// We use a full `window.location.href` redirect (not `navigate`) so the
|
||||
// OOBE works the same way whether it's mounted from the post-auth
|
||||
// callback (inside CustomerPortal's render tree) or from a direct
|
||||
// deep-link (mounted by App.tsx). A full reload also resets any
|
||||
// cached state in the parent component.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/auth/get-session", { credentials: "include" });
|
||||
if (!r.ok) {
|
||||
if (!cancelled) window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
const data = (await r.json().catch(() => null)) as
|
||||
| { user?: { email?: string; name?: string; role?: string | null } }
|
||||
| null;
|
||||
if (cancelled) return;
|
||||
if (!data?.user) {
|
||||
window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (data.user.role === "staff") {
|
||||
window.location.href = "/admin";
|
||||
return;
|
||||
}
|
||||
setSessionEmail(data.user.email ?? null);
|
||||
setForm((prev) => ({ ...prev, name: data.user?.name ?? prev.name }));
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
if (!cancelled) window.location.href = "/login";
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(field: keyof OOBEFormState) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (status === "submitting") return;
|
||||
if (!form.name.trim()) {
|
||||
setError("Please tell us your name so we can set up your portal.");
|
||||
return;
|
||||
}
|
||||
setStatus("submitting");
|
||||
setError(null);
|
||||
try {
|
||||
const r = await fetch("/api/portal/clients-from-auth", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim() || null,
|
||||
address: form.address.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (r.ok) {
|
||||
// Let the parent (or default) decide where to land. The default
|
||||
// is the portal root, which re-runs the SSO bridge. A full
|
||||
// `window.location.href` reload resets any cached state in the
|
||||
// parent (the bridge reads from Better Auth cookies, so a fresh
|
||||
// request picks up the new client row).
|
||||
if (onCompleted) {
|
||||
onCompleted();
|
||||
} else {
|
||||
window.location.href = "/";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (r.status === 409) {
|
||||
setStatus("ready");
|
||||
setError(
|
||||
"A customer record with this email already exists. Please contact your groomer to link your account.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const body = (await r.json().catch(() => null)) as { error?: string } | null;
|
||||
setStatus("ready");
|
||||
setError(body?.error ?? "We couldn't set up your portal. Please try again.");
|
||||
} catch {
|
||||
setStatus("ready");
|
||||
setError("Network error. Please check your connection and try again.");
|
||||
}
|
||||
},
|
||||
[form, onCompleted, status],
|
||||
);
|
||||
|
||||
const handleSignOut = useCallback(async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch {
|
||||
// Best-effort; navigate to /login regardless so the user is never trapped.
|
||||
}
|
||||
window.location.href = "/login";
|
||||
}, []);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center bg-[#faf8f5]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="text-stone-500 text-sm">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6 py-10"
|
||||
role="main"
|
||||
>
|
||||
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8">
|
||||
<div className="w-12 h-12 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center mx-auto mb-4">
|
||||
<Sparkles size={22} />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold text-stone-800 text-center mb-1">
|
||||
Welcome — let's set up your portal
|
||||
</h1>
|
||||
<p className="text-sm text-stone-600 text-center mb-6">
|
||||
You're signed in{sessionEmail ? ` as ${sessionEmail}` : ""}. We just need a few
|
||||
details to create your customer record.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="oobe-name"
|
||||
className="block text-xs font-medium text-stone-700 mb-1"
|
||||
>
|
||||
Your name <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="oobe-name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="name"
|
||||
value={form.name}
|
||||
onChange={handleChange("name")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="oobe-phone"
|
||||
className="block text-xs font-medium text-stone-700 mb-1"
|
||||
>
|
||||
Phone <span className="text-stone-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="oobe-phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
value={form.phone}
|
||||
onChange={handleChange("phone")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="oobe-address"
|
||||
className="block text-xs font-medium text-stone-700 mb-1"
|
||||
>
|
||||
Address <span className="text-stone-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="oobe-address"
|
||||
name="address"
|
||||
type="text"
|
||||
autoComplete="street-address"
|
||||
value={form.address}
|
||||
onChange={handleChange("address")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="oobe-notes"
|
||||
className="block text-xs font-medium text-stone-700 mb-1"
|
||||
>
|
||||
Notes <span className="text-stone-400">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="oobe-notes"
|
||||
name="notes"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
onChange={handleChange("notes")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="flex items-start gap-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2"
|
||||
>
|
||||
<Shield size={14} className="mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{status === "submitting" ? "Setting up…" : "Create my portal"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-stone-100 flex items-center justify-between">
|
||||
<p className="text-xs text-stone-500">
|
||||
Wrong account? Sign out and try a different one.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleSignOut();
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-stone-600 hover:text-stone-900"
|
||||
>
|
||||
<LogOut size={12} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OOBE;
|
||||
Reference in New Issue
Block a user