fix(auth): resolve redirect loop and mount Better-Auth as sub-app (#144)

## Changes
- Replace toNodeHandler with auth.handler(c.req.raw) sub-app mount for Hono compatibility
- Add /api/auth/ path skip in authMiddleware and resolveStaffMiddleware
- Add OIDC_INTERNAL_BASE env var for split-horizon (hairpin NAT) URL resolution
- Replace render-time signIn.social() with LoginPage component (fixes redirect loop)
- Change auth-client baseURL to relative (empty string) for deployed environments
- Add POST /api/portal/appointments/:id/reschedule endpoint with session auth
- Add RescheduleFlow modal, PetForm component, and wire Dashboard/Appointments UI

## CTO Note
Auth fix is P0-critical. Portal mock data (UAT blocker) predates this PR and is tracked separately in GRO-218.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #144.
This commit is contained in:
groombook-engineer[bot]
2026-03-28 22:10:50 +00:00
committed by GitHub
parent 3a31ad71c2
commit 6872342d8f
13 changed files with 480 additions and 53 deletions
+18 -2
View File
@@ -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<Section>("dashboard");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [showReschedule, setShowReschedule] = useState(false);
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
const [session, setSession] = useState<ImpersonationSession | null>(null);
const [sessionExtended, setSessionExtended] = useState(false);
const { branding } = useBranding();
@@ -107,12 +109,17 @@ export function CustomerPortal() {
}
};
const handleReschedule = useCallback((appointment: Record<string, unknown>) => {
setRescheduleAppointment(appointment);
setShowReschedule(true);
}, []);
const isReadOnly = session?.status === "active";
const renderSection = () => {
switch (activeSection) {
case "dashboard":
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} />;
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} onReschedule={handleReschedule} />;
case "appointments":
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={session?.id ?? null} />;
case "pets":
@@ -158,6 +165,15 @@ export function CustomerPortal() {
/>
)}
{showReschedule && rescheduleAppointment && (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<RescheduleFlow
appointment={rescheduleAppointment as any}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={session?.id ?? null}
/>
)}
{/* Mobile Header */}
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-stone-200">
<button