Compare commits

...

15 Commits

Author SHA1 Message Date
Flea Flicker 8253e8a84d Merge pull request 'Promote dev → uat: GRO-2513 Settings role-gate' (#83) from dev into uat
CI / Test (push) Successful in 26s
CI / Lint & Typecheck (push) Successful in 43s
CI / Build & Push Docker Image (push) Successful in 22s
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Image (pull_request) Successful in 15s
Promote dev → uat: GRO-2513 Settings role-gate

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-25 02:08:52 +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
Flea Flicker ddc4e3e052 fix(GRO-2373): add Sign out button to in-portal chrome sidebar (#77)
CI / Test (push) Successful in 35s
CI / Lint & Typecheck (push) Successful in 48s
CI / Build & Push Docker Image (push) Successful in 13s
2026-06-11 18:24:29 +00:00
Flea Flicker 7a8b59ab87 Merge pull request 'feat(GRO-2359): route Authentik new-SSO users into OOBE' (#75) from feature/2357-p2-sso-to-oobe-routing into dev
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 18s
GRO-2359 (web): feat(GRO-2359): route Authentik new-SSO users into OOBE (#75)
2026-06-11 16:34:32 +00:00
Flea Flicker 250c7a5ac9 feat(GRO-2359): route Authentik new-SSO users into OOBE (web)
CI / Test (pull_request) Successful in 25s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 44s
The post-auth handler in CustomerPortal previously rendered the
"Portal access not configured" card when the SSO bridge returned 404
(no client row for the user's email). That trapped first-time SSO
users on a dead-end screen with no path to portal creation.

This change routes the 404 to a new OOBE component (src/portal/OOBE.tsx)
that drives portal creation:
  * Mounts inline inside CustomerPortal so the post-auth flow stays
    inside the portal render tree (no App-level router needed).
  * Also reachable as a direct deep-link via the new /onboarding route
    in App.tsx (for grooming admins or recovery flows).
  * Submits to a new POST /api/portal/clients-from-auth endpoint in
    groombook-api (companion commit) that creates a fresh client row
    bound to the Better Auth email. 409 means the email already has a
    portal record — the OOBE shows a portal-selection message.
  * Uses the canonical shared signOut() from lib/auth-client (GRO-2358
    invariant) for the Sign out button.
  * Full window.location.href reload on submit success to reset the
    bridge's cached state and land the user in their portal.

The no-access card itself is preserved for the deep-link deleted-portal
case (a customer whose portal was disabled/deleted), signalled via
?noAccess=deleted-portal on a portal sub-route. The OOBE handles the
first-time-creation case; the no-access card handles the "had a portal
but lost it" case.

Test coverage:
  * "routes to /onboarding when session-from-auth returns 404 (GRO-2359)"
    — proves the post-auth 404 mounts the OOBE inline, not the legacy
    no-access card.
  * 6 new OOBE tests: render from direct link, name prefill, form
    submission, 409 portal-selection, required-name validation, shared
    signOut(), redirect on no-session.
  * P1 no-access tests reworked to use ?noAccess=deleted-portal so the
    GRO-2358 signOut invariant is still verified on the only surviving
    path to the no-access card.

UAT_PLAYBOOK §5.25.5–6e rewritten to cover the OOBE flow (form submit,
409, deep-link mount, deleted-portal no-access card).

Paired with the api PR on feature/2357-p2-portal-clients-from-auth.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-11 16:30:03 +00:00
Flea Flicker dee7465190 fix(GRO-2358): wire signOut() at higher layer for no-access screen (#72)
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 40s
2026-06-11 14:24:46 +00:00
Flea Flicker 66bac2c6f8 feat(GRO-2319): live-render full StatusBadge palette in portal (#69)
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 26s
CI / Build & Push Docker Image (push) Successful in 13s
2026-06-09 10:41:07 +00:00
Flea Flicker 044eeaae61 feat(GRO-2160): route nav export buttons + offline map polish (#66)
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 11s
2026-06-09 04:31:24 +00:00
Flea Flicker 59a29a2d03 feat(GRO-2159): drag-to-reorder + re-optimize on route planner (#63)
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 11s
2026-06-09 02:57:49 +00:00
Flea Flicker c58e4e4b23 feat(GRO-2158): route planner page at /admin/routes (#60)
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Image (push) Successful in 12s
2026-06-09 01:50:49 +00:00
The Dogfather 98c8a7bb83 fix(GRO-2236): portal Book New service cards show price + duration (#57)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / Build & Push Docker Image (pull_request) Successful in 10s
Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
2026-06-08 23:30:30 +00:00
Flea Flicker 1ceac35437 fix(GRO-2234): transparent re-mint on 401 for portal Book New submit (#55)
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Image (push) Successful in 16s
2026-06-08 19:13:03 +00:00
Lint Roller 3d0c3c551b fix(portal): show Weight/DoB + Size Category in pet read view (GRO-2207) (#54)
CI / Test (push) Successful in 22s
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 34s
CI / Build & Push Docker Image (pull_request) Successful in 15s
CI / Lint & Typecheck (push) Failing after 12m56s
CI / Build & Push Docker Image (push) Has been skipped
2026-06-08 17:31:44 +00:00
Flea Flicker c7417dc9e3 docs(uat): add §5.12e Book New preferredTime test cases (GRO-2218) (#53)
CI / Test (push) Failing after 6s
CI / Lint & Typecheck (push) Successful in 21s
CI / Build & Push Docker Image (push) Has been skipped
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (pull_request) Successful in 11s
2026-06-08 16:54:07 +00:00
Flea Flicker 0d52ddd9f0 fix(portal): send preferredTime as HH:MM:SS and format booking slot labels (GRO-2211) (#51)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 12s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 12s
2026-06-08 16:41:14 +00:00
3 changed files with 85 additions and 47 deletions
+9 -3
View File
@@ -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
+19 -2
View File
@@ -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
</Link>
{NAV_LINKS.map(({ to, label }) => {
{visibleNavLinks.map(({ to, label }) => {
const active =
to === "/admin"
? location.pathname === "/admin"
@@ -308,7 +319,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>
+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>) => {