SSO, settings page, etc #4

Merged
YousifShkara merged 17 commits from full-dev into main 2026-05-22 11:59:47 +02:00
Owner
No description provided.
Item 1 from the polish backlog. Adds a Sessions section to the user-detail page (between Audit and Danger zone) that lists the target user's active op_sessions and lets an admin force-revoke any of them.

Wires the two endpoints just shipped on mokosh-server:
- GET /v1/auth/users/:id/sessions  - admin lists target user's sessions
- POST /v1/auth/users/:id/sessions/:sid/revoke

Hidden when viewing your own detail page (you have /settings/sessions for that). Reuses the same UA -> 'Browser on OS' label helper inline to avoid coupling pages/sessions.rs and pages/user_detail.rs.
Replaces the hardcoded Early-adopter/Standard/Lifetime cards with a use_resource against billing::list_tiers. Each catalogue row (free / starter / pro / enterprise from the seed) renders as its own card; layout flexes between 1, 2, 3, or 4 columns depending on tier count.

Display rules:
- monthly_price_cents == 0 -> 'Free / forever'
- otherwise -> '$N / per month'
- trial_days > 0 on a paid tier surfaces 'Includes an N-day free trial'

Middle card gets a Popular pill + highlighted border. CTA branches: 'Start free' for the free tier, 'Talk to us' for the enterprise tier, 'Start free trial' otherwise. All CTAs link to /signup.

Fallback on a fetch failure: a compact 'sign up and we'll match you to a plan' card rather than a blank or red banner. Pricing is informational; a 5xx shouldn't block the page entirely.
Adds a 'Billing' card under the Organizations section of /settings. Targets the active tenant's billing page when possible (matches the active membership's tenant id against the user's org list), otherwise falls back to the first org. Hidden entirely for personal-tenant-only users since they have no billing surface to view.

Doc 05's 'Settings hub has a Billing card linking to the active tenant's billing page' DoD item.
Replace the terse 'You're not in any organizations yet.' line with a centered empty-state card: dashed border, '+' avatar, headline + body copy explaining what an org is for, and a primary CTA button that opens the existing CreateOrgModal. Same modal, same flow - just a clearer on-ramp for users landing on the page with zero memberships.
Mirrors mokosh-clients PR #18's pattern so one bunyip-web image serves every environment without a CI build matrix.

Resolution per field:
- issuer: `https://msp-api.<window.location.host>` when served from a non-localhost origin (matches docker repo PR #48's apex topology: bunyip-web at the apex, mokosh-api at msp-api.<apex>). Falls back to the build-time `BUNYIP_OIDC_ISSUER` for localhost / docker-compose dev / RFC1918 hosts.
- redirect_uri: `<window.location.origin>/auth/callback` in any browser; build-time fallback otherwise.
- client_id: still build-time only. Operators must register the same client_id UUID in every mokosh-server instance (staging + prod) so one image talks to both. Hook left open for a future runtime injection via `window.__BUNYIP_CONFIG__` if per-env client_ids become unavoidable.
- scopes: unchanged.

is_local_host() recognises localhost / loopback / RFC1918, so docker-compose dev (host typically localhost:4400) keeps using the env-baked issuer pointing at the dev mokosh-server.
Pairs with the runtime-config refactor in eb5468c. The header comment said all four BUNYIP_OIDC_* values were baked into the WASM at build time. After that commit only CLIENT_ID still is - ISSUER and REDIRECT_URI are derived from window.location at runtime and the env vars act as local-dev fallbacks (localhost / RFC1918) only.

Updated comments per var so an operator setting up a fresh env knows which fields need to be set per-environment (CLIENT_ID) vs which are derived.
Pairs with mokosh-server 8ac097a. Audit-log page now exposes the full filter surface:

- Existing: event kind dropdown + custom kind fallback.
- New: free-text search across metadata, severity dropdown, date-from / date-to inputs (HTML date type; widened to 00:00:00Z / 23:59:59Z RFC 3339 bounds before posting).
- All filters compose into a single AuditFilter struct in api/admin.rs; use_resource re-fetches on any change.

Actor column: shows email when available (server LEFT JOIN), falls back to first-8-char UUID otherwise, dash when no actor. Tooltip carries the full UUID for engineers.

Download CSV button (in the filter row, disabled while a download is in flight): fetches GET /v1/auth/audit-logs.csv with the current filter + caller's Bearer token, wraps the body in a Blob, synthesizes a <a download> click. Filename is audit-logs-YYYYMMDD-HHMMSS.csv. Capped at 10,000 rows server-side.

Cargo.toml: enables web-sys features Blob + BlobPropertyBag + HtmlAnchorElement.
Doc 02's deferred second half. Loading and the brief SignedOut frame on /dashboard previously rendered a centered full-screen 'Loading your dashboard...' text - the chrome would pop into existence only after the auth resolution. Replaced with a DashboardSkeleton that:

- Keeps the same header layout (brand, divider, org-pill placeholder, ghost nav links) so the page structure is stable across the auth state transition.
- Renders 6 tile placeholders in the launcher grid matching the post-load layout.
- Uses tailwind animate-pulse on each placeholder for a familiar shimmer cue.

Net effect: reloading /dashboard no longer blanks the screen before the data fetch resolves. SignedOut still bounces to /login via the existing use_effect; this just covers the one-frame window before that fires.
Bug in item 6 (commit eb5468c). The runtime URL derivation assumed the production apex pattern (host -> msp-api.<host>). That works for a8n.systems / psa.systems but not for per-user dev environments on a8n.run, where the bunyip-web host is <user>-bunyip.a8n.run and mokosh-api sits at <user>-mokosh-api.a8n.run - a different first-label stem entirely.

Resulting symptom: /pricing on yousif-bunyip.a8n.run fetched https://msp-api.yousif-bunyip.a8n.run/v1/billing/tiers (non-existent host) and rendered the "Our pricing page is taking a moment" fallback. Same network failure would have broken every authenticated call too, but those route through the OIDC discovery flow which uses the build-time env-baked issuer until the user signs in - so the visible breakage was the public pricing fetch.

New derive_api_host() helper:
- If host's first label ends in `-bunyip` (dev pattern): rewrite to `<user>-mokosh-api.<rest>`.
- Otherwise (apex pattern): prefix `msp-api.<host>`.

Tests cover both patterns plus a non-`-bunyip` subdomain edge case.
The seed migration sets enterprise monthly_price_cents = 0 (the NOT NULL default; intentional since enterprise pricing is contact-us). My price_and_period helper treated every 0 as 'Free / forever', so the Enterprise card displayed 'Free' alongside its 'Talk to us' CTA - confusing.

Branch on tier_key == 'enterprise' before the 0-cents rule. The CTA was already correctly 'Talk to us'.
1. Sessions page widened from max-w-2xl to max-w-4xl so it matches the rest of the /settings hub. (The "no Current session badge after a tenant switch" half of the same report is fixed server-side in mokosh-server c097ae3.)

2. Audit log + sessions + user-detail timestamps now render in the browser's local timezone via chrono::Local (wasmbind -> JS Date). The server still stores + returns UTC; the SPA does the conversion on display. Drops the trailing ' UTC' label since the time shown is the user's wall-clock.

3. OrgSwitcher pill now derives the current-tenant label from its own /v1/auth/memberships fetch (find is_active) instead of the parent's me.memberships.first() - which is always empty because MeResponse.memberships is populated lazily (api/types.rs:78). The prop is kept as a fallback for the pre-fetch first paint, so the pill never blanks out.
Server-side personal tenants are named '<email>'s account' (e.g. yousif@niceguyit.biz's account) since that's what shows up in admin views and the tenant switcher long-form list. Showing the raw server name in the header pill / dropdown row reads awkwardly.

When tenant_kind == 'personal', collapse to literally 'Personal'. Org tenants keep their real display name. Applies to both:
- the header pill (live label after the memberships fetch resolves)
- the TenantRow rows inside the dropdown
Pairs with mokosh-server a253ef3. The user-management list + detail pages now reflect the Owner protection that the server enforces:

- UserView gets is_owner: bool (default false; server populates).
- List page: amber 'Owner' pill renders next to the role badge. Per-row 'Disenroll MFA' and 'Suspend / Reactivate' buttons are hidden when the row is the Owner; server returns 403 if anyone tries anyway.
- Detail page: 'Owner' badge in the header. The Role-and-status section, the Force-disenroll button, and the entire Danger-zone section are all hidden when viewing an Owner.

The amber 'Owner' colour deliberately differs from the role pills so it reads as a separate axis (org_role) rather than just another role variant.

Same logic protects:
- Personal-tenant founders (signup makes them Owner of their own namespace)
- Org creators (POST /v1/orgs makes the founder Owner of the new tenant)
When a tab is throttled in background, the system is suspended, or there's clock skew between the browser and mokosh, the background refresh loop can miss the 30s-before-expiry window. The user then clicks a tile (or any other authed action) and the call 401s — Bunyip surfaced this as an error card on the dashboard ("Your session expired. Please sign in again."), which is misleading because the refresh token is still valid.

get_authed/post_authed now attempt a single force-refresh on 401. If it succeeds (the refresh token is still good), the original request is replayed with the rotated access token and the user sees no interruption. If refresh fails (refresh token revoked / expired), the original 401 surfaces normally and the user is bounced to /login by the SignedOut effect.

Exposes a new pub(crate) helper try_refresh_access_token in stores::auth that the api/mod retry path calls. No public API change.

Touches:
- bunyip-web/src/stores/auth.rs: new try_refresh_access_token wrapping the existing force_refresh.
- bunyip-web/src/api/mod.rs: 401 retry in get_authed + post_authed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add single-flight REFRESH_IN_FLIGHT mutex + RAII RefreshGuard so concurrent callers (background refresh loop + 401-retry path) cannot both present the same single-use refresh_token and trip Mokosh's family-reuse detection. Stale callers detect a rotated token in storage and return it instead of replaying. Guard clears on drop so future cancellation cannot leak the gate.

Add 401-retry to put_authed for parity with get_authed and post_authed.

Guard authed() against empty access-token strings; was sending literal Bearer header and triggering a refresh-spiral on every authed call.

Background refresh loop: floor wait at 5 seconds (was 1 ms) so a backward clock jump cannot burn refresh-token rotations in a tight cycle; after each successful rotation re-fetch /v1/auth/me so server-side role / name / active-tenant changes propagate without a full re-login.

pageshow bfcache handler: if a refresh was in flight when the page froze, clear local tokens before reload so the restarted page lands on /login cleanly rather than racing the unknown rotation state and risking a silent family-reuse revocation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /login page's read_safe_return_to() was reading window.location.search() directly. Dioxus 0.7's router calls history.replaceState() on mount to normalise the URL to its declared route shape, which strips ?return_to=... before the use_memo runs. read_safe_return_to therefore returned None, the use_effect / after_login fell into the "no return_to" branch, and every cross-RP /login arrival landed on Bunyip's own dashboard instead of resuming the inviting RP's OIDC flow.

modules::oidc::current_search() already has the snapshot taken at program entry (used by /auth/callback for the same reason). Promote it to a public re-export and read return_to from there.

Observed against drillmark's SPA: signing in at Bunyip via the drillmark-initiated authorize hop landed on Bunyip dashboard and never returned to drillmark; the OP session cookie was set at Bunyip but never at mokosh, so subsequent drillmark visits looped through /login forever.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into full-dev
Some checks failed
build / Build and push OCI images (pull_request) Has been skipped
build / Lint and type-check (pull_request) Failing after 4s
40c6b23e22
YousifShkara deleted branch full-dev 2026-05-22 11:59:47 +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/bunyip!4
No description provided.