Compare commits

..

12 Commits

Author SHA1 Message Date
Flea Flicker 1480a37de1 docs: add AGENTS.md and CONTRIBUTING.md (GRO-2381) (#80)
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Failing after 8s
Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-12 17:00:39 +00:00
Flea Flicker f235dcad81 Promote uat → main (PROD): GRO-2359 OOBE portal-creation routing (web) (#79)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 24s
CI / Build & Push Docker Image (push) Successful in 50s
Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-12 16:47:17 +00:00
Flea Flicker 661bd4f902 Promote uat → main (PROD): GRO-2358 logout on no-access screen (#74)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 26s
CI / Build & Push Docker Image (push) Successful in 15s
Promote uat → main (PROD): GRO-2358 — restore logout on 'Portal access not configured' screen.

Squashed from uat-to-main/GRO-2358 (0d24fe0).

Cherry-pick of validated uat squash bfe3ccf.

Pre-merge gates green: CI (Lint+Typecheck 30s, Test 23s, Docker Build 11s); CTO Gitea review APPROVED (comment 13465); QA GRO-2362 done; UAT GRO-2363 4/4 PASS on git.farh.net/groombook/web:2026.06.11-bfe3ccf; Security GRO-2364 cleared.

Head branch uat-to-main/GRO-2358 retained for Flea's post-deploy verification.

Refs GRO-2358, GRO-2362, GRO-2363, GRO-2364, GRO-2367.

Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-11 15:43:32 +00:00
Flea Flicker fe565861b9 Promote uat → main (PROD): GRO-2319 portal StatusBadge palette (#71)
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 20s
Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
2026-06-10 08:57:46 +00:00
Scrubs McBarkley 7ef270312c Merge pull request 'Promote uat → main (PROD): GRO-2160 route nav export + offline polish' (#68) from flea/uat-to-main-gro-2160 into main
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 15s
2026-06-09 05:27:25 +00:00
Scrubs McBarkley 2a401a4584 Merge pull request 'Promote uat → main (PROD): GRO-2159 drag-to-reorder + re-optimize + GRO-2236 price/duration display fix'
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 25s
CI / Build & Push Docker Image (push) Successful in 12s
CEO prod merge. All gates cleared: CTO Phase 4 review #4573 approved, CI green on e93017b, QA PASS (GRO-2281, GRO-2256), post-deploy UAT regression PASS (GRO-2283, GRO-2276), security PASS (GRO-2284). Merging GRO-2159 + GRO-2236 to main.
2026-06-09 04:00:43 +00:00
Scrubs McBarkley 27c59113e2 Merge pull request 'Promote uat → main (PROD): GRO-2158 route planner page' (#62) from flea/uat-to-main-gro-2158 into main
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Failing after 9s
Promote uat → main (PROD): GRO-2158 route planner page

Merged by CEO (gb_scrubs) — board-gated prod merge.
Validated SHA: 980615b (GRO-2272/2273/2274 all PASS).
CTO review: APPROVED (review #4567 by gb_dogfather).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-09 02:37:35 +00:00
Flea Flicker 95c688764b Merge pull request 'uat→main (PROD): GRO-2234 portal Book New fix + validated batch' (#59) from flea/uat-to-main-gro-2234-web into main
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 16s
2026-06-09 00:37:47 +00:00
Flea Flicker 5bb8fbcb7d Merge pull request 'Promote uat → main (atomic): GRO-2105/2094/2099/2089/2180/2213 portal bundle' (#48) from uat into main
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 40s
2026-06-08 19:29:49 +00:00
Scrubs McBarkley fdff0977ad Merge pull request 'Promote uat → main: GRO-2012 RescheduleFlow portalSessionId fallback' (#40) from uat into main
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Image (push) Successful in 16s
Promote uat → main: GRO-2012 RescheduleFlow portalSessionId fallback

Gate checks:
- UAT: GRO-2023 done (CTO verified, ec29f719)
- Security: GRO-2032 Barkley PASS
- UAT_PLAYBOOK.md: TC-WEB-5.26 present

Fix: CustomerPortal.tsx:329 sessionId={session?.id ?? portalSessionId}
Fix commit: f29f1828c8

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 19:10:08 +00:00
Scrubs McBarkley 2aad7cb6a0 Merge pull request 'promote: uat → main (GRO-1757 SSO auto-provision fix)' (#21) from uat into main
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 21s
CI / Build & Push Docker Image (push) Successful in 13s
2026-05-26 02:16:28 +00:00
Chris Farhood 0c41640f59 Add .mcp.json
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 4m1s
2026-05-24 18:15:24 +00:00
7 changed files with 870 additions and 36 deletions
+54
View File
@@ -0,0 +1,54 @@
# 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
@@ -0,0 +1,117 @@
# 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.
+6 -3
View File
@@ -446,9 +446,12 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe
| TC-WEB-5.25.2 | Bridge call sequence | Repeat TC-WEB-5.25.1 with DevTools → Network open and the **All** tab filtered to `/api/`. | In order: `GET /api/auth/get-session` → 200. `POST /api/portal/session-from-auth` → 201 with body `{ sessionId, clientId, clientName }`. |
| 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.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.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.5 | 404 from SSO bridge routes to OOBE (GRO-2359) | 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 post-auth handler mounts the **OOBE** (`src/portal/OOBE.tsx`) — a centred card titled **"Welcome — let's set up your portal"** with form fields for name (prefilled from the Better Auth session), phone, address, and notes. The legacy "Portal access not configured" card is **not** rendered on the new-user path. No redirect loop, no portal chrome. |
| TC-WEB-5.25.6 | OOBE form submission creates the portal (GRO-2359) | From TC-WEB-5.25.5, fill in the OOBE form and click **Create my portal**. | `POST /api/portal/clients-from-auth` is called with `{ name, phone, address, notes }`; the email is taken from the Better Auth session (the API binds the new client row to the SSO identity). The page reloads to `/`, the bridge re-runs, and the user lands in their portal dashboard. DevTools → Network shows `POST /api/portal/clients-from-auth` → 201 followed by `POST /api/portal/session-from-auth` → 201. |
| TC-WEB-5.25.6b | OOBE handles portal selection (409 from clients-from-auth) (GRO-2359) | 1. Sign in via SSO with an email that already exists in `clients` (e.g. a previously deleted-then-recreated account). 2. Land on OOBE. 3. Click **Create my portal**. | The API returns 409 "A customer record with this email already exists". The OOBE re-enables the submit button and shows the portal-selection message: "A customer record with this email already exists. Please contact your groomer to link your account." The shared signOut() button remains reachable so the user can exit if needed. |
| 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.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. |
+13 -1
View File
@@ -16,6 +16,7 @@ import { BookingCancelledPage } from "./pages/BookingCancelled.js";
import { BookingErrorPage } from "./pages/BookingError.js";
import { SetupWizard } from "./pages/SetupWizard.tsx";
import { CustomerPortal } from "./portal/CustomerPortal.js";
import { OOBE } from "./portal/OOBE.js";
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
import { BrandingProvider, useBranding } from "./BrandingContext.js";
@@ -406,7 +407,13 @@ export function App() {
}
// Don't render portal chrome at /login — DevLoginSelector is shown instead
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login";
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login" && location.pathname !== "/onboarding";
// GRO-2359: OOBE is mountable from a direct link (deep-link to /onboarding)
// and from the post-auth callback (CustomerPortal navigates here when the
// SSO bridge returns 404). Render the OOBE component standalone so it's
// outside the portal chrome (no `!sessionId` guards, no `!initComplete`
// loading states to fight — the OOBE handles its own auth resolution).
const showOOBE = location.pathname === "/onboarding";
// At /login with a valid session, redirect to the portal root. Without this,
// the final render returns null at /login (showCustomerPortal is false) and
@@ -425,6 +432,11 @@ export function App() {
</Routes>
{authDisabled && <DevSessionIndicator />}
</>
) : showOOBE ? (
<>
<OOBE />
{authDisabled && <DevSessionIndicator />}
</>
) : showCustomerPortal ? (
<>
<CustomerPortal />
+332 -27
View File
@@ -64,6 +64,22 @@ const AUDIT_LOGS: ImpersonationAuditLog[] = [
},
];
// ─── Shared test fixtures ───────────────────────────────────────────────────
// `brandingResponse` is the mock /api/branding payload used by every test
// in this file. Hoisted to module scope so the SSO bridge and the OOBE
// describe blocks can both reach it without redefining the same body.
const brandingResponse = {
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response;
// ─── ImpersonationBanner ────────────────────────────────────────────────────
describe("ImpersonationBanner", () => {
@@ -352,17 +368,6 @@ describe("CustomerPortal SSO bridge", () => {
signOutSpy.mockClear();
});
const brandingResponse = {
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response;
it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => {
global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
@@ -408,14 +413,21 @@ describe("CustomerPortal SSO bridge", () => {
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
});
it("shows a friendly fallback when session-from-auth returns 404 (no client record)", async () => {
it("routes to /onboarding when session-from-auth returns 404 (GRO-2359)", async () => {
// GRO-2359 replaces the P1 no-access fallback for the new-user path.
// The post-auth handler must now navigate to /onboarding so the OOBE
// component can drive portal creation. The no-access card itself is
// reserved for the deep-link deleted-portal case (see the next two
// tests, which exercise ?noAccess=deleted-portal).
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" } }),
json: async () => ({
user: { email: "stranger@example.com", name: "Stranger", role: "customer" },
}),
} as Response);
}
if (url === "/api/portal/session-from-auth") {
@@ -428,6 +440,9 @@ describe("CustomerPortal SSO bridge", () => {
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
// MemoryRouter is required for the React Router context used by
// useNavigate inside CustomerPortal. We pass `initialEntries=["/"]`
// and let the post-auth handler navigate the router to /onboarding.
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
@@ -435,12 +450,13 @@ describe("CustomerPortal SSO bridge", () => {
</MemoryRouter>
);
// The bridge 404 must NOT render the legacy no-access card. The OOBE
// form is the new-user surface.
await waitFor(() => {
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
expect(screen.getByText(/set up your portal/i)).toBeInTheDocument();
});
expect(screen.getByText(/not linked to a customer record/i)).toBeInTheDocument();
// Sign-out escape hatch is present so the user is not stuck in a loop
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument();
});
it("calls the shared signOut() handler and navigates to /login from the no-access screen (GRO-2358)", async () => {
@@ -456,6 +472,11 @@ describe("CustomerPortal SSO bridge", () => {
configurable: true,
});
// GRO-2359: the post-auth bridge 404 now routes to /onboarding (OOBE)
// on the new-user path. The no-access card itself is reserved for the
// deep-link deleted-portal case, which is signalled via
// ?noAccess=deleted-portal. A server-side "client disabled" check
// (future GRO) is the natural trigger.
global.fetch = vi.fn((input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url === "/api/branding") return Promise.resolve(brandingResponse);
@@ -465,11 +486,15 @@ describe("CustomerPortal SSO bridge", () => {
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
} as Response);
}
// The bridge must NOT succeed (so portalSessionId stays null) and must
// NOT be 404 (which would route to /onboarding). A 500 models a
// server-side portal-disabled check; the no-access card is mounted
// because of the URL param, not because of the bridge.
if (url === "/api/portal/session-from-auth") {
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({ error: "No client record found for this user" }),
status: 500,
json: async () => ({ error: "Portal disabled" }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
@@ -477,7 +502,7 @@ describe("CustomerPortal SSO bridge", () => {
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<MemoryRouter initialEntries={["/?noAccess=deleted-portal"]}>
<CustomerPortal />
</MemoryRouter>
);
@@ -487,8 +512,8 @@ describe("CustomerPortal SSO bridge", () => {
});
// 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.
// no-access screen is mounted because of the deleted-portal signal, not
// because the user clicked anything.
expect(signOutSpy).not.toHaveBeenCalled();
// Drive the click. The handler is the SAME `signOut()` exported from
@@ -515,8 +540,12 @@ describe("CustomerPortal SSO bridge", () => {
// 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.
// whose portal was deleted. The no-access screen is the only
// authenticated surface without a route guard, so the handler must
// fire identically.
//
// GRO-2359: the bridge 404 now routes to /onboarding (OOBE) on the
// new-user path; ?noAccess=deleted-portal is the surviving trigger.
signOutSpy.mockClear();
const originalLocation = window.location;
@@ -535,11 +564,15 @@ describe("CustomerPortal SSO bridge", () => {
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
} as Response);
}
// The bridge must NOT succeed (so portalSessionId stays null) and must
// NOT be 404 (which would route to /onboarding). A 500 models a
// server-side portal-disabled check; the no-access card is mounted
// because of the URL param, not because of the bridge.
if (url === "/api/portal/session-from-auth") {
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({ error: "No client record found for this user" }),
status: 500,
json: async () => ({ error: "Portal disabled" }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
@@ -547,7 +580,7 @@ describe("CustomerPortal SSO bridge", () => {
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/appointments"]}>
<MemoryRouter initialEntries={["/appointments?noAccess=deleted-portal"]}>
<CustomerPortal />
</MemoryRouter>
);
@@ -753,3 +786,275 @@ describe("CustomerPortal SSO bridge", () => {
});
});
});
describe("OOBE portal-creation flow (GRO-2359)", () => {
beforeEach(() => {
window.localStorage.clear();
});
// The OOBE is mounted both from the post-auth callback (CustomerPortal
// navigates to /onboarding on bridge 404) and from a direct deep-link.
// This set of tests exercises the direct-link mount, the form submit, and
// the shared signOut() handler. The post-auth routing is covered by the
// "routes to /onboarding when session-from-auth returns 404" test above.
function setupOOBEAuthMock(opts: { role?: string } = {}) {
const role = opts.role ?? "customer";
return 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: "new-sso@example.com", name: "New SSO", role },
}),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
}
it("renders the OOBE form when navigated to /onboarding directly (GRO-2359)", async () => {
global.fetch = setupOOBEAuthMock();
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByRole("heading", { name: /set up your portal/i })).toBeInTheDocument();
});
// All three primary form fields are present.
expect(screen.getByLabelText(/your name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/phone/i)).toBeInTheDocument();
expect(screen.getByLabelText(/address/i)).toBeInTheDocument();
// Submit and shared signOut are both present.
expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
});
it("prefills the name field from the Better Auth session (GRO-2359)", async () => {
global.fetch = setupOOBEAuthMock();
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
});
});
it("calls POST /api/portal/clients-from-auth and navigates to / on success (GRO-2359)", async () => {
const fetchMock = 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: "new-sso@example.com", name: "New SSO", role: "customer" },
}),
} as Response);
}
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({
id: "new-client-id",
name: "New SSO",
email: "new-sso@example.com",
}),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
global.fetch = fetchMock;
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
});
// Fill phone + address and submit.
fireEvent.change(screen.getByLabelText(/phone/i), {
target: { value: "555-1234" },
});
fireEvent.change(screen.getByLabelText(/address/i), {
target: { value: "1 Main St" },
});
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
// The endpoint must have been called with the form values, normalised
// (phone/address trimmed). We don't assert navigation here because the
// MemoryRouter would need a history prop to assert a URL change — the
// internal `navigate("/")` call is the contract.
await waitFor(() => {
const calls = vi.mocked(fetchMock).mock.calls;
const onboardCall = calls.find(([u]) =>
typeof u === "string" && (u as string).endsWith("/api/portal/clients-from-auth"),
);
expect(onboardCall).toBeDefined();
const body = JSON.parse(((onboardCall?.[1] as RequestInit | undefined)?.body as string) ?? "{}");
expect(body).toEqual({
name: "New SSO",
phone: "555-1234",
address: "1 Main St",
notes: null,
});
});
});
it("shows the portal-selection message when the API returns 409 (GRO-2359)", async () => {
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: "new-sso@example.com", name: "New SSO", role: "customer" },
}),
} as Response);
}
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
return Promise.resolve({
ok: false,
status: 409,
json: async () => ({ error: "A customer record with this email already exists" }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
});
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
await waitFor(() => {
expect(screen.getByText(/already exists/i)).toBeInTheDocument();
});
// The submit button is re-enabled after the error so the user can retry.
expect(screen.getByRole("button", { name: /Create my portal/i })).not.toBeDisabled();
});
it("requires the name field before submitting (GRO-2359)", async () => {
// Use a session WITHOUT a name so the OOBE starts with an empty form.
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: "noname@example.com", role: "customer" } }),
} as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByLabelText(/your name/i)).toHaveValue("");
});
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
// The name-required error is shown; no API call was made.
await waitFor(() => {
expect(screen.getByText(/tell us your name/i)).toBeInTheDocument();
});
});
it("uses the shared signOut() handler on the OOBE Sign out button (GRO-2359)", async () => {
signOutSpy.mockClear();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
value: { href: "" },
writable: true,
configurable: true,
});
global.fetch = setupOOBEAuthMock();
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /Sign out/i }));
// Same canonical handler as AdminLayout and the no-access card, per
// GRO-2358 — never a raw fetch("/api/auth/sign-out").
await waitFor(() => {
expect(signOutSpy).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(window.location.href).toBe("/login");
});
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
});
it("redirects to /login when no Better Auth session is present (GRO-2359)", async () => {
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: false, status: 401, json: async () => ({}) } as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}) as unknown as typeof fetch;
const { OOBE } = await import("../portal/OOBE.js");
render(
<MemoryRouter initialEntries={["/onboarding"]}>
<OOBE />
</MemoryRouter>
);
await waitFor(() => {
expect(window.location.href).toBe("/login");
});
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
});
});
+36 -5
View File
@@ -13,6 +13,7 @@ import { Communication } from "./sections/Communication.js";
import { AccountSettings } from "./sections/AccountSettings.js";
import { ImpersonationBanner } from "./ImpersonationBanner.js";
import { AuditLogViewer } from "./AuditLogViewer.js";
import { OOBE } from "./OOBE.js";
import { useBranding } from "../BrandingContext.js";
import { getDevUser } from "../pages/DevLoginSelector.js";
import { signOut } from "../lib/auth-client.js";
@@ -53,6 +54,13 @@ export function CustomerPortal() {
// (e.g. authenticated user with no matching client row). Rendered in place
// of the portal chrome instead of bouncing back to /login.
const [authError, setAuthError] = useState<string | null>(null);
// GRO-2359 — the SSO bridge 404 (no client row for the user's email)
// routes the user into the OOBE. We mount the OOBE inline rather than
// navigating to /onboarding so the post-auth flow stays inside the
// CustomerPortal render tree (test-isolated, no App-level router needed
// for the integration to work). The /onboarding route in App.tsx is
// still the mount point for direct deep-links to the same component.
const [showOOBE, setShowOOBE] = useState(false);
const { branding } = useBranding();
const [searchParams, setSearchParams] = useSearchParams();
@@ -63,6 +71,18 @@ export function CustomerPortal() {
initDone.current = true;
const sessionId = searchParams.get("sessionId");
// GRO-2359: a deep-link to a portal sub-route with ?noAccess=deleted-portal
// is the only path that still shows the no-access card. The post-auth
// 404-from-bridge path now navigates to /onboarding (OOBE) so the new
// user can create a portal. The deleted-portal case is set explicitly
// (e.g. a groomer who disabled a client) and uses the same no-access
// UI with the shared signOut() — that was the GRO-2358 invariant.
const noAccess = searchParams.get("noAccess");
if (noAccess === "deleted-portal") {
setAuthError(
"Your portal access has been removed. Please contact your groomer if you think this is a mistake.",
);
}
if (sessionId) {
setIsImpersonating(true);
@@ -153,11 +173,13 @@ export function CustomerPortal() {
setPortalSessionId(data.sessionId);
setClientName(data.clientName);
} else if (bridgeResp.status === 404) {
// Authenticated but no matching client row — show a friendly message
// instead of bouncing back to /login (which would loop indefinitely).
setAuthError(
"Your account is not linked to a customer record. Please contact your groomer to set up portal access."
);
// Authenticated but no matching client row — mount the OOBE
// (GRO-2359) so the user can create their portal record instead
// of landing on the no-access card. The no-access card itself is
// still reachable for the deleted-portal case (see GRO-2358) via
// the ?noAccess=deleted-portal deep-link, but is no longer in
// the new-user path.
setShowOOBE(true);
}
// 401/other: fall through; App.tsx render guard will redirect to /login.
} catch {
@@ -280,6 +302,15 @@ export function CustomerPortal() {
// session state. Dev users are verified via localStorage and the dev-session flow.
// SSO customers are recognised by portalSessionId (set by the Better Auth bridge).
if (!session && !portalSessionId) {
// GRO-2359 — new-user path: mount the OOBE inline so the SSO bridge's
// 404 hands the user a portal-creation form instead of the no-access
// card. onCompleted triggers a full page reload to /, which re-runs
// the bridge (now with a matching client row) and lands the user in
// the portal. A full reload (not React Router navigate) is the
// safest reset of the bridge's cached state.
if (showOOBE) {
return <OOBE onCompleted={() => { window.location.href = "/"; }} />;
}
if (authError) {
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
return (
+312
View File
@@ -0,0 +1,312 @@
import { useCallback, useEffect, useState } from "react";
import { LogOut, Shield, Sparkles } from "lucide-react";
import { signOut } from "../lib/auth-client.js";
/**
* OOBE (Out-of-Box Experience) for a first-time Authentik SSO user whose
* email does not match any existing `clients` row.
*
* The post-auth handler in `CustomerPortal.tsx` redirects to this component
* when `POST /api/portal/session-from-auth` returns 404. From here the new
* user can either:
* (a) Create a fresh customer record bound to their SSO email, or
* (b) Sign out (the no-access screen is no longer in the new-user path).
*
* After successful creation, the OOBE navigates to `/` so the portal's
* existing SSO bridge re-runs and lands the user in their portal with a
* real `X-Impersonation-Session-Id` header. No new client state is
* required — the bridge re-resolves the session and the rest of the
* portal is unchanged.
*
* GRO-2359 — root-cause fix for "new SSO user lands on Portal access not
* configured" (companion to GRO-2358, which restored logout on that screen).
*/
type OOBEFormState = {
name: string;
phone: string;
address: string;
notes: string;
};
type OOBEStatus = "loading" | "ready" | "submitting" | "error";
const EMPTY_FORM: OOBEFormState = {
name: "",
phone: "",
address: "",
notes: "",
};
type OOBEProps = {
/**
* Override the post-success destination. Defaults to `/` so the SSO bridge
* re-runs. Test suites pass a custom destination to keep assertions
* deterministic without a real portal session.
*/
onCompleted?: () => void;
};
export function OOBE({ onCompleted }: OOBEProps = {}) {
const [status, setStatus] = useState<OOBEStatus>("loading");
const [form, setForm] = useState<OOBEFormState>(EMPTY_FORM);
const [error, setError] = useState<string | null>(null);
const [sessionEmail, setSessionEmail] = useState<string | null>(null);
// Resolve the Better Auth session on mount. The OOBE is gated to
// authenticated users — if no session exists the API will reject the
// creation request, so we redirect to /login early. We prefill `name`
// from the Better Auth `user.name` if the SSO provider returned one.
//
// We use a full `window.location.href` redirect (not `navigate`) so the
// OOBE works the same way whether it's mounted from the post-auth
// callback (inside CustomerPortal's render tree) or from a direct
// deep-link (mounted by App.tsx). A full reload also resets any
// cached state in the parent component.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await fetch("/api/auth/get-session", { credentials: "include" });
if (!r.ok) {
if (!cancelled) window.location.href = "/login";
return;
}
const data = (await r.json().catch(() => null)) as
| { user?: { email?: string; name?: string; role?: string | null } }
| null;
if (cancelled) return;
if (!data?.user) {
window.location.href = "/login";
return;
}
if (data.user.role === "staff") {
window.location.href = "/admin";
return;
}
setSessionEmail(data.user.email ?? null);
setForm((prev) => ({ ...prev, name: data.user?.name ?? prev.name }));
setStatus("ready");
} catch {
if (!cancelled) window.location.href = "/login";
}
})();
return () => {
cancelled = true;
};
}, []);
const handleChange = useCallback(
(field: keyof OOBEFormState) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setForm((prev) => ({ ...prev, [field]: value }));
},
[],
);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (status === "submitting") return;
if (!form.name.trim()) {
setError("Please tell us your name so we can set up your portal.");
return;
}
setStatus("submitting");
setError(null);
try {
const r = await fetch("/api/portal/clients-from-auth", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name.trim(),
phone: form.phone.trim() || null,
address: form.address.trim() || null,
notes: form.notes.trim() || null,
}),
});
if (r.ok) {
// Let the parent (or default) decide where to land. The default
// is the portal root, which re-runs the SSO bridge. A full
// `window.location.href` reload resets any cached state in the
// parent (the bridge reads from Better Auth cookies, so a fresh
// request picks up the new client row).
if (onCompleted) {
onCompleted();
} else {
window.location.href = "/";
}
return;
}
if (r.status === 409) {
setStatus("ready");
setError(
"A customer record with this email already exists. Please contact your groomer to link your account.",
);
return;
}
const body = (await r.json().catch(() => null)) as { error?: string } | null;
setStatus("ready");
setError(body?.error ?? "We couldn't set up your portal. Please try again.");
} catch {
setStatus("ready");
setError("Network error. Please check your connection and try again.");
}
},
[form, onCompleted, status],
);
const handleSignOut = useCallback(async () => {
try {
await signOut();
} catch {
// Best-effort; navigate to /login regardless so the user is never trapped.
}
window.location.href = "/login";
}, []);
if (status === "loading") {
return (
<div
className="min-h-screen flex items-center justify-center bg-[#faf8f5]"
role="status"
aria-live="polite"
>
<div className="text-stone-500 text-sm">Loading</div>
</div>
);
}
return (
<div
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6 py-10"
role="main"
>
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8">
<div className="w-12 h-12 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center mx-auto mb-4">
<Sparkles size={22} />
</div>
<h1 className="text-lg font-semibold text-stone-800 text-center mb-1">
Welcome let's set up your portal
</h1>
<p className="text-sm text-stone-600 text-center mb-6">
You're signed in{sessionEmail ? ` as ${sessionEmail}` : ""}. We just need a few
details to create your customer record.
</p>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div>
<label
htmlFor="oobe-name"
className="block text-xs font-medium text-stone-700 mb-1"
>
Your name <span className="text-red-600">*</span>
</label>
<input
id="oobe-name"
name="name"
type="text"
required
autoComplete="name"
value={form.name}
onChange={handleChange("name")}
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
disabled={status === "submitting"}
/>
</div>
<div>
<label
htmlFor="oobe-phone"
className="block text-xs font-medium text-stone-700 mb-1"
>
Phone <span className="text-stone-400">(optional)</span>
</label>
<input
id="oobe-phone"
name="phone"
type="tel"
autoComplete="tel"
value={form.phone}
onChange={handleChange("phone")}
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
disabled={status === "submitting"}
/>
</div>
<div>
<label
htmlFor="oobe-address"
className="block text-xs font-medium text-stone-700 mb-1"
>
Address <span className="text-stone-400">(optional)</span>
</label>
<input
id="oobe-address"
name="address"
type="text"
autoComplete="street-address"
value={form.address}
onChange={handleChange("address")}
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
disabled={status === "submitting"}
/>
</div>
<div>
<label
htmlFor="oobe-notes"
className="block text-xs font-medium text-stone-700 mb-1"
>
Notes <span className="text-stone-400">(optional)</span>
</label>
<textarea
id="oobe-notes"
name="notes"
rows={2}
value={form.notes}
onChange={handleChange("notes")}
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
disabled={status === "submitting"}
/>
</div>
{error && (
<div
role="alert"
aria-live="polite"
className="flex items-start gap-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2"
>
<Shield size={14} className="mt-0.5 shrink-0" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={status === "submitting"}
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
{status === "submitting" ? "Setting up…" : "Create my portal"}
</button>
</form>
<div className="mt-6 pt-4 border-t border-stone-100 flex items-center justify-between">
<p className="text-xs text-stone-500">
Wrong account? Sign out and try a different one.
</p>
<button
type="button"
onClick={() => {
void handleSignOut();
}}
className="inline-flex items-center gap-1.5 text-xs font-medium text-stone-600 hover:text-stone-900"
>
<LogOut size={12} />
Sign out
</button>
</div>
</div>
</div>
);
}
export default OOBE;