Merge fix/move-user-org: admin can move a user into their org
Build & Deploy / Build & push images (push) Successful in 4m55s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 30s
Build & Deploy / Deploy to VM (push) Successful in 1m32s
Build & Deploy / Report a failed deploy (push) Skipped

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-27 18:52:58 -04:00
co-authored by Claude Opus 5.5
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"
+39
View File
@@ -367,6 +367,26 @@ function UserDetailPanel({
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(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({
</div>
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
<div className="flex justify-between items-center gap-3">
<span className="text-gray-500">Organization</span>
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-gray-300" : "text-yellow-400"}`}>
{!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"})`}
</span>
</div>
{myOrgId && detail.org_id !== myOrgId && (
<button
onClick={handleMoveToMyOrg}
disabled={moving}
className="w-full bg-yellow-900/60 hover:bg-yellow-800/60 disabled:opacity-50 text-yellow-200 px-3 py-1.5 rounded-lg transition-colors"
>
{moving ? "Moving…" : "Move to my organization"}
</button>
)}
<div className="flex justify-between">
<span className="text-gray-500">Status</span>
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
+4 -1
View File
@@ -328,7 +328,10 @@ export const c2api = {
}),
getUser: (uid: string) =>
request<import("@/lib/types").UserRecord>(`/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<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
method: "PATCH",
body: JSON.stringify(body),
+3
View File
@@ -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