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) => {