feat: add network management page and inline accordion device details
This commit is contained in:
@@ -19,8 +19,12 @@ import {
|
||||
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";
|
||||
@@ -56,7 +60,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
|
||||
import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount, AdminUserSshCertificate } from "@/lib/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
@@ -99,6 +103,7 @@ function RoleBadge({ role }: { role: string }) {
|
||||
export default function AdminUsersPage() {
|
||||
const { toast } = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// User list
|
||||
const [users, setUsers] = useState<ApiUser[]>([]);
|
||||
@@ -162,6 +167,11 @@ export default function AdminUsersPage() {
|
||||
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);
|
||||
@@ -203,12 +213,16 @@ export default function AdminUsersPage() {
|
||||
setUserMfaMethods([]);
|
||||
setUserLinkedAccounts([]);
|
||||
setTotalAuthMethods(0);
|
||||
setUserSshCerts([]);
|
||||
setSshCertsCount(0);
|
||||
setIsSshCertsLoading(true);
|
||||
setIsDrawerLoading(true);
|
||||
try {
|
||||
const [userData, mfaData, linkedData] = await Promise.allSettled([
|
||||
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);
|
||||
@@ -216,10 +230,18 @@ export default function AdminUsersPage() {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -929,6 +951,70 @@ export default function AdminUsersPage() {
|
||||
)}
|
||||
</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">
|
||||
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
UserCheck,
|
||||
ShieldOff,
|
||||
Plus,
|
||||
Award,
|
||||
Clock,
|
||||
FileKey,
|
||||
Globe,
|
||||
Terminal,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,6 +33,13 @@ 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -38,7 +52,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api, ApiError, User, SSHKey, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
|
||||
import { api, ApiError, User, SSHKey, AdminMfaMethod, AdminLinkedAccount, AdminUserSshCertificate } from "@/lib/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -155,6 +169,233 @@ function UserManagementSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Certificate Status Badge ─────────────────────────────────────────────────
|
||||
|
||||
function CertStatusBadge({ cert }: { cert: AdminUserSshCertificate }) {
|
||||
if (cert.revoked) {
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<ShieldOff className="w-3 h-3 mr-1" />
|
||||
Revoked
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (cert.status === "superseded") {
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
|
||||
Superseded
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (!cert.is_valid) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
Expired
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
// Active + valid
|
||||
if (cert.days_until_expiry >= 0 && cert.days_until_expiry <= 7) {
|
||||
return (
|
||||
<Badge className="bg-amber-500/10 text-amber-600 border-0 text-xs">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{cert.days_until_expiry === 0 ? "Expires today" : `${cert.days_until_expiry}d left`}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Active
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Certificate Row ──────────────────────────────────────────────────────────
|
||||
|
||||
function CertificateRow({ cert }: { cert: AdminUserSshCertificate }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-start justify-between gap-3 p-4 text-left hover:bg-accent/30 transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium font-mono truncate">
|
||||
{cert.key_id}
|
||||
</span>
|
||||
<CertStatusBadge cert={cert} />
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{cert.cert_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="font-mono">
|
||||
Serial #{cert.serial}
|
||||
</span>
|
||||
<span>
|
||||
{cert.principals.length > 0
|
||||
? cert.principals.join(", ")
|
||||
: "No principals"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Valid {formatDate(cert.valid_after)} → {formatDate(cert.valid_before)}
|
||||
{cert.days_until_expiry < 0 && (
|
||||
<span className="text-red-500 ml-1">
|
||||
(expired {Math.abs(cert.days_until_expiry)}d ago)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 pt-0.5">
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t bg-muted/30 p-4 space-y-3">
|
||||
{/* Revocation info */}
|
||||
{cert.revoked && (
|
||||
<div className="p-3 rounded-md bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 space-y-1">
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300 flex items-center gap-1.5">
|
||||
<ShieldOff className="w-3.5 h-3.5" />
|
||||
Certificate Revoked
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<span className="text-red-600/70 dark:text-red-400/70">Revoked at</span>
|
||||
<span className="text-red-700 dark:text-red-300">{formatDate(cert.revoked_at)}</span>
|
||||
{cert.revoke_reason && (
|
||||
<>
|
||||
<span className="text-red-600/70 dark:text-red-400/70">Reason</span>
|
||||
<span className="text-red-700 dark:text-red-300">{cert.revoke_reason}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificate details grid */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||
<span className="text-muted-foreground">Certificate ID</span>
|
||||
<span className="font-mono truncate">{cert.id}</span>
|
||||
|
||||
<span className="text-muted-foreground">CA ID</span>
|
||||
<span className="font-mono truncate">{cert.ca_id}</span>
|
||||
|
||||
{cert.request_ip && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Request IP</span>
|
||||
<span className="font-mono flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
{cert.request_ip}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{cert.request_user_agent && (
|
||||
<>
|
||||
<span className="text-muted-foreground">User Agent</span>
|
||||
<span className="font-mono truncate">{cert.request_user_agent}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{formatDate(cert.created_at)}</span>
|
||||
|
||||
<span className="text-muted-foreground">Last Updated</span>
|
||||
<span>{formatDate(cert.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
{/* Extensions */}
|
||||
{Object.keys(cert.extensions).length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1">
|
||||
<Terminal className="w-3 h-3" />
|
||||
Extensions
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(cert.extensions).map(([key, val]) => (
|
||||
<Badge key={key} variant="secondary" className="text-[10px] font-mono">
|
||||
{key}{val ? `=${val}` : ""}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Critical options */}
|
||||
{Object.keys(cert.critical_options).length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Critical Options
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(cert.critical_options).map(([key, val]) => (
|
||||
<Badge key={key} variant="outline" className="text-[10px] font-mono text-amber-600 border-amber-300">
|
||||
{key}{val ? `=${val}` : ""}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Key info */}
|
||||
{cert.ssh_key && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1">
|
||||
<Key className="w-3 h-3" />
|
||||
SSH Key
|
||||
</p>
|
||||
<div className="p-3 rounded-md border bg-background space-y-1.5 text-xs">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<span className="text-muted-foreground">Fingerprint</span>
|
||||
<span className="font-mono truncate">{cert.ssh_key.fingerprint}</span>
|
||||
<span className="text-muted-foreground">Type</span>
|
||||
<span className="font-mono">{cert.ssh_key.key_type} ({cert.ssh_key.key_bits} bits)</span>
|
||||
{cert.ssh_key.description && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Description</span>
|
||||
<span>{cert.ssh_key.description}</span>
|
||||
</>
|
||||
)}
|
||||
{cert.ssh_key.key_comment && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Comment</span>
|
||||
<span className="font-mono">{cert.ssh_key.key_comment}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground">Verified</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{cert.ssh_key.verified ? (
|
||||
<><CheckCircle className="w-3 h-3 text-green-500" /> Yes</>
|
||||
) : (
|
||||
<><XCircle className="w-3 h-3 text-amber-500" /> No</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!cert.ssh_key && (
|
||||
<p className="text-xs text-muted-foreground italic">No SSH key linked to this certificate</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UserManagementPage() {
|
||||
@@ -207,6 +448,16 @@ export default function UserManagementPage() {
|
||||
// Role update state
|
||||
const [selectedRole, setSelectedRole] = useState<string>("member");
|
||||
|
||||
// SSH Certificates state
|
||||
const [sshCerts, setSshCerts] = useState<AdminUserSshCertificate[]>([]);
|
||||
const [isCertsLoading, setIsCertsLoading] = useState(false);
|
||||
const [certsPage, setCertsPage] = useState(1);
|
||||
const [certsPages, setCertsPages] = useState(1);
|
||||
const [certsCount, setCertsCount] = useState(0);
|
||||
const [certStatusFilter, setCertStatusFilter] = useState<string>("all");
|
||||
const [certActiveFilter, setCertActiveFilter] = useState<string>("all");
|
||||
const [certTypeFilter, setCertTypeFilter] = useState<string>("all");
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleRemoveMfaMethod = async (method: AdminMfaMethod) => {
|
||||
@@ -545,6 +796,38 @@ export default function UserManagementPage() {
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
// ── Fetch SSH Certificates ───────────────────────────────────────────────────
|
||||
// Reset page when filters change
|
||||
useEffect(() => {
|
||||
setCertsPage(1);
|
||||
}, [certStatusFilter, certActiveFilter, certTypeFilter]);
|
||||
|
||||
// Fetch SSH certificates
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
let cancelled = false;
|
||||
const fetchCerts = async () => {
|
||||
setIsCertsLoading(true);
|
||||
try {
|
||||
const params: Record<string, string | number> = { page: certsPage, per_page: 20 };
|
||||
if (certStatusFilter !== "all") params.status = certStatusFilter;
|
||||
if (certActiveFilter !== "all") params.active = certActiveFilter;
|
||||
if (certTypeFilter !== "all") params.cert_type = certTypeFilter;
|
||||
const data = await api.admin.getUserSshCertificates(userId, params);
|
||||
if (cancelled) return;
|
||||
setSshCerts(data.certificates);
|
||||
setCertsPages(data.pages);
|
||||
setCertsCount(data.count);
|
||||
} catch {
|
||||
if (!cancelled) setSshCerts([]);
|
||||
} finally {
|
||||
if (!cancelled) setIsCertsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCerts();
|
||||
return () => { cancelled = true; };
|
||||
}, [userId, certsPage, certStatusFilter, certActiveFilter, certTypeFilter]);
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) {
|
||||
@@ -588,6 +871,10 @@ export default function UserManagementPage() {
|
||||
<TabsTrigger value="details">User Details</TabsTrigger>
|
||||
<TabsTrigger value="security">Security</TabsTrigger>
|
||||
<TabsTrigger value="access">Access</TabsTrigger>
|
||||
<TabsTrigger value="certs">
|
||||
<FileKey className="w-3.5 h-3.5 mr-1.5" />
|
||||
SSH Certificates
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── User Details Tab ────────────────────────────────────────────── */}
|
||||
@@ -918,6 +1205,110 @@ export default function UserManagementPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── SSH Certificates Tab ──────────────────────────────────────────── */}
|
||||
<TabsContent value="certs">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Award className="w-5 h-5" />
|
||||
SSH Certificates
|
||||
</div>
|
||||
{!isCertsLoading && (
|
||||
<Badge variant="secondary">{certsCount}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
SSH certificates issued to this user via the organization's Certificate Authority
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Filter controls */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Select value={certStatusFilter} onValueChange={setCertStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="issued">Issued</SelectItem>
|
||||
<SelectItem value="revoked">Revoked</SelectItem>
|
||||
<SelectItem value="expired">Expired</SelectItem>
|
||||
<SelectItem value="superseded">Superseded</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={certActiveFilter} onValueChange={setCertActiveFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="All certs" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All certs</SelectItem>
|
||||
<SelectItem value="true">Active only</SelectItem>
|
||||
<SelectItem value="false">Inactive only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={certTypeFilter} onValueChange={setCertTypeFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="host">Host</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isCertsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sshCerts.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Award className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">No certificates found</p>
|
||||
<p className="text-xs mt-1">This user has not been issued any SSH certificates yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sshCerts.map((cert) => (
|
||||
<CertificateRow key={cert.id} cert={cert} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{certsPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page {certsPage} of {certsPages} · {certsCount} total
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCertsPage((p) => Math.max(1, p - 1))}
|
||||
disabled={certsPage === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCertsPage((p) => Math.min(certsPages, p + 1))}
|
||||
disabled={certsPage === certsPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ── Remove all MFA confirmation dialog ───────────────────────────── */}
|
||||
|
||||
Reference in New Issue
Block a user