Merge branch 'main' into fix/disable-stub-portal-buttons

This commit is contained in:
groombook-cto[bot]
2026-03-28 03:55:22 +00:00
committed by GitHub
36 changed files with 695 additions and 131 deletions
+1
View File
@@ -14,6 +14,7 @@
"dependencies": {
"@groombook/types": "workspace:*",
"@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.0.0",
"lucide-react": "^0.577.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+24 -13
View File
@@ -17,6 +17,7 @@ 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 } from "./lib/auth-client.js";
const NAV_LINKS = [
{ to: "/admin", label: "Appointments" },
@@ -133,6 +134,10 @@ function AdminLayout() {
export function App() {
const location = useLocation();
const [authDisabled, setAuthDisabled] = 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")
@@ -141,19 +146,6 @@ export function App() {
.catch(() => setAuthDisabled(false));
}, []);
// Show login selector page
if (location.pathname === "/login") {
return <DevLoginSelector />;
}
// While checking auth config, render nothing briefly
if (authDisabled === null) return null;
// If auth is disabled and no dev user is selected, redirect to login selector
if (authDisabled && !getDevUser() && location.pathname !== "/login") {
return <Navigate to="/login" replace />;
}
// Public booking redirect pages — no auth or portal chrome needed
if (location.pathname === "/booking/confirmed") {
return <BookingConfirmedPage />;
@@ -165,6 +157,25 @@ export function App() {
return <BookingErrorPage />;
}
// Still loading auth state
if (authDisabled === null || sessionLoading) return null;
// Dev mode: show login selector
if (authDisabled && location.pathname === "/login") {
return <DevLoginSelector />;
}
// Dev mode: use dev login selector
if (authDisabled && !getDevUser()) {
return <Navigate to="/login" replace />;
}
// Production mode: if no session, redirect to Authentik sign-in
if (!authDisabled && !session) {
signIn.social({ provider: "authentik" });
return null;
}
return (
<BrandingProvider>
{location.pathname.startsWith("/admin") ? (
+34 -1
View File
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { App } from "../App.js";
import { App } from "../App";
// Mock fetch to return appropriate responses based on URL
beforeEach(() => {
@@ -44,6 +45,32 @@ async function renderApp(route = "/admin") {
}
describe("App navigation", () => {
// Use authDisabled=true (dev mode) so nav renders without needing Better Auth useSession() mock
beforeEach(() => {
localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" }));
global.fetch = vi.fn((url: string) => {
if (url === "/api/dev/config") {
return Promise.resolve({
ok: true,
json: async () => ({ authDisabled: true }),
} as Response);
}
if (url === "/api/branding") {
return Promise.resolve({
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => [] } as Response);
}) as unknown as typeof fetch;
});
it("renders the Groom Book brand", async () => {
const nav = await renderApp();
expect(
@@ -124,6 +151,12 @@ describe("Dev login selector", () => {
}),
} as Response);
}
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: null }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => [] } as Response);
}) as unknown as typeof fetch;
+7
View File
@@ -0,0 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "http://localhost:3000",
});
export const { signIn, signOut, useSession } = authClient;
+3
View File
@@ -9,6 +9,9 @@ const originalFetch = window.fetch;
* Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true).
*/
export function installDevFetchInterceptor() {
// In production, Better-Auth handles auth via cookies — no interception needed
if (!import.meta.env.DEV) return;
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
const user = getDevUser();
if (!user) return originalFetch(input, init);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />