diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d1820f5..251c112 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,7 +2,6 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { logger } from "hono/logger"; import { cors } from "hono/cors"; -import { toNodeHandler } from "better-auth/node"; import { auth } from "./lib/auth.js"; import { clientsRouter } from "./routes/clients.js"; import { petsRouter } from "./routes/pets.js"; @@ -68,19 +67,17 @@ app.get("/api/branding", async (c) => { // Public iCal calendar feed — token auth in URL, no auth middleware required app.route("/api/calendar", calendarRouter); -// Better-Auth handler — public, handles OAuth callbacks, session management -// Mounted BEFORE auth middleware so it's accessible without authentication -app.on(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], "/api/auth/**", (c) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { incoming, outgoing } = c.env as any; - return toNodeHandler(auth)(incoming, outgoing); -}); - // Protected API routes const api = app.basePath("/api"); api.use("*", authMiddleware); api.use("*", resolveStaffMiddleware); +// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes +// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths +const authRouter = new Hono(); +authRouter.all("/*", (c) => auth.handler(c.req.raw)); +api.route("/auth", authRouter); + // ── Role guards ──────────────────────────────────────────────────────────────── // Manager-only: admin settings, reports, invoices, impersonation // Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 3dda63b..8467513 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -4,6 +4,7 @@ import { genericOAuth } from "better-auth/plugins"; import { getDb } from "@groombook/db"; const OIDC_ISSUER = process.env.OIDC_ISSUER; +const OIDC_INTERNAL_BASE = process.env.OIDC_INTERNAL_BASE; // e.g. http://authentik-server.auth.svc.cluster.local const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID; const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET; const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; @@ -28,9 +29,21 @@ export const auth = betterAuth({ providerId: "authentik", clientId: OIDC_CLIENT_ID ?? "", clientSecret: OIDC_CLIENT_SECRET ?? "", - discoveryUrl: OIDC_ISSUER - ? `${OIDC_ISSUER}/.well-known/openid-configuration` - : undefined, + // When OIDC_INTERNAL_BASE is set, use explicit URLs to avoid hairpin NAT: + // - authorizationUrl: external (browser redirect, no server-side fetch) + // - tokenUrl/userInfoUrl: internal (server-to-server, avoids hairpin) + // When not set, fall back to discoveryUrl for local dev. + ...(OIDC_INTERNAL_BASE + ? { + authorizationUrl: `${new URL(OIDC_ISSUER!).origin}/application/o/authorize/`, + tokenUrl: `${OIDC_INTERNAL_BASE}/application/o/token/`, + userInfoUrl: `${OIDC_INTERNAL_BASE}/application/o/userinfo/`, + } + : { + discoveryUrl: OIDC_ISSUER + ? `${OIDC_ISSUER}/.well-known/openid-configuration` + : undefined, + }), scopes: ["openid", "profile", "email"], }, ], diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 66ec3d4..dbdbb1f 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -23,6 +23,12 @@ if (process.env.AUTH_DISABLED === "true") { } export const authMiddleware: MiddlewareHandler = async (c, next) => { + // Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt) + if (c.req.path.startsWith("/api/auth/")) { + await next(); + return; + } + if (process.env.AUTH_DISABLED === "true") { const devUserId = c.req.header("X-Dev-User-Id"); const sub = devUserId ?? "dev-user"; diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index 1bc2228..78c46f2 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -22,6 +22,12 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( c, next ) => { + // Better-Auth's own routes handle their own auth — skip staff resolution + if (c.req.path.startsWith("/api/auth/")) { + await next(); + return; + } + const db = getDb(); if (process.env.AUTH_DISABLED === "true") { diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 7003a43..9335c5d 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db"; +import { and, eq, lt, gt, ne, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db"; import type { AppEnv } from "../middleware/rbac.js"; export const portalRouter = new Hono(); @@ -212,6 +212,105 @@ portalRouter.post("/appointments/:id/cancel", async (c) => { }); }); +// ─── Appointment reschedule ────────────────────────────────────────────────── + +const rescheduleSchema = z.object({ + startTime: z.string().datetime(), +}); + +portalRouter.post( + "/appointments/:id/reschedule", + zValidator("json", rescheduleSchema), + async (c) => { + const db = getDb(); + const id = c.req.param("id"); + const body = c.req.valid("json"); + + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [session] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.id, sessionId), + eq(impersonationSessions.status, "active") + ) + ) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + const [appt] = await db + .select() + .from(appointments) + .where(eq(appointments.id, id)) + .limit(1); + + if (!appt) { + return c.json({ error: "Not found" }, 404); + } + + if (appt.clientId !== session.clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + if (appt.startTime <= new Date()) { + return c.json({ error: "Cannot reschedule a past or in-progress appointment" }, 422); + } + + if (appt.status === "cancelled" || appt.status === "completed") { + return c.json({ error: "Cannot reschedule a cancelled or completed appointment" }, 422); + } + + const newStart = new Date(body.startTime); + const durationMs = appt.endTime.getTime() - appt.startTime.getTime(); + const newEnd = new Date(newStart.getTime() + durationMs); + + const [existingConflict] = await db + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, appt.staffId!), + lt(appointments.startTime, newEnd), + gt(appointments.endTime, newStart), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ne(appointments.id, id) + ) + ) + .limit(1); + + if (existingConflict) { + return c.json({ error: "The selected time slot is no longer available" }, 409); + } + + const [updated] = await db + .update(appointments) + .set({ startTime: newStart, endTime: newEnd, updatedAt: new Date() }) + .where(eq(appointments.id, id)) + .returning(); + + if (!updated) { + return c.json({ error: "Not found" }, 404); + } + + return c.json({ + id: updated.id, + startTime: updated.startTime, + endTime: updated.endTime, + status: updated.status, + updatedAt: updated.updatedAt, + }); + } +); + // ─── Client-facing waitlist routes ─────────────────────────────────────────── const createWaitlistEntrySchema = z.object({ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8840370..e7a103d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -19,6 +19,61 @@ import { BrandingProvider, useBranding } from "./BrandingContext.js"; import { GlobalSearch } from "./components/GlobalSearch.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 ( +
+
+

GroomBook

+

+ Sign in to continue +

+ +
+
+ ); +} + const NAV_LINKS = [ { to: "/admin", label: "Appointments" }, { to: "/admin/clients", label: "Clients" }, @@ -170,10 +225,9 @@ export function App() { return ; } - // Production mode: if no session, redirect to Authentik sign-in + // Production mode: if no session, show login page (avoids redirect loops) if (!authDisabled && !session) { - signIn.social({ provider: "authentik" }); - return null; + return ; } return ( diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 1a4587b..12ff8ed 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -1,7 +1,7 @@ import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ - baseURL: import.meta.env.VITE_API_URL ?? "http://localhost:3000", + baseURL: import.meta.env.VITE_API_URL ?? "", }); export const { signIn, signOut, useSession } = authClient; \ No newline at end of file diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index 65a17e3..575cd37 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -5,7 +5,7 @@ import { Settings, LogOut, Shield, } from "lucide-react"; import { Dashboard } from "./sections/Dashboard.js"; -import { AppointmentsSection } from "./sections/Appointments.js"; +import { AppointmentsSection, RescheduleFlow } from "./sections/Appointments.js"; import { PetProfiles } from "./sections/PetProfiles.js"; import { ReportCards } from "./sections/ReportCards.js"; import { BillingPayments } from "./sections/BillingPayments.js"; @@ -33,6 +33,8 @@ export function CustomerPortal() { const [activeSection, setActiveSection] = useState
("dashboard"); const [mobileNavOpen, setMobileNavOpen] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false); + const [showReschedule, setShowReschedule] = useState(false); + const [rescheduleAppointment, setRescheduleAppointment] = useState | null>(null); const [session, setSession] = useState(null); const [sessionExtended, setSessionExtended] = useState(false); const { branding } = useBranding(); @@ -107,12 +109,17 @@ export function CustomerPortal() { } }; + const handleReschedule = useCallback((appointment: Record) => { + setRescheduleAppointment(appointment); + setShowReschedule(true); + }, []); + const isReadOnly = session?.status === "active"; const renderSection = () => { switch (activeSection) { case "dashboard": - return ; + return ; case "appointments": return ; case "pets": @@ -158,6 +165,15 @@ export function CustomerPortal() { /> )} + {showReschedule && rescheduleAppointment && ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { setShowReschedule(false); setRescheduleAppointment(null); }} + sessionId={session?.id ?? null} + /> + )} + {/* Mobile Header */}
- @@ -145,9 +155,8 @@ function ManagePets({ readOnly }: { readOnly: boolean }) { ))} {!readOnly && ( @@ -376,6 +383,133 @@ export function CustomerNotesSection({ appointment: appt, sessionId }: { appoint ); } +export function RescheduleFlow({ + appointment: appt, + onClose, + sessionId, +}: { + appointment: Appointment; + onClose: () => void; + sessionId?: string | null; +}) { + const [selectedDate, setSelectedDate] = useState(""); + const [selectedTime, setSelectedTime] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const availableTimes = ["9:00 AM", "10:00 AM", "11:00 AM", "1:00 PM", "2:00 PM", "3:00 PM", "4:00 PM"]; + + async function handleSubmit() { + if (!selectedDate || !selectedTime) return; + + const [hoursMinutes = "", period = ""] = selectedTime.split(" "); + const [hoursStr = "0", minutesStr = "0"] = hoursMinutes.split(":"); + let hours = parseInt(hoursStr, 10); + const minutes = parseInt(minutesStr ?? "0", 10); + if (period === "PM" && hours !== 12) hours += 12; + if (period === "AM" && hours === 12) hours = 0; + const isoTime = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:00`; + const startTime = new Date(`${selectedDate}T${isoTime}`).toISOString(); + + setSubmitting(true); + setError(null); + try { + const headers: Record = { "Content-Type": "application/json" }; + if (sessionId) headers["X-Impersonation-Session-Id"] = sessionId; + const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, { + method: "POST", + headers, + body: JSON.stringify({ startTime }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed to reschedule" })); + throw new Error(err.error || `HTTP ${res.status}`); + } + setSuccess(true); + setTimeout(() => { window.location.reload(); }, 1500); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to reschedule"); + setSubmitting(false); + } + } + + return ( +
+
+
+

Reschedule Appointment

+ +
+ +
+ {success ? ( +
+
+

Appointment Rescheduled!

+

Redirecting...

+
+ ) : ( + <> + {/* Current appointment summary */} +
+

{appt.petName} — {appt.services.join(", ")}

+

+ {formatDate(appt.date)} at {appt.time} with {appt.groomerName} +

+
+ +

Pick a New Date & Time

+ setSelectedDate(e.target.value)} + min={new Date().toISOString().split("T")[0]} + className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm mb-3" + /> + {selectedDate && ( +
+ {availableTimes.map(time => ( + + ))} +
+ )} + + {error &&

{error}

} + +
+ + +
+ + )} +
+
+
+ ); +} + function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boolean }) { const [step, setStep] = useState(1); const [selectedPet, setSelectedPet] = useState(null); diff --git a/apps/web/src/portal/sections/Dashboard.tsx b/apps/web/src/portal/sections/Dashboard.tsx index 289d1c7..baffebe 100644 --- a/apps/web/src/portal/sections/Dashboard.tsx +++ b/apps/web/src/portal/sections/Dashboard.tsx @@ -4,6 +4,8 @@ import { PETS, UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, INVOICES, LOYALTY, BUSI interface Props { onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void; readOnly: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onReschedule?: (appointment: any) => void; } function daysUntil(dateStr: string): number { @@ -18,7 +20,7 @@ function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); } -export function Dashboard({ onNavigate, readOnly }: Props) { +export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) { const nextAppt = UPCOMING_APPOINTMENTS[0]; const outstanding = INVOICES.filter(i => i.status === "outstanding").reduce((sum, i) => sum + i.amount, 0); const recentEvents = [ @@ -78,24 +80,15 @@ export function Dashboard({ onNavigate, readOnly }: Props) { {!readOnly && (
- -
diff --git a/apps/web/src/portal/sections/PetForm.tsx b/apps/web/src/portal/sections/PetForm.tsx new file mode 100644 index 0000000..626b042 --- /dev/null +++ b/apps/web/src/portal/sections/PetForm.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { X, Save } from "lucide-react"; +import type { Pet } from "../mockData.js"; + +interface Props { + pet?: Pet; + onSave: (pet: Pet) => void; + onCancel: () => void; +} + +export function PetForm({ pet, onSave, onCancel }: Props) { + const [name, setName] = useState(pet?.name ?? ""); + const [breed, setBreed] = useState(pet?.breed ?? ""); + const [weight, setWeight] = useState(pet?.weight ?? 0); + const [notes, setNotes] = useState(pet?.allergies ?? ""); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!pet) return; + onSave({ ...pet, name, breed, weight, allergies: notes }); + } + + return ( +
+
+

{pet ? "Edit Pet" : "Add Pet"}

+ +
+
+
+ + setName(e.target.value)} + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ + setBreed(e.target.value)} + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ + setWeight(Number(e.target.value))} + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ +