feat(GRO-1867): bridge Better Auth session to CustomerPortal (#34)
This commit was merged in pull request #34.
This commit is contained in:
@@ -43,6 +43,15 @@ export function CustomerPortal() {
|
||||
// Track whether an impersonation session fetch from URL param is in-flight
|
||||
// Dashboard will not redirect while this is true, allowing the session to load
|
||||
const [isImpersonating, setIsImpersonating] = useState(false);
|
||||
// Portal session ID for real SSO customers (GRO-1867). Populated by the
|
||||
// Better Auth → /api/portal/session-from-auth bridge below. Carries the
|
||||
// X-Impersonation-Session-Id header on subsequent portal API calls without
|
||||
// triggering the impersonation banner (the customer is themselves).
|
||||
const [portalSessionId, setPortalSessionId] = useState<string | null>(null);
|
||||
// User-facing message when the SSO bridge cannot resolve a client record
|
||||
// (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);
|
||||
const { branding } = useBranding();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -98,10 +107,64 @@ export function CustomerPortal() {
|
||||
}
|
||||
})
|
||||
.finally(() => setInitComplete(true));
|
||||
} else {
|
||||
// No valid session: staff dev users and unauthenticated users fall through here
|
||||
setInitComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (devUser && devUser.type === "staff") {
|
||||
// Staff dev user — fall through; App.tsx redirects to /admin.
|
||||
setInitComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Real SSO customer (GRO-1867): bridge a Better Auth session into a portal
|
||||
// session via POST /api/portal/session-from-auth. The returned session ID
|
||||
// is used in the X-Impersonation-Session-Id header for portal API calls.
|
||||
(async () => {
|
||||
try {
|
||||
const sessionResp = await fetch("/api/auth/get-session", { credentials: "include" });
|
||||
if (!sessionResp.ok) {
|
||||
setInitComplete(true);
|
||||
return;
|
||||
}
|
||||
let sessionData: { user?: { email?: string; role?: string | null } } | null = null;
|
||||
try {
|
||||
sessionData = (await sessionResp.json()) as { user?: { email?: string; role?: string | null } } | null;
|
||||
} catch {
|
||||
// Better Auth returns an empty body when there is no session
|
||||
}
|
||||
if (!sessionData || !sessionData.user) {
|
||||
setInitComplete(true);
|
||||
return;
|
||||
}
|
||||
// Staff are routed to /admin by App.tsx; don't run the customer bridge.
|
||||
if (sessionData.user.role === "staff") {
|
||||
setInitComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const bridgeResp = await fetch("/api/portal/session-from-auth", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (bridgeResp.ok) {
|
||||
const data = await bridgeResp.json() as { sessionId: string; clientId: string; clientName: string };
|
||||
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."
|
||||
);
|
||||
}
|
||||
// 401/other: fall through; App.tsx render guard will redirect to /login.
|
||||
} catch {
|
||||
// Network error — fall through; the render guard will redirect to /login.
|
||||
} finally {
|
||||
setInitComplete(true);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleEnd = useCallback(async () => {
|
||||
@@ -157,7 +220,7 @@ export function CustomerPortal() {
|
||||
const isReadOnly = session?.status === "active";
|
||||
|
||||
const renderSection = () => {
|
||||
const sessionId = session?.id ?? null;
|
||||
const sessionId = session?.id ?? portalSessionId;
|
||||
switch (activeSection) {
|
||||
case "dashboard":
|
||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} isImpersonating={isImpersonating} />;
|
||||
@@ -183,7 +246,40 @@ export function CustomerPortal() {
|
||||
// For client dev users, we stay on the portal even if session is null — the dev-session
|
||||
// response may not have id set immediately, or there may be timing issues with the
|
||||
// session state. Dev users are verified via localStorage and the dev-session flow.
|
||||
if (initComplete && !session) {
|
||||
// SSO customers are recognised by portalSessionId (set by the Better Auth bridge).
|
||||
if (initComplete && !session && !portalSessionId) {
|
||||
if (authError) {
|
||||
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center mx-auto mb-4">
|
||||
<Shield size={22} />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
|
||||
<p className="text-sm text-stone-600 mb-6">{authError}</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/api/auth/sign-out", { method: "POST", credentials: "include" });
|
||||
} catch {
|
||||
// Best-effort sign-out; redirect to /login regardless.
|
||||
}
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-stone-700 bg-stone-100 hover:bg-stone-200 transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const devUser = getDevUser();
|
||||
if (devUser && devUser.type === "staff") {
|
||||
return <Navigate to="/admin" replace />;
|
||||
|
||||
Reference in New Issue
Block a user