Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bfcf0b970 |
@@ -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.
|
|
||||||
+1
-2
@@ -447,8 +447,7 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe
|
|||||||
| TC-WEB-5.25.3 | Subsequent portal calls use the bridged session ID | After TC-WEB-5.25.1 succeeds, navigate to **Appointments**, **My Pets**, **Billing**, **Settings**. Inspect any `/api/portal/*` request in DevTools → Network. | Each portal API call carries an `X-Impersonation-Session-Id` header whose value equals the `sessionId` returned by `session-from-auth` (not a URL-param value). Each call returns 200 (or 404 for genuinely empty collections), never 401. |
|
| TC-WEB-5.25.3 | Subsequent portal calls use the bridged session ID | After TC-WEB-5.25.1 succeeds, navigate to **Appointments**, **My Pets**, **Billing**, **Settings**. Inspect any `/api/portal/*` request in DevTools → Network. | Each portal API call carries an `X-Impersonation-Session-Id` header whose value equals the `sessionId` returned by `session-from-auth` (not a URL-param value). Each call returns 200 (or 404 for genuinely empty collections), never 401. |
|
||||||
| TC-WEB-5.25.4 | No impersonation chrome for the customer's own session | After TC-WEB-5.25.1, scan the portal UI. | No amber border around the page. No "STAFF VIEW" watermark. No "End Impersonation" button in the sidebar. The customer is themselves; only impersonation sessions started via `?sessionId=` show the banner. |
|
| TC-WEB-5.25.4 | No impersonation chrome for the customer's own session | After TC-WEB-5.25.1, scan the portal UI. | No amber border around the page. No "STAFF VIEW" watermark. No "End Impersonation" button in the sidebar. The customer is themselves; only impersonation sessions started via `?sessionId=` show the banner. |
|
||||||
| TC-WEB-5.25.5 | 404 fallback for authenticated user with no client record | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The portal renders a centred card titled **"Portal access not configured"** with the message about contacting the groomer and a **Sign out** button. No redirect loop, no portal chrome. |
|
| TC-WEB-5.25.5 | 404 fallback for authenticated user with no client record | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The portal renders a centred card titled **"Portal access not configured"** with the message about contacting the groomer and a **Sign out** button. No redirect loop, no portal chrome. |
|
||||||
| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch (GRO-2358) | From TC-WEB-5.25.5 click **Sign out**. | The shared `signOut()` from `lib/auth-client` fires (same handler as `AdminLayout`); browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). 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.6 | 404 fallback Sign-out escape hatch | From TC-WEB-5.25.5 click **Sign out**. | `POST /api/auth/sign-out` fires; browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). |
|
||||||
| TC-WEB-5.25.6b | 404 fallback Sign-out on deep-link (GRO-2358) | From TC-WEB-5.25.5, instead of staying on `/`, navigate directly to a portal sub-route (e.g. `/appointments`, `/pets`, `/billing`). The no-access card renders. Click **Sign out**. | The same shared `signOut()` handler fires and the browser navigates to `/login`. The no-access screen must surface an escape hatch on every authenticated route — not just `/` — so a stale or deep link into a portal the user has no access to can never trap them. |
|
|
||||||
| 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.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.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. |
|
| 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. |
|
||||||
|
|||||||
@@ -21,18 +21,6 @@ vi.mock("../portal/sections/Appointments.js", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spy on the canonical `signOut()` from the shared auth-client so we can
|
|
||||||
// assert the no-access screen's logout button uses the SAME handler as
|
|
||||||
// `AdminLayout`. We mock at the module boundary — the no-access screen is
|
|
||||||
// the one authenticated surface that renders without the portal chrome, so
|
|
||||||
// a regression here would trap the user. We do NOT use `importActual`
|
|
||||||
// because the real `createAuthClient()` requires a runtime `baseURL`
|
|
||||||
// (Better Auth) that the JSDOM test environment can't supply.
|
|
||||||
const signOutSpy = vi.hoisted(() => vi.fn(async () => undefined));
|
|
||||||
vi.mock("../lib/auth-client.js", () => ({
|
|
||||||
signOut: signOutSpy,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const SESSION: ImpersonationSession = {
|
const SESSION: ImpersonationSession = {
|
||||||
id: "sess-1",
|
id: "sess-1",
|
||||||
staffId: "staff-1",
|
staffId: "staff-1",
|
||||||
@@ -348,8 +336,6 @@ describe("CustomerPortal SSO bridge", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Make sure no dev-user leaks across tests
|
// Make sure no dev-user leaks across tests
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
// Reset shared signOut() spy so per-test counts are deterministic
|
|
||||||
signOutSpy.mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const brandingResponse = {
|
const brandingResponse = {
|
||||||
@@ -443,132 +429,6 @@ describe("CustomerPortal SSO bridge", () => {
|
|||||||
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls the shared signOut() handler and navigates to /login from the no-access screen (GRO-2358)", async () => {
|
|
||||||
// Reset the spy so previous tests don't leak into this assertion.
|
|
||||||
signOutSpy.mockClear();
|
|
||||||
|
|
||||||
// JSDOM throws on window.location.href assignment by default; swap in a
|
|
||||||
// writable stub so the navigation is observable, then restore after.
|
|
||||||
const originalLocation = window.location;
|
|
||||||
Object.defineProperty(window, "location", {
|
|
||||||
value: { href: "" },
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
global.fetch = vi.fn((input: RequestInfo) => {
|
|
||||||
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: "stranger@example.com", role: "customer" } }),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
if (url === "/api/portal/session-from-auth") {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: false,
|
|
||||||
status: 404,
|
|
||||||
json: async () => ({ error: "No client record found for this user" }),
|
|
||||||
} 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>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pre-condition: the shared signOut() must NOT have been called yet — the
|
|
||||||
// no-access screen is mounted because the bridge failed, not because the
|
|
||||||
// user clicked anything.
|
|
||||||
expect(signOutSpy).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Drive the click. The handler is the SAME `signOut()` exported from
|
|
||||||
// auth-client that AdminLayout uses, so verifying this call is enough to
|
|
||||||
// prove the no-access screen reaches the canonical sign-out surface.
|
|
||||||
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
|
||||||
fireEvent.click(signOutButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(window.location.href).toBe("/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reaches the same shared signOut() on a deep-link no-access screen (GRO-2358)", async () => {
|
|
||||||
// AC requires verifying the SAME logout handler is reachable from at
|
|
||||||
// least one other authenticated surface — here a deep link to a portal
|
|
||||||
// sub-route (e.g. /appointments) for a user with a Better Auth session
|
|
||||||
// but no client record. The no-access screen is the only authenticated
|
|
||||||
// surface without a route guard, so the handler must fire identically.
|
|
||||||
signOutSpy.mockClear();
|
|
||||||
|
|
||||||
const originalLocation = window.location;
|
|
||||||
Object.defineProperty(window, "location", {
|
|
||||||
value: { href: "" },
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
global.fetch = vi.fn((input: RequestInfo) => {
|
|
||||||
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: "stranger@example.com", role: "customer" } }),
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
if (url === "/api/portal/session-from-auth") {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: false,
|
|
||||||
status: 404,
|
|
||||||
json: async () => ({ error: "No client record found for this user" }),
|
|
||||||
} 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={["/appointments"]}>
|
|
||||||
<CustomerPortal />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
|
||||||
fireEvent.click(signOutButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(window.location.href).toBe("/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not call session-from-auth when there is no Better Auth session", async () => {
|
it("does not call session-from-auth when there is no Better Auth session", async () => {
|
||||||
global.fetch = vi.fn((input: RequestInfo) => {
|
global.fetch = vi.fn((input: RequestInfo) => {
|
||||||
const url = typeof input === "string" ? input : input.toString();
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
|||||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||||
import { signOut } from "../lib/auth-client.js";
|
|
||||||
import type { ImpersonationSession } from "@groombook/types";
|
import type { ImpersonationSession } from "@groombook/types";
|
||||||
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||||
|
|
||||||
@@ -194,19 +193,6 @@ export function CustomerPortal() {
|
|||||||
}
|
}
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
// Shared sign-out handler — wires the canonical Better Auth `signOut()` so
|
|
||||||
// every authenticated surface (no-access screen, portal chrome, etc.) uses
|
|
||||||
// the same implementation as `AdminLayout`. Failure to reach the server
|
|
||||||
// still leaves the SPA free to navigate to /login.
|
|
||||||
const handleSignOut = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await signOut();
|
|
||||||
} catch {
|
|
||||||
// Best-effort; navigate to /login regardless so the user is never trapped.
|
|
||||||
}
|
|
||||||
window.location.href = "/login";
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const logPageView = useCallback((page: string) => {
|
const logPageView = useCallback((page: string) => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
|
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
|
||||||
@@ -295,7 +281,14 @@ export function CustomerPortal() {
|
|||||||
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
|
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
|
||||||
<p className="text-sm text-stone-600 mb-6">{authError}</p>
|
<p className="text-sm text-stone-600 mb-6">{authError}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => { void handleSignOut(); }}
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/sign-out", { method: "POST", credentials: "include" });
|
||||||
|
} catch {
|
||||||
|
// Best-effort sign-out; redirect to /login regardless.
|
||||||
|
}
|
||||||
|
window.location.href = "/login";
|
||||||
|
}}
|
||||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-stone-700 bg-stone-100 hover:bg-stone-200 transition-colors"
|
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-stone-700 bg-stone-100 hover:bg-stone-200 transition-colors"
|
||||||
>
|
>
|
||||||
<LogOut size={14} />
|
<LogOut size={14} />
|
||||||
|
|||||||
Reference in New Issue
Block a user