050d478621
Two root causes fixed: 1. VITE_API_URL was empty in .env.production, so Better-Auth's client had no baseURL and could not correctly route the OAuth callback. 2. OAuth callbackURL was window.location.origin (root path), causing Better-Auth to redirect to / instead of /admin after login — since unauthenticated users at / are redirected to /login, this created a loop that appeared as 'session not persisting.' With VITE_API_URL=https://uat.groombook.dev and callbackURL=/admin, the callback lands on /admin which renders the admin layout and correctly establishes the session cookie. Co-authored-by: Chris Farhood <chris@farhood.org> Co-authored-by: Paperclip <noreply@paperclip.ing>
422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
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 { MessagesPage } from "./pages/Messages.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<string[]>([]);
|
|
const [error, setError] = useState<string | null>(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);
|
|
// Use /admin as callback URL so Better-Auth redirects to the app's dashboard
|
|
// after the OAuth callback completes, rather than back to /login
|
|
const callbackURL = `${window.location.origin}/admin`;
|
|
const result = await signIn.social({ provider, callbackURL });
|
|
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 (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
height: "100vh",
|
|
fontFamily: "system-ui, sans-serif",
|
|
background: "#f0f2f5",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
background: "#fff",
|
|
borderRadius: 12,
|
|
padding: "2rem 2.5rem",
|
|
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
|
textAlign: "center",
|
|
minWidth: 280,
|
|
}}
|
|
>
|
|
<h1 style={{ fontSize: 22, marginBottom: "0.5rem", color: "#1a202c" }}>GroomBook</h1>
|
|
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
|
|
Sign in to continue
|
|
</p>
|
|
{error && (
|
|
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.5rem 0.75rem", marginBottom: "1rem", color: "#991b1b", fontSize: 13 }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
{isGoogle && (
|
|
<button
|
|
onClick={() => handleSocialLogin("google")}
|
|
disabled={isLoading}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
width: "100%",
|
|
padding: "0.6rem 1.5rem",
|
|
borderRadius: 6,
|
|
border: "1px solid #e2e8f0",
|
|
background: "#fff",
|
|
color: "#1a202c",
|
|
fontWeight: 600,
|
|
fontSize: 14,
|
|
cursor: isLoading ? "wait" : "pointer",
|
|
opacity: isLoading ? 0.7 : 1,
|
|
marginBottom: "0.5rem",
|
|
}}
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24">
|
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
|
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
|
</svg>
|
|
Sign in with Google
|
|
</button>
|
|
)}
|
|
{isGitHub && (
|
|
<button
|
|
onClick={() => handleSocialLogin("github")}
|
|
disabled={isLoading}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
width: "100%",
|
|
padding: "0.6rem 1.5rem",
|
|
borderRadius: 6,
|
|
border: "1px solid #e2e8f0",
|
|
background: "#24292f",
|
|
color: "#fff",
|
|
fontWeight: 600,
|
|
fontSize: 14,
|
|
cursor: isLoading ? "wait" : "pointer",
|
|
opacity: isLoading ? 0.7 : 1,
|
|
marginBottom: isAuthentik ? "0.5rem" : 0,
|
|
}}
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff">
|
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
|
</svg>
|
|
Sign in with GitHub
|
|
</button>
|
|
)}
|
|
{isAuthentik && (
|
|
<button
|
|
onClick={() => handleSocialLogin("authentik")}
|
|
disabled={isLoading}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
width: "100%",
|
|
padding: "0.6rem 1.5rem",
|
|
borderRadius: 6,
|
|
border: "none",
|
|
background: "#4f8a6f",
|
|
color: "#fff",
|
|
fontWeight: 600,
|
|
fontSize: 14,
|
|
cursor: isLoading ? "wait" : "pointer",
|
|
opacity: isLoading ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const NAV_LINKS = [
|
|
{ to: "/admin", label: "Appointments" },
|
|
{ to: "/admin/messages", label: "Messages" },
|
|
{ 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 (
|
|
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif", background: "#f0f2f5" }}>
|
|
<nav
|
|
style={{
|
|
padding: "0 1.25rem",
|
|
height: 52,
|
|
borderBottom: "1px solid #e2e8f0",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.25rem",
|
|
background: "#fff",
|
|
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)",
|
|
position: "sticky",
|
|
top: 0,
|
|
zIndex: 50,
|
|
}}
|
|
>
|
|
<div style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
marginRight: "1.25rem",
|
|
flexShrink: 0,
|
|
}}>
|
|
{logoSrc && (
|
|
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
|
|
)}
|
|
<strong style={{
|
|
fontSize: 17,
|
|
color: "#1a202c",
|
|
letterSpacing: "-0.02em",
|
|
}}>
|
|
{branding.businessName}
|
|
</strong>
|
|
</div>
|
|
<GlobalSearch />
|
|
<div style={{
|
|
display: "flex",
|
|
overflowX: "auto",
|
|
flex: 1,
|
|
minWidth: 0,
|
|
gap: "0.25rem",
|
|
}}>
|
|
<Link
|
|
to="/admin/book"
|
|
style={{
|
|
padding: "0.4rem 0.85rem",
|
|
borderRadius: 6,
|
|
textDecoration: "none",
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
color: "#fff",
|
|
background: branding.primaryColor,
|
|
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
Book
|
|
</Link>
|
|
{NAV_LINKS.map(({ to, label }) => {
|
|
const active =
|
|
to === "/admin"
|
|
? location.pathname === "/admin"
|
|
: location.pathname.startsWith(to);
|
|
return (
|
|
<Link
|
|
key={to}
|
|
to={to}
|
|
style={{
|
|
padding: "0.4rem 0.75rem",
|
|
borderRadius: 6,
|
|
textDecoration: "none",
|
|
fontSize: 13,
|
|
fontWeight: active ? 600 : 500,
|
|
color: active ? "#2d6a4f" : "#4b5563",
|
|
background: active ? "#ecfdf5" : "transparent",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
<button
|
|
onClick={async () => {
|
|
await signOut();
|
|
navigate("/login");
|
|
}}
|
|
style={{
|
|
flexShrink: 0,
|
|
padding: "0.4rem 0.85rem",
|
|
borderRadius: 6,
|
|
border: "1px solid #e2e8f0",
|
|
background: "#fff",
|
|
color: "#4b5563",
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Logout
|
|
</button>
|
|
</nav>
|
|
<main style={{ padding: "1.25rem 1.5rem" }}>
|
|
<Routes>
|
|
<Route path="/" element={<AppointmentsPage />} />
|
|
<Route path="/messages" element={<MessagesPage />} />
|
|
<Route path="/clients" element={<ClientsPage />} />
|
|
<Route path="/clients/:clientId" element={<ClientDetailPage />} />
|
|
<Route path="/services" element={<ServicesPage />} />
|
|
<Route path="/staff" element={<StaffPage />} />
|
|
<Route path="/invoices" element={<InvoicesPage />} />
|
|
<Route path="/book" element={<BookPage />} />
|
|
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
|
<Route path="/reports" element={<ReportsPage />} />
|
|
<Route path="/settings" element={<SettingsPage />} />
|
|
</Routes>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function App() {
|
|
const location = useLocation();
|
|
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
|
const [needsSetup, setNeedsSetup] = useState<boolean | null>(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
|
|
useEffect(() => {
|
|
if (authDisabled === null || sessionLoading) return;
|
|
// Skip if no authenticated session (will redirect to login or dev selector)
|
|
if (!authDisabled && !session) return;
|
|
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 <BookingConfirmedPage />;
|
|
}
|
|
if (location.pathname === "/booking/cancelled") {
|
|
return <BookingCancelledPage />;
|
|
}
|
|
if (location.pathname === "/booking/error") {
|
|
return <BookingErrorPage />;
|
|
}
|
|
|
|
// Setup wizard — standalone, no admin chrome
|
|
if (location.pathname === "/setup") {
|
|
return (
|
|
<BrandingProvider>
|
|
<SetupWizard onSetupComplete={() => setNeedsSetup(false)} />
|
|
</BrandingProvider>
|
|
);
|
|
}
|
|
|
|
// 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 <DevLoginSelector />;
|
|
}
|
|
|
|
// Dev mode: use dev login selector (no setup check needed in dev mode)
|
|
if (authDisabled && !getDevUser()) {
|
|
return <Navigate to="/login" replace />;
|
|
}
|
|
|
|
// Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users)
|
|
if (!authDisabled && !session) {
|
|
return <LoginPage />;
|
|
}
|
|
|
|
// Production: need setup check
|
|
if (needsSetup === null) return null;
|
|
|
|
// Redirect to setup wizard if needed
|
|
if (needsSetup) {
|
|
return <Navigate to="/setup" replace />;
|
|
}
|
|
|
|
// Redirect authenticated users to /admin (but preserve impersonation flow via ?sessionId=)
|
|
const searchParams = new URLSearchParams(location.search);
|
|
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId")) {
|
|
return <Navigate to="/admin" replace />;
|
|
}
|
|
|
|
// Don't render portal chrome at /login — DevLoginSelector is shown instead
|
|
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login";
|
|
|
|
return (
|
|
<BrandingProvider>
|
|
{location.pathname.startsWith("/admin") ? (
|
|
<>
|
|
<Routes>
|
|
<Route path="/admin/*" element={<AdminLayout />} />
|
|
</Routes>
|
|
{authDisabled && <DevSessionIndicator />}
|
|
</>
|
|
) : showCustomerPortal ? (
|
|
<>
|
|
<CustomerPortal />
|
|
{authDisabled && <DevSessionIndicator />}
|
|
</>
|
|
) : null}
|
|
</BrandingProvider>
|
|
);
|
|
}
|