import { useState, useCallback, useEffect } from "react"; import { Search, User, CheckCircle, XCircle, Key, Loader2, Plus, ChevronRight, ShieldCheck, Shield, Ban, UserCheck, AlertTriangle, Trash2, ShieldOff, Smartphone, KeyRound, Link2, Unlink, Lock, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api"; import { useAuth } from "@/contexts/AuthContext"; function formatDate(d: string | null) { if (!d) return "—"; return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } function capitalize(s: string) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; } function isSuspended(status: string | undefined) { return status === "suspended" || status === "compliance_suspended"; } function RoleBadge({ role }: { role: string }) { const r = (role || "").toLowerCase(); if (r === "owner") { return ( Owner ); } if (r === "admin") { return ( Admin ); } return ( Member ); } export default function AdminUsersPage() { const { toast } = useToast(); const { user: currentUser } = useAuth(); // User list const [users, setUsers] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [pages, setPages] = useState(1); const [isLoading, setIsLoading] = useState(false); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [roleFilter, setRoleFilter] = useState("all"); // Debounce search useEffect(() => { const t = setTimeout(() => setDebouncedSearch(search), 300); return () => clearTimeout(t); }, [search]); // User detail drawer const [selectedUser, setSelectedUser] = useState(null); const [userSshKeys, setUserSshKeys] = useState([]); const [isDrawerLoading, setIsDrawerLoading] = useState(false); // Role update const [isUpdatingRole, setIsUpdatingRole] = useState(false); // Admin add SSH key dialog const [showAddKey, setShowAddKey] = useState(false); const [addKeyPublicKey, setAddKeyPublicKey] = useState(""); const [addKeyDescription, setAddKeyDescription] = useState(""); const [isAddingKey, setIsAddingKey] = useState(false); const [addKeyError, setAddKeyError] = useState(null); // Suspend / unsuspend const [isSuspending, setIsSuspending] = useState(false); const [showSuspendConfirm, setShowSuspendConfirm] = useState(false); // Force-verify email const [isVerifyingEmail, setIsVerifyingEmail] = useState(false); // Hard delete const [showHardDelete, setShowHardDelete] = useState(false); const [hardDeleteConfirmEmail, setHardDeleteConfirmEmail] = useState(""); const [isHardDeleting, setIsHardDeleting] = useState(false); // MFA management const [userMfaMethods, setUserMfaMethods] = useState([]); const [isMfaLoading, setIsMfaLoading] = useState(false); const [removingMfaId, setRemovingMfaId] = useState(null); const [showRemoveAllMfa, setShowRemoveAllMfa] = useState(false); const [isRemovingAllMfa, setIsRemovingAllMfa] = useState(false); // Linked accounts management const [userLinkedAccounts, setUserLinkedAccounts] = useState([]); const [totalAuthMethods, setTotalAuthMethods] = useState(0); const [unlinkingProvider, setUnlinkingProvider] = useState(null); // Admin password reset const [showPasswordReset, setShowPasswordReset] = useState(false); const [newPassword, setNewPassword] = useState(""); const [newPasswordConfirm, setNewPasswordConfirm] = useState(""); const [passwordResetError, setPasswordResetError] = useState(null); const [isResettingPassword, setIsResettingPassword] = useState(false); // ── Fetch users ───────────────────────────────────────────────────────────── const fetchUsers = useCallback(async (q: string, pg: number) => { setIsLoading(true); try { const params: Record = { page: String(pg), per_page: "50" }; if (q) params.q = q; const data = await api.admin.listUsers(params); setUsers(data.users); setTotal(data.count); setPages(data.pages); } catch (err) { if (err instanceof ApiError && err.code === 403) { toast({ variant: "destructive", title: "Access denied", description: "Admin or owner role required to view all users.", }); } else { toast({ variant: "destructive", title: "Failed to load users" }); } } finally { setIsLoading(false); } }, [toast]); useEffect(() => { setPage(1); fetchUsers(debouncedSearch, 1); }, [debouncedSearch, fetchUsers]); useEffect(() => { fetchUsers(debouncedSearch, page); }, [page]); // eslint-disable-line react-hooks/exhaustive-deps // ── Open user drawer ───────────────────────────────────────────────────────── const openUserDrawer = async (user: ApiUser) => { setSelectedUser(user); setUserSshKeys([]); setUserMfaMethods([]); setUserLinkedAccounts([]); setTotalAuthMethods(0); setIsDrawerLoading(true); try { const [userData, mfaData, linkedData] = await Promise.allSettled([ api.admin.getUser(user.id), api.admin.getUserMfa(user.id), api.admin.getUserLinkedAccounts(user.id), ]); if (userData.status === "fulfilled") setUserSshKeys(userData.value.ssh_keys); if (mfaData.status === "fulfilled") setUserMfaMethods(mfaData.value.mfa_methods); if (linkedData.status === "fulfilled") { setUserLinkedAccounts(linkedData.value.linked_accounts); setTotalAuthMethods(linkedData.value.total_auth_methods); } } catch { // Non-fatal } finally { setIsDrawerLoading(false); } }; // ── Update role ────────────────────────────────────────────────────────────── const handleRoleChange = async (newRole: string) => { if (!selectedUser || !selectedUser.org_id) return; setIsUpdatingRole(true); try { await api.admin.updateUserRole(selectedUser.org_id, selectedUser.id, newRole.toUpperCase()); const updated = { ...selectedUser, org_role: newRole }; setSelectedUser(updated); setUsers((prev) => prev.map((u) => (u.id === selectedUser.id ? { ...u, org_role: newRole } : u)) ); toast({ title: "Role updated", description: `${selectedUser.full_name || selectedUser.email} is now a ${newRole}.`, }); } catch (err) { toast({ variant: "destructive", title: "Failed to update role", description: err instanceof ApiError ? err.message : "Something went wrong", }); } finally { setIsUpdatingRole(false); } }; // ── Admin add SSH key ──────────────────────────────────────────────────────── const handleAddKey = async () => { if (!selectedUser) return; setAddKeyError(null); if (!addKeyPublicKey.trim()) { setAddKeyError("Public key is required"); return; } setIsAddingKey(true); try { const key = await api.ssh.adminAddKey(selectedUser.id, addKeyPublicKey.trim(), addKeyDescription.trim() || undefined); setUserSshKeys((prev) => [...prev, key]); toast({ title: "SSH key added", description: `Key added for ${selectedUser.email}` }); setShowAddKey(false); setAddKeyPublicKey(""); setAddKeyDescription(""); } catch (err) { setAddKeyError(err instanceof ApiError ? err.message : "Failed to add key"); } finally { setIsAddingKey(false); } }; // ── Suspend / Unsuspend user ───────────────────────────────────────────────── const handleSuspend = async () => { if (!selectedUser) return; setIsSuspending(true); try { const data = await api.admin.suspendUser(selectedUser.id); const updated = { ...selectedUser, status: data.user.status }; setSelectedUser(updated); setUsers((prev) => prev.map((u) => u.id === selectedUser.id ? { ...u, status: data.user.status } : u)); setShowSuspendConfirm(false); toast({ title: "User suspended", description: `${selectedUser.full_name || selectedUser.email} has been suspended.` }); } catch (err) { setShowSuspendConfirm(false); if (err instanceof ApiError && err.type === "OWNER_PROTECTION") { toast({ variant: "destructive", title: "Cannot suspend organization owner", description: "Transfer ownership to another member before suspending this account.", }); } else { toast({ variant: "destructive", title: "Failed to suspend user", description: err instanceof ApiError ? err.message : "Something went wrong" }); } } finally { setIsSuspending(false); } }; const handleUnsuspend = async () => { if (!selectedUser) return; setIsSuspending(true); try { const data = await api.admin.unsuspendUser(selectedUser.id); const updated = { ...selectedUser, status: data.user.status }; setSelectedUser(updated); setUsers((prev) => prev.map((u) => u.id === selectedUser.id ? { ...u, status: data.user.status } : u)); toast({ title: "User unsuspended", description: `${selectedUser.full_name || selectedUser.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); } }; // ── Force-verify email ─────────────────────────────────────────────────────── const handleVerifyEmail = async () => { if (!selectedUser) return; setIsVerifyingEmail(true); try { const data = await api.admin.adminVerifyUserEmail(selectedUser.id); const updated = { ...selectedUser, email_verified: data.user.email_verified, status: data.user.status }; setSelectedUser(updated); setUsers((prev) => prev.map((u) => u.id === selectedUser.id ? { ...u, email_verified: data.user.email_verified, status: data.user.status } : u)); toast({ title: "Email verified", description: `${selectedUser.email} is now verified and active.` }); } catch (err) { toast({ variant: "destructive", title: "Failed to verify email", description: err instanceof ApiError ? err.message : "Something went wrong" }); } finally { setIsVerifyingEmail(false); } }; // ── Hard delete user ───────────────────────────────────────────────────────── const handleHardDelete = async () => { if (!selectedUser) return; setIsHardDeleting(true); try { const result = await api.admin.hardDeleteUser(selectedUser.id); setUsers((prev) => prev.filter((u) => u.id !== selectedUser.id)); setTotal((t) => t - 1); setShowHardDelete(false); setSelectedUser(null); toast({ title: "User permanently deleted", description: `${result.deleted_user_email} — ${result.certs_revoked} cert(s) revoked, ${result.ssh_keys_deleted} key(s) deleted.`, }); } catch (err) { toast({ variant: "destructive", title: "Failed to delete user", description: err instanceof ApiError ? err.message : "Something went wrong", }); } finally { setIsHardDeleting(false); } }; // ── Remove single MFA method ───────────────────────────────────────────────── const handleRemoveMfaMethod = async (method: AdminMfaMethod) => { if (!selectedUser) return; setRemovingMfaId(method.id); try { const credentialId = method.type === "webauthn" ? method.id : undefined; await api.admin.removeUserMfa(selectedUser.id, method.type as "totp" | "webauthn", credentialId); // Refresh MFA methods list const mfaData = await api.admin.getUserMfa(selectedUser.id); setUserMfaMethods(mfaData.mfa_methods); toast({ title: "MFA method removed", description: `${method.name} has been removed for ${selectedUser.email}. They can now re-enroll.`, }); } catch (err) { toast({ variant: "destructive", title: "Failed to remove MFA method", description: err instanceof ApiError ? err.message : "Something went wrong", }); } finally { setRemovingMfaId(null); } }; // ── Remove ALL MFA methods ─────────────────────────────────────────────────── const handleRemoveAllMfa = async () => { if (!selectedUser) return; setIsRemovingAllMfa(true); try { await api.admin.removeUserMfa(selectedUser.id, "all"); setUserMfaMethods([]); setShowRemoveAllMfa(false); toast({ title: "All MFA methods removed", description: `All MFA methods for ${selectedUser.email} have been cleared. They can now re-enroll.`, }); } catch (err) { setShowRemoveAllMfa(false); toast({ variant: "destructive", title: "Failed to remove MFA methods", description: err instanceof ApiError ? err.message : "Something went wrong", }); } finally { setIsRemovingAllMfa(false); } }; const handleUnlinkProvider = async (account: AdminLinkedAccount) => { if (!selectedUser) return; setUnlinkingProvider(account.id); try { await api.admin.adminUnlinkUserProvider(selectedUser.id, account.provider_type); setUserLinkedAccounts((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 ${selectedUser.email}.`, }); } catch (err) { toast({ variant: "destructive", title: "Failed to unlink provider", description: err instanceof ApiError ? err.message : "Something went wrong", }); } finally { setUnlinkingProvider(null); } }; // ── Admin password reset ───────────────────────────────────────────────────── const handlePasswordReset = async () => { if (!selectedUser) return; setPasswordResetError(null); if (newPassword.length < 8) { setPasswordResetError("Password must be at least 8 characters"); return; } if (newPassword !== newPasswordConfirm) { setPasswordResetError("Passwords do not match"); return; } setIsResettingPassword(true); try { await api.admin.adminSetUserPassword(selectedUser.id, newPassword); setShowPasswordReset(false); setNewPassword(""); setNewPasswordConfirm(""); toast({ title: "Password updated", description: `Password has been set for ${selectedUser.email}. They can now log in with it.`, }); } catch (err) { setPasswordResetError(err instanceof ApiError ? err.message : "Failed to set password"); } finally { setIsResettingPassword(false); } }; // Filter by role client-side const filteredUsers = users.filter((u) => { if (roleFilter === "all") return true; const r = (u.org_role || "member").toLowerCase(); return r === roleFilter; }); // ────────────────────────────────────────────────────────────────────────────── return (

User Management

View and manage users across your organizations

{/* Search + filter bar */}
setSearch(e.target.value)} />
Users {!isLoading && {total}} Click a user to view details and manage their role or SSH keys {isLoading ? (
) : filteredUsers.length === 0 ? (

{debouncedSearch ? "No users match your search" : "No users found"}

) : (
{filteredUsers.map((user) => ( ))}
)} {/* Pagination */} {pages > 1 && (

Page {page} of {pages} · {total} total

)}
{/* ── User detail drawer ─────────────────────────────────────────────────── */} { if (!open) setSelectedUser(null); }}> {selectedUser && ( <> {selectedUser.full_name || selectedUser.email} {selectedUser.email} {/* Basic info */}
Status {isSuspended(selectedUser.status) ? ( <>Suspended{selectedUser.status === "compliance_suspended" ? " (compliance)" : ""} ) : ( <>Active )} Joined {formatDate(selectedUser.created_at)} Activated {selectedUser.activated === false ? ( <> No ) : ( <> Yes )} Last login {formatDate(selectedUser.last_login_at)}
{/* Suspend / Unsuspend — only for other users */} {selectedUser.id !== currentUser?.id && (

Account Access

{/* Unverified / inactive email block */} {(!selectedUser.email_verified || selectedUser.status === "inactive") && (

{selectedUser.status === "inactive" ? "This account is inactive — the user has not verified their email and cannot log in, set up OAuth, or configure MFA." : "This user's email address is not verified."}

)} {isSuspended(selectedUser.status) ? (

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

) : (

Suspending blocks this user from logging in and requesting SSH certificates.

)}
)} {/* Role management — only if not viewing yourself and user has org_id */} {selectedUser.org_id && selectedUser.id !== currentUser?.id && (

Organization Role

{isUpdatingRole && }
{(selectedUser.org_role || "").toLowerCase() === "owner" && (

Owner role cannot be changed here. Transfer ownership from the Members page.

)}
)} {/* ── MFA Methods section ────────────────────────────────────────── */} {selectedUser.id !== currentUser?.id && (

MFA Methods

{userMfaMethods.length > 1 && ( )}
{isMfaLoading ? (
) : userMfaMethods.length === 0 ? (

No MFA methods configured.

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

{method.name}

{method.last_used_at && (

Last used: {formatDate(method.last_used_at)}

)}
))}
)}

Remove an MFA method if the user has lost access (e.g. lost phone or passkey). The user will be able to re-enroll after removal.

)} {/* ── Linked Accounts section ────────────────────────────────── */} {selectedUser.id !== currentUser?.id && (

Linked OAuth Accounts

{userLinkedAccounts.length === 0 ? (

No OAuth providers linked.

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

{account.provider_type}

{account.email && (

{account.email}

)} {account.linked_at && (

Linked: {formatDate(account.linked_at)}

)}
); })}
)}

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

)} {/* ── Admin Password Reset section ──────────────────────────── */} {selectedUser.id !== currentUser?.id && (

Password

Set a new password for this user. Use this when a user is locked out or needs a password added to their account.

)} {/* SSH Keys section */}

SSH Keys

{isDrawerLoading ? (
) : userSshKeys.length === 0 ? (
No SSH keys registered
) : (
{userSshKeys.map((k) => (
{k.description || No description} {k.verified ? ( Verified ) : ( Unverified )}
{k.fingerprint ?? k.public_key.slice(0, 64) + "…"}
Added {formatDate(k.created_at)}
))}
)}
{/* Danger zone — Hard delete */} {selectedUser.id !== currentUser?.id && (

Danger Zone

Permanently delete this account. This cannot be undone — all SSH keys and certificates will be revoked immediately.

)} )}
{/* ── Admin add SSH key dialog ───────────────────────────────────────────── */} { setShowAddKey(open); setAddKeyError(null); }}> Add SSH Key for {selectedUser?.email} Add an SSH public key on behalf of this user (admin action).
{addKeyError && (
{addKeyError}
)}