From e8987e28f7eeb5bc2cebfa604a230a5c6e264153 Mon Sep 17 00:00:00 2001 From: Cory Hawkvelt Date: Wed, 8 Apr 2026 16:45:57 +0930 Subject: [PATCH] feat(admin): add dedicated user management page Extract user management functionality from MembersPage drawer into a dedicated UserManagementPage at /org/members/:userId. The new page provides a full-page interface with tabs for user details, security settings (MFA methods), and access management (OAuth accounts, SSH keys). This improves code organization by separating concerns and provides better UX for user administration tasks. --- src/App.tsx | 2 + src/pages/admin/UserManagementPage.tsx | 1032 ++++++++++++++++++++++++ src/pages/org/MembersPage.tsx | 758 +---------------- 3 files changed, 1042 insertions(+), 750 deletions(-) create mode 100644 src/pages/admin/UserManagementPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 376c2de..9371f8a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,7 @@ import CLIGuidePage from "@/pages/user/CLIGuidePage"; // Organization pages import OrgOverviewPage from "@/pages/org/OrgOverviewPage"; import MembersPage from "@/pages/org/MembersPage"; +import UserManagementPage from "@/pages/admin/UserManagementPage"; import PoliciesPage from "@/pages/org/PoliciesPage"; import CompliancePage from "@/pages/org/CompliancePage"; import OrgAuditPage from "@/pages/org/OrgAuditPage"; @@ -198,6 +199,7 @@ function AppRoutes() { {/* Organization management routes — org admins/owners only */} } /> + } /> } /> } /> } /> diff --git a/src/pages/admin/UserManagementPage.tsx b/src/pages/admin/UserManagementPage.tsx new file mode 100644 index 0000000..15af94a --- /dev/null +++ b/src/pages/admin/UserManagementPage.tsx @@ -0,0 +1,1032 @@ +import { useState, useEffect } from "react"; +import { + User as UserIcon, + Mail, + Shield, + ShieldCheck, + Loader2, + CheckCircle, + XCircle, + Ban, + Key, + Smartphone, + KeyRound, + Lock, + Link2, + AlertTriangle, + Trash2, + Crown, + UserCheck, + ShieldOff, + Plus, +} from "lucide-react"; +import { useParams, useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { api, ApiError, User, SSHKey, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const getInitials = (name: string | null | undefined): string => { + if (!name) return "?"; + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); +}; + +function formatDate(d: string | null | undefined) { + if (!d) return "—"; + const raw = typeof d === "string" && !(d.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(d)) ? d + "Z" : d; + return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(new Date(raw)); +} + +function capitalize(s: string) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function isSuspended(status: string | undefined) { + return status === "suspended" || status === "compliance_suspended"; +} + +// ── Skeleton ────────────────────────────────────────────────────────────────── + +function UserManagementSkeleton() { + return ( +
+
+ + +
+ +
+ + + + + + +
+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + + + + +
+ + +
+
+
+ + + + + + + +
+ + +
+
+
+ + + + + + + +
+ + +
+
+
+
+
+ ); +} + +// ── Main Component ──────────────────────────────────────────────────────────── + +export default function UserManagementPage() { + const { userId } = useParams<{ userId: string }>(); + const { user: currentUser } = useAuth(); + const { toast } = useToast(); + const navigate = useNavigate(); + + // ── State ──────────────────────────────────────────────────────────────────── + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const [user, setUser] = useState(null); + const [sshKeys, setSshKeys] = useState([]); + const [mfaMethods, setMfaMethods] = useState([]); + const [linkedAccounts, setLinkedAccounts] = useState([]); + const [totalAuthMethods, setTotalAuthMethods] = useState(0); + + const [removingMfaId, setRemovingMfaId] = useState(null); + const [unlinkingProvider, setUnlinkingProvider] = useState(null); + const [isAddingKey, setIsAddingKey] = useState(false); + + const [showRemoveAllMfa, setShowRemoveAllMfa] = useState(false); + const [showRemoveMfaConfirm, setShowRemoveMfaConfirm] = useState(false); + const [mfaMethodToRemove, setMfaMethodToRemove] = useState(null); + const [showAddKey, setShowAddKey] = useState(false); + + const [addKeyPublicKey, setAddKeyPublicKey] = useState(""); + const [addKeyDescription, setAddKeyDescription] = useState(""); + const [addKeyError, setAddKeyError] = useState(null); + + // Admin actions state + const [isSuspending, setIsSuspending] = useState(false); + const [isUpdatingRole, setIsUpdatingRole] = useState(false); + const [isTransferring, setIsTransferring] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isSettingPw, setIsSettingPw] = useState(false); + + // Dialog states + const [showSuspendConfirm, setShowSuspendConfirm] = useState(false); + const [showTransferOwnership, setShowTransferOwnership] = useState(false); + const [showHardDelete, setShowHardDelete] = useState(false); + const [deleteConfirmEmail, setDeleteConfirmEmail] = useState(""); + + // Password form state + const [adminPwNew, setAdminPwNew] = useState(""); + const [adminPwConfirm, setAdminPwConfirm] = useState(""); + const [adminPwError, setAdminPwError] = useState(null); + + // Role update state + const [selectedRole, setSelectedRole] = useState("member"); + + // ── Handlers ───────────────────────────────────────────────────────────────── + + const handleRemoveMfaMethod = async (method: AdminMfaMethod) => { + setMfaMethodToRemove(method); + setShowRemoveMfaConfirm(true); + }; + + const confirmRemoveMfaMethod = async () => { + if (!userId || !mfaMethodToRemove) return; + setRemovingMfaId(mfaMethodToRemove.id); + try { + const methodType = mfaMethodToRemove.type as 'totp' | 'webauthn'; + const credId = mfaMethodToRemove.type === 'webauthn' ? mfaMethodToRemove.id : undefined; + await api.admin.removeUserMfa(userId, methodType, credId); + setMfaMethods((prev) => prev.filter((m) => m.id !== mfaMethodToRemove.id)); + toast({ + title: "MFA method removed", + description: `${mfaMethodToRemove.name} has been removed for ${user?.email}.`, + }); + setShowRemoveMfaConfirm(false); + setMfaMethodToRemove(null); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to remove MFA method", + description: err instanceof ApiError ? err.message : "Something went wrong.", + }); + } finally { + setRemovingMfaId(null); + } + }; + + const handleRemoveAllMfa = async () => { + if (!userId) return; + try { + await api.admin.removeUserMfa(userId, 'all'); + setMfaMethods([]); + setShowRemoveAllMfa(false); + toast({ + title: "All MFA methods removed", + description: `All MFA methods for ${user?.email} have been cleared.`, + }); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to remove MFA methods", + description: err instanceof ApiError ? err.message : "Something went wrong.", + }); + } + }; + + const handleUnlinkProvider = async (account: AdminLinkedAccount) => { + if (!userId) return; + setUnlinkingProvider(account.id); + try { + await api.admin.adminUnlinkUserProvider(userId, account.provider_type); + setLinkedAccounts((prev) => prev.filter((a) => a.id !== account.id)); + setTotalAuthMethods((prev) => Math.max(0, prev - 1)); + toast({ + title: "Provider unlinked", + description: `${capitalize(account.provider_type)} has been unlinked from ${user?.email}.`, + }); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to unlink provider", + description: err instanceof ApiError ? err.message : "Something went wrong.", + }); + } finally { + setUnlinkingProvider(null); + } + }; + + const handleAddKey = async () => { + if (!userId) return; + setAddKeyError(null); + if (!addKeyPublicKey.trim()) { + setAddKeyError("Public key is required."); + return; + } + setIsAddingKey(true); + try { + const key = await api.ssh.adminAddKey( + userId, + addKeyPublicKey.trim(), + addKeyDescription.trim() || undefined + ); + setSshKeys((prev) => [...prev, key]); + toast({ + title: "SSH key added", + description: `Key added for ${user?.email}`, + }); + setShowAddKey(false); + setAddKeyPublicKey(""); + setAddKeyDescription(""); + } catch (err) { + setAddKeyError(err instanceof ApiError ? err.message : "Failed to add key."); + } finally { + setIsAddingKey(false); + } + }; + + // Suspend user + const handleSuspend = async () => { + if (!userId) return; + setIsSuspending(true); + try { + const data = await api.admin.suspendUser(userId); + setUser((prev) => prev ? { ...prev, status: data.user.status } : prev); + setShowSuspendConfirm(false); + toast({ + title: "User suspended", + description: `${user?.full_name || user?.email} has been suspended.`, + }); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to suspend user", + description: err instanceof ApiError ? err.message : "Something went wrong.", + }); + } finally { + setIsSuspending(false); + } + }; + + // Unsuspend user + const handleUnsuspend = async () => { + if (!userId) return; + setIsSuspending(true); + try { + const data = await api.admin.unsuspendUser(userId); + setUser((prev) => prev ? { ...prev, status: data.user.status } : prev); + toast({ + title: "User unsuspended", + description: `${user?.full_name || user?.email} is now active.`, + }); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to unsuspend user", + description: err instanceof ApiError ? err.message : "Something went wrong.", + }); + } finally { + setIsSuspending(false); + } + }; + + // Update role + const handleUpdateRole = async () => { + if (!userId) return; + // Prevent self-modification + if (userId === currentUser?.id) { + toast({ + variant: "destructive", + title: "Cannot change own role", + description: "Please use organization settings to manage your own role.", + }); + return; + } + setIsUpdatingRole(true); + try { + // Note: This would need the organization ID from context + // Placeholder for future implementation + toast({ + title: "Role update", + description: `Role update to ${selectedRole} requires organization context.`, + variant: "destructive", + }); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to update role", + description: err instanceof ApiError ? err.message : "Something went wrong.", + }); + } finally { + setIsUpdatingRole(false); + } + }; + + // Transfer ownership + const handleTransferOwnership = async () => { + if (!userId) return; + // Prevent self-transfer + if (userId === currentUser?.id) { + toast({ + variant: "destructive", + title: "Cannot transfer to self", + description: "Select a different user to transfer ownership to.", + }); + return; + } + setIsTransferring(true); + try { + // Note: This would need the organization ID from context + // Placeholder for future implementation + toast({ + title: "Transfer ownership", + description: "Ownership transfer requires organization context.", + variant: "destructive", + }); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to transfer ownership", + description: err instanceof ApiError ? err.message : "Something went wrong.", + }); + } finally { + setIsTransferring(false); + } + }; + + // Hard delete user + const handleHardDelete = async () => { + if (!userId) return; + // Prevent self-deletion + if (userId === currentUser?.id) { + toast({ + variant: "destructive", + title: "Cannot delete own account", + description: "Please use the profile settings to delete your own account.", + }); + return; + } + setIsDeleting(true); + try { + const result = await api.admin.hardDeleteUser(userId); + toast({ + title: "User permanently deleted", + description: `${result.deleted_user_email} — ${result.certs_revoked} cert(s) revoked, ${result.ssh_keys_deleted} key(s) deleted.`, + }); + setShowHardDelete(false); + navigate("/org/members"); + } catch (err) { + toast({ + variant: "destructive", + title: "Failed to delete user", + description: err instanceof ApiError ? err.message : "Something went wrong.", + }); + } finally { + setIsDeleting(false); + } + }; + + // Admin set password + const handleAdminSetPassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (!userId) return; + setAdminPwError(null); + + if (adminPwNew.length < 8) { + setAdminPwError("Password must be at least 8 characters."); + return; + } + if (adminPwNew !== adminPwConfirm) { + setAdminPwError("Passwords do not match."); + return; + } + + setIsSettingPw(true); + try { + await api.admin.adminSetUserPassword(userId, adminPwNew); + setAdminPwNew(""); + setAdminPwConfirm(""); + toast({ + title: user?.has_password ? "Password updated" : "Password set", + description: `Password ${user?.has_password ? "changed" : "created"} for ${user?.email}.`, + }); + } catch (err) { + setAdminPwError(err instanceof ApiError ? err.message : "Failed to update password."); + } finally { + setIsSettingPw(false); + } + }; + + // ── Data Fetching ──────────────────────────────────────────────────────────── + useEffect(() => { + if (!userId) { + setError("User ID is required"); + setIsLoading(false); + return; + } + + let cancelled = false; + + const fetchUserData = async () => { + setError(null); + if (!user) { + setIsLoading(true); + } + + try { + const [userData, mfaData, linkedData] = await Promise.allSettled([ + api.admin.getUser(userId), + api.admin.getUserMfa(userId), + api.admin.getUserLinkedAccounts(userId), + ]); + + if (cancelled) return; + + if (userData.status === "fulfilled") { + setUser(userData.value.user); + setSshKeys(userData.value.ssh_keys); + } else { + setError("Failed to load user details"); + setIsLoading(false); + return; + } + + if (mfaData.status === "fulfilled") { + setMfaMethods(mfaData.value.mfa_methods); + } else { + setMfaMethods([]); + } + + if (linkedData.status === "fulfilled") { + setLinkedAccounts(linkedData.value.linked_accounts); + setTotalAuthMethods(linkedData.value.total_auth_methods); + } else { + setLinkedAccounts([]); + setTotalAuthMethods(0); + } + } catch (err) { + if (cancelled) return; + setError(err instanceof ApiError ? err.message : "Failed to load user data"); + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + fetchUserData(); + + return () => { + cancelled = true; + }; + }, [userId]); + + // ── Render ─────────────────────────────────────────────────────────────────── + + if (isLoading) { + return ; + } + + if (error || !user) { + return ( +
+
+

User Management

+

Error loading user details

+
+ + +
+ +

Failed to load user

+

{error || "User not found"}

+ +
+
+
+
+ ); + } + + return ( +
+
+

User Management

+

+ Manage {user.full_name || user.email}'s account, security settings, and access +

+
+ + + + User Details + Security + Access + + + {/* ── User Details Tab ────────────────────────────────────────────── */} + +
+ + + + + Basic Information + + User profile details and account status + + +
+ + + + {getInitials(user.full_name)} + + +
+

{user.full_name || "No name provided"}

+

+ + {user.email} +

+
+
+ +
+
+

Status

+
+ {isSuspended(user.status) ? ( + <> + + + Suspended{user.status === "compliance_suspended" ? " (compliance)" : ""} + + + ) : ( + <> + + Active + + )} +
+
+ +
+

Last Login

+

{formatDate(user.last_login_at)}

+
+ +
+

Joined

+

{formatDate(user.created_at)}

+
+ + {user.activated !== undefined && ( +
+

Activated

+
+ {user.activated === false ? ( + <> + + No + + ) : ( + <> + + Yes + + )} +
+
+ )} +
+
+
+ + {/* Account Access - Suspend/Unsuspend */} + {user.id !== currentUser?.id && ( + + + + + Account Access + + Suspend or restore user access + + + {isSuspended(user.status) ? ( +
+

+ {user.status === "compliance_suspended" + ? "This account is suspended due to MFA compliance. The user cannot request certificates." + : "This account is suspended. The user cannot request certificates."} +

+ +
+ ) : ( +
+

+ Suspending blocks this user from requesting SSH certificates. +

+ +
+ )} +
+
+ )} +
+
+ + {/* ── Security Tab ────────────────────────────────────────────────── */} + +
+ + + + + MFA Methods + + Multi-factor authentication methods configured by this user + + + {mfaMethods.length === 0 ? ( +

No MFA methods configured.

+ ) : ( +
+ {mfaMethods.map((method) => ( +
+
+ {method.type === "totp" ? ( + + ) : ( + + )} +
+

{method.name}

+ {method.last_used_at && ( +

+ Last used: {formatDate(method.last_used_at)} +

+ )} +
+
+ +
+ ))} +
+ )} + {mfaMethods.length > 1 && ( + + )} +

+ Remove an MFA method if the user has lost access (e.g. lost phone or passkey). They can re-enroll after removal. +

+
+
+ +
+
+ + {/* ── Access Tab ──────────────────────────────────────────────────── */} + +
+ + + + + Linked OAuth Accounts + + External authentication providers linked to this account + + + {linkedAccounts.length === 0 ? ( +

No OAuth providers linked.

+ ) : ( +
+ {linkedAccounts.map((account) => { + const isOnlyMethod = totalAuthMethods <= 1; + return ( +
+
+ +
+

{account.provider_type}

+ {account.email && ( +

{account.email}

+ )} + {account.linked_at && ( +

+ Linked: {formatDate(account.linked_at)} +

+ )} +
+
+ +
+ ); + })} +
+ )} +

+ Unlink a provider to prevent sign-in via that provider. Cannot unlink if it is the user's only sign-in method. +

+
+
+ + + + +
+ + SSH Keys +
+ +
+ SSH public keys registered by this user +
+ + {sshKeys.length === 0 ? ( +
+ No SSH keys registered +
+ ) : ( +
+ {sshKeys.map((k) => ( +
+
+ + {k.description || No description} + + {k.verified ? ( + + + Verified + + ) : ( + + Unverified + + )} +
+
+ {k.fingerprint ?? (k.public_key.slice(0, 64) + "…")} +
+
+ Added {formatDate(k.created_at)} +
+
+ ))} +
+ )} +
+
+
+
+
+ + {/* ── Remove all MFA confirmation dialog ───────────────────────────── */} + + + + + + Remove all MFA methods? + + + This will remove all MFA methods (TOTP and passkeys) for{" "} + {user.email}. They will be able to re-enroll after this action. + + + + + + + + + + {/* ── Remove single MFA method confirmation dialog ────────────────────── */} + { setShowRemoveMfaConfirm(open); if (!open) setMfaMethodToRemove(null); }}> + + + + + Remove MFA method? + + + This will remove {mfaMethodToRemove?.name} for{" "} + {user.email}. The user will be able to re-enroll this method after removal. + + + + + + + + + + {/* ── Admin add SSH key dialog ──────────────────────────────────────────── */} + { + setShowAddKey(open); + if (!open) { + setAddKeyError(null); + setAddKeyPublicKey(""); + setAddKeyDescription(""); + } + }} + > + + + Add SSH Key for {user.email} + + Add an SSH public key on behalf of this user (admin action). + + +
+ {addKeyError && ( +
+ {addKeyError} +
+ )} +
+ +