From 2b42e5ee9afc56721596803be893f0e5e09f6ad2 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 27 Sep 2026 18:52:55 -0400 Subject: [PATCH] Admin Users: show each user's org and move a user into yours A viewer whose first login ran self-serve signup got an empty org of their own (org_role owner), so they saw no incidents or calls, and the earlier fix deliberately never moved a user who already had an org. PATCH /admin/users/{uid} now moves a user when org_id is passed explicitly (claims + org_members, audited with left_org_id; the old org is not deleted). The user list returns org_id/org_role, and the admin user panel shows the org and a 'Move to my organization' button when it isn't yours. Verified: c2-core pytest 496 passed; frontend tsc --noEmit clean. Co-Authored-By: Claude Opus 5.5 --- drb-c2-core/app/routers/users.py | 17 ++++++++++--- drb-c2-core/tests/test_users_org.py | 11 +++++++- drb-frontend/app/admin/page.tsx | 39 +++++++++++++++++++++++++++++ drb-frontend/lib/c2api.ts | 5 +++- drb-frontend/lib/types.ts | 3 +++ 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/drb-c2-core/app/routers/users.py b/drb-c2-core/app/routers/users.py index 318a259..c59d8a9 100644 --- a/drb-c2-core/app/routers/users.py +++ b/drb-c2-core/app/routers/users.py @@ -97,6 +97,9 @@ def _format_user(fb_user: firebase_auth.UserRecord, link: Optional[dict] = None) "discord_linked": bool(link and link.get("discord_user_id")), "discord_username": link.get("discord_username") if link else None, "discord_user_id": link.get("discord_user_id") if link else None, + # Which org's data this user can read (firestore.rules gates on it). + "org_id": (fb_user.custom_claims or {}).get("org_id"), + "org_role": (fb_user.custom_claims or {}).get("org_role"), } @@ -233,13 +236,19 @@ async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(requir # Heal users created before POST /users set an org (they could read # nothing): any edit attaches them to the requested/admin's org. An # existing org is never silently moved. + # Heal users created before POST /admin/users set an org (they could read + # nothing): any edit attaches them to the requested/admin's org. Moving a + # user who already has an org only happens when org_id is passed + # explicitly — e.g. a viewer whose first login self-provisioned an empty + # org of their own via POST /auth/signup (seen 2026-09-27). attached_org: Optional[str] = None - if not existing_claims.get("org_id"): + left_org: Optional[str] = None + current_org = existing_claims.get("org_id") + if not current_org or (body.org_id and body.org_id != current_org): attached_org = await _resolve_org(body.org_id, decoded) + left_org = current_org new_claims["org_id"] = attached_org new_claims["org_role"] = "member" - elif body.org_id and body.org_id != existing_claims["org_id"]: - raise HTTPException(400, "User already belongs to another org; moving orgs isn't supported here.") await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims) if attached_org: @@ -265,6 +274,8 @@ async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(requir "old_nodes": current_nodes, "new_nodes": new_nodes, **({"attached_org_id": attached_org} if attached_org else {}), + # The org left behind is not deleted: it may hold nodes or data. + **({"left_org_id": left_org} if left_org else {}), }, ) diff --git a/drb-c2-core/tests/test_users_org.py b/drb-c2-core/tests/test_users_org.py index 7861e85..a76fc78 100644 --- a/drb-c2-core/tests/test_users_org.py +++ b/drb-c2-core/tests/test_users_org.py @@ -82,4 +82,13 @@ def test_editing_never_silently_moves_an_existing_org(): assert resp.status_code == 200 assert set_claims.call_args.args[1]["org_id"] == "org-B" assert not members - assert _run("patch", "/admin/users/u1", {"org_id": "org-A"}, fb)[0].status_code == 400 + + +def test_explicit_org_id_moves_a_self_provisioned_owner_into_the_network(): + _as(ADMIN) + fb = _fb(custom_claims={"role": "viewer", "org_id": "own-empty-org", "org_role": "owner"}) + resp, set_claims, members = _run("patch", "/admin/users/u1", {"org_id": "org-A"}, fb) + assert resp.status_code == 200, resp.text + claims = set_claims.call_args.args[1] + assert (claims["org_id"], claims["org_role"]) == ("org-A", "member") + assert members and members[0].args[2]["org_id"] == "org-A" diff --git a/drb-frontend/app/admin/page.tsx b/drb-frontend/app/admin/page.tsx index df178c3..5b2cf44 100644 --- a/drb-frontend/app/admin/page.tsx +++ b/drb-frontend/app/admin/page.tsx @@ -367,6 +367,26 @@ function UserDetailPanel({ const [deleting, setDeleting] = useState(false); const [error, setError] = useState(null); const [showSessions, setShowSessions] = useState(false); + const { orgId: myOrgId } = useAuth(); + const [moving, setMoving] = useState(false); + + // A user outside the admin's org reads none of its incidents or calls — + // e.g. a viewer whose first login self-provisioned an empty org. + async function handleMoveToMyOrg() { + if (!myOrgId) return; + if (!confirm(`Move ${detail.email ?? "this user"} into your organization as a member? They'll need to sign out and back in.`)) return; + setMoving(true); + setError(null); + try { + const updated = await c2api.updateUser(user.uid, { org_id: myOrgId }); + onUpdated(updated); + setDetail((d) => ({ ...d, ...updated })); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setMoving(false); + } + } // Fetch full detail (sessions) lazily useEffect(() => { @@ -496,6 +516,25 @@ function UserDetailPanel({
+
+ Organization + + {!detail.org_id + ? "None: sees no data" + : detail.org_id === myOrgId + ? `Your org (${detail.org_role ?? "member"})` + : `Other org ${detail.org_id.slice(0, 8)}… (${detail.org_role ?? "member"})`} + +
+ {myOrgId && detail.org_id !== myOrgId && ( + + )}
Status diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index 52561ae..c1a7ab8 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -328,7 +328,10 @@ export const c2api = { }), getUser: (uid: string) => request(`/admin/users/${uid}`), - updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) => + updateUser: ( + uid: string, + body: { role?: string; owned_node_ids?: string[]; display_name?: string; org_id?: string }, + ) => request(`/admin/users/${uid}`, { method: "PATCH", body: JSON.stringify(body), diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 7e72ebd..53708db 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -14,6 +14,9 @@ export interface UserRecord { discord_linked: boolean; discord_username: string | null; discord_user_id: string | null; + /** The org whose data this user can read; null = none (sees nothing). */ + org_id?: string | null; + org_role?: "owner" | "member" | null; // only present on GET /admin/users/{uid} sessions?: UserSession[]; // only present on POST /admin/users response