diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19f391c..b95ad64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] workflow_dispatch: inputs: ref: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..38582cf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,90 @@ +# Contributing to GroomBook + +## Branch Strategy + +GroomBook uses a three-branch GitOps model: + +| Branch | Environment | Purpose | +|--------|-------------|---------| +| `dev` | Development | Active development target — all feature/fix PRs target this branch | +| `uat` | UAT / Staging | Promoted from `dev` by the CTO for acceptance testing | +| `main` | Production | Promoted from `uat` by the CEO; triggers production deployment | + +**Never open a PR directly to `uat` or `main`.** All work flows through `dev` first. + +## Developer Workflow + +1. **Branch from `dev`** — create a feature or fix branch: + ```bash + git checkout dev + git pull origin dev + git checkout -b feat/my-feature + ``` + +2. **Open a PR targeting `dev`** — include the issue identifier in the title and cc @cpfarhood: + ```bash + gh pr create --base dev --title "feat: description (GRO-NNN)" \ + --body $'Closes GRO-NNN\n\ncc @cpfarhood' + ``` + +3. **Pipeline gates before merge to `dev`:** + - QA (Lint Roller) reviews first — code quality, test coverage, CI pass + - CTO (The Dogfather) reviews second — architecture and final approval + - Both must approve; 2 approving reviews required by branch protection + +## Promotion Flow + +### Dev → UAT + +After merging to `dev`, the CTO opens a PR from `dev` → `uat`: + +```bash +gh pr create --base uat --head dev \ + --title "chore: promote dev to uat (YYYY.MM.DD)" \ + --body $'Promoting dev to UAT for regression and security review.\n\ncc @cpfarhood' +``` + +Gates: +- Shedward Scissorhands runs regression/acceptance tests +- Barkley Trimsworth performs security review +- CTO approves and merges (1 approving review required) + +### UAT → Main (Production) + +After UAT passes, the CTO opens a PR from `uat` → `main` and assigns it to the CEO: + +```bash +gh pr create --base main --head uat \ + --title "chore: promote uat to main (YYYY.MM.DD)" \ + --body $'Promoting UAT to production.\n\ncc @cpfarhood' +``` + +Gates: +- CEO (Scrubs McBarkley) reviews for business alignment and merges +- 1 approving review required; triggers auto-deploy to Production + +## Branch Protection Summary + +| Branch | Required Approvals | Who approves | +|--------|--------------------|-------------| +| `dev` | 2 | QA (Lint Roller) + CTO (The Dogfather) | +| `uat` | 1 | CTO (The Dogfather) | +| `main` | 1 | CEO (Scrubs McBarkley) | + +Force-pushes and branch deletions are disabled on all three branches. + +## Commit Style + +Use [Conventional Commits](https://www.conventionalcommits.org/): +- `feat:` — new feature +- `fix:` — bug fix +- `chore:` — maintenance (dependency updates, build config, promotions) +- `docs:` — documentation only +- `ci:` — CI/CD changes +- `refactor:` — code restructure without behaviour change + +Reference the Paperclip issue in the commit body: `Refs GRO-NNN`. + +## Questions? + +Open a Paperclip issue in the GRO project or ask in the team channel. diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a3d97fd..c6e90a5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -72,6 +72,60 @@ app.route("/api/webhooks/stripe", webhooksRouter); // Dev/demo routes — config is always public, users endpoint is guarded internally app.route("/api/dev", devRouter); +// Magic bytes for allowed image types +const ALLOWED_IMAGE_TYPES: Record = { + "image/png": new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + "image/jpeg": new Uint8Array([0xff, 0xd8, 0xff]), + "image/gif": new Uint8Array([0x47, 0x49, 0x46, 0x38]), + "image/webp": new Uint8Array([0x52, 0x49, 0x46, 0x46]), // followed by size then WEBP +}; + +/** + * Validates that the given base64 content matches the declared MIME type + * by checking magic bytes. Returns null if valid, or the field to clear if not. + */ +function validateLogoMagicBytes( + logoBase64: string | null, + logoMimeType: string | null +): "logoBase64" | "logoMimeType" | null { + if (!logoBase64 || !logoMimeType) return null; + + const expectedMagic = ALLOWED_IMAGE_TYPES[logoMimeType]; + if (!expectedMagic) return "logoMimeType"; // unknown MIME type — reject + + try { + const binary = Buffer.from(logoBase64, "base64"); + // WebP needs a special check (RIFF....WEBP at offset 0, size at offset 4) + if (logoMimeType === "image/webp") { + if (binary.length < 12) return "logoBase64"; + const webpMagic = binary.slice(0, 4); + const webpSig = binary.slice(8, 12); + if ( + webpMagic[0] !== 0x52 || + webpMagic[1] !== 0x49 || + webpMagic[2] !== 0x46 || + webpMagic[3] !== 0x46 || + webpSig[0] !== 0x57 || + webpSig[1] !== 0x45 || + webpSig[2] !== 0x42 || + webpSig[3] !== 0x50 + ) { + return "logoBase64"; + } + return null; + } + + // All other types: check prefix + if (binary.length < expectedMagic.length) return "logoBase64"; + for (let i = 0; i < expectedMagic.length; i++) { + if (binary[i] !== expectedMagic[i]) return "logoBase64"; + } + return null; + } catch { + return "logoBase64"; + } +} + // Public branding endpoint — no auth required, returns business name/colors/logo app.get("/api/branding", async (c) => { const db = getDb(); @@ -87,13 +141,19 @@ app.get("/api/branding", async (c) => { } } + // Defensive: validate magic bytes to prevent MIME type confusion attacks + // via the legacy base64 logo fields + const badField = validateLogoMagicBytes(settings.logoBase64 ?? null, settings.logoMimeType ?? null); + const safeLogoBase64 = badField === "logoBase64" ? null : settings.logoBase64; + const safeLogoMimeType = badField === "logoMimeType" ? null : settings.logoMimeType; + return c.json({ businessName: settings.businessName, primaryColor: settings.primaryColor, accentColor: settings.accentColor, logoUrl, - logoBase64: settings.logoBase64, - logoMimeType: settings.logoMimeType, + logoBase64: safeLogoBase64, + logoMimeType: safeLogoMimeType, }); }); diff --git a/apps/api/src/services/reminders.ts b/apps/api/src/services/reminders.ts index 5aa2abc..1981258 100644 --- a/apps/api/src/services/reminders.ts +++ b/apps/api/src/services/reminders.ts @@ -5,6 +5,7 @@ import { eq, getDb, gte, + inArray, lt, appointments, clients, @@ -59,68 +60,77 @@ export async function runReminderCheck(): Promise { ) ); + const appointmentIds: string[] = upcoming.map((a) => a.id as string); + if (appointmentIds.length === 0) continue; + + // Bulk check: which appointments already have email and SMS reminders sent? + const sentRows = await db + .select({ appointmentId: reminderLogs.appointmentId, channel: reminderLogs.channel }) + .from(reminderLogs) + .where( + and( + eq(reminderLogs.reminderType, window.label), + appointmentIds.length === 1 + ? eq(reminderLogs.appointmentId, appointmentIds[0]!) + : inArray(reminderLogs.appointmentId, appointmentIds) + ) + ); + + const sentEmail = new Set( + sentRows.filter((r) => r.channel === "email").map((r) => r.appointmentId) + ); + const sentSms = new Set( + sentRows.filter((r) => r.channel === "sms").map((r) => r.appointmentId) + ); + + // Bulk JOIN: fetch all client/pet/service/staff data in one query + const joinedRows = await db + .select({ + appointmentId: appointments.id, + startTime: appointments.startTime, + clientId: appointments.clientId, + petId: appointments.petId, + serviceId: appointments.serviceId, + staffId: appointments.staffId, + confirmationToken: appointments.confirmationToken, + clientName: clients.name, + clientEmail: clients.email, + clientEmailOptOut: clients.emailOptOut, + clientSmsOptIn: clients.smsOptIn, + clientPhone: clients.phone, + petName: pets.name, + serviceName: services.name, + staffName: staff.name, + }) + .from(appointments) + .innerJoin(clients, eq(appointments.clientId, clients.id)) + .innerJoin(pets, eq(appointments.petId, pets.id)) + .innerJoin(services, eq(appointments.serviceId, services.id)) + .leftJoin(staff, eq(appointments.staffId, staff.id)) + .where( + and( + gte(appointments.startTime, windowStart), + lt(appointments.startTime, windowEnd), + eq(appointments.status, "scheduled") + ) + ); + + const appointmentMap = new Map(); + for (const row of joinedRows) { + appointmentMap.set(row.appointmentId, row); + } + for (const appt of upcoming) { - const [emailLog] = await db - .select({ id: reminderLogs.id }) - .from(reminderLogs) - .where( - and( - eq(reminderLogs.appointmentId, appt.id), - eq(reminderLogs.reminderType, window.label), - eq(reminderLogs.channel, "email") - ) - ) - .limit(1); + const joined = appointmentMap.get(appt.id as string); + if (!joined) continue; - const [smsLog] = await db - .select({ id: reminderLogs.id }) - .from(reminderLogs) - .where( - and( - eq(reminderLogs.appointmentId, appt.id), - eq(reminderLogs.reminderType, window.label), - eq(reminderLogs.channel, "sms") - ) - ) - .limit(1); + const { clientName, clientEmail, clientEmailOptOut, clientSmsOptIn, clientPhone, petName, serviceName, staffName } = joined; - const [client] = await db - .select({ - name: clients.name, - email: clients.email, - emailOptOut: clients.emailOptOut, - smsOptIn: clients.smsOptIn, - phone: clients.phone, - }) - .from(clients) - .where(eq(clients.id, appt.clientId)) - .limit(1); + if (!clientEmail || clientEmailOptOut) continue; + if (!petName || !serviceName) continue; - if (!client || !client.email || client.emailOptOut) continue; - - const [pet] = await db - .select({ name: pets.name }) - .from(pets) - .where(eq(pets.id, appt.petId)) - .limit(1); - - const [service] = await db - .select({ name: services.name }) - .from(services) - .where(eq(services.id, appt.serviceId)) - .limit(1); - - let groomerName: string | null = null; - if (appt.staffId) { - const [groomer] = await db - .select({ name: staff.name }) - .from(staff) - .where(eq(staff.id, appt.staffId)) - .limit(1); - groomerName = groomer?.name ?? null; - } - - if (!pet || !service) continue; + const emailSent = sentEmail.has(appt.id as string); + const smsSent = sentSms.has(appt.id as string); let confirmationToken = appt.confirmationToken; if (!confirmationToken) { @@ -131,15 +141,15 @@ export async function runReminderCheck(): Promise { .where(eq(appointments.id, appt.id)); } - if (!emailLog) { + if (!emailSent) { const sent = await sendEmail( buildReminderEmail( - client.email, + clientEmail, { - clientName: client.name, - petName: pet.name, - serviceName: service.name, - groomerName, + clientName, + petName, + serviceName, + groomerName: staffName, startTime: appt.startTime, }, window.hours, @@ -155,20 +165,20 @@ export async function runReminderCheck(): Promise { } } - if (!smsLog && client.smsOptIn && client.phone) { + if (!smsSent && clientSmsOptIn && clientPhone) { const apiUrl = process.env.API_URL ?? "http://localhost:3000"; const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`; const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`; const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`; const smsBody = [ - `Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`, - `Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`, + `Hi ${clientName}, just a reminder: ${petName}'s grooming appointment is ${when}.`, + `Service: ${serviceName}${staffName ? ` with ${staffName}` : ""}`, `Confirm: ${confirmUrl}`, `Cancel: ${cancelUrl}`, TCPA_OPT_OUT, ].join(". "); try { - const smsOk = await smsSend(client.phone, smsBody); + const smsOk = await smsSend(clientPhone, smsBody); if (smsOk) { await db .insert(reminderLogs) diff --git a/apps/web/e2e/playwright.config.ts b/apps/web/e2e/playwright.config.ts index fad7857..ddc725b 100644 --- a/apps/web/e2e/playwright.config.ts +++ b/apps/web/e2e/playwright.config.ts @@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test"; /** * Playwright configuration for GroomBook Web E2E tests. * - * Targets the deployed dev environment at groombook.dev.farh.net. + * Targets the deployed dev environment at dev.groombook.dev. * Uses the dev login selector (/login) for authentication — no hardcoded credentials. * * Run locally: @@ -19,7 +19,7 @@ export default defineConfig({ reporter: process.env.CI ? "github" : "list", use: { - baseURL: "https://groombook.dev.farh.net", + baseURL: "https://dev.groombook.dev", trace: "on-first-retry", screenshot: "only-on-failure", serviceWorkers: "block", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 28bde7c..83e95d6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,7 +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.jsx"; +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"; diff --git a/apps/web/src/components/GlobalSearch.tsx b/apps/web/src/components/GlobalSearch.tsx index 8971fde..deb4770 100644 --- a/apps/web/src/components/GlobalSearch.tsx +++ b/apps/web/src/components/GlobalSearch.tsx @@ -26,6 +26,7 @@ export function GlobalSearch() { const [query, setQuery] = useState(""); const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); const [open, setOpen] = useState(false); const inputRef = useRef(null); const dropdownRef = useRef(null); @@ -45,15 +46,18 @@ export function GlobalSearch() { debounceRef.current = setTimeout(async () => { setLoading(true); + setError(null); try { const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`); if (res.ok) { const data: SearchResults = await res.json(); setResults(data); setOpen(true); + } else { + setError("Search failed. Please try again."); } - } catch (err) { - console.warn("GlobalSearch: fetch error", err); + } catch { + setError("Search failed. Please try again."); } finally { setLoading(false); } @@ -160,7 +164,13 @@ export function GlobalSearch() { )} - {!loading && !hasResults && ( + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && !hasResults && (
No results found
diff --git a/apps/web/src/components/PetPhotoUpload.tsx b/apps/web/src/components/PetPhotoUpload.tsx index f33ce48..0b479f3 100644 --- a/apps/web/src/components/PetPhotoUpload.tsx +++ b/apps/web/src/components/PetPhotoUpload.tsx @@ -71,6 +71,12 @@ export function PetPhotoUpload({ petId, onUploaded }: Props) { } async function handleFile(file: File) { + const MAX_FILE_SIZE = 50 * 1024 * 1024; + if (file.size > MAX_FILE_SIZE) { + setState({ status: "error", message: "File exceeds 50MB limit. Please choose a smaller image." }); + return; + } + if (!ACCEPTED_TYPES.includes(file.type)) { setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." }); return; diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 386354d..1dd7046 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types"; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -273,7 +273,15 @@ export function AppointmentsPage() { cascade !== "this_only" ? `/api/appointments/${id}?cascade=${cascade}` : `/api/appointments/${id}`; - await fetch(url, { method: "DELETE" }); + try { + const res = await fetch(url, { method: "DELETE" }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + } catch (e: unknown) { + alert(e instanceof Error ? e.message : "Failed to delete appointment"); + } setSelectedAppt(null); await loadAppointments(); } @@ -819,8 +827,49 @@ function AppointmentDetail({ } function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + const modalRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.current?.querySelectorAll(focusableSelectors); + const firstFocusable = focusableElements?.[0]; + firstFocusable?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab") return; + if (!modalRef.current) return; + const focusables = modalRef.current.querySelectorAll(focusableSelectors); + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previouslyFocused?.focus(); + }; + }, [onClose]); + return (
{ if (e.target === e.currentTarget) onClose(); }} >
0 && tipSplits.length > 0) { + const totalPct = tipSplits.reduce((s, r) => s + r.pct, 0); + if (Math.abs(totalPct - 100) >= 0.01) { + setError("Tip split percentages must sum to 100%"); + setSaving(false); + return; + } + } try { const res = await fetch(`/api/invoices/${invoice.id}`, { method: "PATCH", diff --git a/apps/web/src/pages/Reports.tsx b/apps/web/src/pages/Reports.tsx index a3515ce..4cd2631 100644 --- a/apps/web/src/pages/Reports.tsx +++ b/apps/web/src/pages/Reports.tsx @@ -199,11 +199,11 @@ export function ReportsPage() { } const [summData, revData, apptData, svcData, clientData] = await Promise.all([ - summRes.json() as Promise, - revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>, - apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>, - svcRes.json() as Promise<{ rows: ServiceRow[] }>, - clientRes.json() as Promise, + summRes.ok ? summRes.json() as Promise : summRes.text().then(() => { throw new Error("summary response not ok"); }), + revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }), + apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }), + svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }), + clientRes.ok ? clientRes.json() as Promise : clientRes.text().then(() => { throw new Error("clients response not ok"); }), ]); setSummary(summData); diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 088a685..8d70d06 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -27,6 +27,8 @@ interface AuthProviderForm { const REDACTED = "••••••••"; +const ALLOWED_LOGO_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); + interface CurrentUser { id: string; name: string; @@ -149,9 +151,9 @@ export function SettingsPage() { return; } - const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"]; + const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"]; if (!validTypes.includes(file.type)) { - setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." }); + setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." }); return; } @@ -326,7 +328,7 @@ issuerUrl: authForm.issuerUrl, if (!loaded) return

Loading settings...

; - const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null); + const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType && ALLOWED_LOGO_TYPES.has(form.logoMimeType) ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null); return (
@@ -393,7 +395,7 @@ issuerUrl: authForm.issuerUrl, diff --git a/apps/web/src/pages/SetupWizard.d.ts b/apps/web/src/pages/SetupWizard.d.ts index 5758e2b..786c80d 100644 --- a/apps/web/src/pages/SetupWizard.d.ts +++ b/apps/web/src/pages/SetupWizard.d.ts @@ -1 +1 @@ -export { SetupWizard } from "./SetupWizard.jsx"; +export { SetupWizard } from "./SetupWizard.tsx"; diff --git a/apps/web/src/pages/SetupWizard.jsx b/apps/web/src/pages/SetupWizard.tsx similarity index 89% rename from apps/web/src/pages/SetupWizard.jsx rename to apps/web/src/pages/SetupWizard.tsx index 666b67c..8587519 100644 --- a/apps/web/src/pages/SetupWizard.jsx +++ b/apps/web/src/pages/SetupWizard.tsx @@ -2,16 +2,39 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useBranding } from "../BrandingContext.js"; -export function SetupWizard({ onSetupComplete }) { +interface SetupStatus { + showAuthProviderStep?: boolean; +} + +interface TestResult { + ok: boolean; + error?: string; +} + +interface AuthFormState { + providerId: string; + displayName: string; + issuerUrl: string; + internalBaseUrl: string; + clientId: string; + clientSecret: string; + scopes: string; +} + +interface Step { + id: string; + title: string; + description: string; +} + +export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void }) { const navigate = useNavigate(); const { refresh: refreshBranding } = useBranding(); - // Fetch setup status to determine if auth provider step is needed - const [setupStatus, setSetupStatus] = useState(null); // null = loading + const [setupStatus, setSetupStatus] = useState(null); const [loadingStatus, setLoadingStatus] = useState(true); - // Auth provider form state - const [authForm, setAuthForm] = useState({ + const [authForm, setAuthForm] = useState({ providerId: "authentik", displayName: "", issuerUrl: "", @@ -21,16 +44,16 @@ export function SetupWizard({ onSetupComplete }) { scopes: "openid profile email", }); const [testingConnection, setTestingConnection] = useState(false); - const [testResult, setTestResult] = useState(null); // {ok: boolean, error?: string} + const [testResult, setTestResult] = useState(null); const [step, setStep] = useState(0); const [businessName, setBusinessName] = useState(""); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); useEffect(() => { fetch("/api/setup/status") - .then((r) => r.json()) + .then((r) => r.json() as Promise) .then((data) => { setSetupStatus(data); setLoadingStatus(false); @@ -40,8 +63,7 @@ export function SetupWizard({ onSetupComplete }) { }); }, []); - // Build steps dynamically based on setup status - const STEPS = setupStatus?.showAuthProviderStep + const STEPS: Step[] = setupStatus?.showAuthProviderStep ? [ { id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." }, { id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." }, @@ -63,9 +85,8 @@ export function SetupWizard({ onSetupComplete }) { const isFirst = step === 0; const canGoBack = step > 0 && step < STEPS.length - 1; - // Determine if we can proceed - depends on which step we're on const canGoNext = (() => { - if (step === STEPS.length - 1) return true; // done step + if (step === STEPS.length - 1) return true; if (current?.id === "business") return businessName.trim().length > 0; if (current?.id === "auth") { return ( @@ -94,9 +115,9 @@ export function SetupWizard({ onSetupComplete }) { scopes: authForm.scopes, }), }); - const data = await res.json(); + const data = (await res.json()) as TestResult; setTestResult(data); - } catch (e) { + } catch { setTestResult({ ok: false, error: "Network error. Please try again." }); } finally { setTestingConnection(false); @@ -105,12 +126,10 @@ export function SetupWizard({ onSetupComplete }) { const handleNext = async () => { if (step === STEPS.length - 1) { - // Done - redirect to admin navigate("/admin"); return; } - // Submit auth provider config if (current?.id === "auth") { setLoading(true); setError(null); @@ -129,12 +148,12 @@ export function SetupWizard({ onSetupComplete }) { }), }); if (!res.ok) { - const data = await res.json(); + const data = (await res.json()) as { error?: string }; setError(data.error || "Failed to save auth provider configuration. Please try again."); setLoading(false); return; } - } catch (e) { + } catch { setError("Network error. Please try again."); setLoading(false); return; @@ -142,7 +161,6 @@ export function SetupWizard({ onSetupComplete }) { setLoading(false); } - // Submit business name and complete setup if (current?.id === "business" && businessName.trim()) { setLoading(true); setError(null); @@ -153,16 +171,14 @@ export function SetupWizard({ onSetupComplete }) { body: JSON.stringify({ businessName: businessName.trim() }), }); if (!res.ok) { - const data = await res.json(); + const data = (await res.json()) as { error?: string }; setError(data.error || "Setup failed. Please try again."); setLoading(false); return; } - // Refresh branding so the nav bar shows the new business name refreshBranding(); - // Clear needsSetup state in App so the redirect to /admin sticks if (onSetupComplete) onSetupComplete(); - } catch (e) { + } catch { setError("Network error. Please try again."); setLoading(false); return; @@ -192,7 +208,7 @@ export function SetupWizard({ onSetupComplete }) { ); } - const inputStyle = { + const inputStyle: React.CSSProperties = { width: "100%", padding: "0.6rem 0.85rem", borderRadius: 8, @@ -220,7 +236,6 @@ export function SetupWizard({ onSetupComplete }) { maxWidth: 480, width: "100%", }}> - {/* Progress dots */}
{STEPS.map((_, i) => (
- {/* Step indicator */}

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

- {/* Title */}

{current?.title}

- {/* Description */}

{current?.description}

- {/* Step: Business name input */} {current?.id === "business" && ( setBusinessName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()} + onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()} autoFocus style={inputStyle} /> )} - {/* Step: Auth provider config form */} {current?.id === "auth" && (
- {/* Provider ID */}
- {/* Display Name */}
- {/* Issuer URL */}
- {/* Internal Base URL (optional) */}
- {/* Client ID */}
- {/* Client Secret */}
- {/* Scopes */}
- {/* Test Connection button */} - {/* Test result */} {testResult && (
)} - {/* Step: Super user info */} {current?.id === "superuser" && (
)} - {/* Step: Second admin info */} {current?.id === "admin" && (
)} - {/* Error message */} {error && (

)} - {/* Navigation buttons */}

)}