Compare commits

..

13 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
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
5 changed files with 89 additions and 172 deletions
-54
View File
@@ -1,54 +0,0 @@
# AGENTS.md
This repository (`groombook/web`) is part of the GroomBook application stack. The
authoritative process, quality bar, and safety rules live in the shared
[`groombook/org`](https://git.farh.net/groombook/org) skills repository. Read
those first; this file is only a pointer.
## Authoritative skills
- **SDLC (branching, PRs, phases, handoffs):**
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
- **Coding standards (priority ordering, PR discipline, tests, no-hardcoded-values, CalVer):**
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
- **Safety (no plaintext secrets, no direct `kubectl apply` to `groombook`, no self-merge, board approval for destructive actions):**
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
For human contributors and humans reviewing agent work, see
[`CONTRIBUTING.md`](./CONTRIBUTING.md) in this repo for the phase-by-phase PR
flow and the `uat→main` merge-gate policy summary.
## Non-negotiable operational rules
These mirror the org skills; they are restated here so any agent landing in
this repo sees them without a cross-repo fetch.
- **All changes go through a PR.** Never push directly to `dev`, `uat`, or `main`.
- **Branch strategy:** `feature/<name>``dev``uat``main`. Engineers
always target `dev` first.
- **No self-merge contract.** The engineer who opened a PR clicks merge only
after the named reviewer (CI / QA / UAT / Security / CTO per phase)
approves. Issue-thread QA / UAT / security approvals do **not** clear the
Gitea `required_approvals` gate on `uat→main` — only a Gitea **Approve**
click from a member of the `approvals_whitelist_username` does. On this
repo that whitelist is `["gb_flea", "gb_dogfather"]` (engineer team).
Board-level accounts cannot give the Approve click by policy.
- **Always include `cc @cpfarhood`** at the bottom of every PR body for
board visibility (not as a reviewer).
- **Secrets in code are forbidden.** Use Bitnami Sealed Secrets; never commit
plaintext. See the `safety` skill.
- **Production (`groombook` namespace) is Flux-managed.** Never
`kubectl apply` directly. Infrastructure changes go through PRs in
`groombook/infra`.
## Local development
See the repo's own README, package scripts, and CI workflow. The
authoritative pipeline (Gitea Actions, image build, deploy hooks) is the
shared `groombook/infra` overlay; do not reimplement it here.
## When uncertain
If a task conflicts with the org skills, **the org skills win**. Open an
issue in `groombook/org` to propose a change rather than encoding a local
exception.
-117
View File
@@ -1,117 +0,0 @@
# Contributing to `groombook/web`
Thanks for contributing. This document is the human-facing companion to
[`AGENTS.md`](./AGENTS.md) and the authoritative
[`groombook/org`](https://git.farh.net/groombook/org) skills. The org skills
govern; this file is a quick-reference for the human/agent PR flow in this
repo.
## Branch strategy
Three long-lived branches; one PR per promotion step.
| Branch | Environment | Who merges | Prerequisites for merge |
|---------|-------------|-----------|-------------------------|
| `dev` | Dev | Engineer | CI passes |
| `uat` | UAT | Engineer | QA code review approval |
| `main` | Production | Engineer | UAT validation + CTO Gitea Approve when the `uat→main` merge-gate policy applies (see below) |
Engineers always target `dev` first. Feature branches: `<agent-name>/<short-description>`.
## Phase-by-phase PR flow
### Phase 1 — Dev
1. Branch from `dev`: `git checkout -b <name>/<short-description> origin/dev`.
2. Write code + tests. Run unit tests, type check, and lint locally (or rely on CI).
3. Open a PR against `dev`:
```bash
tea pr create --base dev --title "..." --body "..."
```
Include `cc @cpfarhood` at the bottom of the body for board visibility.
4. CI must pass. CI green → engineer self-merges.
5. CI builds and deploys to Dev automatically.
### Phase 2 — UAT promotion
1. Open a PR from `dev` to `uat`.
2. CI must pass.
3. **QA (Lint Roller)** reviews and approves on the Gitea PR.
4. QA approved → engineer self-merges.
5. CI builds and deploys to UAT automatically.
### Phase 3 — UAT regression + Security review
1. **UAT (Shedward Scissorhands)** runs full regression against UAT — every
feature, old and new, no exceptions.
2. **Security (Barkley Trimsworth)** reviews the changes.
3. Failures in either gate bounce back to Phase 1.
### Phase 4 — Production promotion (`uat → main`)
This is the gate the org PR
[`groombook/org#13`](https://git.farh.net/groombook/org/pulls/13) defines.
The full rule is in
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
and
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md);
the summary is below.
**The CTO Gitea Approve click is NOT the default gate.** Once the four
pre-gates (QA, UAT deploy, UAT regression, security) are green, the engineer
self-merges.
**A CTO Gitea Approve click IS required** only for PRs in one of three
categories:
1. **Novel auth / session paths** — login, OIDC, OOBE, session middleware,
token issuance, password reset, MFA, new auth provider integrations.
Routine auth-gated UI (button styling, error messages, form layout) is
**not** in this category.
2. **Infra / prod-affecting merges** — deploys, infra manifests, secrets,
GitOps overlays, CI/CD, `main` branch protection, production
routing/ingress, prod state mutations. All Phase 5 infra overlay PRs in
`groombook/infra` require CTO Gitea Approve without exception.
3. **Risk-flagged merges** — `risk:cto-approve` label, or explicit CTO/CEO
sign-off request in the PR or issue thread.
The engineer opens the `uat→main` PR, classifies it against the three
categories above, and adds `cc @cpfarhood`. If the PR is in scope, the CTO
clicks Approve; once approved (and the four pre-gates are green), the
engineer merges.
### Phase 5 — Production deployment
A separate PR in `groombook/infra` bumps the overlay image tag for prod.
Handed to QA (Lint Roller) for review, then self-merged by the engineer.
## The four pre-gates (uat→main)
A `uat→main` PR is mergeable when **all four** are green:
1. **QA code review** — done on the dev→uat promotion PR.
2. **UAT deploy** — the UAT image built from the uat tip is live in UAT.
3. **UAT regression** — Shedward's full-feature UAT pass is green (no
pre-existing defects, no new defects).
4. **Security review** — Barkley's security code review is green.
Issue-thread QA / UAT / security approvals do **not** clear the Gitea
`required_approvals` gate. Only a Gitea **Approve** click from a member of
the `approvals_whitelist_username` for `main` clears it. In this repo that
whitelist is the engineer team (`gb_flea`, `gb_dogfather`).
## Style, tests, and quality bar
See
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
for the engineering priority ordering, test requirements, no-hardcoded-values
rules, CalVer versioning policy, and the `git.farh.net` container registry
policy.
## Safety
See
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
for the non-negotiable rules: no plaintext secrets, no `kubectl apply` to
`groombook`, no self-merge, no direct `tofu` runs, board approval for
destructive actions, escalation protocol.
+1
View File
@@ -452,6 +452,7 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe
| TC-WEB-5.25.6c | OOBE uses the shared signOut() handler (GRO-2358, GRO-2359) | From TC-WEB-5.25.5, click **Sign out** in the OOBE footer. | The same shared `signOut()` from `lib/auth-client` fires (same handler as `AdminLayout` and the no-access card); browser navigates to `/login`; the Authentik session cookie is cleared. The handler always navigates to `/login` — even if the network call to `/api/auth/sign-out` fails — so a transient auth-server hiccup never leaves the user trapped on an authenticated screen. |
| TC-WEB-5.25.6d | OOBE is mountable from a direct deep-link (GRO-2359) | 1. Sign in via SSO as any customer. 2. In a new tab, navigate to `https://uat.groombook.dev/onboarding`. | The OOBE form mounts (the App.tsx `/onboarding` route resolves before the CustomerPortal `!sessionId` guards). The submit, signOut, and field-validation behaviour are identical to the post-auth mount. |
| TC-WEB-5.25.6e | Deleted-portal deep-link still reaches the no-access card (GRO-2358, GRO-2359) | 1. Sign in via SSO as a customer whose `clients` row was disabled/deleted by the groomer. 2. Land on a portal sub-route with `?noAccess=deleted-portal` (e.g. visit `https://uat.groombook.dev/appointments?noAccess=deleted-portal` directly). | The no-access card renders (the deep-link deleted-portal case — the OOBE is reserved for first-time creation). The shared signOut() from GRO-2358 is wired identically. This proves the no-access card is still reachable for non-new-user failure modes and the CMPO "no-trap" invariant holds across the auth boundary. |
| TC-WEB-5.25.6f | In-portal chrome sidebar exposes a Sign out button (GRO-2373) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the portal chrome, look at the sidebar footer (the section below the navigation links, where "Customer Portal v1.0" sits). 3. Locate the **Sign out** button (a stone-grey button above the version label, with a LogOut icon). 4. Click it. | A **Sign out** button is present in the sidebar footer (not buried in the Settings page, not hidden in a dropdown — it's visible on every portal sub-route, including Home, Appointments, My Pets, Report Cards, Billing, Messages, Settings). Clicking it fires the same shared `signOut()` from `lib/auth-client` (same handler as the OOBE footer, the no-access card, and `AdminLayout`'s top-bar "Logout"); `POST /api/auth/sign-out` → 200 `{"success":true}`; the browser navigates to `/login`; the Better Auth / Authentik session cookie is cleared. Proves the CMPO "no-trap" invariant (originally established in GRO-2355) holds on the third authenticated surface — the in-portal chrome — which the GRO-2358 P1 fix did not cover. |
| TC-WEB-5.25.7 | Bridge precedence — impersonation URL wins | 1. Sign in via SSO as a customer. 2. Open a new tab to `https://uat.groombook.dev/?sessionId=<a-valid-staff-impersonation-session-id>`. | The impersonation path runs; the amber banner appears for the impersonated client. The Better Auth bridge is **not** called on this load (`session-from-auth` absent in Network). |
| TC-WEB-5.25.8 | Bridge precedence — dev user wins | In dev mode (e.g. local) with `localStorage["dev-user"]` set to a client persona, navigate to `/`. | The dev-session path runs (`POST /api/portal/dev-session`). The Better Auth bridge is **not** called (`session-from-auth` absent in Network). Staff dev users still redirect to `/admin`. |
| TC-WEB-5.25.9 | Staff Better Auth session does not run the customer bridge | Sign in via SSO with a staff identity. Navigate to `/`. | `App.tsx` routing redirects to `/admin`. `POST /api/portal/session-from-auth` is **not** called. |
+71
View File
@@ -1057,4 +1057,75 @@ describe("OOBE portal-creation flow (GRO-2359)", () => {
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
});
it("reaches the shared signOut() handler from the in-portal chrome sidebar (GRO-2373)", async () => {
// Pre-GRO-2373, 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. This
// leaves users signed-in with no escape hatch. The fix lands a
// "Sign out" button in the sidebar footer that wires to the same
// canonical `signOut()` already used by OOBE / no-access / AdminLayout.
signOutSpy.mockClear();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
value: { href: "" },
writable: true,
configurable: true,
});
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
if (url === "/api/auth/get-session") {
return Promise.resolve({
ok: true,
json: async () => ({ user: { email: "uat-customer@groombook.dev", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({ sessionId: "chrome-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
// Land on the chrome (proof: customer greeting is rendered, no
// no-access card, no OOBE).
await waitFor(() => {
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
});
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
expect(screen.queryByText(/set up your portal/i)).not.toBeInTheDocument();
// The new chrome sign-out is scoped by data-testid so it doesn't
// collide with other surfaces that may also render "Sign out" labels
// (e.g. the impersonation banner uses "End Session").
const signOutButton = screen.getByTestId("portal-chrome-signout");
expect(signOutButton).toHaveTextContent(/Sign out/i);
fireEvent.click(signOutButton);
// Same canonical handler as OOBE / no-access / AdminLayout — never
// a raw fetch("/api/auth/sign-out") and never a navigate() without
// signOut() (the OOBE/no-access surface uses window.location.href
// for a hard reload so cached state is reset).
await waitFor(() => {
expect(signOutSpy).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(window.location.href).toBe("/login");
});
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
});
});
+17 -1
View File
@@ -451,7 +451,14 @@ export function CustomerPortal() {
})}
</div>
{/* Session controls (only shown during active impersonation) */}
{/* Session controls — Sign out is always reachable from the portal
chrome (GRO-2373). End Impersonation is staff-only and only
appears during an active impersonation session. Both share the
same LogOut icon for visual consistency, but route to distinct
handlers: handleSignOut calls the canonical Better Auth
`signOut()` (mirroring OOBE and the no-access card); handleEnd
tears down the staff impersonation session and returns to the
admin clients list. */}
<div className="border-t border-stone-100 p-4 space-y-2">
{session?.status === "active" && (
<button
@@ -462,6 +469,15 @@ export function CustomerPortal() {
End Impersonation
</button>
)}
<button
type="button"
onClick={() => { void handleSignOut(); }}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-stone-700 bg-stone-50 hover:bg-stone-100 transition-colors"
data-testid="portal-chrome-signout"
>
<LogOut size={14} />
Sign out
</button>
<div className="flex items-center gap-2 px-3 py-2 text-xs text-stone-400">
<Shield size={12} />
Customer Portal v1.0