diff --git a/.gitignore b/.gitignore
index 729a784..d73b7a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,14 @@ node_modules/
dist/
playwright-report/
test-results/
-*.log
\ No newline at end of file
+*.log
+# Agent runtime artifacts — never commit
+.gh-token
+*.gh-token
+**/.gh-token
+.config/gh/
+**/.config/gh/
+**/AGENT_HOME/**
+$AGENT_HOME/**
+.claude/
+.codex/
diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md
index 59d6de8..9bbef37 100644
--- a/UAT_PLAYBOOK.md
+++ b/UAT_PLAYBOOK.md
@@ -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 | 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-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-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 |
@@ -320,6 +320,15 @@ 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 |
diff --git a/src/App.tsx b/src/App.tsx
index d6a8057..bf98a37 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -45,6 +45,12 @@ 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;
}
};
diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx
index edddc4f..c0d898d 100644
--- a/src/__tests__/App.test.tsx
+++ b/src/__tests__/App.test.tsx
@@ -1,5 +1,6 @@
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";
@@ -232,6 +233,7 @@ 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) => {
@@ -269,3 +271,61 @@ 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(
+