1264 lines
55 KiB
TypeScript
1264 lines
55 KiB
TypeScript
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,
|
|
Award,
|
|
ExternalLink,
|
|
Lock,
|
|
FileKey,
|
|
} from "lucide-react";
|
|
import { useNavigate } from "react-router-dom";
|
|
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, AdminUserSshCertificate } from "@/lib/api";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
|
|
function formatDate(d: string | null) {
|
|
if (!d) return "—";
|
|
const raw = !(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 ? 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 (
|
|
<Badge className="bg-purple-500/10 text-purple-600 border-purple-200 text-xs">
|
|
<ShieldCheck className="w-3 h-3 mr-1" />Owner
|
|
</Badge>
|
|
);
|
|
}
|
|
if (r === "admin") {
|
|
return (
|
|
<Badge className="bg-blue-500/10 text-blue-600 border-blue-200 text-xs">
|
|
<Shield className="w-3 h-3 mr-1" />Admin
|
|
</Badge>
|
|
);
|
|
}
|
|
return (
|
|
<Badge variant="outline" className="text-xs text-muted-foreground">
|
|
Member
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
export default function AdminUsersPage() {
|
|
const { toast } = useToast();
|
|
const { user: currentUser } = useAuth();
|
|
const navigate = useNavigate();
|
|
|
|
// User list
|
|
const [users, setUsers] = useState<ApiUser[]>([]);
|
|
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<ApiUser | null>(null);
|
|
const [userSshKeys, setUserSshKeys] = useState<SSHKey[]>([]);
|
|
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<string | null>(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<AdminMfaMethod[]>([]);
|
|
const [isMfaLoading, setIsMfaLoading] = useState(false);
|
|
const [removingMfaId, setRemovingMfaId] = useState<string | null>(null);
|
|
const [showRemoveAllMfa, setShowRemoveAllMfa] = useState(false);
|
|
const [isRemovingAllMfa, setIsRemovingAllMfa] = useState(false);
|
|
|
|
// Linked accounts management
|
|
const [userLinkedAccounts, setUserLinkedAccounts] = useState<AdminLinkedAccount[]>([]);
|
|
const [totalAuthMethods, setTotalAuthMethods] = useState(0);
|
|
const [unlinkingProvider, setUnlinkingProvider] = useState<string | null>(null);
|
|
|
|
// Admin password reset
|
|
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
|
const [newPassword, setNewPassword] = useState("");
|
|
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
|
|
const [passwordResetError, setPasswordResetError] = useState<string | null>(null);
|
|
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
|
|
|
// SSH Certificates summary
|
|
const [userSshCerts, setUserSshCerts] = useState<AdminUserSshCertificate[]>([]);
|
|
const [sshCertsCount, setSshCertsCount] = useState(0);
|
|
const [isSshCertsLoading, setIsSshCertsLoading] = useState(false);
|
|
|
|
// ── Fetch users ─────────────────────────────────────────────────────────────
|
|
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
|
setIsLoading(true);
|
|
try {
|
|
const params: Record<string, string> = { 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);
|
|
setUserSshCerts([]);
|
|
setSshCertsCount(0);
|
|
setIsSshCertsLoading(true);
|
|
setIsDrawerLoading(true);
|
|
try {
|
|
const [userData, mfaData, linkedData, certsData] = await Promise.allSettled([
|
|
api.admin.getUser(user.id),
|
|
api.admin.getUserMfa(user.id),
|
|
api.admin.getUserLinkedAccounts(user.id),
|
|
api.admin.getUserSshCertificates(user.id, { per_page: 5 }),
|
|
]);
|
|
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);
|
|
}
|
|
if (certsData.status === "fulfilled") {
|
|
setUserSshCerts(certsData.value.certificates);
|
|
setSshCertsCount(certsData.value.count);
|
|
} else {
|
|
setUserSshCerts([]);
|
|
setSshCertsCount(0);
|
|
}
|
|
} catch {
|
|
// Non-fatal
|
|
} finally {
|
|
setIsDrawerLoading(false);
|
|
setIsSshCertsLoading(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 (
|
|
<div className="page-container">
|
|
<div className="page-header">
|
|
<h1 className="page-title">User Management</h1>
|
|
<p className="page-description">
|
|
View and manage users across your organizations
|
|
</p>
|
|
</div>
|
|
|
|
{/* Search + filter bar */}
|
|
<div className="flex gap-3 mb-4">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
className="pl-9"
|
|
placeholder="Search by name or email…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
|
<SelectTrigger className="w-[160px]">
|
|
<SelectValue placeholder="All roles" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All roles</SelectItem>
|
|
<SelectItem value="owner">Owner</SelectItem>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
<SelectItem value="member">Member</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<User className="w-4 h-4" />
|
|
Users
|
|
{!isLoading && <Badge variant="secondary" className="ml-1">{total}</Badge>}
|
|
</CardTitle>
|
|
<CardDescription>Click a user to view details and manage their role or SSH keys</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : filteredUsers.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<User className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
|
<p className="text-sm">{debouncedSearch ? "No users match your search" : "No users found"}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{filteredUsers.map((user) => (
|
|
<button
|
|
key={user.id}
|
|
className="w-full flex items-center justify-between p-3 rounded-lg border hover:bg-accent/50 transition-colors text-left"
|
|
onClick={() => openUserDrawer(user)}
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
<User className="w-4 h-4 text-primary" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">{user.full_name || user.email}</p>
|
|
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<RoleBadge role={user.org_role || "member"} />
|
|
{isSuspended(user.status) && (
|
|
<Badge variant="outline" className="text-xs text-red-600 border-red-300 bg-red-50">
|
|
<Ban className="w-3 h-3 mr-1" />Suspended
|
|
</Badge>
|
|
)}
|
|
{user.activated === false && (
|
|
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
|
|
Not activated
|
|
</Badge>
|
|
)}
|
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{pages > 1 && (
|
|
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
|
<p className="text-xs text-muted-foreground">
|
|
Page {page} of {pages} · {total} total
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
|
disabled={page === pages}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ── User detail drawer ─────────────────────────────────────────────────── */}
|
|
<Sheet open={!!selectedUser} onOpenChange={(open) => { if (!open) setSelectedUser(null); }}>
|
|
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
|
{selectedUser && (
|
|
<>
|
|
<SheetHeader className="mb-4">
|
|
<SheetTitle className="flex items-center gap-2">
|
|
<User className="w-5 h-5" />
|
|
{selectedUser.full_name || selectedUser.email}
|
|
</SheetTitle>
|
|
<SheetDescription>{selectedUser.email}</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
{/* Basic info */}
|
|
<div className="space-y-3 mb-6">
|
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
<span className="text-muted-foreground">Status</span>
|
|
<span className="flex items-center gap-1">
|
|
{isSuspended(selectedUser.status) ? (
|
|
<><Ban className="w-4 h-4 text-red-500" /><span className="text-red-600 font-medium">Suspended{selectedUser.status === "compliance_suspended" ? " (compliance)" : ""}</span></>
|
|
) : (
|
|
<><CheckCircle className="w-4 h-4 text-green-500" /><span className="text-green-600">Active</span></>
|
|
)}
|
|
</span>
|
|
<span className="text-muted-foreground">Joined</span>
|
|
<span>{formatDate(selectedUser.created_at)}</span>
|
|
<span className="text-muted-foreground">Activated</span>
|
|
<span className="flex items-center gap-1">
|
|
{selectedUser.activated === false ? (
|
|
<><XCircle className="w-4 h-4 text-amber-500" /> No</>
|
|
) : (
|
|
<><CheckCircle className="w-4 h-4 text-green-500" /> Yes</>
|
|
)}
|
|
</span>
|
|
<span className="text-muted-foreground">Last login</span>
|
|
<span>{formatDate(selectedUser.last_login_at)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Suspend / Unsuspend — only for other users */}
|
|
{selectedUser.id !== currentUser?.id && (
|
|
<div className="mb-6 p-4 border rounded-lg space-y-3">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
<Ban className="w-4 h-4" />
|
|
Account Access
|
|
</h3>
|
|
|
|
{/* Unverified / inactive email block */}
|
|
{(!selectedUser.email_verified || selectedUser.status === "inactive") && (
|
|
<div className="space-y-2 pb-3 border-b">
|
|
<p className="text-sm text-muted-foreground">
|
|
{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."}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleVerifyEmail}
|
|
disabled={isVerifyingEmail}
|
|
className="text-blue-600 border-blue-300 hover:bg-blue-50"
|
|
>
|
|
{isVerifyingEmail ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <CheckCircle className="w-4 h-4 mr-2" />}
|
|
Verify email & activate account
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{isSuspended(selectedUser.status) ? (
|
|
<div className="space-y-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
{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."}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleUnsuspend}
|
|
disabled={isSuspending}
|
|
className="text-green-600 border-green-300 hover:bg-green-50"
|
|
>
|
|
{isSuspending ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <UserCheck className="w-4 h-4 mr-2" />}
|
|
Restore account
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<p className="text-sm text-muted-foreground">Suspending blocks this user from logging in and requesting SSH certificates.</p>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setShowSuspendConfirm(true)}
|
|
disabled={isSuspending}
|
|
className="text-red-600 border-red-300 hover:bg-red-50"
|
|
>
|
|
<Ban className="w-4 h-4 mr-2" />
|
|
Suspend account
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Role management — only if not viewing yourself and user has org_id */}
|
|
{selectedUser.org_id && selectedUser.id !== currentUser?.id && (
|
|
<div className="mb-6 p-4 border rounded-lg space-y-3">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
<Shield className="w-4 h-4" />
|
|
Organization Role
|
|
</h3>
|
|
<div className="flex items-center gap-3">
|
|
<Select
|
|
value={(selectedUser.org_role || "member").toLowerCase()}
|
|
onValueChange={handleRoleChange}
|
|
disabled={isUpdatingRole || (selectedUser.org_role || "").toLowerCase() === "owner"}
|
|
>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="member">Member</SelectItem>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
<SelectItem value="owner">Owner</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{isUpdatingRole && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
|
</div>
|
|
{(selectedUser.org_role || "").toLowerCase() === "owner" && (
|
|
<p className="text-xs text-muted-foreground">Owner role cannot be changed here. Transfer ownership from the Members page.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── MFA Methods section ────────────────────────────────────────── */}
|
|
{selectedUser.id !== currentUser?.id && (
|
|
<div className="mb-6 p-4 border rounded-lg space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
<ShieldOff className="w-4 h-4" />
|
|
MFA Methods
|
|
</h3>
|
|
{userMfaMethods.length > 1 && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setShowRemoveAllMfa(true)}
|
|
className="text-red-600 border-red-300 hover:bg-red-50 text-xs"
|
|
>
|
|
<Trash2 className="w-3 h-3 mr-1" />
|
|
Remove all
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{isMfaLoading ? (
|
|
<div className="flex items-center justify-center py-4">
|
|
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : userMfaMethods.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
No MFA methods configured.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{userMfaMethods.map((method) => (
|
|
<div
|
|
key={method.id}
|
|
className="flex items-center justify-between p-3 border rounded-lg text-sm"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{method.type === "totp" ? (
|
|
<Smartphone className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
|
) : (
|
|
<KeyRound className="w-4 h-4 text-purple-500 flex-shrink-0" />
|
|
)}
|
|
<div className="min-w-0">
|
|
<p className="font-medium truncate">{method.name}</p>
|
|
{method.last_used_at && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Last used: {formatDate(method.last_used_at)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleRemoveMfaMethod(method)}
|
|
disabled={removingMfaId === method.id}
|
|
className="text-red-600 hover:bg-red-50 flex-shrink-0 ml-2"
|
|
title={`Remove ${method.name}`}
|
|
>
|
|
{removingMfaId === method.id ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
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.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Linked Accounts section ────────────────────────────────── */}
|
|
{selectedUser.id !== currentUser?.id && (
|
|
<div className="mb-6 p-4 border rounded-lg space-y-3">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
<Link2 className="w-4 h-4" />
|
|
Linked OAuth Accounts
|
|
</h3>
|
|
|
|
{userLinkedAccounts.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
No OAuth providers linked.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{userLinkedAccounts.map((account) => {
|
|
const isOnlyMethod = totalAuthMethods <= 1;
|
|
return (
|
|
<div
|
|
key={account.id}
|
|
className="flex items-center justify-between p-3 border rounded-lg text-sm"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Link2 className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="font-medium capitalize">{account.provider_type}</p>
|
|
{account.email && (
|
|
<p className="text-xs text-muted-foreground truncate">{account.email}</p>
|
|
)}
|
|
{account.linked_at && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Linked: {formatDate(account.linked_at)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleUnlinkProvider(account)}
|
|
disabled={unlinkingProvider === account.id || isOnlyMethod}
|
|
className="text-red-600 hover:bg-red-50 flex-shrink-0 ml-2"
|
|
title={
|
|
isOnlyMethod
|
|
? "Cannot unlink — this is the user's only sign-in method"
|
|
: `Unlink ${account.provider_type}`
|
|
}
|
|
>
|
|
{unlinkingProvider === account.id ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Unlink className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
Unlink an OAuth provider to prevent sign-in via that provider.
|
|
Cannot unlink if it is the user's only sign-in method.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Admin Password Reset section ──────────────────────────── */}
|
|
{selectedUser.id !== currentUser?.id && (
|
|
<div className="mb-6 p-4 border rounded-lg space-y-3">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
<Lock className="w-4 h-4" />
|
|
Password
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Set a new password for this user. Use this when a user is locked out or needs a password added to their account.
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => { setPasswordResetError(null); setNewPassword(""); setNewPasswordConfirm(""); setShowPasswordReset(true); }}
|
|
>
|
|
<Lock className="w-3 h-3 mr-1" />
|
|
Set password
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* SSH Keys section */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
<Key className="w-4 h-4" />
|
|
SSH Keys
|
|
</h3>
|
|
<Button size="sm" variant="outline" onClick={() => setShowAddKey(true)}>
|
|
<Plus className="w-3 h-3 mr-1" />
|
|
Add key
|
|
</Button>
|
|
</div>
|
|
|
|
{isDrawerLoading ? (
|
|
<div className="flex items-center justify-center py-6">
|
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : userSshKeys.length === 0 ? (
|
|
<div className="text-center py-6 text-muted-foreground text-sm">
|
|
No SSH keys registered
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{userSshKeys.map((k) => (
|
|
<div key={k.id} className="p-3 border rounded-lg text-sm">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="font-medium">{k.description || <em className="text-muted-foreground">No description</em>}</span>
|
|
{k.verified ? (
|
|
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">
|
|
<CheckCircle className="w-3 h-3 mr-1" />Verified
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
|
|
Unverified
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground font-mono truncate">
|
|
{k.fingerprint ?? k.public_key.slice(0, 64) + "…"}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
Added {formatDate(k.created_at)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* SSH Certificates summary */}
|
|
{selectedUser.id !== currentUser?.id && (
|
|
<div className="mt-6 p-4 border rounded-lg space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
<Award className="w-4 h-4" />
|
|
SSH Certificates
|
|
</h3>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={() => {
|
|
setSelectedUser(null);
|
|
navigate(`/org/members/${selectedUser.id}`);
|
|
}}
|
|
>
|
|
<ExternalLink className="w-3 h-3 mr-1" />
|
|
Full details
|
|
</Button>
|
|
</div>
|
|
|
|
{isDrawerLoading || isSshCertsLoading ? (
|
|
<div className="flex items-center justify-center py-4">
|
|
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : sshCertsCount === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No SSH certificates issued.</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{/* Total count badge */}
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge variant="secondary" className="text-xs">
|
|
<FileKey className="w-3 h-3 mr-1" />
|
|
{sshCertsCount} certificate{sshCertsCount !== 1 ? "s" : ""}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Recent certificates (up to 5) */}
|
|
<div className="space-y-1.5">
|
|
{userSshCerts.slice(0, 5).map((cert) => (
|
|
<div key={cert.id} className="flex items-center justify-between p-2 border rounded text-xs">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="font-mono truncate">{cert.key_id}</span>
|
|
{cert.revoked ? (
|
|
<Badge variant="destructive" className="text-[10px] px-1 py-0">Revoked</Badge>
|
|
) : !cert.is_valid ? (
|
|
<Badge variant="outline" className="text-[10px] px-1 py-0 text-muted-foreground">Expired</Badge>
|
|
) : (
|
|
<Badge className="bg-green-500/10 text-green-600 border-0 text-[10px] px-1 py-0">Active</Badge>
|
|
)}
|
|
</div>
|
|
<span className="text-muted-foreground flex-shrink-0 ml-2">
|
|
{cert.principals.slice(0, 2).join(", ")}
|
|
{cert.principals.length > 2 && ` +${cert.principals.length - 2}`}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Danger zone — Hard delete */}
|
|
{selectedUser.id !== currentUser?.id && (
|
|
<div className="mt-6 p-4 border border-destructive/30 rounded-lg space-y-3">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2 text-destructive">
|
|
<Trash2 className="w-4 h-4" />
|
|
Danger Zone
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Permanently delete this account. This cannot be undone — all SSH keys and certificates will be revoked immediately.
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => { setHardDeleteConfirmEmail(""); setShowHardDelete(true); }}
|
|
className="text-destructive border-destructive/40 hover:bg-destructive/10"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Permanently delete account
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* ── Admin add SSH key dialog ───────────────────────────────────────────── */}
|
|
<Dialog open={showAddKey} onOpenChange={(open) => { setShowAddKey(open); setAddKeyError(null); }}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Add SSH Key for {selectedUser?.email}</DialogTitle>
|
|
<DialogDescription>
|
|
Add an SSH public key on behalf of this user (admin action).
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
{addKeyError && (
|
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{addKeyError}</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<Label>Public key</Label>
|
|
<Textarea
|
|
placeholder="ssh-ed25519 AAAA..."
|
|
value={addKeyPublicKey}
|
|
onChange={(e) => setAddKeyPublicKey(e.target.value)}
|
|
className="font-mono text-xs min-h-[80px]"
|
|
disabled={isAddingKey}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Description <span className="text-muted-foreground">(optional)</span></Label>
|
|
<Input
|
|
placeholder="Laptop key"
|
|
value={addKeyDescription}
|
|
onChange={(e) => setAddKeyDescription(e.target.value)}
|
|
disabled={isAddingKey}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowAddKey(false)} disabled={isAddingKey}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleAddKey} disabled={isAddingKey || !addKeyPublicKey.trim()}>
|
|
{isAddingKey && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
Add key
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Suspend confirmation dialog ───────────────────────────────────────── */}
|
|
<Dialog open={showSuspendConfirm} onOpenChange={setShowSuspendConfirm}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-red-600">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
Suspend account?
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
<strong>{selectedUser?.full_name || selectedUser?.email}</strong> will be blocked from logging in and requesting SSH certificates. You can restore their access at any time.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowSuspendConfirm(false)} disabled={isSuspending}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleSuspend}
|
|
disabled={isSuspending}
|
|
>
|
|
{isSuspending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
Suspend
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Remove All MFA confirmation ───────────────────────────────────────── */}
|
|
<Dialog open={showRemoveAllMfa} onOpenChange={setShowRemoveAllMfa}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-amber-600">
|
|
<ShieldOff className="w-5 h-5" />
|
|
Remove all MFA methods?
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
All MFA methods for{" "}
|
|
<strong>{selectedUser?.full_name || selectedUser?.email}</strong> will
|
|
be removed. They will be able to re-enroll after this action. Use this
|
|
when the user has lost access to their authenticator app or passkey.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowRemoveAllMfa(false)} disabled={isRemovingAllMfa}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleRemoveAllMfa}
|
|
disabled={isRemovingAllMfa}
|
|
>
|
|
{isRemovingAllMfa && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
Remove all MFA
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Hard delete confirmation ──────────────────────────────────────────── */}
|
|
<Dialog
|
|
open={showHardDelete}
|
|
onOpenChange={(open) => { setShowHardDelete(open); if (!open) setHardDeleteConfirmEmail(""); }}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
|
<Trash2 className="w-5 h-5" />
|
|
Permanently delete account?
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
This will <strong>permanently</strong> delete{" "}
|
|
<strong>{selectedUser?.full_name || selectedUser?.email}</strong>,
|
|
revoke all their SSH certificates, and remove all their SSH keys. This action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="py-2 space-y-2">
|
|
<Label className="text-sm">
|
|
Type <span className="font-mono font-semibold">{selectedUser?.email}</span> to confirm
|
|
</Label>
|
|
<Input
|
|
value={hardDeleteConfirmEmail}
|
|
onChange={(e) => setHardDeleteConfirmEmail(e.target.value)}
|
|
placeholder={selectedUser?.email ?? ""}
|
|
disabled={isHardDeleting}
|
|
className="font-mono"
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowHardDelete(false)}
|
|
disabled={isHardDeleting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleHardDelete}
|
|
disabled={isHardDeleting || hardDeleteConfirmEmail !== selectedUser?.email}
|
|
>
|
|
{isHardDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
Delete permanently
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
{/* ── Admin password reset dialog ───────────────────────────────────── */}
|
|
<Dialog
|
|
open={showPasswordReset}
|
|
onOpenChange={(open) => {
|
|
setShowPasswordReset(open);
|
|
if (!open) { setNewPassword(""); setNewPasswordConfirm(""); setPasswordResetError(null); }
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Lock className="w-5 h-5" />
|
|
Set password for {selectedUser?.email}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
The user will be able to log in with this password immediately. This does not affect their existing OAuth logins.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="py-2 space-y-3">
|
|
{passwordResetError && (
|
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{passwordResetError}</div>
|
|
)}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="admin-new-password">New password</Label>
|
|
<Input
|
|
id="admin-new-password"
|
|
type="password"
|
|
placeholder="Min. 8 characters"
|
|
value={newPassword}
|
|
onChange={(e) => { setNewPassword(e.target.value); setPasswordResetError(null); }}
|
|
disabled={isResettingPassword}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="admin-new-password-confirm">Confirm password</Label>
|
|
<Input
|
|
id="admin-new-password-confirm"
|
|
type="password"
|
|
placeholder="Repeat new password"
|
|
value={newPasswordConfirm}
|
|
onChange={(e) => { setNewPasswordConfirm(e.target.value); setPasswordResetError(null); }}
|
|
disabled={isResettingPassword}
|
|
onKeyDown={(e) => { if (e.key === "Enter" && newPassword && newPasswordConfirm) handlePasswordReset(); }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowPasswordReset(false)}
|
|
disabled={isResettingPassword}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handlePasswordReset}
|
|
disabled={isResettingPassword || !newPassword || !newPasswordConfirm}
|
|
>
|
|
{isResettingPassword && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
Set password
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|