> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Admin console frontend

> Architecture and operator workflows for stralos-admin-web

`stralos-admin-web` is a standalone React SPA for authorised platform operators. It
talks only to `stralos-admin-api` and is not part of the main shell's Module
Federation graph.

## Local development

`stralos-admin-web` is a pnpm workspace member. Its `@stralos-co-nz/ui` dependency
resolves from the sibling `stralos-ui` workspace checkout, not from the registry.
Run `pnpm install` and build the sibling packages from the workspace root before
starting the dev server. See [Development setup — admin console](https://help.stralos.app/internal/dev-setup.md#admin-console)
for the full sequence.

## CI and deployment

Every merge to `stralos-admin-web` `main` runs type checking, linting, tests, and
a production build, then automatically deploys to dev. The deploy job assumes the
dev environment's OIDC role, writes an immutable SHA-keyed bundle to the artifact
store, syncs the bundle to the private S3 origin, invalidates CloudFront, and
probes the SPA and same-origin `/health` route. Pull requests build and verify
without deploying.

The artifact store uses a single authoritative bucket (`stralos-artifacts-dev`) shared
across environments. Bundles are written under the `sites/admin-web/` prefix. The Dev
publisher role has write access to that prefix; Test and Production publisher roles have
read-only access so they can promote exact S3 object versions without rebuilding.

The `stralos-admin-api` Terraform stack provisions the publisher role and its artifact
store permissions via the `artifact_store_bucket_arn` variable. This variable is
required and is set via `terraform/artifact-store.auto.tfvars`; the stack validates
that the value is a well-formed commercial-partition S3 bucket ARN before applying.

Test and production use the immutable publish-freeze-promote path described in
[Admin console static site release path](https://help.stralos.app/internal/deployment.md#admin-console-static-site-release-path).

## List screen layout

All six admin list screens keep their page title and action controls visible as you scroll. The app-bar height is measured and published to all screens so the sticky controls sit flush below it. On narrow screens and short landscape viewports the controls wrap rather than overflow.

| Screen | Sticky controls |
| - | - |
| **Organisations** | Page title and actions; table header and mobile card header |
| **Users** | Page title and actions; table header and mobile card header |
| **Memberships** | Page title and actions; table header and mobile card header |
| **Discount codes** | Section title and **Create discount code** action |
| **Global lists** | Page title and nested active-list title with **Add** action |
| **Seed templates** | Page title and **Type of business** filter; nested section title and **Add** action |

On mobile, row actions occupy the card footer so a long action strip does not compress the email or name fields.

When you change the business type on the **Seed templates** screen, the view scrolls back to the app-bar offset before the editor remounts. This prevents a stale scroll position appearing above a short or empty result list.

Wide tables scroll horizontally inside their wrapper. The column headers remain aligned with their cells and the page itself does not scroll horizontally.

## Current screens

| Screen | Source capability |
| - | - |
| **Login** | Sign in with Google (OAuth2 authorisation code + PKCE). In environments with operator auto-provisioning enabled, a verified account in the configured Google Workspace domain creates its audited operator row on first sign-in; otherwise an operator row must already exist. |
| **Organisations** | Create (requires a business type), rename, set timezone and home region, or disable. Open the billing dialog from the organisation list to view pricing, seat usage, subscription status, and active discounts for any organisation. |
| **Discount codes** | Create discount codes for signup and checkout. Deactivate a code to stop new redemptions without affecting existing subscriptions. |
| **Users** | Create, edit identity details, change email, disable, or start a support session (impersonate). |
| **Memberships** | Grant or invite, change role, or revoke. |
| **Global lists** | Manage signup vocabulary (`signup_vocab` scope). The immutable unit catalogue is not exposed here. |
| **Seed templates** | Maintain rate-category templates, default disposal substances, and default takeoff categories keyed by business type. |

There is no public signup or password-reset screen. Operator auto-provisioning is
an explicit per-environment setting. It is enabled for the maintained dev, test,
and production configurations; when disabled, rows must be created by an existing
operator or the bootstrap CLI and an unknown address receives the same refusal as
an inactive or otherwise ineligible account.

## Global lists screen

The **Global lists** screen manages signup vocabulary (`signup_vocab` scope). Operators create, edit, and delete list items under the `business_types` key.

### Deleting a global list item

When an operator clicks **Delete** on a list item, a confirmation dialog appears. The dialog states that the item will be kept for history rather than permanently removed. Confirming removes the item from the active list; it is soft-deleted and is no longer served to the signup form or editable, but its record is retained in the admin store.

A failure from the delete path surfaces as a toast notification.

## Organisations screen

When creating a new organisation, the **New organisation** form requires a **Type of business** field in addition to the organisation name. The business type is chosen from the same live list used by the signup form. It determines which rate category templates and default disposal substances the new organisation receives when it is provisioned. The field is mandatory for create and is absent from the edit form — the type cannot be changed after an organisation is created.

If the API refuses the create with `business_type_required`, the form surfaces this as a field error asking the operator to select a type.

## Seed templates screen

The **Seed templates** screen manages rate-category templates, default disposal substances, and default takeoff categories keyed by business type.

### Business type selector

The screen opens with a **Type of business** picker. Business types are managed under **Global lists → Business types** (`signup_vocab` scope). Templates are keyed by a business type's value; relabelling a type is safe, but renaming its value orphans every template under it.

Once a type is selected, the screen shows only that type's templates. Switching types resets all in-progress edits and loads the new type's set for every section.

### Rate category templates

Each rate category template belongs to exactly one business type. An organisation receives the set of templates for the type it chose at signup, and nothing else. Edits apply to organisations created afterwards only; existing organisations keep the snapshot taken at their create time.

Operators create, edit, and delete rate category templates. Each template has:

* a **name** (unique within its business type);
* a **parent group** classifying it within one of six taxonomy nodes; and
* an **ordered list of allowed units** — the units available when pricing items under this category.

| Parent group token | Display label |
| - | - |
| `labour` | Labour |
| `plant_vehicles` | Plant & Vehicles |
| `quarry_materials` | Bulk Materials |
| `other_materials` | Materials |
| `subcontractors` | Subcontractors |
| `disposal` | Disposal |

#### Allowed units

Every template carries one or more allowed unit codes. The unit picker groups options by category (Length, Area, Volume, Weight, Count, Time), mirroring the picker that operators see on the tenant side. The first unit in the ordered list (`[0]`) becomes the tenant's default unit for that category. The order is part of the payload's meaning and is never sorted by the API.

At least one unit is required. The API validates that the codes are non-empty strings and unique within the array; it does not validate membership against the unit catalogue (the catalogue lives in api-global and is not reachable from the admin service).

The picker reads the shared `UNIT_CATALOGUE` exported by `@stralos-co-nz/ui`, which mirrors the `unit_catalogue` table in api-global. The catalogue holds 27 units:

| Category | Code | Display name | Symbol |
| - | - | - | - |
| Length | `millimetre` | Millimetre | mm |
| Length | `metre` | Metre | m |
| Length | `kilometre` | Kilometre | km |
| Length | `length` | Length | len |
| Length | `roll_length` | Roll Length | roll |
| Area | `square_metre` | Square metre | m² |
| Area | `sheet` | Sheet | sheet |
| Area | `roll_area` | Roll Area | roll |
| Area | `hectare` | Hectare | ha |
| Volume | `cubic_metre` | Cubic metre | m³ |
| Volume | `litre` | Litre | L |
| Volume | `kilolitre` | Kilolitre | kL |
| Volume | `megalitre` | Megalitre | ML |
| Weight | `kilogram` | Kilogram | kg |
| Weight | `tonne` | Tonne | t |
| Count | `each` | Each | ea |
| Count | `number` | Number | no. |
| Count | `set` | Set | set |
| Count | `pair` | Pair | pair |
| Count | `lot` | Lot | lot |
| Count | `item` | Item | item |
| Count | `lump_sum` | Lump Sum | LS |
| Count | `provisional_sum` | Provisional Sum | PS |
| Time | `hour` | Hour | hr |
| Time | `day` | Day | day |
| Time | `week` | Week | week |
| Time | `month` | Month | month |

The stock units `length`, `roll_length`, `sheet`, and `roll_area` are purchasing units, distinct from the measured metre and square metre. `length` and `roll_length` sit in the Length group; `sheet` and `roll_area` sit in the Area group. The two Roll units share the symbol `roll`, so the code and category tell them apart. Provisional Sum (`provisional_sum`, symbol PS) was added alongside Lump Sum to let operators configure categories where a provisional allowance is the appropriate pricing unit.

Each picker option and each template row reads "Name (symbol)", for example "Roll Length (roll)" or "Roll Area (roll)". The name is what tells the two Roll units apart. A stored code the catalogue does not carry shows as the raw code.

admin-web keeps no copy of the catalogue (the old `src/lib/unitCatalogue.ts` was removed in admin-web PR 84). The picker is not a live read from api-global, so a unit added by a future api-global migration appears in the picker only once `@stralos-co-nz/ui` carries it and admin-web's `@stralos-co-nz/ui` pin moves to that release.

#### Category editor modal

The category editor is `CategoryEditorModal` from `@stralos-co-nz/ui-reference-data`. It is shared across all screens that manage rate categories; the seed templates screen passes `orderedUnits` and a seed-specific `deleteConfirm` to tailor it for this context.

**Deleting from inside the editor.** When an operator clicks **Delete** inside the open editor, the confirmation dialog title shows the template's saved name — not the current mid-edit draft — so the confirmation describes what the API will remove rather than a name that has not been persisted yet. The confirmation states that the deleted template will be kept for history. If the delete fails, the error is shown inline inside the editor rather than as a toast notification, because the editor's accessibility overlay mutes toasts raised while it is open.

**Deleting from the list row.** The **Delete** button on a row outside the editor uses a separate `ConfirmDialog`. The confirmation states that the deleted template will be kept for history. A failure from this path surfaces as a toast notification, since no modal is open to suppress it.

#### Cartage vehicle flag

The **Cartage vehicle** flag is available only on templates with `parent_group = plant_vehicles`. Setting it means rates under that category show Payload (t) and Bin volume (m³) fields on the tenant side. The editor refuses to set this flag for any other parent group, and the API returns `422 cartage_requires_plant_vehicles` if it receives the combination.

#### Sub-categories

Sub-category templates have been retired. The tenant category tree was flattened in api-global migration `0036`, so the authored sub-category level existed only to be promoted straight back to one. The `/rate-sub-category-templates` routes are gone and this screen no longer exposes a sub-category editor.

### Disposal substance templates

Below the rate category section, the **Seed templates** screen shows a second section: **Disposal substances**. This list is the default set of substances a new organisation's excavation calculators start with. Like rate category templates, it is keyed by business type and frozen into the seed snapshot when an organisation is created. Changes here affect only organisations created afterwards.

Operators create, edit, and delete disposal substance templates in a tabular editor. The columns are **Excavated material**, **In-situ density (t/m³)**, **Bulking (%)**, **Loose density (t/m³)**, **Reinstatement**, and **Compaction from loose (%)**, the same order as the tenant **Settings → Disposal substances** screen. Each substance has:

| Field | Notes |
| - | - |
| **Name** | Shown as **Excavated material**. Up to 120 characters. Must be unique within the business type. |
| **In-situ density** | Read-only, derived as loose density × (1 + bulking ÷ 100) and shown to 2 decimal places with exact half-up rounding, matching the tenant screen. Blank in a draft while either input is invalid. Never stored or sent to the API. |
| **Bulking** | Bulking percentage (0–200%), with up to 3 decimal places. Shown as stored, without padding. |
| **Loose density** | Density in t/m³. Must be greater than 0 and no more than 10, with up to 3 decimal places. |
| **Reinstatement** | Tick to indicate that calculators may backfill a reinstatement layer with this substance. |
| **Compaction from loose** | Compaction percentage (0–100%), with up to 3 decimal places, shown as stored. Available only when **Reinstatement** is ticked. Unticking the box clears the stored figure. |

New rows are added via an entry row at the top of the table. Press Enter or click **Add** to save a new substance. To edit an existing row, click it and edit the fields inline; press Enter or click away to save. A substance can be deleted with the **Delete** button on its row — deletion is soft (the record is kept for history) and a confirmation dialog appears before the row is removed. The same deletion behaviour and consequence wording applies here as for rate category templates: existing organisations keep their own substances and are unaffected by template changes.

### Takeoff category templates

Below the disposal substances, the **Seed templates** screen shows a third section: **Takeoff categories** (`TakeoffCategoryTemplatesSection.tsx`, backed by admin-api `/takeoff-category-templates`). It is the ordered list of takeoff categories a new organisation of the selected business type starts with, frozen into the seed snapshot at organisation create (`org_seed_snapshots.takeoff_categories`, admin-api migration `0047`). A banner reads **New businesses only.**: existing organisations keep their own list.

Order is part of the seed: each row's position becomes the tenant's `sort_order`, so every row has **Up** and **Down** buttons.

| Action | Notes |
| - | - |
| **Add** | Type a name (up to 120 characters) in the entry row and press Enter or click **Add**. The header's **Add takeoff category** button focuses the entry row. New rows go to the end of the list. |
| **Edit** | Rename in place; press Enter or click **Save**. The position does not change. |
| **Up** / **Down** | Moves the row one place and saves straight away. If a move fails part-way, the list reloads to show the stored order. |
| **Delete** | Confirm in the dialog. Deletion is soft: the row is kept for history and dropped from the seed set. |

Labels are unique within a business type, compared case- and edge-space-insensitively (`409 label_taken`). Two labels are special:

* **Other** is the fallback category for new measurements and stays in every list. The API refuses a rename or delete (`409 other_protected`), so the row shows a **Default** badge and **Kept in every list** instead of **Edit** and **Delete**. It can still be moved.
* **All** is the takeoff UI's built-in show-everything filter, not a stored category. The API refuses it on create and rename (`422 label_reserved`), and the table footer explains why it is not listed.

API error messages are shown as the admin API words them. A business type added later through **Global lists** receives the default (civil contractor) list.

## Organisation billing triage

The **Billing** button on each row of the **Organisations** screen opens the billing triage panel for that organisation as a right-hand side-sheet. The panel is also addressable directly via the `?billing=<orgId>` query parameter, so a triage link can be copied and shared with another operator without navigating through the list.

### What the panel shows

The panel fetches the organisation's billing record from `GET /organisations/{id}/billing` and displays:

| Field | Description |
| - | - |
| Access | Current access mode (`Full access`, `Read only`, or `Blocked`), plus whether enforcement is active. When enforcement is off the mode is shown but has no effect. |
| Plan | `Contractor` for a contractor company, otherwise `Standard`. A billing record without a plan reads as `Standard`. |
| Billing covered by | Shown only for a company whose billing is covered by the contractor company that created it. Shows `Contractor <organisation ID>`. |
| Subscription | Stripe subscription status, or `No subscription` when none exists. |
| Reason | The restriction reason, or `No billing restriction`. Underscores in the raw value are replaced with spaces. |
| Seats used / paid | Active members and pending invitations counted against paid seats. |
| Seats available | Remaining seats before the organisation goes over its limit. |
| Payment first failed | Timestamp of the first payment failure, or `—`. |
| Access blocked after | Deadline after which the access mode escalates to `Blocked` unless billing recovers. |
| Read-only allowance | Grace period in days between payment failure and access block, or `—` when not set. |
| Current period ends | End of the current billing period. |
| Cancel at period end | Whether the subscription cancels at the current period end. |
| Price per seat | Unit amount, currency, and interval for the active price, or `—`. |
| Billing record updated | When the billing record was last written. |
| Last Stripe event | When the last Stripe webhook event was processed. |
| Stripe mode | `test`, `live`, or `Unconfigured`. |
| Stripe customer | Stripe customer ID, with a direct link to the customer in the Stripe dashboard. |
| Stripe subscription | Stripe subscription ID, with a direct link to the subscription in the Stripe dashboard. |
| Organisation ID | The organisation's internal UUID. |
| Region | The region key where the billing record lives. |

The panel shows a warning when the organisation is over its seat limit (active members plus pending invitations exceed paid seats). When the access mode is `read_only`, a note explains that writes are restricted and product access will be blocked at the deadline unless billing recovers. When the mode is `blocked`, a note explains that organisation administrators can still reach **Settings → Billing** to resolve payment.

Provider links (customer and subscription) are validated before rendering. A URL that is not a plain `https://dashboard.stripe.com` link is silently suppressed.

### Refreshing billing from Stripe

Click **Refresh from Stripe** to call `POST /organisations/{id}/billing/refresh`. The admin API fetches the latest state from Stripe and returns the updated billing record. The panel replaces the displayed values immediately without reloading the page.

The **Refresh from Stripe** button is disabled when Stripe billing is not configured for the organisation's region. A `Stripe billing is not configured for this region` notice is shown at the top of the panel when this is the case.

### Contractor links

Below the billing fields, a read-only **Contractor links** section (`ContractorLinksSection`) lists the organisation's contractor company links in both directions, from `GET /organisations/{id}/contractor-links`. Changes to links belong to the companies themselves.

When a contractor company created the organisation, the section first shows `Created by contractor <organisation ID>`. With no links it shows `No contractor links.`

Each link has a one-line summary of who, how and where it stands, for example `Acme Contracting works in this company · invited by the company · Active`:

* **Who**: `<contractor> works in this company` when a contractor works in this organisation, or `Works in <company>` when this organisation is the contractor.
* **How**: `created by the contractor`, `created by the contractor, handed over`, or `invited by the company`.
* **Status**: `Invited`, `Active`, or `Ended`.

A second line gives the detail for that status:

| Status | Detail |
| - | - |
| `Invited` | The invitation's email address and expiry time. |
| `Active` | When the link became active and, if the company took it over, when it was handed over. |
| `Ended` | When the link ended and the recorded reason. |

## Discount codes screen

The **Discount codes** screen lets operators create and manage discount codes used at signup and checkout.

### Creating a discount code

Click **Create discount code** and fill in the form:

| Field | Notes |
| - | - |
| **Code** | Alphanumeric with hyphens, 1–100 characters. Used at checkout. |
| **Name** | Internal label, up to 40 characters. |
| **Discount type** | **Percentage** (0–100%) or **Fixed amount**. Fixed amounts require a currency (NZD or USD). |
| **Applies to** | **First invoice** (`once`), **Every invoice** (`forever`), or **A number of months** (`repeating`). Repeating requires a duration of 1–120 months. |
| **Redemption limit** | Optional. Stops new redemptions after the given number of uses. |
| **Expiry** | Optional future datetime after which the code can no longer be redeemed. |

Codes cannot be edited after creation. To offer different terms later, create a new code.

### Activating and deactivating

Each listed code shows its discount value, name, duration, active state, and redemption count. Use the **Activate** or **Deactivate** button to toggle a code. **Deactivate** requires confirmation before taking effect. Deactivating a code stops new redemptions; existing subscriptions keep their agreed duration and price.

## Organisation billing dialog

From the **Organisations** screen, open the billing dialog for any organisation to inspect its billing state. The dialog fetches live data from the admin API and shows:

* subscription status and current period end;
* access mode (`Full access`, `Read only`, or `Blocked`) and the reason;
* price per user (unit amount, currency, billing interval, and whether tax is inclusive);
* active discounts — each named with percentage or fixed-amount off and the date it ends;
* seat counts (used, available, paid) and whether the organisation is over its seat limit;
* Stripe customer and subscription IDs with links to the Stripe dashboard; and
* the signup discount code shown as awaiting checkout when no subscription exists yet.

Click **Refresh from Stripe** to pull the latest state from Stripe without waiting for a scheduled sync.

## Unsaved-change guard

All record editors in the admin console prompt for confirmation before closing when the form contains unsaved changes. Clicking outside the sheet, pressing Escape, or clicking **Cancel** while the form has been edited shows a confirmation dialog. Confirming discards the changes and closes the editor. If no edits have been made, the sheet closes immediately.

Save errors are shown inside the editor sheet rather than as a toast notification.

## Sign-in flow

Operator sign-in is Google-only. Clicking **Sign in with Google** starts an OAuth2
authorisation-code ceremony with PKCE:

1. The browser fetches the Google sign-in configuration from the admin API (`GET /auth/google/config`). Both the authorisation endpoint and the redirect URI are pinned to their expected origins before the navigation proceeds.
2. A single-use, TTL-bounded state record is written to `sessionStorage`. The record carries the PKCE verifier, nonce, and the operator's intended destination path.
3. The browser navigates to Google's authorisation endpoint. The `hd` parameter narrows the account chooser to the Stralos Google Workspace domain; the binding check is the verified `hd` claim, performed server-side.
4. Google redirects to the `/auth/callback` route. The callback verifies the state record, posts the code, verifier, and nonce to the admin API, and then navigates the operator to their intended destination.

The second factor is whatever the Stralos Google Workspace domain enforces. The console does not see which factors were used.

A `?reason=expired` query parameter on the login page indicates the previous session ended. A failed ceremony redirects back to the login page with a `?authError=` code that explains the failure.

The `from` destination path is validated as a same-origin path and carried inside the `sessionStorage` state entry. It is never included in the URL sent to Google.

## Support session flow

Operators can start a support session as a Stralos user from the **Users** screen. The session runs under the user's permissions and is fully audited.

Only users with `active` status and at least one active organisation membership have the **Log in as** button enabled.

To start a session:

1. Click **Log in as** next to the target user.
2. Select the organisation context for the session.
3. Enter a reason of at least three characters. The reason is stored in the admin audit log alongside the operator's identity.
4. Click **Start support session**. The admin console calls `POST /users/{id}/impersonate` with the chosen `organisationId` and `reason`.

On success, the same sheet transitions to a result step showing two handoff options:

* **Copy support session link** — copies the single-use handoff URL to the clipboard, suitable for pasting into another browser or an incognito window.
* **Open support session** — opens the handoff URL in a new browser tab.

The sheet stays open across both steps so keyboard focus and the sheet title remain in place for the full 60-second link window.

The handoff link is single-use and expires within 60 seconds of generation.

The API records `user.impersonation_requested` after validating the user,
membership, organisation, and environment configuration but before contacting the
login service. The audit entry names the operator, target user, organisation, and
reason, so a relay failure still leaves a durable record of the accepted request.

## Environment banner

The admin console displays a banner at the top of every page when accessed on a non-production hostname. The banner is passive — it carries no action or dismiss control.

| Hostname pattern | Banner label | Description shown |
| - | - | - |
| `localhost`, `127.0.0.1`, `::1`, `dev.stralos.app`, `*.dev.stralos.app` | **Development environment** | Changes and data here do not affect production. |
| `test.stralos.app`, `*.test.stralos.app` | **Test environment** | Use this environment to verify changes before production. |

Hostname matching is case-insensitive and strips a trailing dot. A hostname such as `dev.stralos.app.example.com` does not match either pattern and receives no banner — the registrable-domain suffix is required to prevent spoofing.

No banner is shown on any other hostname, including the production domain.

## Session behaviour

The session is held in memory and mirrored to `sessionStorage`, so it survives a
refresh but not a closed tab. A `401` clears the session and returns the operator to
login. Protected deep links are retained so the operator can continue to the
intended page after signing in.

### Silent session renewal

While the console is open, the session renews automatically. The `AuthProvider`
schedules a token refresh five minutes before the 30-minute bearer token expires,
so an operator working in the console is not redirected to Google every half hour.

A `visibilitychange` or `focus` event triggers an immediate renewal check in case
the browser throttled the background timer while the tab was hidden.

Failure handling is intentionally asymmetric:

* A transient failure (network unavailable, `5xx`) retains the current session and
  retries every 30 seconds until the token reaches its own expiry. Only a `401` from
  the API clears the session — the same `401` guard used by all other requests.
* At the API's absolute 12-hour session ceiling, a refresh returns a freshly signed
  token without extending the expiry. The `AuthProvider` keeps the existing bearer in
  that case and clears the session once at that boundary, preventing a renewal loop.

Concurrent renewal attempts (for example, a `focus` event arriving at the same moment
as the expiry timer) share a single in-flight request. A logout or a completed second
sign-in wins over a late refresh response.

## Runtime boundary

The frontend mirrors the admin API's validation and mutation rules, including
membership residency and list-mutation restrictions. Supported admin-store
changes propagate through scheduled signed events when event synchronisation is
enabled for the target environment.

**Revoke** disables a membership record rather than deleting it. The admin
frontend no longer exposes the retired suite-global pricing configuration editor;
default markup is owned by each organisation in the main Stralos settings area.

See [Admin console API](https://help.stralos.app/internal/admin-console.md) for the backend and event-delivery
boundary.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.