Skip to main content
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 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.

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. 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

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.

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: 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: 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. 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: 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. 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:

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: 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 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 for the backend and event-delivery boundary.