Merge branch 'main' into fix/gro-301-duplicate-services
This commit is contained in:
@@ -314,7 +314,7 @@ jobs:
|
|||||||
if [ -z "$TAG" ]; then
|
if [ -z "$TAG" ]; then
|
||||||
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
TAG="$(date -u +%Y.%m.%d)-${GITHUB_SHA::7}"
|
||||||
fi
|
fi
|
||||||
SHORT_SHA="${SHA::7}"
|
export SHORT_SHA="${SHA::7}"
|
||||||
echo "Updating dev overlay image tags to: $TAG"
|
echo "Updating dev overlay image tags to: $TAG"
|
||||||
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
echo "Updating migration/seed Job names with SHA: $SHORT_SHA"
|
||||||
cd /tmp/infra
|
cd /tmp/infra
|
||||||
@@ -330,7 +330,7 @@ jobs:
|
|||||||
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||||
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
||||||
yq -i '.spec.ttlSecondsAfterFinished //= 86400' "$MIGRATE_JOB"
|
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$MIGRATE_JOB"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Update seed Job name to include short SHA (immutable template fix)
|
# Update seed Job name to include short SHA (immutable template fix)
|
||||||
@@ -339,7 +339,7 @@ jobs:
|
|||||||
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||||
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||||
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
# Ensure ttlSecondsAfterFinished is set for automatic cleanup
|
||||||
yq -i '.spec.ttlSecondsAfterFinished //= 86400' "$SEED_JOB"
|
yq -i '.spec.ttlSecondsAfterFinished = (.spec.ttlSecondsAfterFinished // 86400)' "$SEED_JOB"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git -C /tmp/infra diff --stat
|
git -C /tmp/infra diff --stat
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { test, expect } from "./fixtures.js";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E tests for customer portal impersonation flow.
|
* E2E tests for customer portal impersonation flow.
|
||||||
* Tests ImpersonationBanner display, actions, and session management.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const MOCK_SESSION = {
|
const MOCK_SESSION = {
|
||||||
@@ -19,6 +18,7 @@ const MOCK_SESSION = {
|
|||||||
|
|
||||||
test.describe("ImpersonationBanner", () => {
|
test.describe("ImpersonationBanner", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
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) =>
|
await page.route("**/api/impersonation/sessions/session-1", (route) =>
|
||||||
route.fulfill({ json: MOCK_SESSION })
|
route.fulfill({ json: MOCK_SESSION })
|
||||||
);
|
);
|
||||||
@@ -31,6 +31,8 @@ test.describe("ImpersonationBanner", () => {
|
|||||||
await page.route("**/api/impersonation/sessions/session-1/audit-log", (route) =>
|
await page.route("**/api/impersonation/sessions/session-1/audit-log", (route) =>
|
||||||
route.fulfill({ json: { logs: [] } })
|
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 }) => {
|
test("banner displays when session is active", async ({ page }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams, Navigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Home, Calendar, PawPrint, FileText, CreditCard, MessageSquare,
|
Home, Calendar, PawPrint, FileText, CreditCard, MessageSquare,
|
||||||
Settings, LogOut, Shield,
|
Settings, LogOut, Shield,
|
||||||
@@ -38,6 +38,10 @@ export function CustomerPortal() {
|
|||||||
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
||||||
const [sessionExtended, setSessionExtended] = useState(false);
|
const [sessionExtended, setSessionExtended] = useState(false);
|
||||||
const [clientName, setClientName] = useState<string>("");
|
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 { branding } = useBranding();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -50,6 +54,7 @@ export function CustomerPortal() {
|
|||||||
const sessionId = searchParams.get("sessionId");
|
const sessionId = searchParams.get("sessionId");
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
|
setIsImpersonating(true);
|
||||||
// Real impersonation session from URL param
|
// Real impersonation session from URL param
|
||||||
fetch(`/api/impersonation/sessions/${sessionId}`)
|
fetch(`/api/impersonation/sessions/${sessionId}`)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
@@ -68,7 +73,8 @@ export function CustomerPortal() {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
});
|
})
|
||||||
|
.finally(() => { setInitComplete(true); setIsImpersonating(false); });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +96,10 @@ export function CustomerPortal() {
|
|||||||
setClientName(devUser.name);
|
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;
|
const sessionId = session?.id ?? null;
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
case "dashboard":
|
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":
|
case "appointments":
|
||||||
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={sessionId} />;
|
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={sessionId} />;
|
||||||
case "pets":
|
case "pets":
|
||||||
@@ -168,6 +177,24 @@ export function CustomerPortal() {
|
|||||||
|
|
||||||
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
|
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.
|
||||||
|
// For client dev users, we stay on the portal even if session is null — the dev-session
|
||||||
|
// response may not have id set immediately, or there may be timing issues with the
|
||||||
|
// session state. Dev users are verified via localStorage and the dev-session flow.
|
||||||
|
if (initComplete && !session) {
|
||||||
|
const devUser = getDevUser();
|
||||||
|
if (devUser && devUser.type === "staff") {
|
||||||
|
return <Navigate to="/admin" replace />;
|
||||||
|
}
|
||||||
|
if (devUser && devUser.type === "client") {
|
||||||
|
// Don't redirect — dev session creation may have failed or session.id may be null
|
||||||
|
// The portal should still render for client dev users
|
||||||
|
} else {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="min-h-screen bg-[#faf8f5] font-sans"
|
className="min-h-screen bg-[#faf8f5] font-sans"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
|
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
|
||||||
|
import { getDevUser } from "../../pages/DevLoginSelector";
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -7,6 +9,8 @@ interface DashboardProps {
|
|||||||
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
|
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
onReschedule: (appointmentId: string) => void;
|
onReschedule: (appointmentId: string) => void;
|
||||||
|
/** True when a sessionId param was in the URL and the session is still loading */
|
||||||
|
isImpersonating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Appointment {
|
interface Appointment {
|
||||||
@@ -72,6 +76,7 @@ export function Dashboard({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
readOnly,
|
readOnly,
|
||||||
onReschedule,
|
onReschedule,
|
||||||
|
isImpersonating,
|
||||||
}: DashboardProps) {
|
}: DashboardProps) {
|
||||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
const [pets, setPets] = useState<Pet[]>([]);
|
const [pets, setPets] = useState<Pet[]>([]);
|
||||||
@@ -182,14 +187,12 @@ export function Dashboard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionId) {
|
// Don't redirect to /login if we have a dev user — dev sessions may not have
|
||||||
return (
|
// sessionId set immediately after creation (session?.id may be null due to
|
||||||
<div className="space-y-6">
|
// timing or API response issues). Dev users are stored in localStorage and
|
||||||
<div className="bg-stone-100 rounded-2xl p-5 text-center">
|
// verified via the dev-session flow, so they should see the portal.
|
||||||
<p className="text-stone-600">Please sign in to view your dashboard.</p>
|
if (!sessionId && !isImpersonating && !getDevUser()) {
|
||||||
</div>
|
return <Navigate to="/login" replace />;
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const upcomingAppointments = getUpcomingAppointments();
|
const upcomingAppointments = getUpcomingAppointments();
|
||||||
|
|||||||
Reference in New Issue
Block a user