Skip to content

Commit 9b7eed5

Browse files
voidstackloopclaude
andcommitted
fix(admin-console): first-time org creation silently bounced back to the picker
Live-tested the full desktop app (every route, every Settings/Runtime Manager tab, headless Electron via e2e/full-app-tour.js) and the admin console end to end through a real browser session with a throwaway fake OIDC provider. Desktop app: zero console/page errors across all 27 screens. Refreshed docs/screenshots/ with the new captures and updated CLINICAL_WORKSPACE.md to reflect the e2e coverage this pass actually exercised. Admin console: found a real bug creating the very first organization. POST /organizations succeeded, but navigating into it immediately raced MeProvider's stale pre-creation membership snapshot, so RequireOrg bounced back to the picker before the refreshed membership list ever landed, indistinguishable from the creation having silently failed until a full re-login. Fixed by making useMe()'s refresh() awaitable and having OrgPicker await it before navigating. Verified live: no more bounce. Documented both in docs/COMPUTE_CONTROL_PLANE.md alongside the earlier CORS/Content-Type findings from the prior live-testing pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 5c3f3e0 commit 9b7eed5

18 files changed

Lines changed: 228 additions & 20 deletions

admin-console/src/lib/org-context.tsx

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
1+
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
22
import { Navigate, useParams } from "react-router-dom";
33
import { getMe } from "./api/client";
44
import { loadPermissions, type PermissionMap } from "./authz/permissions";
@@ -7,7 +7,12 @@ import type { MeResponse } from "./api/types";
77
interface MeContextValue {
88
me: MeResponse | undefined;
99
error: string | undefined;
10-
refresh: () => void;
10+
/** Awaitable so a caller that just mutated org membership (e.g.
11+
* OrgPicker creating a new organization) can wait for the refreshed
12+
* membership list before navigating into it — RequireOrg below bounces
13+
* back to "/" the instant its organizationId isn't in `me.memberships`
14+
* yet, which fired immediately on a fire-and-forget refresh. */
15+
refresh: () => Promise<void>;
1116
}
1217

1318
const MeContext = createContext<MeContextValue | undefined>(undefined);
@@ -19,27 +24,36 @@ const MeContext = createContext<MeContextValue | undefined>(undefined);
1924
export function MeProvider({ children }: { children: ReactNode }) {
2025
const [me, setMe] = useState<MeResponse | undefined>(undefined);
2126
const [error, setError] = useState<string | undefined>(undefined);
22-
const [generation, setGeneration] = useState(0);
23-
27+
const mountedRef = useRef(true);
2428
useEffect(() => {
25-
let cancelled = false;
29+
// StrictMode's mount→cleanup→mount dance runs this cleanup once
30+
// before the "real" mount — reset to true on every mount, not just
31+
// once, or the ref stays permanently false and load() below never
32+
// commits its result again.
33+
mountedRef.current = true;
34+
return () => {
35+
mountedRef.current = false;
36+
};
37+
}, []);
38+
39+
const load = useCallback(async () => {
2640
// Intentional fetch-on-mount/refresh, same pattern (and same
2741
// suppression) as frontend/'s sessions-context.tsx.
2842
// eslint-disable-next-line react-hooks/set-state-in-effect
2943
setError(undefined);
30-
getMe()
31-
.then((response) => {
32-
if (!cancelled) setMe(response);
33-
})
34-
.catch((err: unknown) => {
35-
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load your account.");
36-
});
37-
return () => {
38-
cancelled = true;
39-
};
40-
}, [generation]);
44+
try {
45+
const response = await getMe();
46+
if (mountedRef.current) setMe(response);
47+
} catch (err) {
48+
if (mountedRef.current) setError(err instanceof Error ? err.message : "Could not load your account.");
49+
}
50+
}, []);
51+
52+
useEffect(() => {
53+
void load();
54+
}, [load]);
4155

42-
return <MeContext.Provider value={{ me, error, refresh: () => setGeneration((g) => g + 1) }}>{children}</MeContext.Provider>;
56+
return <MeContext.Provider value={{ me, error, refresh: load }}>{children}</MeContext.Provider>;
4357
}
4458

4559
// eslint-disable-next-line react-refresh/only-export-components -- shared with MeProvider, same as sessions-context.tsx's useSessions

admin-console/src/pages/OrgPicker.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ export default function OrgPicker() {
4444
setCreateError(undefined);
4545
try {
4646
const { organization } = await createOrganization(newOrgName.trim());
47+
// RequireOrg checks the freshly-created org against `me`'s
48+
// membership list before rendering — without this, it's still
49+
// the pre-creation snapshot and RequireOrg bounces straight back
50+
// to "/", which looked like the creation had silently failed.
51+
await refresh();
4752
navigate(`/organizations/${organization.id}/users`);
4853
} catch (err) {
4954
setCreateError(err instanceof ApiError ? (err.body?.message ?? err.message) : "Could not create the organization.");

docs/CLINICAL_WORKSPACE.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,15 @@ Notably:
602602
tested in this codebase (see [Development: testing](DEVELOPMENT.md#testing)).
603603

604604
Playwright e2e (`e2e/tests/*.spec.ts`) has not been extended for the clinical
605-
layer or run against it in this environment — see [Known limitations](#known-limitations).
605+
layer with scripted assertions, but `e2e/full-app-tour.js` (a headless,
606+
non-mocked Electron launch — the same `_electron.launch({headless: "new"})`
607+
pattern as `manual-app-test.js`) has been run against it end to end: every
608+
clinical route (Patient Cases list, create, detail; Evidence Library;
609+
Knowledge Graph; Audit & Privacy) plus every Settings and Runtime Manager tab,
610+
with zero console errors or uncaught page errors captured across the whole
611+
run. Screenshots from that pass are in `docs/screenshots/`. This is visual/
612+
crash verification, not scripted behavioral assertions — see [Known
613+
limitations](#known-limitations) for what that still leaves uncovered.
606614

607615
## Known limitations
608616

docs/COMPUTE_CONTROL_PLANE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,12 @@ Verified directly against the desktop code rather than assumed, since "enforceme
8181

8282
`admin-console/src/pages/ComputePolicies.tsx` covers signed resource policies: a full policy-version history per pool (every version, its status, and one-click activate — including activating a retired version, which is the rollback path), a draft composer for `hardLimits`/per-priority `workloadClassLimits` with a live effective-policy preview (the exact `{...hardLimits, ...workloadClassLimits[priority]}` merge the scheduler itself applies), a "download draft JSON" step, and a "paste the signed result and submit" step. Signing itself stays strictly offline — the Ed25519 private key never touches the browser or the server. `server/scripts/sign-compute-policy.js` is the tool that actually signs a draft (mirrors `app/scripts/sign-policy.js`'s pattern for the *different*, desktop-only central-policy system — verified end-to-end against the server's real `createComputePolicySignatureVerifier()` before shipping, including that a wrong organization id or a tampered payload both correctly fail verification).
8383

84-
**Two real, previously-undetected bugs were found and fixed by actually running the server + admin console through a real browser session** (a throwaway fake OIDC provider, in-memory stores — see the roadmap memory for the harness) rather than only `app.inject()`-based integration tests, which bypass CORS entirely, and admin-console unit tests, which mock `fetch` directly:
84+
**Real, previously-undetected bugs were found and fixed by actually running the server + admin console through a real browser session** (a throwaway fake OIDC provider, in-memory stores — see the roadmap memory for the harness) rather than only `app.inject()`-based integration tests, which bypass CORS entirely, and admin-console unit tests, which mock `fetch` directly:
8585
1. `@fastify/cors`'s own default `methods` is `GET,HEAD,POST` only — every `PUT`/`PATCH`/`DELETE` call the admin console makes (quota saves, this page's policy activation, break-glass policy, user/group/policy/service-principal updates, deletes) was silently failing CORS preflight. Fixed in `app.ts` by passing an explicit methods list.
8686
2. `authorizedRequest()` unconditionally set `Content-Type: application/json` even on bodyless POST calls (policy activation, `createAccessReviewCampaign`, `verifyAiInferenceDeployment`) — Fastify correctly 400s an empty body declared as JSON. Fixed to only set the header when a body is actually present.
87+
3. **(Found on a later pass.)** `OrgPicker`'s "Create organization" silently appeared to fail: `POST /organizations` succeeded (201) and the org was really created, but the immediate `navigate()` into it raced `MeProvider`'s stale, pre-creation `me.memberships` snapshot — `RequireOrg` couldn't find the new org in that stale list and bounced straight back to the picker, which still read "you don't belong to any organization yet." The org was only reachable after a full re-login. Fixed by making `useMe()`'s `refresh()` awaitable (`org-context.tsx`) and having `handleCreateOrganization` await it before navigating — first-run org creation now lands directly on the new org's Users page. Verified live: created an org through the real browser session and confirmed no bounce.
8788

88-
Both verified against the real fix, not just re-running the existing suite: a real quota save, and a real signed policy created via `server/scripts/sign-compute-policy.js` and activated, all through an actual browser session end to end.
89+
All verified against the real fix, not just re-running the existing suite: a real quota save, a real signed policy created via `server/scripts/sign-compute-policy.js` and activated, and a real first-time "create organization" flow, all through an actual browser session end to end.
8990

9091
**Compute audit events use the general Audit page, not a dedicated view** — deliberately not duplicated, since `listAudit`'s `action`/`targetType` filters are exact-match only (no prefix/wildcard support server-side), so a single "show all compute events" link isn't possible without a broader audit-search change this pass didn't make. Filter by `targetType` to browse a specific compute entity's history: `computeNode`, `computePool`, `computeRequest`, `computeLease`, `computePolicy`. Real action strings recorded: `computeNode.stateChanged`, `computePool.created`, `computeQuota.updated`, `computePolicy.created`, `computePolicy.activated`, `computeRequest.submitted`, `computeRequest.cancelled`, `computeLease.allocated`, `computeLease.acknowledged`, `computeLease.released`.
9192

docs/screenshots/audit-privacy.png

185 KB
Loading

docs/screenshots/chat.png

-45.2 KB
Loading
84.2 KB
Loading
121 KB
Loading
120 KB
Loading
79.9 KB
Loading

0 commit comments

Comments
 (0)