fix: Continue as default dev user button navigates to /admin #165
+7
-92
@@ -12,7 +12,6 @@ import { SettingsPage } from "./pages/Settings.js";
|
|||||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||||
import { SetupWizard } from "./pages/SetupWizard.jsx";
|
|
||||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
@@ -20,61 +19,6 @@ import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
|||||||
import { GlobalSearch } from "./components/GlobalSearch.js";
|
import { GlobalSearch } from "./components/GlobalSearch.js";
|
||||||
import { useSession, signIn } from "./lib/auth-client.js";
|
import { useSession, signIn } from "./lib/auth-client.js";
|
||||||
|
|
||||||
function LoginPage() {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
await signIn.social({ provider: "authentik", callbackURL: window.location.origin });
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
<button
|
|
||||||
onClick={handleLogin}
|
|
||||||
disabled={isLoading}
|
|
||||||
style={{
|
|
||||||
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 = [
|
const NAV_LINKS = [
|
||||||
{ to: "/admin", label: "Appointments" },
|
{ to: "/admin", label: "Appointments" },
|
||||||
{ to: "/admin/clients", label: "Clients" },
|
{ to: "/admin/clients", label: "Clients" },
|
||||||
@@ -190,7 +134,6 @@ function AdminLayout() {
|
|||||||
export function App() {
|
export function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
||||||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
|
||||||
const { data: rawSession, isPending: rawSessionLoading } = useSession();
|
const { data: rawSession, isPending: rawSessionLoading } = useSession();
|
||||||
// In dev mode (authDisabled=true), session state is irrelevant - skip useSession result
|
// In dev mode (authDisabled=true), session state is irrelevant - skip useSession result
|
||||||
const session = authDisabled ? null : rawSession;
|
const session = authDisabled ? null : rawSession;
|
||||||
@@ -203,19 +146,6 @@ export function App() {
|
|||||||
.catch(() => setAuthDisabled(false));
|
.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
|
// Public booking redirect pages — no auth or portal chrome needed
|
||||||
if (location.pathname === "/booking/confirmed") {
|
if (location.pathname === "/booking/confirmed") {
|
||||||
return <BookingConfirmedPage />;
|
return <BookingConfirmedPage />;
|
||||||
@@ -227,39 +157,24 @@ export function App() {
|
|||||||
return <BookingErrorPage />;
|
return <BookingErrorPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup wizard — standalone, no admin chrome
|
// Still loading auth state
|
||||||
if (location.pathname === "/setup") {
|
|
||||||
return (
|
|
||||||
<BrandingProvider>
|
|
||||||
<SetupWizard />
|
|
||||||
</BrandingProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still loading auth state or setup check (skip setup check in dev mode)
|
|
||||||
if (authDisabled === null || sessionLoading) return null;
|
if (authDisabled === null || sessionLoading) return null;
|
||||||
|
|
||||||
// Dev mode: show login selector (no setup check needed in dev mode)
|
// Dev mode: show login selector
|
||||||
if (authDisabled && location.pathname === "/login") {
|
if (authDisabled && location.pathname === "/login") {
|
||||||
return <DevLoginSelector />;
|
return <DevLoginSelector />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev mode: use dev login selector (no setup check needed in dev mode)
|
// Dev mode: use dev login selector for non-admin routes
|
||||||
if (authDisabled && !getDevUser()) {
|
// Allow /admin/* access in dev mode even without stored dev user (skipLogin flow)
|
||||||
|
if (authDisabled && !getDevUser() && !location.pathname.startsWith("/admin")) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production: need setup check
|
|
||||||
if (needsSetup === null) return null;
|
|
||||||
|
|
||||||
// Production mode: if no session, redirect to Authentik sign-in
|
// Production mode: if no session, redirect to Authentik sign-in
|
||||||
if (!authDisabled && !session) {
|
if (!authDisabled && !session) {
|
||||||
return <LoginPage />;
|
signIn.social({ provider: "authentik" });
|
||||||
}
|
return null;
|
||||||
|
|
||||||
// Redirect to setup wizard if needed
|
|
||||||
if (needsSetup) {
|
|
||||||
return <Navigate to="/setup" replace />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -122,7 +122,47 @@ describe("App navigation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Dev login selector", () => {
|
describe("Dev login selector", () => {
|
||||||
it("redirects to /login when auth is disabled and no user selected", async () => {
|
it("renders /admin routes without redirect when auth is disabled and no user selected", async () => {
|
||||||
|
// authDisabled=true, no dev-user in localStorage
|
||||||
|
// /admin/* routes should render without requiring a stored dev user
|
||||||
|
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;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/admin"]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render admin nav (skipLogin flow: /admin accessible without stored dev user)
|
||||||
|
const nav = await screen.findByRole("navigation");
|
||||||
|
expect(
|
||||||
|
within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects non-/admin routes to /login when auth is disabled and no user selected", async () => {
|
||||||
|
// authDisabled=true, no dev-user in localStorage
|
||||||
|
// non-/admin/* routes should redirect to /login
|
||||||
global.fetch = vi.fn((url: string) => {
|
global.fetch = vi.fn((url: string) => {
|
||||||
if (url === "/api/dev/config") {
|
if (url === "/api/dev/config") {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -151,17 +191,11 @@ describe("Dev login selector", () => {
|
|||||||
}),
|
}),
|
||||||
} as Response);
|
} 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);
|
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/admin"]}>
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
<App />
|
<App />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user