Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f7f96b00b | |||
| 7a8b59ab87 | |||
| 250c7a5ac9 | |||
| dee7465190 | |||
| 66bac2c6f8 | |||
| 044eeaae61 | |||
| 59a29a2d03 | |||
| c58e4e4b23 | |||
| 98c8a7bb83 | |||
| 1ceac35437 | |||
| 3d0c3c551b | |||
| c7417dc9e3 | |||
| 0d52ddd9f0 |
@@ -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
@@ -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.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user