Compare commits

...

5 Commits

Author SHA1 Message Date
Flea Flicker cff37d400d [GRO-2572] fix: SSO button follows Better Auth redirect URL (#91)
CI / Test (push) Successful in 30s
CI / Lint & Typecheck (push) Successful in 35s
CI / Build & Push Docker Image (push) Successful in 13s
CI / Test (pull_request) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 35s
CI / Build & Push Docker Image (pull_request) Successful in 11s
2026-06-26 12:07:28 +00:00
Flea Flicker 8d005942df fix(GRO-1026): re-apply GRO-730 scrollbar-hide to portal tab rows (#88)
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 16s
fix(GRO-1026): re-apply GRO-730 scrollbar-hide to portal tab rows

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-26 08:57:46 +00:00
Flea Flicker b49978710b Merge pull request 'feat(GRO-2516): add agent-runtime credential stanza to .gitignore' (#84) from feature/gro-2516-harden-gitignore into dev
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 35s
CI / Test (pull_request) Successful in 21s
CI / Build & Push Docker Image (push) Successful in 16s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / Build & Push Docker Image (pull_request) Successful in 15s
feat(GRO-2516): add agent-runtime credential stanza to .gitignore
2026-06-25 02:24:19 +00:00
Stockboy Steve 88995ff59b feat(GRO-2516): add agent-runtime credential stanza to .gitignore
CI / Test (pull_request) Successful in 28s
CI / Lint & Typecheck (pull_request) Successful in 36s
CI / Build & Push Docker Image (pull_request) Successful in 14s
Appends canonical ignore rules for .gh-token, .config/gh/,
.claude/, .codex/, and AGENT_HOME patterns per GRO-2516 guardrail
to prevent accidental commit of agent credential artifacts.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-25 02:18:14 +00:00
Lint Roller 2ce7966fe9 feat(GRO-2513): gate Settings nav+route to manager/super-user, eliminate groomer 403 (#82)
CI / Test (push) Successful in 38s
CI / Lint & Typecheck (push) Successful in 47s
CI / Test (pull_request) Successful in 23s
CI / Lint & Typecheck (pull_request) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 15s
feat(GRO-2513): gate Settings nav+route to manager/super-user, eliminate groomer 403

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-authored-by: Lint Roller <23+gb_lint@noreply.git.farh.net>
Co-committed-by: Lint Roller <23+gb_lint@noreply.git.farh.net>
2026-06-25 01:58:13 +00:00
8 changed files with 184 additions and 52 deletions
+11 -1
View File
@@ -5,4 +5,14 @@ node_modules/
dist/
playwright-report/
test-results/
*.log
*.log
# Agent runtime artifacts — never commit
.gh-token
*.gh-token
**/.gh-token
.config/gh/
**/.config/gh/
**/AGENT_HOME/**
$AGENT_HOME/**
.claude/
.codex/
+19 -4
View File
@@ -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 |
@@ -291,12 +291,18 @@ 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
### 5.14 Settings UI (manager / super-user only — GRO-2513)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| 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 |
| 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. |
### 5.15 Navigation
@@ -314,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 |
+25 -2
View File
@@ -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;
}
};
@@ -187,6 +193,17 @@ 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}`
@@ -251,7 +268,7 @@ function AdminLayout() {
>
Book
</Link>
{NAV_LINKS.map(({ to, label }) => {
{visibleNavLinks.map(({ to, label }) => {
const active =
to === "/admin"
? location.pathname === "/admin"
@@ -308,7 +325,13 @@ function AdminLayout() {
<Route path="/group-bookings" element={<GroupBookingPage />} />
<Route path="/routes" element={<RoutesPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings" element={
staffUser === null
? null
: canSettings
? <SettingsPage />
: <Navigate to="/admin" replace />
} />
</Routes>
</main>
</div>
+60
View File
@@ -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(
<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"
);
});
});
});
+9
View File
@@ -78,6 +78,15 @@ 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;
+57 -42
View File
@@ -86,51 +86,66 @@ 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/admin/settings")
fetch("/api/staff/me")
.then((r) => r.json())
.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);
})
.catch(() => setLoaded(true));
}, []);
.then((u) => {
const user = u as CurrentUser;
setCurrentUser(user);
const isManager = user.role === "manager" || user.isSuperUser;
// 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);
});
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(() => {
setLoaded(true);
setAuthLoaded(true);
});
}, []);
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
+1 -1
View File
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div>
)}
<div className="flex gap-2 flex-wrap">
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
+2 -2
View File
@@ -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">
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{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">
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto scrollbar-hide">
{([
{ id: "info", label: "Basic Info", icon: PawPrint },
{ id: "medical", label: "Medical", icon: Heart },