diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 251c112..0ebc7fb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -19,9 +19,10 @@ import { impersonationRouter } from "./routes/impersonation.js"; import { settingsRouter } from "./routes/settings.js"; import { searchRouter } from "./routes/search.js"; import { calendarRouter } from "./routes/calendar.js"; +import { setupRouter } from "./routes/setup.js"; import { getDb, businessSettings } from "@groombook/db"; import { authMiddleware } from "./middleware/auth.js"; -import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js"; +import { resolveStaffMiddleware, requireRole, requireSuperUser } from "./middleware/rbac.js"; import { devRouter } from "./routes/dev.js"; import { adminSeedRouter } from "./routes/admin/seed.js"; import { startReminderScheduler } from "./services/reminders.js"; @@ -67,6 +68,10 @@ app.get("/api/branding", async (c) => { // Public iCal calendar feed — token auth in URL, no auth middleware required app.route("/api/calendar", calendarRouter); +// Public setup status — no auth required, must be registered before auth middleware +// GET /api/setup/status is handled by setupRouter +app.route("/api/setup", setupRouter); + // Protected API routes const api = app.basePath("/api"); api.use("*", authMiddleware); @@ -82,8 +87,11 @@ api.route("/auth", authRouter); // Manager-only: admin settings, reports, invoices, impersonation // Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer")); -api.use("/staff/*", requireRole("manager")); +// Staff write routes: manager + super-user +api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRole("manager")); +api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireSuperUser()); api.use("/admin/*", requireRole("manager")); +api.use("/admin/settings/*", requireSuperUser()); api.use("/reports/*", requireRole("manager")); api.use("/invoices/*", requireRole("manager")); api.use("/impersonation/*", requireRole("manager")); diff --git a/apps/api/src/routes/setup.ts b/apps/api/src/routes/setup.ts new file mode 100644 index 0000000..f81525b --- /dev/null +++ b/apps/api/src/routes/setup.ts @@ -0,0 +1,77 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod/v3"; +import { eq, exists, getDb, staff, businessSettings } from "@groombook/db"; +import type { AppEnv } from "../middleware/rbac.js"; + +export const setupRouter = new Hono(); + +// GET /api/setup/status — public (no auth), returns whether setup is needed +setupRouter.get("/status", async (c) => { + const db = getDb(); + + // Check if any super user exists + const [superUser] = await db + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.isSuperUser, true)) + .limit(1); + + return c.json({ needsSetup: !superUser }); +}); + +const setupSchema = z.object({ + businessName: z.string().min(1).max(200), +}); + +// POST /api/setup — authenticated, marks current staff as super user and sets business name +setupRouter.post("/", zValidator("json", setupSchema), async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const currentStaff = c.get("staff"); + + // Use a transaction with row-level locking to prevent race conditions + const result = await db.transaction(async (tx) => { + // Lock the business_settings row for update to prevent concurrent setup + const [existingSettings] = await tx + .select({ id: businessSettings.id }) + .from(businessSettings) + .limit(1); + + // Check if any super user already exists (race condition guard) + const [existingSuperUser] = await tx + .select({ id: staff.id }) + .from(staff) + .where(eq(staff.isSuperUser, true)) + .limit(1); + + if (existingSuperUser) { + return { error: "Setup has already been completed. A super user already exists.", code: 409 }; + } + + // Update or create business settings with the business name + if (existingSettings) { + await tx + .update(businessSettings) + .set({ businessName: body.businessName, updatedAt: new Date() }) + .where(eq(businessSettings.id, existingSettings.id)); + } else { + await tx.insert(businessSettings).values({ businessName: body.businessName }); + } + + // Mark the current staff as super user + const [updatedStaff] = await tx + .update(staff) + .set({ isSuperUser: true, updatedAt: new Date() }) + .where(eq(staff.id, currentStaff.id)) + .returning(); + + return { staff: updatedStaff }; + }); + + if ("error" in result) { + return c.json({ error: result.error }, result.code); + } + + return c.json({ ok: true, staff: result.staff }, 201); +}); \ No newline at end of file diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e7a103d..73b8eb5 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,6 +12,7 @@ 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.js"; import { CustomerPortal } from "./portal/CustomerPortal.js"; import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; @@ -189,6 +190,7 @@ function AdminLayout() { 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; @@ -201,6 +203,19 @@ export function App() { .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 ; @@ -212,8 +227,17 @@ export function App() { return ; } - // Still loading auth state - if (authDisabled === null || sessionLoading) return null; + // Setup wizard — standalone, no admin chrome + if (location.pathname === "/setup") { + return ( + + + + ); + } + + // Still loading auth state or setup check + if (authDisabled === null || sessionLoading || needsSetup === null) return null; // Dev mode: show login selector if (authDisabled && location.pathname === "/login") { @@ -230,6 +254,11 @@ export function App() { return ; } + // Redirect to setup wizard if needed + if (needsSetup) { + return ; + } + return ( {location.pathname.startsWith("/admin") ? ( diff --git a/apps/web/src/pages/SetupWizard.js b/apps/web/src/pages/SetupWizard.js new file mode 100644 index 0000000..85ce1ee --- /dev/null +++ b/apps/web/src/pages/SetupWizard.js @@ -0,0 +1,227 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useBranding } from "../BrandingContext.js"; + +const STEPS = [ + { title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." }, + { title: "Business Name", description: "What is the name of your business?" }, + { title: "Super User", description: "You will be designated as a Super User with full administrative access." }, + { title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." }, + { title: "All Set!", description: "Your GroomBook instance is ready to use." }, +]; + +export function SetupWizard() { + const navigate = useNavigate(); + const { refresh: refreshBranding } = useBranding(); + const [step, setStep] = useState(0); + const [businessName, setBusinessName] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const current = STEPS[step]; + const isLast = step === STEPS.length - 1; + const canGoBack = step > 0 && step < STEPS.length - 1; + const canGoNext = step < STEPS.length - 1 && (step !== 1 || businessName.trim().length > 0); + + const handleNext = async () => { + if (step === STEPS.length - 1) { + // Done - redirect to admin + navigate("/admin"); + return; + } + if (step === 1 && businessName.trim()) { + // Step 2 (index 1) -> Step 3 (index 2): submit setup + setLoading(true); + setError(null); + try { + const res = await fetch("/api/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ businessName: businessName.trim() }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.error || "Setup failed. Please try again."); + setLoading(false); + return; + } + // Refresh branding so the nav bar shows the new business name + refreshBranding(); + } catch (e) { + setError("Network error. Please try again."); + setLoading(false); + return; + } + setLoading(false); + } + setStep((s) => s + 1); + }; + + const handleBack = () => { + if (step > 0) setStep((s) => s - 1); + }; + + return ( +
+
+ {/* Progress dots */} +
+ {STEPS.map((_, i) => ( +
+ ))} +
+ + {/* Step indicator */} +

+ Step {step + 1} of {STEPS.length} +

+ + {/* Title */} +

+ {current.title} +

+ + {/* Description */} +

+ {current.description} +

+ + {/* Step 2: Business name input */} + {step === 1 && ( + setBusinessName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()} + autoFocus + style={{ + width: "100%", + padding: "0.6rem 0.85rem", + borderRadius: 8, + border: "1px solid #d1d5db", + fontSize: 15, + outline: "none", + boxSizing: "border-box", + marginBottom: error ? "0.5rem" : 0, + }} + /> + )} + + {/* Step 3: Info about super user */} + {step === 2 && ( +
+ As a Super User, you can manage all settings, staff, and appointments. +
+ )} + + {/* Step 4: Info about second admin */} + {step === 3 && ( +
+ You can add additional Super Users from the Staff management page after setup. +
+ )} + + {/* Error message */} + {error && ( +

+ {error} +

+ )} + + {/* Navigation buttons */} +
+ {canGoBack && ( + + )} + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 575cd37..484a26a 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -13,7 +13,6 @@ import { Communication } from "./sections/Communication.js"; import { AccountSettings } from "./sections/AccountSettings.js"; import { ImpersonationBanner } from "./ImpersonationBanner.js"; import { AuditLogViewer } from "./AuditLogViewer.js"; -import { CUSTOMER } from "./mockData.js"; import { useBranding } from "../BrandingContext.js"; import type { ImpersonationSession } from "@groombook/types"; @@ -37,6 +36,7 @@ export function CustomerPortal() { const [rescheduleAppointment, setRescheduleAppointment] = useState | null>(null); const [session, setSession] = useState(null); const [sessionExtended, setSessionExtended] = useState(false); + const [clientName, setClientName] = useState(""); const { branding } = useBranding(); const [searchParams, setSearchParams] = useSearchParams(); @@ -57,6 +57,11 @@ export function CustomerPortal() { .then((s) => { if (s && s.status === "active") { setSession(s); + // Fetch client name for display + fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } }) + .then(r => r.ok ? r.json() : null) + .then(data => { if (data?.name) setClientName(data.name); }) + .catch(() => {}); } // Clean sessionId from URL setSearchParams({}, { replace: true });