feat: runtime URL derivation + calendar wire + org-aware TopBar #18
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/runtime-url-derivation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Three related changes for the v0.2.0 milestone:
1. Runtime URL derivation (commits the previous compile-time matrix idea)
fetch.rs::api_base()andOidcConfig::for_current_origin()derive the APIbase and OIDC issuer from
window.location.hostat runtime:msp.a8n.systems(staging) -> issuermsp-api.a8n.systems, huba8n.systemsmsp.psa.systems(prod) -> issuermsp-api.psa.systems, hubpsa.systemslocalhost/ anything else -> compile-time defaults fromoption_env!()One image now serves both staging and production; no CI build matrix needed.
All five existing
OidcConfig::from_env()callsites switch tofor_current_origin().2. Calendar wired to live backend (progressive enablement)
CalendarPageswapsdemo_events_for()for ause_resourcethat calls/api/v1/calendar/events. Falls back to the seeded Jan-2025 demo set onauth/network/404 error and surfaces an amber banner explaining why. The
"New Appointment" CTA stays disabled until a
POSTendpoint exists.3. Org-aware TopBar + companies page wire
AuthContext::active_membership()/active_org_name()helpers exposethe active tenant's row without re-walking the membership list.
"Mokosh Platform". Switching tenants refreshes it automatically.
CompanyListPagetriesGET /api/v1/companies(tenant-scoped perYousif'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 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>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.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.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.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.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 indb821a9. 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 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.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.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>restartrecipe 04d7627e2bPairs 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>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>