From c7b96eebc46d2e1eb5eb6d548b6cdb6c47955063 Mon Sep 17 00:00:00 2001 From: Stockboy Steve Date: Thu, 25 Jun 2026 01:56:23 +0000 Subject: [PATCH] feat(GRO-2513): gate Settings nav+route to manager/super-user, eliminate groomer 403 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx AdminLayout: fetch /api/staff/me on mount, filter NAV_LINKS so Settings only appears for role=manager or isSuperUser (fail-closed while loading). Guard /admin/settings route to redirect non-managers to /admin. - Settings.tsx: replace parallel-fire useEffects with a single sequential flow — fetch /api/staff/me first, then only call /api/admin/settings for managers/super-users and /api/admin/auth-provider for super-users only. Groomers/receptionists never trigger the 403. - UAT_PLAYBOOK.md §5.14: updated with role-gated test cases (TC-WEB-5.14.1–8) covering manager-sees-tab, groomer-no-tab, direct-URL redirect, zero-403, and shared-endpoint regression. Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 12 +++-- src/App.tsx | 21 ++++++++- src/pages/Settings.tsx | 99 ++++++++++++++++++++++++------------------ 3 files changed, 85 insertions(+), 47 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index eb3de6c..59d6de8 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -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 diff --git a/src/App.tsx b/src/App.tsx index c6fee9d..d6a8057 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -187,6 +187,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 +262,7 @@ function AdminLayout() { > Book - {NAV_LINKS.map(({ to, label }) => { + {visibleNavLinks.map(({ to, label }) => { const active = to === "/admin" ? location.pathname === "/admin" @@ -308,7 +319,13 @@ function AdminLayout() { } /> } /> } /> - } /> + + : + } /> diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c1f0078..c275a7f 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -86,51 +86,66 @@ export function SettingsPage() { const [loaded, setLoaded] = useState(false); const fileInputRef = useRef(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) => { -- 2.52.0