Merge fix/move-user-org: admin can move a user into their org
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user