Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85294b108d | |||
| 4213c1f2e7 | |||
| 505904d8bd | |||
| 65686c8563 | |||
| 106d31a95e |
@@ -98,6 +98,15 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| TC-WEB-OOBE-4 | Admin panel accessible after setup | After completing OOBE, navigate to admin panel | Admin features accessible | 403 on admin panel, insufficient permissions |
|
||||
| TC-WEB-OOBE-5 | SSO login during OOBE does not interfere | During fresh OOBE, attempt SSO login before completing setup | SSO login redirected appropriately, setup can still complete | Auto-provision creates staff prematurely, setup flow broken |
|
||||
|
||||
### 5.4.3 Role-Based Redirect After SSO Login (GRO-1822)
|
||||
|
||||
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|
||||
|---|----------|-------|---------------|---------------|
|
||||
| TC-WEB-SSO-ROLE-1 | Customer SSO redirects to portal | Sign in via Authentik as a **customer** account, return to app root `/` | Customer portal is displayed at `/`; URL stays at `/` | Redirects to `/admin`, customer cannot access portal |
|
||||
| TC-WEB-SSO-ROLE-2 | Staff SSO redirects to admin | Sign in via Authentik as a **staff** (groomer/manager/receptionist) account, return to app root `/` | Browser redirects to `/admin` | URL stays at `/`, staff cannot reach admin panel |
|
||||
| TC-WEB-SSO-ROLE-3 | Impersonation bypasses role redirect | Append `?sessionId=<active-impersonation-id>` to any URL | Impersonation session activates; role redirect is skipped | Role redirect runs despite `?sessionId=`, impersonation blocked |
|
||||
| TC-WEB-SSO-ROLE-4 | Dev mode unaffected | Set `AUTH_DISABLED=true`, load app, select a dev user | Dev login selector works; role redirect logic does not interfere | Dev login broken or redirected incorrectly |
|
||||
|
||||
### 5.5 Dashboard
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
@@ -195,6 +204,17 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
|
||||
| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown |
|
||||
|
||||
#### 5.12c Waitlist/Booking Status Badges (GRO-1795)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.12 | Confirmed badge | View appointment card with confirmed status | Green "Confirmed" badge displayed |
|
||||
| TC-WEB-5.12.13 | Pending badge | View appointment card with pending status | Amber "Pending" badge displayed |
|
||||
| TC-WEB-5.12.14 | Waitlisted badge | View appointment card with waitlisted status | Blue "Waitlisted" badge displayed |
|
||||
| TC-WEB-5.12.15 | Badge uses CSS classes | Inspect badge element | Badge uses CSS variable-based classes (e.g., bg-green-100, text-amber-600), not hardcoded colors |
|
||||
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
|
||||
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
|
||||
|
||||
### 5.13 Reports UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|
||||
+3
-2
@@ -386,9 +386,10 @@ export function App() {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
// Redirect authenticated users to /admin (but preserve impersonation flow via ?sessionId=)
|
||||
// Redirect authenticated staff (non-customer) users to /admin (but preserve impersonation flow via ?sessionId=)
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId")) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Better Auth session.user extends Record<string,unknown>; role field is injected by Authentik OIDC
|
||||
if (!authDisabled && session && (session as any)?.user?.role !== "customer" && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId")) {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
|
||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection, StatusBadge } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
@@ -381,6 +381,66 @@ describe("ConfirmationSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it("renders Confirmed for confirmed status", () => {
|
||||
render(<StatusBadge status="confirmed" />);
|
||||
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Pending for pending status", () => {
|
||||
render(<StatusBadge status="pending" />);
|
||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Waitlisted for waitlisted status", () => {
|
||||
render(<StatusBadge status="waitlisted" />);
|
||||
expect(screen.getByText("Waitlisted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Completed for completed status", () => {
|
||||
render(<StatusBadge status="completed" />);
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Cancelled for cancelled status", () => {
|
||||
render(<StatusBadge status="cancelled" />);
|
||||
expect(screen.getByText("Cancelled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to status string for unknown status", () => {
|
||||
render(<StatusBadge status="custom-status" />);
|
||||
expect(screen.getByText("custom-status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses correct CSS class for confirmed status", () => {
|
||||
render(<StatusBadge status="confirmed" />);
|
||||
const badge = screen.getByText("Confirmed").closest('span');
|
||||
expect(badge?.className).toContain("bg-green-100");
|
||||
expect(badge?.className).toContain("text-green-700");
|
||||
});
|
||||
|
||||
it("uses correct CSS class for waitlisted status", () => {
|
||||
render(<StatusBadge status="waitlisted" />);
|
||||
const badge = screen.getByText("Waitlisted").closest('span');
|
||||
expect(badge?.className).toContain("bg-blue-100");
|
||||
expect(badge?.className).toContain("text-blue-600");
|
||||
});
|
||||
|
||||
it("uses correct CSS class for pending status", () => {
|
||||
render(<StatusBadge status="pending" />);
|
||||
const badge = screen.getByText("Pending").closest('span');
|
||||
expect(badge?.className).toContain("bg-amber-100");
|
||||
expect(badge?.className).toContain("text-amber-600");
|
||||
});
|
||||
|
||||
it("uses fallback styling for unknown status", () => {
|
||||
render(<StatusBadge status="unknown" />);
|
||||
const badge = screen.getByText("unknown").closest('span');
|
||||
expect(badge?.className).toContain("bg-stone-100");
|
||||
expect(badge?.className).toContain("text-stone-600");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RescheduleFlow dynamic time slots", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -82,14 +82,34 @@ export function isUpcoming(appt: Appointment): boolean {
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
confirmed: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
waitlisted: 'bg-blue-100 text-blue-700',
|
||||
pending: 'bg-amber-100 text-amber-600',
|
||||
waitlisted: 'bg-blue-100 text-blue-600',
|
||||
completed: 'bg-stone-100 text-stone-600',
|
||||
cancelled: 'bg-red-100 text-red-600',
|
||||
'no-show': 'bg-yellow-100 text-yellow-700',
|
||||
scheduled: 'bg-blue-100 text-blue-700',
|
||||
scheduled: 'bg-blue-100 text-blue-600',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
confirmed: 'Confirmed',
|
||||
pending: 'Pending',
|
||||
waitlisted: 'Waitlisted',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
'no-show': 'No-show',
|
||||
scheduled: 'Scheduled',
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const label = STATUS_LABELS[status] ?? status;
|
||||
const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600';
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
||||
confirmed: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
@@ -297,13 +317,7 @@ function AppointmentCard({
|
||||
<span>with {appt.groomerName || 'First Available'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_COLORS[appt.status] || ''
|
||||
}`}
|
||||
>
|
||||
{appt.status}
|
||||
</span>
|
||||
<StatusBadge status={appt.status} />
|
||||
{expanded ? (
|
||||
<ChevronDown size={16} className="text-stone-400" />
|
||||
) : (
|
||||
|
||||
@@ -39,6 +39,8 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||
navigateFallbackDenylist: [
|
||||
/^\/api\/auth\//,
|
||||
|
||||
Reference in New Issue
Block a user