import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom"; import { useEffect, useState } from "react"; import { AppointmentsPage } from "./pages/Appointments.js"; import { ClientsPage } from "./pages/Clients.js"; import { ClientDetailPage } from "./pages/ClientDetailPage.js"; import { ServicesPage } from "./pages/Services.js"; import { StaffPage } from "./pages/Staff.js"; import { InvoicesPage } from "./pages/Invoices.js"; import { BookPage } from "./pages/Book.js"; import { ReportsPage } from "./pages/Reports.js"; import { GroupBookingPage } from "./pages/GroupBooking.js"; import { SettingsPage } from "./pages/Settings.js"; import { BookingConfirmedPage } from "./pages/BookingConfirmed.js"; import { BookingCancelledPage } from "./pages/BookingCancelled.js"; import { BookingErrorPage } from "./pages/BookingError.js"; import { SetupWizard } from "./pages/SetupWizard.tsx"; import { CustomerPortal } from "./portal/CustomerPortal.js"; import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; import { BrandingProvider, useBranding } from "./BrandingContext.js"; import { GlobalSearch } from "./components/GlobalSearch.js"; import { useSession, signIn, signOut } from "./lib/auth-client.js"; function LoginPage() { const [isLoading, setIsLoading] = useState(false); const [providers, setProviders] = useState([]); const [error, setError] = useState(null); useEffect(() => { fetch("/api/auth/providers") .then((r) => r.json()) .then((data) => setProviders(data.providers ?? [])) .catch(() => setProviders([])); const params = new URLSearchParams(window.location.search); const authError = params.get("error"); if (authError) setError(authError.replace(/_/g, " ")); }, []); const handleSocialLogin = async (provider: string) => { setIsLoading(true); setError(null); const result = await signIn.social({ provider, callbackURL: window.location.origin }); if (result?.error) { setError(result.error.message ?? "Sign-in failed"); setIsLoading(false); } }; const isGoogle = providers.includes("google"); const isGitHub = providers.includes("github"); const isAuthentik = providers.includes("authentik"); return (

GroomBook

Sign in to continue

{error && (
{error}
)} {isGoogle && ( )} {isGitHub && ( )} {isAuthentik && ( )}
); } const NAV_LINKS = [ { to: "/admin", label: "Appointments" }, { to: "/admin/clients", label: "Clients" }, { to: "/admin/services", label: "Services" }, { to: "/admin/staff", label: "Staff" }, { to: "/admin/invoices", label: "Invoices" }, { to: "/admin/group-bookings", label: "Group Bookings" }, { to: "/admin/reports", label: "Reports" }, { to: "/admin/settings", label: "Settings" }, { to: "/", label: "Customer Portal" }, ]; function AdminLayout() { const location = useLocation(); const navigate = useNavigate(); const { branding } = useBranding(); const logoSrc = branding.logoBase64 && branding.logoMimeType ? `data:${branding.logoMimeType};base64,${branding.logoBase64}` : null; return (
} /> } /> } /> } /> } /> } /> } /> } /> } /> } />
); } export function App() { const location = useLocation(); const [authDisabled, setAuthDisabled] = useState(null); const [needsSetup, setNeedsSetup] = useState(null); const { data: rawSession, isPending: rawSessionLoading } = useSession(); // In dev mode (authDisabled=true), session state is irrelevant - skip useSession result const session = authDisabled ? null : rawSession; const sessionLoading = authDisabled ? false : rawSessionLoading; useEffect(() => { fetch("/api/dev/config") .then((r) => r.json()) .then((data) => setAuthDisabled(data.authDisabled === true)) .catch(() => setAuthDisabled(false)); }, []); // After session is confirmed, check if setup is needed. // Always run the setup/status fetch as soon as the auth state is known — even for // unauthenticated users, so the `needsSetup` value is in place if they sign in // mid-session. The unauth branch in the render below is handled before // `needsSetup` is consulted, so this is safe and avoids a stuck-`null` state. // See GRO-2011. useEffect(() => { if (authDisabled === null || sessionLoading) return; // In dev mode, only fetch when a dev user has been selected — otherwise the // user is mid-redirect to the dev login selector and we don't need setup state. if (authDisabled && !getDevUser()) return; fetch("/api/setup/status") .then((r) => r.json()) .then((data) => setNeedsSetup(data.needsSetup === true)) .catch(() => setNeedsSetup(false)); }, [authDisabled, session, sessionLoading]); // Public booking redirect pages — no auth or portal chrome needed if (location.pathname === "/booking/confirmed") { return ; } if (location.pathname === "/booking/cancelled") { return ; } if (location.pathname === "/booking/error") { return ; } // Setup wizard — standalone, no admin chrome if (location.pathname === "/setup") { return ( setNeedsSetup(false)} /> ); } // Still loading auth state or setup check (skip setup check in dev mode) if (authDisabled === null || sessionLoading) return null; // Dev mode: show login selector (no setup check needed in dev mode) if (authDisabled && location.pathname === "/login") { return ; } // Dev mode: use dev login selector (no setup check needed in dev mode) if (authDisabled && !getDevUser()) { return ; } // Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users). // At /login with a valid session, fall through so the staff redirect below can // route staff to /admin and the final render can redirect customers to / (portal). // Previously, an authenticated customer at /login would see a blank page because // the final render returns null at /login (showCustomerPortal is false). See GRO-2099. if (!authDisabled && !session && location.pathname === "/login") { return ; } // Production: need setup check if (needsSetup === null) return null; // Redirect to setup wizard if needed if (needsSetup) { return ; } // Redirect staff to /admin; allow customers to access portal (preserve impersonation via ?sessionId=) const searchParams = new URLSearchParams(location.search); const isStaff = session?.user && (session.user as any).role === "staff"; if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId") && isStaff) { return ; } // Don't render portal chrome at /login — DevLoginSelector is shown instead const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login"; // At /login with a valid session, redirect to the portal root. Without this, // the final render returns null at /login (showCustomerPortal is false) and // the user sees a blank page after a successful sign-in. Staff are routed // to /admin by the earlier staff check. See GRO-2099. if (!authDisabled && session && location.pathname === "/login") { return ; } return ( {location.pathname.startsWith("/admin") ? ( <> } /> {authDisabled && } ) : showCustomerPortal ? ( <> {authDisabled && } ) : null} ); }