Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7f2e2e6b3 | |||
| a12bf019fa | |||
| bfe3ccf3b2 | |||
| b52b8e10ad | |||
| f58a0e569b | |||
| e93017b279 | |||
| db11e5f2bd | |||
| 980615b8e6 | |||
| f549101962 | |||
| 62dc85b560 | |||
| bc21d6de09 | |||
| 32ef3bca4d | |||
| 47c29ecbc2 | |||
| de7386e47a | |||
| ec29f71974 | |||
| bd2a0d9516 | |||
| 0e5e9d1f16 | |||
| 3b4d0f15f6 | |||
| 87939e5413 | |||
| 4e3a038bf3 | |||
| 8349ea00de | |||
| 0306c7fbd9 | |||
| 93da2f1dd8 | |||
| 62cbfe4e43 | |||
| db6a2a1bbf | |||
| 032a3796ba | |||
| cac8fc947e | |||
| 592be1301c |
+1
-11
@@ -5,14 +5,4 @@ node_modules/
|
||||
dist/
|
||||
playwright-report/
|
||||
test-results/
|
||||
*.log
|
||||
# Agent runtime artifacts — never commit
|
||||
.gh-token
|
||||
*.gh-token
|
||||
**/.gh-token
|
||||
.config/gh/
|
||||
**/.config/gh/
|
||||
**/AGENT_HOME/**
|
||||
$AGENT_HOME/**
|
||||
.claude/
|
||||
.codex/
|
||||
*.log
|
||||
+4
-20
@@ -86,7 +86,7 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| # | Scenario | Steps | Pass Criteria | Fail Criteria |
|
||||
|---|----------|-------|---------------|---------------|
|
||||
| TC-WEB-SSO-1 | Sign-in page shows SSO button | Navigate to app root URL | Sign-in page displayed with "Sign in with SSO" button visible | No SSO button, 403 before page loads |
|
||||
| TC-WEB-SSO-2 | Click SSO redirects to Authentik (GRO-2572) | **Fresh session only (no pre-existing auth cookie).** Click "Sign in with SSO" button | Browser navigates to Authentik login at auth.farh.net within ~1 s — address bar changes to auth.farh.net URL | No redirect, error shown, button stays disabled, user remains on /login. Regression: prior to GRO-2572 fix the client never followed the `data.url` returned by Better Auth. Run from a clean incognito context to avoid a stale cookie masking the defect. |
|
||||
| TC-WEB-SSO-2 | Click SSO redirects to Authentik | Click "Sign in with SSO" button | Browser redirected to Authentik login at auth.farh.net | No redirect, error shown, button does nothing |
|
||||
| TC-WEB-SSO-3 | Valid OIDC credentials authenticate | At Authentik, enter valid credentials and authenticate | Redirected back to app with active session | Redirect loop, 403, session not established |
|
||||
| TC-WEB-SSO-4 | Post-login dashboard accessible | After SSO flow completes, dashboard loads | Dashboard displays correctly with user identity shown | Blank page, 403, session not active |
|
||||
| TC-WEB-SSO-5 | User identity displayed correctly | After SSO login, check header/nav | User name/email/initials shown in nav, role reflected in UI | No user indicator, wrong user shown |
|
||||
@@ -291,18 +291,12 @@ the seeded UAT customer (`uat-customer@groombook.dev`), not just unit-rendered.
|
||||
| TC-WEB-5.13.1 | Revenue charts | Navigate to Reports | Revenue charts display with data |
|
||||
| TC-WEB-5.13.2 | Utilization graphs | View reports | Staff/resource utilization graphs visible |
|
||||
|
||||
### 5.14 Settings UI (manager / super-user only — GRO-2513)
|
||||
### 5.14 Settings UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.14.1 | Manager sees Settings tab | Sign in as `uat-manager`, go to `/admin` | **Settings** link is visible in the admin nav bar |
|
||||
| TC-WEB-5.14.2 | Manager loads Settings page (200, no 403) | Click **Settings** in the nav | Page loads with Branding & Appearance form; DevTools → Network shows `GET /api/admin/settings` → **200**. Zero 403 responses anywhere in the Network tab. |
|
||||
| TC-WEB-5.14.3 | Manager can save branding | Modify Business Name, click Save | `PATCH /api/admin/settings` → 200; success message shown |
|
||||
| TC-WEB-5.14.4 | Super-user sees auth-provider section | Sign in as a super-user, navigate to Settings | Auth provider config section is visible below Branding |
|
||||
| TC-WEB-5.14.5 | Groomer does NOT see Settings tab | Sign in as `uat-groomer`, go to `/admin` | **Settings** link is **absent** from the nav bar. Network panel shows zero requests to `/api/admin/settings`. |
|
||||
| TC-WEB-5.14.6 | Groomer navigating directly to `/admin/settings` is redirected | While signed in as `uat-groomer`, navigate to `https://uat.groombook.dev/admin/settings` | Browser redirects to `/admin` (Appointments page). No 403 error in Network tab, no error UI. |
|
||||
| TC-WEB-5.14.7 | Receptionist does NOT see Settings tab | Sign in as `uat-receptionist` (if seeded), go to `/admin` | **Settings** link is **absent** from the nav bar. Network panel shows zero requests to `/api/admin/settings`. |
|
||||
| TC-WEB-5.14.8 | Shared staff endpoints still work for groomer | Sign in as `uat-groomer` and navigate through Appointments, Clients, Staff pages | All return 200. No 403 on any shared endpoint. |
|
||||
| TC-WEB-5.14.1 | Configuration page | Navigate to Settings | Settings page loads without errors |
|
||||
| TC-WEB-5.14.2 | Form interactions | Modify settings, save | Settings saved successfully, changes reflected |
|
||||
|
||||
### 5.15 Navigation
|
||||
|
||||
@@ -320,15 +314,6 @@ the seeded UAT customer (`uat-customer@groombook.dev`), not just unit-rendered.
|
||||
| TC-WEB-5.16.2 | PWA install prompt | Load app on supported browser | Install prompt appears when criteria met |
|
||||
| TC-WEB-5.16.3 | Touch interactions | Use touch gestures on mobile | All interactions work with touch input |
|
||||
|
||||
#### 5.16a Portal Tab Rows — Mobile Overflow (GRO-730 / GRO-1026)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.16.4 | My Pets tab row — horizontal scroll, no visible scrollbar | Sign in as customer → My Pets. Set viewport to 390px. If 3+ pets are seeded, the pet-selector row overflows. | Pet selector row scrolls horizontally; native scrollbar is **not** visible (`scrollbar-width: none` / `scrollbar-hide` applied). |
|
||||
| TC-WEB-5.16.5 | My Pets section tab row — no visible scrollbar | On the same My Pets view, observe the tabs row (Basic Info / Medical / Grooming / History). | Tabs row scrolls horizontally when needed; native scrollbar is not visible. |
|
||||
| TC-WEB-5.16.6 | Billing/Payments tab row — no wrap, no visible scrollbar | Sign in as customer → Billing/Payments at 390px. | Tab row (Invoices / Payment Methods / Packages) does **not** wrap to a second line; scrolls horizontally if needed; native scrollbar not visible. |
|
||||
| TC-WEB-5.16.7 | Desktop — no visual regression | Open My Pets and Billing/Payments at ≥1024px. | No layout change; tab rows display identically to before the fix. |
|
||||
|
||||
### 5.17 Error & Empty States
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
@@ -467,7 +452,6 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe
|
||||
| TC-WEB-5.25.6c | OOBE uses the shared signOut() handler (GRO-2358, GRO-2359) | From TC-WEB-5.25.5, click **Sign out** in the OOBE footer. | The same shared `signOut()` from `lib/auth-client` fires (same handler as `AdminLayout` and the no-access card); browser navigates to `/login`; the Authentik session cookie is cleared. The handler always navigates to `/login` — even if the network call to `/api/auth/sign-out` fails — so a transient auth-server hiccup never leaves the user trapped on an authenticated screen. |
|
||||
| TC-WEB-5.25.6d | OOBE is mountable from a direct deep-link (GRO-2359) | 1. Sign in via SSO as any customer. 2. In a new tab, navigate to `https://uat.groombook.dev/onboarding`. | The OOBE form mounts (the App.tsx `/onboarding` route resolves before the CustomerPortal `!sessionId` guards). The submit, signOut, and field-validation behaviour are identical to the post-auth mount. |
|
||||
| TC-WEB-5.25.6e | Deleted-portal deep-link still reaches the no-access card (GRO-2358, GRO-2359) | 1. Sign in via SSO as a customer whose `clients` row was disabled/deleted by the groomer. 2. Land on a portal sub-route with `?noAccess=deleted-portal` (e.g. visit `https://uat.groombook.dev/appointments?noAccess=deleted-portal` directly). | The no-access card renders (the deep-link deleted-portal case — the OOBE is reserved for first-time creation). The shared signOut() from GRO-2358 is wired identically. This proves the no-access card is still reachable for non-new-user failure modes and the CMPO "no-trap" invariant holds across the auth boundary. |
|
||||
| TC-WEB-5.25.6f | In-portal chrome sidebar exposes a Sign out button (GRO-2373) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the portal chrome, look at the sidebar footer (the section below the navigation links, where "Customer Portal v1.0" sits). 3. Locate the **Sign out** button (a stone-grey button above the version label, with a LogOut icon). 4. Click it. | A **Sign out** button is present in the sidebar footer (not buried in the Settings page, not hidden in a dropdown — it's visible on every portal sub-route, including Home, Appointments, My Pets, Report Cards, Billing, Messages, Settings). Clicking it fires the same shared `signOut()` from `lib/auth-client` (same handler as the OOBE footer, the no-access card, and `AdminLayout`'s top-bar "Logout"); `POST /api/auth/sign-out` → 200 `{"success":true}`; the browser navigates to `/login`; the Better Auth / Authentik session cookie is cleared. Proves the CMPO "no-trap" invariant (originally established in GRO-2355) holds on the third authenticated surface — the in-portal chrome — which the GRO-2358 P1 fix did not cover. |
|
||||
| TC-WEB-5.25.7 | Bridge precedence — impersonation URL wins | 1. Sign in via SSO as a customer. 2. Open a new tab to `https://uat.groombook.dev/?sessionId=<a-valid-staff-impersonation-session-id>`. | The impersonation path runs; the amber banner appears for the impersonated client. The Better Auth bridge is **not** called on this load (`session-from-auth` absent in Network). |
|
||||
| TC-WEB-5.25.8 | Bridge precedence — dev user wins | In dev mode (e.g. local) with `localStorage["dev-user"]` set to a client persona, navigate to `/`. | The dev-session path runs (`POST /api/portal/dev-session`). The Better Auth bridge is **not** called (`session-from-auth` absent in Network). Staff dev users still redirect to `/admin`. |
|
||||
| TC-WEB-5.25.9 | Staff Better Auth session does not run the customer bridge | Sign in via SSO with a staff identity. Navigate to `/`. | `App.tsx` routing redirects to `/admin`. `POST /api/portal/session-from-auth` is **not** called. |
|
||||
|
||||
+2
-25
@@ -45,12 +45,6 @@ function LoginPage() {
|
||||
if (result?.error) {
|
||||
setError(result.error.message ?? "Sign-in failed");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Better Auth returns the IdP authorize URL in data.url with redirect:true rather than
|
||||
// issuing an HTTP 30x — the client must follow it (GRO-2572).
|
||||
if (result?.data?.url) {
|
||||
window.location.href = result.data.url;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -193,17 +187,6 @@ function AdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { branding } = useBranding();
|
||||
const [staffUser, setStaffUser] = useState<{ role: string; isSuperUser: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/staff/me")
|
||||
.then((r) => r.json())
|
||||
.then((u) => setStaffUser({ role: u.role, isSuperUser: !!u.isSuperUser }))
|
||||
.catch(() => setStaffUser({ role: "", isSuperUser: false }));
|
||||
}, []);
|
||||
|
||||
const canSettings = staffUser !== null && (staffUser.role === "manager" || staffUser.isSuperUser);
|
||||
const visibleNavLinks = NAV_LINKS.filter(({ to }) => to !== "/admin/settings" || canSettings);
|
||||
|
||||
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
||||
? `data:${branding.logoMimeType};base64,${branding.logoBase64}`
|
||||
@@ -268,7 +251,7 @@ function AdminLayout() {
|
||||
>
|
||||
Book
|
||||
</Link>
|
||||
{visibleNavLinks.map(({ to, label }) => {
|
||||
{NAV_LINKS.map(({ to, label }) => {
|
||||
const active =
|
||||
to === "/admin"
|
||||
? location.pathname === "/admin"
|
||||
@@ -325,13 +308,7 @@ function AdminLayout() {
|
||||
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/settings" element={
|
||||
staffUser === null
|
||||
? null
|
||||
: canSettings
|
||||
? <SettingsPage />
|
||||
: <Navigate to="/admin" replace />
|
||||
} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { App } from "../App";
|
||||
|
||||
@@ -233,7 +232,6 @@ describe("Dev login selector", () => {
|
||||
});
|
||||
|
||||
it("does not redirect when a dev user is already selected", async () => {
|
||||
|
||||
localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" }));
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
@@ -271,61 +269,3 @@ describe("Dev login selector", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GRO-2572 — SSO button follows redirect URL", () => {
|
||||
it("navigates to data.url when signIn.social returns a redirect", async () => {
|
||||
// Mock signIn.social to return the redirect payload Better Auth sends
|
||||
vi.mock("../lib/auth-client.js", () => ({
|
||||
useSession: () => ({ data: null, isPending: false }),
|
||||
signIn: {
|
||||
social: vi.fn().mockResolvedValue({
|
||||
data: { redirect: true, url: "https://auth.farh.net/application/o/authorize/?test=1" },
|
||||
error: null,
|
||||
}),
|
||||
},
|
||||
signOut: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
}));
|
||||
|
||||
const assignMock = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { ...window.location, href: "", origin: "https://uat.groombook.dev" },
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window.location, "href", {
|
||||
set: assignMock,
|
||||
get: () => "",
|
||||
});
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ authDisabled: false }) } as Response);
|
||||
}
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({ ok: true, json: async () => null } as unknown as Response);
|
||||
}
|
||||
if (url === "/api/setup/status") {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ needsSetup: false }) } as Response);
|
||||
}
|
||||
if (url === "/api/auth/providers") {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ providers: ["authentik"] }) } as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const ssoButton = await screen.findByRole("button", { name: /sign in with sso/i });
|
||||
await userEvent.click(ssoButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(assignMock).toHaveBeenCalledWith(
|
||||
"https://auth.farh.net/application/o/authorize/?test=1"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1057,75 +1057,4 @@ describe("OOBE portal-creation flow (GRO-2359)", () => {
|
||||
|
||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||
});
|
||||
|
||||
it("reaches the shared signOut() handler from the in-portal chrome sidebar (GRO-2373)", async () => {
|
||||
// Pre-GRO-2373, the customer portal chrome (Home, Appointments, My Pets,
|
||||
// Report Cards, Billing, Messages, Settings) had no visible sign-out
|
||||
// control — only the OOBE and the no-access card exposed one. This
|
||||
// leaves users signed-in with no escape hatch. The fix lands a
|
||||
// "Sign out" button in the sidebar footer that wires to the same
|
||||
// canonical `signOut()` already used by OOBE / no-access / AdminLayout.
|
||||
signOutSpy.mockClear();
|
||||
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: { email: "uat-customer@groombook.dev", role: "customer" } }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ sessionId: "chrome-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Land on the chrome (proof: customer greeting is rendered, no
|
||||
// no-access card, no OOBE).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/set up your portal/i)).not.toBeInTheDocument();
|
||||
|
||||
// The new chrome sign-out is scoped by data-testid so it doesn't
|
||||
// collide with other surfaces that may also render "Sign out" labels
|
||||
// (e.g. the impersonation banner uses "End Session").
|
||||
const signOutButton = screen.getByTestId("portal-chrome-signout");
|
||||
expect(signOutButton).toHaveTextContent(/Sign out/i);
|
||||
fireEvent.click(signOutButton);
|
||||
|
||||
// Same canonical handler as OOBE / no-access / AdminLayout — never
|
||||
// a raw fetch("/api/auth/sign-out") and never a navigate() without
|
||||
// signOut() (the OOBE/no-access surface uses window.location.href
|
||||
// for a hard reload so cached state is reset).
|
||||
await waitFor(() => {
|
||||
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/login");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,15 +78,6 @@ input:focus, select:focus, textarea:focus {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ─── Scrollbar hide utility ─── */
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar polish ─── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
+42
-57
@@ -86,66 +86,51 @@ export function SettingsPage() {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load user role first, then gate settings/auth-provider fetches on role
|
||||
useEffect(() => {
|
||||
fetch("/api/staff/me")
|
||||
fetch("/api/admin/settings")
|
||||
.then((r) => r.json())
|
||||
.then((u) => {
|
||||
const user = u as CurrentUser;
|
||||
setCurrentUser(user);
|
||||
const isManager = user.role === "manager" || user.isSuperUser;
|
||||
|
||||
if (isManager) {
|
||||
fetch("/api/admin/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setForm({
|
||||
businessName: data.businessName ?? "GroomBook",
|
||||
primaryColor: data.primaryColor ?? "#4f8a6f",
|
||||
accentColor: data.accentColor ?? "#8b7355",
|
||||
logoKey: data.logoKey ?? null,
|
||||
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
|
||||
logoBase64: data.logoBase64 ?? null,
|
||||
logoMimeType: data.logoMimeType ?? null,
|
||||
});
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
} else {
|
||||
setLoaded(true);
|
||||
}
|
||||
|
||||
if (user.isSuperUser) {
|
||||
fetch("/api/admin/auth-provider")
|
||||
.then(async (r) => {
|
||||
if (r.ok) return r.json();
|
||||
if (r.status === 404) return null;
|
||||
throw new Error(`HTTP ${r.status}`);
|
||||
})
|
||||
.then((auth) => {
|
||||
if (auth) {
|
||||
setAuthConfig(auth as AuthProviderConfig);
|
||||
setAuthForm({
|
||||
providerId: (auth as AuthProviderConfig).providerId,
|
||||
displayName: (auth as AuthProviderConfig).displayName,
|
||||
issuerUrl: (auth as AuthProviderConfig).issuerUrl,
|
||||
internalBaseUrl: (auth as AuthProviderConfig).internalBaseUrl ?? "",
|
||||
clientId: (auth as AuthProviderConfig).clientId,
|
||||
clientSecret: (auth as AuthProviderConfig).clientSecret,
|
||||
scopes: (auth as AuthProviderConfig).scopes,
|
||||
});
|
||||
}
|
||||
setAuthLoaded(true);
|
||||
})
|
||||
.catch(() => setAuthLoaded(true));
|
||||
} else {
|
||||
setAuthLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.then(async (data) => {
|
||||
// The logo is now proxied through the API server so the browser
|
||||
// never receives an S3 URL — use the proxy path directly as the src.
|
||||
setForm({
|
||||
businessName: data.businessName ?? "GroomBook",
|
||||
primaryColor: data.primaryColor ?? "#4f8a6f",
|
||||
accentColor: data.accentColor ?? "#8b7355",
|
||||
logoKey: data.logoKey ?? null,
|
||||
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
|
||||
logoBase64: data.logoBase64 ?? null,
|
||||
logoMimeType: data.logoMimeType ?? null,
|
||||
});
|
||||
setLoaded(true);
|
||||
setAuthLoaded(true);
|
||||
});
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
// Load current user (for isSuperUser check) and auth provider config
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch("/api/staff/me").then((r) => r.json()).catch(() => null),
|
||||
fetch("/api/admin/auth-provider").then(async (r) => {
|
||||
if (r.ok) return r.json();
|
||||
if (r.status === 404) return null;
|
||||
throw new Error(`HTTP ${r.status}`);
|
||||
}).catch(() => null),
|
||||
]).then(([user, auth]) => {
|
||||
setCurrentUser(user as CurrentUser | null);
|
||||
if (auth) {
|
||||
setAuthConfig(auth as AuthProviderConfig);
|
||||
setAuthForm({
|
||||
providerId: (auth as AuthProviderConfig).providerId,
|
||||
displayName: (auth as AuthProviderConfig).displayName,
|
||||
issuerUrl: (auth as AuthProviderConfig).issuerUrl,
|
||||
internalBaseUrl: (auth as AuthProviderConfig).internalBaseUrl ?? "",
|
||||
clientId: (auth as AuthProviderConfig).clientId,
|
||||
clientSecret: (auth as AuthProviderConfig).clientSecret,
|
||||
scopes: (auth as AuthProviderConfig).scopes,
|
||||
});
|
||||
}
|
||||
setAuthLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -451,14 +451,7 @@ export function CustomerPortal() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Session controls — Sign out is always reachable from the portal
|
||||
chrome (GRO-2373). End Impersonation is staff-only and only
|
||||
appears during an active impersonation session. Both share the
|
||||
same LogOut icon for visual consistency, but route to distinct
|
||||
handlers: handleSignOut calls the canonical Better Auth
|
||||
`signOut()` (mirroring OOBE and the no-access card); handleEnd
|
||||
tears down the staff impersonation session and returns to the
|
||||
admin clients list. */}
|
||||
{/* Session controls (only shown during active impersonation) */}
|
||||
<div className="border-t border-stone-100 p-4 space-y-2">
|
||||
{session?.status === "active" && (
|
||||
<button
|
||||
@@ -469,15 +462,6 @@ export function CustomerPortal() {
|
||||
End Impersonation
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleSignOut(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-stone-700 bg-stone-50 hover:bg-stone-100 transition-colors"
|
||||
data-testid="portal-chrome-signout"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sign out
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs text-stone-400">
|
||||
<Shield size={12} />
|
||||
Customer Portal v1.0
|
||||
|
||||
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{([
|
||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||
|
||||
@@ -145,7 +145,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Pet Selector */}
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||
{pets.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
@@ -191,7 +191,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto scrollbar-hide">
|
||||
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
|
||||
{([
|
||||
{ id: "info", label: "Basic Info", icon: PawPrint },
|
||||
{ id: "medical", label: "Medical", icon: Heart },
|
||||
|
||||
Reference in New Issue
Block a user