Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b49978710b | |||
| 88995ff59b | |||
| 2ce7966fe9 |
+11
-1
@@ -5,4 +5,14 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
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/
|
||||||
|
|||||||
+9
-3
@@ -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.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 |
|
| 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 |
|
| # | Scenario | Steps | Expected |
|
||||||
|---|----------|-------|----------|
|
|---|----------|-------|----------|
|
||||||
| TC-WEB-5.14.1 | Configuration page | Navigate to Settings | Settings page loads without errors |
|
| 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 | Form interactions | Modify settings, save | Settings saved successfully, changes reflected |
|
| 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
|
### 5.15 Navigation
|
||||||
|
|
||||||
|
|||||||
+19
-2
@@ -187,6 +187,17 @@ function AdminLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { branding } = useBranding();
|
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
|
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
||||||
? `data:${branding.logoMimeType};base64,${branding.logoBase64}`
|
? `data:${branding.logoMimeType};base64,${branding.logoBase64}`
|
||||||
@@ -251,7 +262,7 @@ function AdminLayout() {
|
|||||||
>
|
>
|
||||||
Book
|
Book
|
||||||
</Link>
|
</Link>
|
||||||
{NAV_LINKS.map(({ to, label }) => {
|
{visibleNavLinks.map(({ to, label }) => {
|
||||||
const active =
|
const active =
|
||||||
to === "/admin"
|
to === "/admin"
|
||||||
? location.pathname === "/admin"
|
? location.pathname === "/admin"
|
||||||
@@ -308,7 +319,13 @@ function AdminLayout() {
|
|||||||
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
||||||
<Route path="/routes" element={<RoutesPage />} />
|
<Route path="/routes" element={<RoutesPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={
|
||||||
|
staffUser === null
|
||||||
|
? null
|
||||||
|
: canSettings
|
||||||
|
? <SettingsPage />
|
||||||
|
: <Navigate to="/admin" replace />
|
||||||
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-42
@@ -86,51 +86,66 @@ export function SettingsPage() {
|
|||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Load user role first, then gate settings/auth-provider fetches on role
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/settings")
|
fetch("/api/staff/me")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(async (data) => {
|
.then((u) => {
|
||||||
// The logo is now proxied through the API server so the browser
|
const user = u as CurrentUser;
|
||||||
// never receives an S3 URL — use the proxy path directly as the src.
|
setCurrentUser(user);
|
||||||
setForm({
|
const isManager = user.role === "manager" || user.isSuperUser;
|
||||||
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));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load current user (for isSuperUser check) and auth provider config
|
if (isManager) {
|
||||||
useEffect(() => {
|
fetch("/api/admin/settings")
|
||||||
Promise.all([
|
.then((r) => r.json())
|
||||||
fetch("/api/staff/me").then((r) => r.json()).catch(() => null),
|
.then((data) => {
|
||||||
fetch("/api/admin/auth-provider").then(async (r) => {
|
setForm({
|
||||||
if (r.ok) return r.json();
|
businessName: data.businessName ?? "GroomBook",
|
||||||
if (r.status === 404) return null;
|
primaryColor: data.primaryColor ?? "#4f8a6f",
|
||||||
throw new Error(`HTTP ${r.status}`);
|
accentColor: data.accentColor ?? "#8b7355",
|
||||||
}).catch(() => null),
|
logoKey: data.logoKey ?? null,
|
||||||
]).then(([user, auth]) => {
|
logoUrl: data.logoKey ? "/api/admin/settings/logo" : null,
|
||||||
setCurrentUser(user as CurrentUser | null);
|
logoBase64: data.logoBase64 ?? null,
|
||||||
if (auth) {
|
logoMimeType: data.logoMimeType ?? null,
|
||||||
setAuthConfig(auth as AuthProviderConfig);
|
});
|
||||||
setAuthForm({
|
setLoaded(true);
|
||||||
providerId: (auth as AuthProviderConfig).providerId,
|
})
|
||||||
displayName: (auth as AuthProviderConfig).displayName,
|
.catch(() => setLoaded(true));
|
||||||
issuerUrl: (auth as AuthProviderConfig).issuerUrl,
|
} else {
|
||||||
internalBaseUrl: (auth as AuthProviderConfig).internalBaseUrl ?? "",
|
setLoaded(true);
|
||||||
clientId: (auth as AuthProviderConfig).clientId,
|
}
|
||||||
clientSecret: (auth as AuthProviderConfig).clientSecret,
|
|
||||||
scopes: (auth as AuthProviderConfig).scopes,
|
if (user.isSuperUser) {
|
||||||
});
|
fetch("/api/admin/auth-provider")
|
||||||
}
|
.then(async (r) => {
|
||||||
setAuthLoaded(true);
|
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>) => {
|
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user