fix(portal): redirect unauthenticated users to login — never show portal chrome (GRO-309) #191
@@ -2,7 +2,6 @@ import { test, expect } from "./fixtures.js";
|
||||
|
||||
/**
|
||||
* E2E tests for customer portal impersonation flow.
|
||||
* Tests ImpersonationBanner display, actions, and session management.
|
||||
*/
|
||||
|
||||
const MOCK_SESSION = {
|
||||
@@ -19,6 +18,7 @@ const MOCK_SESSION = {
|
||||
|
||||
test.describe("ImpersonationBanner", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Only mock impersonation endpoints - portal/me is NOT called in impersonation flow
|
||||
await page.route("**/api/impersonation/sessions/session-1", (route) =>
|
||||
route.fulfill({ json: MOCK_SESSION })
|
||||
);
|
||||
@@ -31,6 +31,8 @@ test.describe("ImpersonationBanner", () => {
|
||||
await page.route("**/api/impersonation/sessions/session-1/audit-log", (route) =>
|
||||
route.fulfill({ json: { logs: [] } })
|
||||
);
|
||||
// NOTE: NOT mocking portal/me - this endpoint is only called in the CLIENT
|
||||
// dev user flow (devUser.type === "client"), NOT in the impersonation flow
|
||||
});
|
||||
|
||||
test("banner displays when session is active", async ({ page }) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams, Navigate } from "react-router-dom";
|
||||
import {
|
||||
Home, Calendar, PawPrint, FileText, CreditCard, MessageSquare,
|
||||
Settings, LogOut, Shield,
|
||||
@@ -38,6 +38,10 @@ export function CustomerPortal() {
|
||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||
const [sessionExtended, setSessionExtended] = useState(false);
|
||||
const [clientName, setClientName] = useState<string>("");
|
||||
const [initComplete, setInitComplete] = useState(false);
|
||||
// Track whether an impersonation session fetch from URL param is in-flight
|
||||
// Dashboard will not redirect while this is true, allowing the session to load
|
||||
const [isImpersonating, setIsImpersonating] = useState(false);
|
||||
const { branding } = useBranding();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -50,6 +54,7 @@ export function CustomerPortal() {
|
||||
const sessionId = searchParams.get("sessionId");
|
||||
|
||||
if (sessionId) {
|
||||
setIsImpersonating(true);
|
||||
// Real impersonation session from URL param
|
||||
fetch(`/api/impersonation/sessions/${sessionId}`)
|
||||
.then((r) => {
|
||||
@@ -68,7 +73,8 @@ export function CustomerPortal() {
|
||||
})
|
||||
.catch(() => {
|
||||
setSearchParams({}, { replace: true });
|
||||
});
|
||||
})
|
||||
.finally(() => { setInitComplete(true); setIsImpersonating(false); });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,7 +96,10 @@ export function CustomerPortal() {
|
||||
setClientName(devUser.name);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.finally(() => setInitComplete(true));
|
||||
} else {
|
||||
// No valid session: staff dev users and unauthenticated users fall through here
|
||||
setInitComplete(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -150,7 +159,7 @@ export function CustomerPortal() {
|
||||
const sessionId = session?.id ?? null;
|
||||
switch (activeSection) {
|
||||
case "dashboard":
|
||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} />;
|
||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} isImpersonating={isImpersonating} />;
|
||||
case "appointments":
|
||||
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||
case "pets":
|
||||
@@ -168,6 +177,16 @@ export function CustomerPortal() {
|
||||
|
||||
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
|
||||
|
||||
// After init completes, redirect unauthenticated users to /login and staff to /admin.
|
||||
// The portal chrome must NEVER be visible to users without a valid client session.
|
||||
if (initComplete && !session) {
|
||||
const devUser = getDevUser();
|
||||
if (devUser && devUser.type === "staff") {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-[#faf8f5] font-sans"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -7,6 +8,8 @@ interface DashboardProps {
|
||||
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
|
||||
readOnly: boolean;
|
||||
onReschedule: (appointmentId: string) => void;
|
||||
/** True when a sessionId param was in the URL and the session is still loading */
|
||||
isImpersonating?: boolean;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
@@ -72,6 +75,7 @@ export function Dashboard({
|
||||
onNavigate,
|
||||
readOnly,
|
||||
onReschedule,
|
||||
isImpersonating,
|
||||
}: DashboardProps) {
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
@@ -182,14 +186,8 @@ export function Dashboard({
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-stone-100 rounded-2xl p-5 text-center">
|
||||
<p className="text-stone-600">Please sign in to view your dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!sessionId && !isImpersonating) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const upcomingAppointments = getUpcomingAppointments();
|
||||
|
||||
+21
-15
@@ -18,7 +18,7 @@
|
||||
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
// ── Deterministic PRNG (Mulberry32) ──────────────────────────────────────────
|
||||
@@ -234,18 +234,18 @@ const productsUsed = [
|
||||
];
|
||||
|
||||
// ── Service definitions ──────────────────────────────────────────────────────
|
||||
|
||||
// Deterministic service IDs so seed is idempotent (ON CONFLICT targets id, not name).
|
||||
const servicesDef = [
|
||||
{ name: "Bath & Brush", desc: "Full bath, blow-dry, brush out, and ear cleaning", price: 4500, dur: 45 },
|
||||
{ name: "Full Groom — Small", desc: "Complete grooming for dogs under 25 lbs", price: 6500, dur: 60 },
|
||||
{ name: "Full Groom — Medium", desc: "Complete grooming for dogs 25-50 lbs", price: 8000, dur: 75 },
|
||||
{ name: "Full Groom — Large", desc: "Complete grooming for dogs over 50 lbs", price: 9500, dur: 90 },
|
||||
{ name: "Nail Trim", desc: "Nail clipping and filing", price: 1500, dur: 15 },
|
||||
{ name: "Teeth Brushing", desc: "Dental cleaning with enzymatic toothpaste", price: 1000, dur: 10 },
|
||||
{ name: "De-shedding Treatment", desc: "Specialised de-shedding bath and blowout", price: 5500, dur: 60 },
|
||||
{ name: "Puppy First Groom", desc: "Gentle introduction to grooming for puppies under 6 months", price: 4000, dur: 30 },
|
||||
{ name: "Flea & Tick Treatment", desc: "Medicated bath with flea and tick shampoo", price: 5000, dur: 45 },
|
||||
{ name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000001", name: "Bath & Brush", desc: "Full bath, blow-dry, brush out, and ear cleaning", price: 4500, dur: 45 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000002", name: "Full Groom — Small", desc: "Complete grooming for dogs under 25 lbs", price: 6500, dur: 60 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000003", name: "Full Groom — Medium", desc: "Complete grooming for dogs 25-50 lbs", price: 8000, dur: 75 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000004", name: "Full Groom — Large", desc: "Complete grooming for dogs over 50 lbs", price: 9500, dur: 90 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000005", name: "Nail Trim", desc: "Nail clipping and filing", price: 1500, dur: 15 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000006", name: "Teeth Brushing", desc: "Dental cleaning with enzymatic toothpaste", price: 1000, dur: 10 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000007", name: "De-shedding Treatment", desc: "Specialised de-shedding bath and blowout", price: 5500, dur: 60 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000008", name: "Puppy First Groom", desc: "Gentle introduction to grooming for puppies under 6 months", price: 4000, dur: 30 },
|
||||
{ id: "b0000001-0000-0000-0000-000000000009", name: "Flea & Tick Treatment", desc: "Medicated bath with flea and tick shampoo", price: 5000, dur: 45 },
|
||||
{ id: "b0000001-0000-0000-0000-00000000000a", name: "Sanitary Trim", desc: "Hygienic trim of paw pads, face, and sanitary areas", price: 2500, dur: 20 },
|
||||
];
|
||||
|
||||
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
|
||||
@@ -424,13 +424,19 @@ async function seed() {
|
||||
console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`);
|
||||
|
||||
// ── Services ──
|
||||
// Deduplicate existing services (keep lowest id per name) before inserting.
|
||||
await db.execute(sql`
|
||||
DELETE FROM services WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM services GROUP BY name
|
||||
)
|
||||
`);
|
||||
|
||||
const serviceIds: string[] = [];
|
||||
for (const s of servicesDef) {
|
||||
const id = uuid();
|
||||
serviceIds.push(id);
|
||||
serviceIds.push(s.id);
|
||||
await db.insert(schema.services)
|
||||
.values({
|
||||
id,
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.desc,
|
||||
basePriceCents: s.price,
|
||||
|
||||
Reference in New Issue
Block a user