feat: runtime URL derivation + calendar wire + org-aware TopBar #18

Merged
YousifShkara merged 57 commits from feat/runtime-url-derivation into main 2026-05-15 05:37:32 +02:00
Owner

Three related changes for the v0.2.0 milestone:

1. Runtime URL derivation (commits the previous compile-time matrix idea)

fetch.rs::api_base() and OidcConfig::for_current_origin() derive the API
base and OIDC issuer from window.location.host at runtime:

  • msp.a8n.systems (staging) -> issuer msp-api.a8n.systems, hub a8n.systems
  • msp.psa.systems (prod) -> issuer msp-api.psa.systems, hub psa.systems
  • localhost / anything else -> compile-time defaults from option_env!()

One image now serves both staging and production; no CI build matrix needed.
All five existing OidcConfig::from_env() callsites switch to for_current_origin().

2. Calendar wired to live backend (progressive enablement)

CalendarPage swaps demo_events_for() for a use_resource that calls
/api/v1/calendar/events. Falls back to the seeded Jan-2025 demo set on
auth/network/404 error and surfaces an amber banner explaining why. The
"New Appointment" CTA stays disabled until a POST endpoint exists.

3. Org-aware TopBar + companies page wire

  • AuthContext::active_membership() / active_org_name() helpers expose
    the active tenant's row without re-walking the membership list.
  • TopBar reads auth and shows the active org name as a small caption under
    "Mokosh Platform". Switching tenants refreshes it automatically.
  • CompanyListPage tries GET /api/v1/companies (tenant-scoped per
    Yousif's feature-sso work) and renders real rows when reachable. Falls
    back to the existing five-row demo set on error with the same banner.

🤖 Generated with Claude Code

Three related changes for the v0.2.0 milestone: **1. Runtime URL derivation** (commits the previous compile-time matrix idea) `fetch.rs::api_base()` and `OidcConfig::for_current_origin()` derive the API base and OIDC issuer from `window.location.host` at runtime: - `msp.a8n.systems` (staging) -> issuer `msp-api.a8n.systems`, hub `a8n.systems` - `msp.psa.systems` (prod) -> issuer `msp-api.psa.systems`, hub `psa.systems` - `localhost` / anything else -> compile-time defaults from `option_env!()` One image now serves both staging and production; no CI build matrix needed. All five existing `OidcConfig::from_env()` callsites switch to `for_current_origin()`. **2. Calendar wired to live backend** (progressive enablement) `CalendarPage` swaps `demo_events_for()` for a `use_resource` that calls `/api/v1/calendar/events`. Falls back to the seeded Jan-2025 demo set on auth/network/404 error and surfaces an amber banner explaining why. The "New Appointment" CTA stays disabled until a `POST` endpoint exists. **3. Org-aware TopBar + companies page wire** - `AuthContext::active_membership()` / `active_org_name()` helpers expose the active tenant's row without re-walking the membership list. - TopBar reads auth and shows the active org name as a small caption under "Mokosh Platform". Switching tenants refreshes it automatically. - `CompanyListPage` tries `GET /api/v1/companies` (tenant-scoped per Yousif's feature-sso work) and renders real rows when reachable. Falls back to the existing five-row demo set on error with the same banner. 🤖 Generated with Claude Code
The Dioxus 0.7 router calls history.replaceState during initialisation to normalise the URL to its declared route shape. Our /auth/callback route definition has no query bindings, so the OAuth code+state were being erased before AuthCallbackPage could read window.location.search, surfacing as "Sign-in failed: missing code".

Fix: capture window.location.search in a thread-local at program entry (before dioxus::launch mounts the Router), and have complete_login read from the snapshot. Falls back to live location.search if no snapshot is taken so the helper remains usable from in-app navigations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# Conflicts:
#	Cargo.toml
#	src/hooks/auth.rs
#	src/hooks/mod.rs
#	src/main.rs
main's audit-batch refactor (655ba4a) renamed the base compose service from `dev-mokosh-client` to `client`, switched the private network's local key from `dev-mokosh-private` to `private` with a per-USER name (`dev-mokosh-private-${USER}`), and marked it `external: true`. The SSO overlay still targeted the old names, which would silently create a second service rather than override the existing one.

Three changes:
- Service key in compose.dev-sso.yml: `dev-mokosh-client` -> `client`. container_name no longer restated (the base file already sets it).
- Network reference: `dev-mokosh-private` -> `private` (the base's local key, which maps to the per-USER name).
- justfile dev-sso and down: defensively `docker network inspect || create dev-mokosh-private-${USER}` since compose refuses to start (or even teardown) when an external network is missing.

Verified `docker compose --file compose.yml --file compose.dev-sso.yml config` produces a clean merge: one `client` service joined to `dev-mokosh-private-yousif` plus `network-traefik-public`, Traefik labels intact, env vars correct.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tab title was rendering as "Mokosh Platformdioxus | " - dx 0.7 injects a `<title>` from `[web.app] title` into the served HTML, falling back to its own "dioxus | " default when the field is missing. The previous P1-09 fix removed `title` from Dioxus.toml and left a literal `<title>Mokosh Platform</title>` in index.html, so the literal concatenated with the dx-injected default.

Set `title = "Mokosh Platform"` in `[web.app]` and drop the literal from index.html. Dioxus.toml is now the only place the title lives, dx stops injecting its default, and there is exactly one `<title>` in the rendered HTML.
dx 0.7.7 appends `[web.app] title` (defaulting to "dioxus | " when unset) to whatever already sits inside the <title> element in index.html. Previous attempts:

- literal in index.html, no toml title -> "Mokosh Platformdioxus | " (dx default got appended).
- toml title set, no literal in index.html -> empty title, browser falls back to URL "localhost:4301" (dx only appends, it never injects a fresh <title>).
- literal in index.html AND toml title both set to "Mokosh Platform" -> "Mokosh PlatformMokosh Platform" (the original P1-09 doubling).

Set `title = ""` so the append is a no-op, keep the literal `<title>Mokosh Platform</title>` in index.html as the single source of truth. Verified by inspecting the built artifact at target/dx/mokosh-client/debug/web/public/index.html.
The SPA's /login page was collecting email + password in form fields,
then start_login redirected to mokosh-server's /login form WITHOUT
sending those credentials. Users typed creds twice: once into a form
that threw them away, once at the IdP. Looked like a bug ("I login
then it takes me to another login page").

Authentication is delegated to the OP. The SPA's only job at /login is
to kick off the OIDC code+PKCE flow, so the page now shows two
buttons: \"Sign in with Mokosh\" (start_login -> authorize -> /login on
OP) and the existing \"Sign in with Google\" popup. No form fields,
no remember-me, no forgot-password link (the IdP owns those screens).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reverts the form removal from eb9fed3 per user request. The familiar
email + password + remember-me + forgot-password form is back as the
primary UI; "Sign in with Mokosh" sits below as an explicit shortcut
to the same OIDC flow. Both paths call into use_login_form's submit
callback, which kicks off start_login.

To wire one callback into two onclick/onsubmit handlers, use_login_form
now returns a Callback<()> (Copy) instead of an `impl Fn()` (not
Copy). Same behaviour, no other call-site impact.

Note: the form fields are still visual today - submit triggers the
OIDC redirect, which sends the user to mokosh-server's /login form
where credentials are actually entered. A follow-up could wire the
SPA form to POST directly to /v1/auth/login if we want one-screen
login (cookie-only session, no Bearer token for PSA calls).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the admin-side flow for issuing invites and the recipient-side flow for accepting them, paired with the server-side endpoints landed on feature-sso of mokosh-server.

- InviteCreatePage at /settings/users/invite: admin-only form (email, role, optional note, expiry). Maps backend errors (409 already-invited, 400 invalid_request field errors) to inline UI states.
- InviteListPage at /settings/users/invites: admin-only list of open invites with revoke + resend actions.
- InviteAcceptPage at /invite/:token: public route (no auth). Loading -> Ready (preview) -> Submitting -> Done state machine. Collapses 404/used/revoked/expired into a single \"invite not available\" message to mirror the server's enumeration-resistant 404 contract. Sets password + optional first/last name, then redirects to /login on success.
- Route + module wiring in lib.rs and pages/mod.rs.

Pairs with server commit 8bda417.
The login screen used to send users through an OIDC redirect that landed them on the OP's own login form (a second, differently-styled page asking for the same credentials). With mokosh-clients being the only relying party today, that dance just confused the UX.

- use_login_form now POSTs the email + password to /v1/auth/login with our client_id and scope, then unpacks the returned token bundle into AuthContext exactly like /auth/callback does after a code exchange. Single round-trip, single page.
- Added password_login() in modules::oidc::flow as the typed wrapper around /v1/auth/login. Tokens carry id_token (required), access_token, and refresh_token; we treat a missing tokens field as a protocol error (probably a server compiled against an older API).
- Dropped the "Sign in with Mokosh" button + its OR divider from the login page. Google sign-in stays.
- The OIDC redirect helpers (start_login, complete_login, refresh_tokens) are kept; the background refresh loop and /auth/callback still work, and they will be the entry points if/when this SPA ever needs to authenticate against a different OP.

Pairs with mokosh-server feature-sso commit 3ebc1d6.
cargo binstall was picking up the latest dx (0.7.9), which mismatched the dioxus library version (0.7.7) declared in Cargo.toml. The drift caused dx serve to fail with "Failed to write executable: No such file or directory" on every rebuild because 0.7.9 expects an output layout 0.7.7 doesn't produce. Pinning the CLI to 0.7.7 restores the working baseline.
Hitting browser-back after logout was reviving the authenticated session. Two causes, both fixed here:

- Logout used `location.set_href` to redirect, which leaves the prior `/dashboard` URL in history. Back-button replays it, the SPA reboots at `/dashboard`, and the page flashes protected content before any redirect-to-login fires. Switched to `location.replace("/login")` so the authenticated entry is evicted from history.
- Logout never revoked the refresh-token family server-side (the cross-origin redirect to `/oauth2/logout` was unreliable and is for back-channel-logout of OTHER relying parties anyway). Added a direct RFC 7009 POST to `/oauth2/revoke` with the SPA's refresh token before navigating, so any tokens that survive in browser caches are useless.

`revoke_refresh_token` is exported from `modules::oidc` for symmetry with `refresh_tokens`. The OP-side `/oauth2/logout` machinery stays in place for future external relying parties.
Even with `location.replace` and refresh-token revocation, browser back from /login could still flash the dashboard. The cause is bfcache: browsers snapshot the entire JS heap (including in-memory auth signals) when a page is unloaded and restore it verbatim on back-navigation, bypassing any code that would have noticed the user is now logged out.

Added `use_bfcache_invalidator`, mounted on App, that listens for `pageshow` and triggers a full `location.reload` whenever `event.persisted` is true. Persisted=true only fires for bfcache restorations, so normal page loads pay nothing. After reload, `initial_auth_context()` runs against the (now empty) token store and the route guards send the user to /login.

We read `persisted` via `js_sys::Reflect::get` to avoid pulling in the PageTransitionEvent web-sys feature.
The previous fix removed `/dashboard` from history in theory but in practice the back button still returned to it. The cause was order-of-operations:

1. Logout cleared the auth signal first, which scheduled a Dioxus re-render.
2. The re-render fired the route guard's `use_effect`, which saw an unauthenticated user on `/dashboard` and called `navigator.push(Login)` - pushing `/login` ON TOP of `/dashboard` in history rather than replacing it.
3. The async revoke task then ran `location.replace("/login")` from the now-current `/login` URL, which only replaced the just-pushed `/login` entry. `/dashboard` stayed at history[-1], reachable via back.

Fix: do the `location.replace` synchronously, before any auth signal write. The full-page reload that follows resets every in-memory bit, so we don't need to clear the signal manually. The refresh-token revoke runs as a fire-and-forget spawn; modern browsers complete in-flight fetches even after the document starts unloading.
Previous logout fixes (history.replace, refresh-token revoke, ordering) addressed surface symptoms but not the root cause: `use_require_auth` checks auth in `use_effect`, which fires AFTER the protected route renders. On a back-button popstate the router rerenders Dashboard, paints its content, and only then notices the user is logged out and redirects. Result: a clean flash of authenticated UI on every back navigation.

Added an `AuthGuard` layout component wrapped around every authenticated route via `#[layout(AuthGuard)]`. The guard checks the auth signal during render (not after) and returns `rsx! {}` plus a `nav.replace(Route::Login {})` the moment the user reads as unauthenticated. No descendant route component ever gets rendered, so there is no DOM commit and no flash. `use_require_auth` calls inside individual pages remain as a redundant safety net.

The portal routes use a different layout/auth model and stay outside the guard; the catch-all 404 stays public so logged-out users see a real 404.
The previous commit put `AuthGuard` after the `Route` enum, which broke the in-container build with `cannot find value AuthGuard in this scope`. The Routable derive expands the `#[layout(AuthGuard)]` reference at the enum site, so the component must already be in scope. Local cargo check missed it because the target dir was warm. Moved AuthGuard up to module top, marked it `pub`.
Lists every device the signed-in user has open and lets them revoke any of them. Pairs with the server-side /v1/auth/sessions endpoints landed on mokosh-server feature-sso.

- New page `pages::sessions::SessionsPage` and `Route::SessionsList` mounted at `/settings/sessions`. Sits inside the AuthGuard layout.
- Settings index gains an "Active sessions" card.
- Two helpers in `modules::oidc::flow`: `issuer_get_authed` and `issuer_post_authed_empty`. Both target the issuer URL directly (cross-origin, Bearer-authed) since the auth router is not nested under the same-origin /api/* proxy.
- Each row shows user_agent, IP, last-active timestamp, and signed-in timestamp. Revoke posts to /v1/auth/sessions/{id}/revoke and reloads the list. Revoking the user's current session signs them out via the next 401 (refresh family is killed server-side, so token rotation fails).

User-profile editing and TOTP/MFA are next per the agreed plan.
Pairs with mokosh-server feature-sso 1d48e5c.

- InviteCreatePage, InviteListPage, and the row-level Resend/Revoke calls now hit /v1/auth/invites at the issuer URL via the issuer-Bearer pattern (issuer_post_authed / issuer_get_authed). The previous /api/v1/auth/invites path was being rewritten by the same-origin proxy onto the legacy psa_router which has no invite endpoints, so every call was 404'ing in the browser. End-to-end browser testing was deferred until now; the curl smoke test had bypassed this layer entirely.
- issuer_post_authed (with body) added in modules::oidc::flow alongside the existing issuer_get_authed and issuer_post_authed_empty.
- New InviteSuccessCard component shown after a successful issue: displays the accept_url with a Copy button (uses navigator.clipboard.writeText through Reflect to skip the web-sys Clipboard feature). Admin can copy and paste into Slack / DM / whatever until SMTP is wired up. "Invite another" resets the form; "Done" navigates to the pending list.
- IssueInviteSuccess gains accept_url, ResendInviteSuccess added (the resend response now also returns the link though the row UI does not yet display it on resend).
AuthContext was memory-only by design (XSS posture). The downside: typing a protected URL into the address bar, refreshing the tab, or opening a bookmark all dropped the user back to /login because the WASM booted with an empty AuthContext and AuthGuard immediately replaced to Login.

We now mirror the access/id/refresh-token bundle into sessionStorage on login and on every refresh-rotation. `initial_auth_context` reads it on boot and rebuilds AuthContext from the id_token claims (same parsing as the OIDC callback). `use_logout` and the refresh-loop's failure path clear it. `sessionStorage` (not localStorage) means the bundle disappears on tab close, matching the OP-session lifetime and confining the rest-of-window XSS exposure to the same tab a memory-only token already lives in.

- New helpers in modules::oidc::storage: save_auth / load_auth / clear_auth on a `StoredTokens` payload.
- initial_auth_context calls rehydrate_from_storage in non-bypass paths (both debug and release builds), preserving the existing ADMIN_EMAIL/ADMIN_PASSWORD compile-time bypass for dev.
- access token holder (api::set_access_token) is repopulated synchronously during rehydrate so the very first authed fetch after reload carries the Authorization header.
Replaces the hardcoded John Smith / Jane Doe demo with a live admin user-management page backed by the new /v1/auth/users endpoints on mokosh-server feature-sso.

- Fetches the tenant's users via the issuer-Bearer pattern (issuer_get_authed).
- Each row: avatar initial, display name (first+last or fallback to email), role badge, status badge, last-login relative time. Suspend/Reactivate row action toggles the account; refetches on success. Self-suspension is rejected at the API layer so the only-admin case can't lock themselves out by accident.
- Page header now has TWO actions: "Pending invites (N)" linking to /settings/users/invites (with a live count fetched alongside the user list, best-effort), and "Invite user" linking to /settings/users/invite. Makes the registration flow discoverable from the page admins land on first.
- Page is admin-gated via use_require_role("admin").

Pairs with mokosh-server bdc1643.
The API already refuses self-suspend (and now also refuses suspending the last active admin), but rendering an action button that would be refused is bad UX. The current user's row now shows "(you)" next to the name and renders no row action at all - reactivate is unreachable from your own row anyway since you couldn't be signed in if you were suspended.

Pairs with mokosh-server's last-active-admin guard.
Two fixes that pair up.

1. /invite/<token> was 404'ing because InviteAcceptPage used api::get / api::post which target /api/v1/* via the same-origin proxy. The invite endpoints live on the SSO router merged at the mokosh-server root (/v1/auth/invites/by-token/<token>{,/accept}) - not under /api/v1. Same root cause as the admin-side bug fixed in db821a9. Switched to two new public-cross-origin helpers issuer_get / issuer_post in modules::oidc::flow.

2. The InviteList row "Revoke" button looked broken because the server returns 204 NoContent and issuer_post_authed unconditionally tries to JSON-parse the response body, which then fails. Added issuer_post_authed_no_body for endpoints we know return no body, and the row-level revoke uses it. Resend continues to use issuer_post_authed because its response carries the new accept_url.

After this an admin can issue, copy the link, share it, the recipient can land on /invite/<token>, accept, sign in, and the issuer side can revoke or resend without the silent 204 / JSON-parse confusion.
Pairs with mokosh-server feature-sso bd6ecd7 to complete the single-login bridge from docs/mokosh-auth/09-single-login-bridge.md.

When /oauth2/authorize 302s an unauthenticated user to /login it appends ?return_to=<serialized authorize query>. The SPA now:

- Reads return_to from window.location.search at LoginPage mount.
- Validates it through a strict guard (must start with `response_type=`, no CR/LF). Rejects everything else so this query parameter cannot be turned into an open-redirect to an arbitrary URL.
- On successful login, builds <issuer>/oauth2/authorize?<return_to> and uses location.assign() instead of nav.push(Dashboard{}). The OP-session cookie just set by /v1/auth/login rides this top-level navigation; authorize sees the session and 302s on to the RP with a code.
- If a user lands on /login already authenticated AND has a return_to, the same bounce fires from the existing redirect-if-authenticated effect.

Implementation:
- New helpers `read_safe_return_to` and `is_safe_return_to` in pages/auth.rs (the latter is the open-redirect guard).
- `use_login_form_with_return_to(Option<String>)` in hooks/auth.rs is the new entry point; `use_login_form()` becomes a thin wrapper passing None for backward compatibility.
- LoginPage threads return_to through both the redirect-if-authenticated effect and the new hook.

No behaviour change for plain `/login` visits (no return_to => navigator.push(Dashboard{}) as today).
Pairs with mokosh-server feature-sso a527e93.

Two new public pages:
  - /signup            (SignupPage)         email entry; POSTs /v1/auth/signup; shows "check your email". Same UX whether the email is new or already in use (server is enumeration-resistant; SPA does not break that).
  - /signup/:token     (SignupCompletePage) preview-load + password set form. Mirrors invite_accept's three-state pattern: Loading -> Ready -> Done. Error states collapse to a single "link not available" message.

Both call the issuer cross-origin via the existing issuer_get / issuer_post helpers (no Bearer needed, public endpoints).

Login form's "Don't have an account? Contact us" replaced with "Don't have an account? Sign up" linking to /signup. The server still gates the endpoints behind MOKOSH_PUBLIC_SIGNUP_ENABLED, so on PSA hub builds that env stays false and the link lands on a page that surfaces "Sign up unavailable" with a back-to-sign-in.

No env-flag at the SPA level for now: the link is always rendered. If the deployment disables signup, the API responds with signup_disabled and the SPA renders an explanatory state. Cleaner than build-time stripping for a single text link.
Signed-off-by: David Randall <David@NiceGuyIT.biz>
Pairs with mokosh-server feature-sso 176d56d.

InvitePreview now carries a `kind` field, defaulted to `"new_account"` for older servers (forward-compatible). Two branches in the Ready state:

- new_account: unchanged - first/last name, password + confirm, "Accept invite" button.
- join_tenant: no password fields, no name fields. A short explanation that the existing account will be added to the tenant, and a "Join {tenant_name}" button.

The submit closure builds different request bodies per branch (full body for new_account, empty {} for join_tenant; the server ignores the password on the join path anyway). The "Done" copy is gentler now ("Done" rather than "Account created") so it fits both flows.

End-to-end intent: an admin can invite an existing Mokosh user by email, the recipient lands on /invite/<token>, sees a single confirm button, accepts, becomes a member of the org tenant while keeping their personal tenant. No duplicate accounts.
Pairs with mokosh-server feature-sso 3143ed4.

AuthContext changes:
  - New `active_tenant_id: Option<Uuid>` field, sourced from the new mokosh_active_tenant id-token claim (and the LoginResponse for explicit confirmation).
  - New `memberships: Vec<MembershipView>` cache. Loaded on app start by the new use_memberships_loader hook, which mounts on App alongside use_token_refresh and re-fetches whenever the user transitions to authenticated with an empty membership list (login, page reload that rehydrates from sessionStorage).

OIDC types:
  - IdTokenClaims gains `active_tenant_id` (mokosh_active_tenant), defaulted Option for backcompat with older servers.

Login + rehydrate:
  - use_login_form now reads claims.active_tenant_id and stores it in AuthContext. Falls back to home tenant if the server omits the claim.
  - rehydrate_from_storage does the same on URL-bar / reload.

New page /settings/active-tenant:
  - Lists every membership with tenant name + kind badge ("personal" or "org"), role, and a "Current" marker.
  - Switching POSTs /v1/auth/active-tenant {tenant_id, client_id}, replaces the in-memory + sessionStorage token bundle with the fresh one returned, and reloads the SPA so every page refetches under the new tenant.
  - Errors surface inline; rate-limit and forbidden responses pass through as text.

Settings index gains a "Switch tenant" card. The page is reachable for everyone with at least one membership; users with only one see their current tenant marked active and no other rows.
Both /settings/users/invite and /settings/users/invites are reachable from /settings/users via the action buttons in the page header, but neither page had a path back. Added a "← Back to user management" Link above each page's heading.

Sidebar nav still works for jumping anywhere; this just gives a clear in-content path back to the parent page so the invite flow does not feel like a one-way trip.
SessionView gains `is_current` (server flag from the new mokosh_op_session_id claim, default false for older servers). The matching row renders a green "Current session" badge next to the user-agent line, and its action button changes from "Revoke" to "Sign out" so the consequence of clicking is clearer.

Pairs with mokosh-server feature-sso ec8f1dd.
Revoking the current session via /settings/sessions hit the server (session row + refresh family revoked) but left the SPA holding a still-valid access token in memory + sessionStorage. The user could keep clicking around until the access token's 10-minute exp / next refresh failure.

When the row being revoked is `is_current`, mirror what `use_logout` does after the server call: clear the persisted token bundle, drop the in-memory access token holder, and `location.replace("/login")` so the prior URL stays out of history.

Other-device revokes are unchanged - they refetch the list and the row disappears.
Pairs with mokosh-server feature-sso 9f8f07a. The pages have existed as stubs since the SPA was first written; this commit removes the TODOs and calls the new endpoints.

ForgotPasswordPage:
  - Submit POSTs /v1/auth/password-reset {email}. Always renders the existing "If an account exists for that email, we've sent password reset instructions." page on the way out. Whether the email is registered or not, network or not, the user sees the same screen - mirrors the server's enumeration-resistant 200 shape.

ResetPasswordPage:
  - Submit POSTs /v1/auth/password-reset/by-token/{token}/complete with {password, password_confirmation}.
  - Branches on the response:
      * 200 -> existing "Your password has been reset" + link to /login.
      * 400 with {error: invalid_request, details: { <field>: <msg> }} -> field message surfaced inline (catches weak password, mismatched confirmation).
      * 404 reset_not_found -> "This reset link is invalid, expired, or has already been used."
      * 429-flavored -> "Too many attempts."
      * Anything else -> generic "Could not reset password: <raw>".
  - Removed the 8-char password length pre-check that lived on the SPA: the server's `validate_password_strength` is authoritative and returns a more useful error string than "at least 8 characters".
  - Added a small parse_first_field_error helper at the top of auth.rs that pulls the first details.<field> message out of the standard invalid_request envelope.

Existing UX is preserved: the "Forgot your password?" link from the login form still routes to /forgot-password; on success ResetPasswordPage links the user back to /login.
`LoginPage` forks on the new `mfa_required` response from `/v1/auth/login`. When the server returns a challenge, the form pivots to a single-field "Enter your authenticator code" prompt that POSTs to `/v1/auth/mfa/verify` with the challenge + the code. Both six-digit TOTP and 11-char recovery codes flow through the same input. The challenge stays valid across as many invalid-code submits as the server's rate-limit allows; a "Cancel and sign in again" button clears state and brings the user back to email+password. `challenge_not_found` (expired or already-consumed) collapses to "Your code prompt expired. Please sign in again." and resets the form.

`/settings/security` (new `SecurityPage`): state-machine over `/v1/auth/mfa/status`. Disabled state renders an "Enable two-factor" button. Enrolling state walks the user through three sub-steps: ScanQr (renders the server-rendered SVG QR + an `<details>` for the typeable secret), ConfirmCode (a single 6-digit input POSTed to `/mfa/confirm`), and SaveCodes (renders the 10 recovery codes in a font-mono grid with a "I've saved them" button). Enrolled state renders "X recovery codes remaining" with a yellow "Low; regenerate now" hint when low_warning is true, plus two action buttons: "Regenerate recovery codes" and "Disable two-factor". Both action buttons trigger an inline step-up flow: POST `/mfa/step-up/start` -> render an "Enter your code to continue" prompt -> POST `/mfa/step-up/verify` -> use the returned `step_up_token` to POST the destructive endpoint. Regenerate displays the new codes via the same one-shot `RecoveryCodesView`; disable refetches status (and re-renders the Disabled state).

Plumbing: `password_login` now returns a `LoginOutcome` enum (`Success(Tokens)` or `MfaRequired { challenge, expires_in }`). New `mfa_verify(cfg, challenge, code)` function POSTs to `/v1/auth/mfa/verify` and returns `Tokens`. `LoginFormState` grows `mfa_challenge: Option<String>` and `mfa_code: String`. `use_login_form_with_return_to` now returns `(state, submit, submit_mfa)` and `LoginPage` picks the callback based on whether the challenge is set. The token-into-context plumbing is extracted to a shared `apply_login_tokens` helper so both branches share it.

Tested: `cargo check --target wasm32-unknown-unknown` in the docker rust-builder passes. Manual smoke (login + MFA prompt + enrollment + regenerate + disable) is gated on a fresh `dx serve` against the live dev-sso stack.
`/settings/profile` (`ProfilePage`): reads `/v1/auth/me` to seed a form for first_name / last_name / timezone / avatar_url, PUTs `/v1/auth/me/profile` on save with empty-string-to-NULL normalization. Below it a Change Password card POSTs `/v1/auth/me/password` with current/new/confirmation; success and error states render inline. Form follows the same Card + Input components the rest of Settings uses; no new visual style.

`/settings/audit-logs` (`AuditLogsPage`, admin-only via `use_require_role`): paginated table off `/v1/auth/audit-logs?limit=50&offset=&kind=`. Filter input (event_kind), Previous / Next buttons advance offset by 50. Severity badges color by level (gray / yellow / red).

The avatar dropdown's "Profile" link in `layout.rs` now points at `Route::Profile` instead of the placeholder `Route::Settings`. The Settings index gets two new SettingsCards (Profile and Audit logs).
The Security page was setting `status` to Err whenever start_enrollment failed, so a 409 conflict ("already_enrolled") would replace the entire card body with `Failed to load: ...`. Now the start-enrollment error path writes to a separate `notice` signal that renders as an inline banner above the card body, leaving the status fetch's result alone. When the error specifically carries "already_enrolled" we bump the refetch counter so the SPA re-reads /v1/auth/mfa/status, which pulls the latest DB state into the Enrolled view. Other start-enrollment errors render as a red banner without otherwise disturbing the page.
LoginPage MFA prompt now fires `submit_mfa` automatically the moment the user types the sixth digit of an all-numeric code. Recovery codes (11-char alphanumeric with a hyphen) still require the Verify button because we can't tell mid-input that a partial recovery code is "complete."

Added a "Trust this browser for 7 days" checkbox on the MFA prompt. When checked, the verify request carries `remember_device: true`; the server responds with a 7-day opaque `trust_token` which the SPA stores in localStorage under `mokosh.trust_token`. On the next sign-in, `password_login` reads the token from localStorage and sends it; the server skips the MFA prompt and the user is logged in directly (acr stays at the MFA LOA because the server marks the session amr=['pwd', 'trusted_device']).

Friendly error messages: invalid code now reads "That code didn't match. Try again." (with the mfa_code field cleared so the user can re-type), challenge_not_found stays "Your code prompt expired. Please sign in again.", and rate_limited gets its own "Too many attempts. Wait a few minutes before trying again." All three keep the page on the MFA screen rather than nuking the challenge state, except the expired-challenge case which has to reset.

Plumbing: `password_login` gained a `trust_token: Option<&str>` parameter; `mfa_verify` returns a new `MfaVerifyOk { tokens, trust_token }` and takes `remember_device: bool`. `LoginFormState` gains `remember_device: bool`.
The Sessions row now reads "Chrome on Windows" (parsed from the UA fragment) instead of the full 200-character user-agent string. Users can click "Rename" inline to give a session a custom label like "Work laptop" or "Phone"; the new label POSTs to /v1/auth/sessions/{id}/rename and the row reloads. Cancel reverts to the placeholder; saving an empty string clears the custom name and falls back to the UA-derived default.

Combined with the server-side UA dedup in the matching mokosh-server commit (f30733e), the Sessions page now actually represents "one row per logical browser" rather than "one row per /v1/auth/login call".
Phase 08 from docs/bunyip/08-mokosh-clients-cleanup.md. The Bunyip hub now owns every login / signup / password-reset / invite-accept / profile / security / sessions / audit-logs / user-management page; mokosh-clients keeps only its PSA features and the OIDC plumbing it needs as a relying party.

Deletes:
- pages/{auth, signup, signup_complete, invite_accept, invites, settings, security, profile, sessions, audit_logs}.rs - 10 files, ~3.6 KLOC.
- hooks/auth.rs::{LoginFormState, use_login_form, use_login_form_with_return_to, apply_login_tokens, read/write/clear_trust_token, TRUST_TOKEN_STORAGE_KEY} - dead with the LoginPage gone.
- Route enum entries for Settings / UserManagement / InviteCreate / InviteList / SessionsList / Security / Profile / AuditLogs / TeamManagement / NotificationSettings / IntegrationSettings / BillingSettings, plus their wrapper components in lib.rs.
- Sidebar's "Configuration > Settings" NavItem.

Redirect stubs (`HubRedirect` component in lib.rs):
- /login, /forgot-password, /reset-password/:token, /invite/:token, /signup, /signup/:token now render a one-line "Redirecting..." and window.location.replace() to the matching path on the hub. Bookmarks keep working; SPA does not 404.

Avatar dropdown:
- Profile / Apps link via cross-origin <a href=hub_url>. Logout clears local tokens and redirects to ${hub}/login.

Plumbing:
- OidcConfig grows a MOKOSH_HUB_BASE_URL compile-time env (with a localhost default) plus a `hub_url(path)` helper. compose.dev-sso.yml sets it to https://${USER}-bunyip.a8n.run.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: YousifShkara <yousif@niceguyit.biz>
- compose.dev-sso.yml: MOKOSH_HUB_BASE_URL switches from https to http; bunyip hub is reachable over plain HTTP in dev.
- src/modules/oidc/config.rs: localhost fallback port corrected from 4302 to 4400 (bunyip-web's actual dev port).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: YousifShkara <yousif@niceguyit.biz>
The /login route is a HubRedirect that bounces to bunyip; if the AuthGuard sent un-authed users there, the user would land on bunyip without any PKCE state stashed on this origin - and even the SSO bridge round-trip would land on /auth/callback with no PendingFlow to exchange the code against. The Bunyip launcher hit exactly that path and surfaced "storage: no pending OIDC flow".

Fix: AuthGuard calls start_login directly on un-authed (and waits while is_loading). start_login:

- stores PendingFlow (code_verifier + state + nonce) in *this* SPA's sessionStorage so /auth/callback can complete the code exchange.
- replaces the page with /oauth2/authorize. From there: with an OP cookie (launched from the hub) authorize 302s straight back to /auth/callback?code=; without one, authorize 302s to bunyip's /login?return_to=, the user signs in, and the SSO bridge closes the loop.

The /login HubRedirect stays in place as a friendly stub for users with the legacy URL bookmarked - that path is now reachable only via direct navigation, not from any in-app guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: YousifShkara <yousif@niceguyit.biz>
Sequences `down` then `dev-sso` so a single command tears the stack down and brings the rebuilt one up. Useful after pulling code or editing compose env vars where you need the new state inside running containers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: YousifShkara <yousif@niceguyit.biz>
Implements the mokosh-clients half of docs/migration/settings-split.md. The active-tenant claim is identity-level so the switcher belongs on the hub next to Profile / Security / Sessions; bunyip's new /settings/active-tenant page took over the surface.

- pages/active_tenant.rs deleted.
- pages/mod.rs drops the module.
- lib.rs ActiveTenant wrapper component is now a HubRedirect to https://${hub}/settings/active-tenant; the /settings/active-tenant route still exists so bookmarks survive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: YousifShkara <yousif@niceguyit.biz>
Pairs with bunyip's new /logout page. The previous flow only cleared this SPA's local tokens and sent the user to ${hub}/login; the .a8n.run-scoped OP session cookie on mokosh-server stayed alive and bunyip's localStorage tokens stayed alive too, so the SSO bridge would silently sign the user back in.

UserMenu now redirects to ${hub}/logout, which owns the full teardown (POST /v1/auth/logout to clear the OP cookie, clear bunyip's tokens, then /login). This SPA still clears its own local state synchronously before redirecting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: YousifShkara <yousif@niceguyit.biz>
Part of the post-demo polish pass (the bunyip-web side lives in a sibling commit on migrate/bunyip-frontend-foundation). Focus here is honesty: every visible button now either does something or shows a "Coming soon" tooltip when its backend isn't wired yet.

pages/calendar.rs: the page used to render a hard-coded January 2025 grid with prev/next/today buttons that had no onclick. Refactored to drive the grid from an `active_month: Signal<NaiveDate>` and a `calendar_cells` helper that generates the 6x7 day grid via chrono. Prev/next/today are now wired client-side. Demo events stay pinned to specific January 2025 days (`demo_events_for`), so navigating to other months renders an empty grid - clearer than carrying stale events around. The "New Appointment" CTA and the Week/Day view toggles stay visible (roadmap signal) but use `disabled + title="Coming soon..."` so they don't pretend to work. TODO(calendar-api) and TODO(calendar-views) markers point at what each stub is waiting on. Dispatch Board page gets the same prev/next/today wiring for day-level navigation.

pages/time.rs: timesheets week selector now drives off a `week_start: Signal<NaiveDate>`, with prev/next-week buttons mutating it via chrono. Week label is generated dynamically instead of hard-coded "January 13-19, 2025".

components/layout.rs: notifications bell in the top bar stubbed with `disabled + title="Notifications coming soon"`. Keeping it visible (and keeping the red unread-dot) is intentional - users still see the affordance, but it doesn't silently no-op. TODO(notifications-api) marker.

Verified: `cargo check --target wasm32-unknown-unknown` clean (only the one pre-existing unused-doc-comment warning); `cargo fmt --all` applied.
Drops the compile-time assumption that one image is bound to one origin.
The deployed image now adapts based on the browser hostname:

  msp.a8n.systems (staging)  -> issuer msp-api.a8n.systems, hub a8n.systems
  msp.psa.systems (prod)     -> issuer msp-api.psa.systems, hub psa.systems
  localhost / anything else  -> compile-time defaults from option_env!()

Two derivations needed:
- `api_base()` in hooks/fetch.rs replaces the previous `/api/v1` constant.
- `OidcConfig::for_current_origin()` overrides `issuer` and `hub_base_url`
  from the host when it matches `msp.<tld>`. All five `from_env()` callsites
  (layout.rs, lib.rs x2, auth_callback.rs, hooks/auth.rs x3) switch to it.

This unlocks a single `:v0.1.0` mokosh-clients image serving prod (psa.systems)
and a `:latest` build serving staging (a8n.systems) without a CI build matrix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the hardcoded demo_events_for() with a use_resource that
fetches GET /api/v1/calendar/events when authenticated.

Progressive enablement pattern:
- EventSource::Backend  -> render whatever the API returned (incl. [])
- EventSource::Demo     -> fall back to the seeded January-2025 demo events
                           and surface an amber banner explaining why

Falls back to demo when:
- No access token (user not signed in)
- API request fails (404 because deploy hasn't picked up the route yet,
  network error, CORS reject, etc.)

The "New Appointment" CTA stays disabled - it'll enable once the
backend gains a create endpoint. The whole shape stays demo-able even
when the backend can't talk to the page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(client): org-aware TopBar + companies page wired to live backend
Some checks failed
Build OCI container / Build and push mokosh-www image (push) Failing after 1m10s
Check / clippy + fmt + tests (pull_request) Failing after 8s
4d956c1fc5
Block 6 (org-aware slice):
- `AuthContext::active_membership()` and `active_org_name()` helpers look
  up the active tenant's membership row by id; no extra context provider
  needed since the data lives on AuthContext already.
- TopBar now reads auth and shows the active org name as a small caption
  under "Mokosh Platform" once auth resolves. Hidden until then to avoid
  a confusing flash on cold load. Switching tenants via the existing
  switcher refreshes the caption automatically (signal-driven).

Block 5 (second component wire):
- CompanyListPage tries GET /api/v1/companies (tenant-scoped per Yousif's
  feature-sso work) and renders real rows when the backend is reachable.
- Falls back to the existing five-row demo set on auth/network/404 error
  and surfaces the same amber "backend not reachable" banner Calendar uses.
- humanize_company_type() maps the server's lowercased CompanyType enum
  tag to the title-case label the CompanyRow badge variant keys on.

Block 7 (branding context) intentionally minimal for v0.1.0: the TopBar
caption is the active-org indicator. PortalLayout / AuthLayout brand
strings stay "Mokosh Platform" because their context isn't the user's
own org (Portal = MSP-owned brand for end customers; Auth = pre-login).
Logo support deferred until MembershipView gains a logo_url.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
docs: milestone-1 handoff for the PSA Systems v0.1.0 cutover
Some checks failed
Check / clippy + fmt + tests (pull_request) Failing after 12s
Create release / Create release from merged PR (pull_request) Has been skipped
de872bbedb
Captures session state across all four PRs (bunyip, mokosh-server,
mokosh-clients, docker) plus the SOPS/OAuth/DNS work still pending,
the three-way URL split, the image-tag policy, and the deploy order.
Mirror copies in mokosh-server/dev-docs and bunyip/dev-docs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
YousifShkara deleted branch feat/runtime-url-derivation 2026-05-15 05:37:33 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-apps!18
No description provided.