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