Compare commits

..

1 Commits

Author SHA1 Message Date
Flea Flicker 1f7f96b00b fix(GRO-2373): add Sign out button to in-portal chrome sidebar
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Image (pull_request) Successful in 45s
The customer portal chrome (Home, Appointments, My Pets, Report Cards,
Billing, Messages, Settings) had no visible sign-out control. Only the
OOBE and the no-access card exposed one. Users had to clear cookies or
use devtools to sign out.

The CMPO ruling for GRO-2355 required the logout control to be
reachable from the OOBE screen, the in-portal screen, and the
deleted-portal deep-link. GRO-2358 (P1) covered no-access + OOBE but
missed the in-portal chrome.

Fix: add a 'Sign out' button in the sidebar footer (next to 'End
Impersonation') wired to the existing handleSignOut(), which calls the
canonical signOut() from lib/auth-client — the SAME handler used by
OOBE, the no-access card, and AdminLayout's top-bar Logout.

Test: portal.test.tsx renders the CustomerPortal with a successful SSO
bridge, lands on the chrome, clicks the new portal-chrome-signout
button, and asserts the shared signOutSpy fires + window.location.href
navigates to /login.

UAT_PLAYBOOK.md: added TC-WEB-5.25.6f covering the new chrome
sign-out reachability.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-11 18:22:11 +00:00
4 changed files with 48 additions and 96 deletions
-10
View File
@@ -6,13 +6,3 @@ 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/
+3 -9
View File
@@ -291,18 +291,12 @@ 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 (manager / super-user only — GRO-2513) ### 5.14 Settings UI
| # | Scenario | Steps | Expected | | # | Scenario | Steps | Expected |
|---|----------|-------|----------| |---|----------|-------|----------|
| 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.1 | Configuration page | Navigate to Settings | Settings page loads without errors |
| 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.2 | Form interactions | Modify settings, save | Settings saved successfully, changes reflected |
| 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
+2 -19
View File
@@ -187,17 +187,6 @@ 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}`
@@ -262,7 +251,7 @@ function AdminLayout() {
> >
Book Book
</Link> </Link>
{visibleNavLinks.map(({ to, label }) => { {NAV_LINKS.map(({ to, label }) => {
const active = const active =
to === "/admin" to === "/admin"
? location.pathname === "/admin" ? location.pathname === "/admin"
@@ -319,13 +308,7 @@ 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={ <Route path="/settings" element={<SettingsPage />} />
staffUser === null
? null
: canSettings
? <SettingsPage />
: <Navigate to="/admin" replace />
} />
</Routes> </Routes>
</main> </main>
</div> </div>
+12 -27
View File
@@ -86,19 +86,12 @@ 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/staff/me")
.then((r) => r.json())
.then((u) => {
const user = u as CurrentUser;
setCurrentUser(user);
const isManager = user.role === "manager" || user.isSuperUser;
if (isManager) {
fetch("/api/admin/settings") fetch("/api/admin/settings")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .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({ setForm({
businessName: data.businessName ?? "GroomBook", businessName: data.businessName ?? "GroomBook",
primaryColor: data.primaryColor ?? "#4f8a6f", primaryColor: data.primaryColor ?? "#4f8a6f",
@@ -111,18 +104,19 @@ export function SettingsPage() {
setLoaded(true); setLoaded(true);
}) })
.catch(() => setLoaded(true)); .catch(() => setLoaded(true));
} else { }, []);
setLoaded(true);
}
if (user.isSuperUser) { // Load current user (for isSuperUser check) and auth provider config
fetch("/api/admin/auth-provider") useEffect(() => {
.then(async (r) => { 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.ok) return r.json();
if (r.status === 404) return null; if (r.status === 404) return null;
throw new Error(`HTTP ${r.status}`); throw new Error(`HTTP ${r.status}`);
}) }).catch(() => null),
.then((auth) => { ]).then(([user, auth]) => {
setCurrentUser(user as CurrentUser | null);
if (auth) { if (auth) {
setAuthConfig(auth as AuthProviderConfig); setAuthConfig(auth as AuthProviderConfig);
setAuthForm({ setAuthForm({
@@ -136,15 +130,6 @@ export function SettingsPage() {
}); });
} }
setAuthLoaded(true); setAuthLoaded(true);
})
.catch(() => setAuthLoaded(true));
} else {
setAuthLoaded(true);
}
})
.catch(() => {
setLoaded(true);
setAuthLoaded(true);
}); });
}, []); }, []);