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
+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