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 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-27 18:52:55 -04:00
co-authored by Claude Opus 5.5
parent fa41b9a30c
commit 2b42e5ee9a
5 changed files with 70 additions and 5 deletions
+14 -3
View File
@@ -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 {}),
},
)
+10 -1
View File
@@ -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"