feat(zerotier): add ZeroTier network access management UI

Add comprehensive ZeroTier integration and access control:

- NetworksPage for managing ZeroTier portal networks
- DevicesPage for device registration and membership management
- AccessPage for approval workflows, session management, and kill switch
- Complete API client with TypeScript types for ZeroTier entities
- Navigation updates with ZeroTier section in sidebar
This commit is contained in:
2026-03-20 21:52:52 +10:30
parent 819f33229d
commit 4e669160eb
8 changed files with 3324 additions and 21 deletions
+840
View File
@@ -0,0 +1,840 @@
import { useState, useEffect, useCallback } from "react";
import {
Shield,
CheckCircle,
XCircle,
Clock,
Users,
Zap,
ZapOff,
AlertTriangle,
Loader2,
Search,
MoreHorizontal,
UserPlus,
Trash2,
RefreshCw,
Skull,
Activity,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import {
api,
ApiError,
UserNetworkApproval,
ActivationSession,
KillSwitchEvent,
PortalNetwork,
OrganizationMember,
ApprovalState,
MembershipState,
EnrichedMembership,
DeviceStatus,
} from "@/lib/api";
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
function cn(...classes: (string | boolean | undefined | null)[]) {
return classes.filter(Boolean).join(" ");
}
function formatDate(d: string | null | undefined) {
if (!d) return "—";
return new Date(d).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function formatExpiry(d: string | null | undefined) {
if (!d) return "—";
const date = new Date(d);
const now = new Date();
if (date < now) return "Expired";
const diff = Math.floor((date.getTime() - now.getTime()) / 1000 / 60);
if (diff < 60) return `${diff}m left`;
if (diff < 1440) return `${Math.floor(diff / 60)}h ${diff % 60}m left`;
return `${Math.floor(diff / 1440)}d ${Math.floor((diff % 1440) / 60)}h left`;
}
function ApprovalStateBadge({ state }: { state: ApprovalState }) {
const config: Record<ApprovalState, { color: string; icon: React.ReactNode; label: string }> = {
pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: <Clock className="w-3 h-3 mr-1" />, label: "Pending" },
approved: { color: "bg-green-500/10 text-green-600 border-green-200", icon: <CheckCircle className="w-3 h-3 mr-1" />, label: "Approved" },
rejected: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: "Rejected" },
revoked: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: "Revoked" },
suspended: { color: "bg-orange-500/10 text-orange-600 border-orange-200", icon: <AlertTriangle className="w-3 h-3 mr-1" />, label: "Suspended" },
};
const { color, icon, label } = config[state] ?? { color: "bg-gray-500/10 text-gray-600 border-gray-200", icon: null, label: state };
return (
<Badge className={cn("text-xs", color)}>
{icon}{label}
</Badge>
);
}
export default function AccessPage() {
const { orgId } = useCurrentOrganizationId();
const { toast } = useToast();
const [approvals, setApprovals] = useState<UserNetworkApproval[]>([]);
const [pendingApprovals, setPendingApprovals] = useState<UserNetworkApproval[]>([]);
const [sessions, setSessions] = useState<ActivationSession[]>([]);
const [killSwitchEvents, setKillSwitchEvents] = useState<KillSwitchEvent[]>([]);
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
const [orgMembers, setOrgMembers] = useState<OrganizationMember[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [selectedNetworkFilter, setSelectedNetworkFilter] = useState<string>("all");
const [approveId, setApproveId] = useState<string | null>(null);
const [rejectId, setRejectId] = useState<string | null>(null);
const [revokeId, setRevokeId] = useState<string | null>(null);
const [isApproving, setIsApproving] = useState(false);
const [showAssign, setShowAssign] = useState(false);
const [assignUserId, setAssignUserId] = useState("");
const [assignNetworkId, setAssignNetworkId] = useState("");
const [assignJustification, setAssignJustification] = useState("");
const [isAssigning, setIsAssigning] = useState(false);
const [assignError, setAssignError] = useState<string | null>(null);
const [showKillSwitch, setShowKillSwitch] = useState(false);
const [killTargetUserId, setKillTargetUserId] = useState("");
const [killScope, setKillScope] = useState<"organization" | "global">("organization");
const [killReason, setKillReason] = useState("");
const [isKilling, setIsKilling] = useState(false);
const [killError, setKillError] = useState<string | null>(null);
const [endSessionId, setEndSessionId] = useState<string | null>(null);
const [isEndingSession, setIsEndingSession] = useState(false);
const [selectedApproval, setSelectedApproval] = useState<UserNetworkApproval | null>(null);
const [allMemberships, setAllMemberships] = useState<EnrichedMembership[]>([]);
const [isAllMembersLoading, setIsAllMembersLoading] = useState(false);
const [allMembersSearch, setAllMembersSearch] = useState("");
const [allMembersNetworkFilter, setAllMembersNetworkFilter] = useState<string>("all");
const [allMembersStateFilter, setAllMembersStateFilter] = useState<string>("all");
const [selectedMembership, setSelectedMembership] = useState<EnrichedMembership | null>(null);
const [adminActivatingId, setAdminActivatingId] = useState<string | null>(null);
const [adminDeactivatingId, setAdminDeactivatingId] = useState<string | null>(null);
const [adminDeletingId, setAdminDeletingId] = useState<string | null>(null);
const fetchData = useCallback(async () => {
if (!orgId) { setIsLoading(false); return; }
setIsLoading(true);
setError(null);
try {
const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([
api.zerotier.listPendingApprovals(orgId),
api.zerotier.listMyApprovals(orgId),
api.zerotier.listSessions(orgId),
api.zerotier.listNetworks(orgId),
api.organizations.getMembers(orgId),
api.zerotier.adminListAllMemberships(orgId),
]);
if (pendingRes.status === "fulfilled") setPendingApprovals(pendingRes.value.approvals || []);
if (allApprovalsRes.status === "fulfilled") setApprovals(allApprovalsRes.value.approvals || []);
if (sessionsRes.status === "fulfilled") setSessions(sessionsRes.value.sessions || []);
if (networksRes.status === "fulfilled") setNetworks(networksRes.value.networks || []);
if (membersRes.status === "fulfilled") setOrgMembers(membersRes.value.members || []);
if (allMemsRes.status === "fulfilled") setAllMemberships(allMemsRes.value.memberships || []);
} catch {
setError("Failed to load access data. Please try again.");
} finally {
setIsLoading(false);
}
}, [orgId]);
useEffect(() => {
setApprovals([]);
setPendingApprovals([]);
fetchData();
}, [fetchData]);
const handleApprove = async (approvalId: string) => {
if (!orgId) return;
setApproveId(approvalId);
setIsApproving(true);
try {
await api.zerotier.approveRequest(orgId, approvalId);
toast({ title: "Request approved" });
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to approve", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setApproveId(null);
}
};
const handleReject = async (approvalId: string) => {
if (!orgId) return;
setRejectId(approvalId);
setIsApproving(true);
try {
await api.zerotier.rejectRequest(orgId, approvalId);
toast({ title: "Request rejected" });
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to reject", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setRejectId(null);
}
};
const handleRevoke = async (approvalId: string) => {
if (!orgId) return;
setRevokeId(approvalId);
setIsApproving(true);
try {
await api.zerotier.revokeApproval(orgId, approvalId);
toast({ title: "Approval revoked" });
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to revoke", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setRevokeId(null);
}
};
const handleAssign = async () => {
if (!orgId) return;
setAssignError(null);
if (!assignUserId) { setAssignError("Please select a user."); return; }
if (!assignNetworkId) { setAssignError("Please select a network."); return; }
setIsAssigning(true);
try {
await api.zerotier.assignAccess(orgId, {
target_user_id: assignUserId,
portal_network_id: assignNetworkId,
justification: assignJustification.trim() || undefined,
});
toast({ title: "Access assigned", description: "The user can now register devices for this network." });
setShowAssign(false);
setAssignUserId(""); setAssignNetworkId(""); setAssignJustification("");
fetchData();
} catch (err) {
setAssignError(err instanceof ApiError ? err.message : "Failed to assign access.");
} finally {
setIsAssigning(false);
}
};
const handleKillSwitch = async () => {
if (!orgId) return;
setKillError(null);
if (!killTargetUserId) { setKillError("Please select a user."); return; }
setIsKilling(true);
try {
await api.zerotier.triggerKillSwitch(orgId, {
target_user_id: killTargetUserId,
scope: killScope,
reason: killReason.trim() || undefined,
});
toast({ title: "Kill switch triggered", description: "All active sessions have been terminated." });
setShowKillSwitch(false);
setKillTargetUserId(""); setKillScope("organization"); setKillReason("");
fetchData();
} catch (err) {
setKillError(err instanceof ApiError ? err.message : "Failed to trigger kill switch.");
} finally {
setIsKilling(false);
}
};
const handleEndSession = async (sessionId: string) => {
if (!orgId) return;
setEndSessionId(sessionId);
setIsEndingSession(true);
try {
await api.zerotier.endSession(orgId, sessionId);
toast({ title: "Session ended" });
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to end session", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setEndSessionId(null);
}
};
const handleAdminActivate = async (membershipId: string) => {
if (!orgId) return;
setAdminActivatingId(membershipId);
try {
await api.zerotier.activateMembership(orgId, membershipId);
toast({ title: "Membership activated" });
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to activate", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setAdminActivatingId(null);
}
};
const handleAdminDeactivate = async (membershipId: string) => {
if (!orgId) return;
setAdminDeactivatingId(membershipId);
try {
await api.zerotier.deactivateMembership(orgId, membershipId);
toast({ title: "Membership deactivated" });
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to deactivate", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setAdminDeactivatingId(null);
}
};
const handleAdminDelete = async (membershipId: string) => {
if (!orgId) return;
setAdminDeletingId(membershipId);
try {
await api.zerotier.adminDeleteMembership(orgId, membershipId);
toast({ title: "Membership permanently deleted" });
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to delete membership", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setAdminDeletingId(null);
}
};
const filteredPending = pendingApprovals.filter((a) => {
if (selectedNetworkFilter !== "all" && a.portal_network_id !== selectedNetworkFilter) return false;
if (search) {
const q = search.toLowerCase();
if (!a.user_id.toLowerCase().includes(q)) return false;
}
return true;
});
const filteredSessions = sessions.filter((s) => s.is_active);
const activeSessions = filteredSessions;
const getNetworkName = (networkId: string) => {
return networks.find((n) => n.id === networkId)?.name ?? networkId;
};
const getUserDisplay = (userId: string) => {
const member = orgMembers.find((m) => m.user_id === userId);
return member?.user?.email ?? member?.user?.full_name ?? userId;
};
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">Access Control</h1>
<p className="page-description">Manage network access requests, approvals, and active sessions</p>
</div>
<div className="mb-4 flex items-center gap-4 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search by user…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select value={selectedNetworkFilter} onValueChange={setSelectedNetworkFilter}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All networks" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Networks</SelectItem>
{networks.map((n) => <SelectItem key={n.id} value={n.id}>{n.name}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="outline" onClick={() => setShowAssign(true)} className="gap-2">
<UserPlus className="w-4 h-4" /> Assign Access
</Button>
<Button variant="destructive" onClick={() => setShowKillSwitch(true)} className="gap-2">
<Skull className="w-4 h-4" /> Kill Switch
</Button>
</div>
<Tabs defaultValue="pending" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="pending">
Pending Requests
{filteredPending.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-white text-[10px] font-bold">
{filteredPending.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="sessions">
Active Sessions
{activeSessions.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-green-500 text-white text-[10px] font-bold">
{activeSessions.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="approvals">
All Approvals ({approvals.length})
</TabsTrigger>
<TabsTrigger value="allmembers">
All Members ({allMemberships.length})
</TabsTrigger>
</TabsList>
{/* Pending Requests */}
<TabsContent value="pending">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Clock className="w-4 h-4" />
Pending Access Requests
</CardTitle>
<CardDescription>Review and approve or reject network access requests</CardDescription>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading</span>
</div>
) : filteredPending.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
{search || selectedNetworkFilter !== "all" ? "No pending requests match your filters." : "No pending requests at this time."}
</div>
) : (
<div className="divide-y">
{filteredPending.map((approval) => (
<div key={approval.id} className="flex items-center gap-4 p-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium truncate">{getUserDisplay(approval.user_id)}</p>
<Badge variant="outline" className="text-xs">{getNetworkName(approval.portal_network_id)}</Badge>
<ApprovalStateBadge state={approval.state} />
</div>
<p className="text-sm text-muted-foreground">
{approval.grant_type === "requested" ? "User request" : "Manager assignment"}
{approval.justification && ` — "${approval.justification}"`}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatDate(approval.created_at)}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
size="sm"
variant="outline"
className="text-green-600 border-green-300 hover:bg-green-50 gap-1"
onClick={() => handleApprove(approval.id)}
disabled={approveId === approval.id || isApproving}
>
{approveId === approval.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <CheckCircle className="w-3 h-3" />}
Approve
</Button>
<Button
size="sm"
variant="outline"
className="text-red-600 border-red-300 hover:bg-red-50 gap-1"
onClick={() => handleReject(approval.id)}
disabled={rejectId === approval.id || isApproving}
>
{rejectId === approval.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
Reject
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Active Sessions */}
<TabsContent value="sessions">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Zap className="w-4 h-4 text-green-500" />
Active Sessions
</CardTitle>
<CardDescription>Temporarily activated memberships currently in use</CardDescription>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading</span>
</div>
) : activeSessions.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">No active sessions.</div>
) : (
<div className="divide-y">
{activeSessions.map((session) => (
<div key={session.id} className="flex items-center gap-4 p-4">
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center flex-shrink-0">
<Zap className="w-4 h-4 text-green-500" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium font-mono truncate">{session.device_network_membership_id}</p>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>Activated: {formatDate(session.authenticated_at)}</span>
<span className="text-green-600 font-medium flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatExpiry(session.expires_at)}
</span>
</div>
</div>
<Button
size="sm"
variant="outline"
className="text-orange-600 border-orange-300 hover:bg-orange-50 gap-1 flex-shrink-0"
onClick={() => handleEndSession(session.id)}
disabled={endSessionId === session.id}
>
{endSessionId === session.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
End Session
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* All Approvals */}
<TabsContent value="approvals">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Shield className="w-4 h-4" />
All Approvals
</CardTitle>
<CardDescription>Complete history of network access grants</CardDescription>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading</span>
</div>
) : approvals.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">No approvals found.</div>
) : (
<div className="divide-y">
{approvals.map((approval) => (
<div key={approval.id} className="flex items-center gap-4 p-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium truncate">{getUserDisplay(approval.user_id)}</p>
<Badge variant="outline" className="text-xs">{getNetworkName(approval.portal_network_id)}</Badge>
<ApprovalStateBadge state={approval.state} />
</div>
<p className="text-sm text-muted-foreground">
{approval.grant_type === "requested" ? "User request" : "Manager assignment"}
{approval.justification && ` — "${approval.justification}"`}
</p>
<p className="text-xs text-muted-foreground mt-1">
{formatDate(approval.created_at)}
{approval.granted_by_user_id && ` · Granted by: ${getUserDisplay(approval.granted_by_user_id)}`}
</p>
</div>
{(approval.state === "approved" || approval.state === "suspended") && (
<Button
size="sm"
variant="outline"
className="text-red-600 border-red-300 hover:bg-red-50 gap-1 flex-shrink-0"
onClick={() => handleRevoke(approval.id)}
disabled={revokeId === approval.id || isApproving}
>
{revokeId === approval.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
Revoke
</Button>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* All Members */}
<TabsContent value="allmembers">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Users className="w-4 h-4" />
All Members
</CardTitle>
<CardDescription>Every device membership across all users and networks</CardDescription>
</CardHeader>
<CardContent className="p-0">
{isAllMembersLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading</span>
</div>
) : allMemberships.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">No memberships found.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left p-3 font-medium">User</th>
<th className="text-left p-3 font-medium">Device</th>
<th className="text-left p-3 font-medium">Network</th>
<th className="text-left p-3 font-medium">State</th>
<th className="text-left p-3 font-medium">Active Session</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{allMemberships.map((m) => (
<tr key={m.id} className="hover:bg-accent/30">
<td className="p-3">
<div>
<p className="font-medium">{m.user_full_name || "—"}</p>
<p className="text-xs text-muted-foreground">{m.user_email || m.user_id}</p>
</div>
</td>
<td className="p-3">
<div>
<p className="font-mono text-xs">{m.device_node_id || "—"}</p>
<p className="text-xs text-muted-foreground">{m.device_nickname || m.device_hostname || "—"}</p>
</div>
</td>
<td className="p-3">
<div>
<p className="font-medium text-xs">{m.network_name || m.portal_network_id}</p>
{m.network_environment && (
<Badge variant="outline" className="text-xs">{m.network_environment}</Badge>
)}
</div>
</td>
<td className="p-3">
{m.state ? (
<Badge
variant={
m.state === "active_authorized" ? "default" :
m.state === "approved_inactive" ? "secondary" :
"outline"
}
className="text-xs"
>
{m.state}
</Badge>
) : "—"}
</td>
<td className="p-3">
{m.active_session ? (
<span className="text-xs text-green-600 flex items-center gap-1">
<Zap className="w-3 h-3" />
{formatExpiry(m.active_session.expires_at)}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="p-3 text-right">
<div className="flex items-center justify-end gap-1">
{m.approved_for_activation && !m.currently_authorized && (
<Button
size="sm"
variant="outline"
onClick={() => handleAdminActivate(m.id)}
disabled={adminActivatingId === m.id}
className="gap-1 h-7 px-2"
>
{adminActivatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
Activate
</Button>
)}
{m.currently_authorized && (
<Button
size="sm"
variant="outline"
onClick={() => handleAdminDeactivate(m.id)}
disabled={adminDeactivatingId === m.id}
className="gap-1 h-7 px-2 text-orange-600 border-orange-300 hover:bg-orange-50"
>
{adminDeactivatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
Deactivate
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleAdminDelete(m.id)}
disabled={adminDeletingId === m.id}
className="gap-1 h-7 px-2 text-destructive hover:bg-destructive/10"
>
{adminDeletingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Trash2 className="w-3 h-3" />}
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Assign Access Dialog */}
<Dialog open={showAssign} onOpenChange={(open) => { if (!open) setShowAssign(false); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Assign Network Access</DialogTitle>
<DialogDescription>Grant a user direct access to a network without a request.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>User *</Label>
<Select value={assignUserId} onValueChange={setAssignUserId}>
<SelectTrigger><SelectValue placeholder="Select a user…" /></SelectTrigger>
<SelectContent>
{orgMembers.map((m) => (
<SelectItem key={m.user_id} value={m.user_id}>
{m.user?.full_name || m.user?.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Network *</Label>
<Select value={assignNetworkId} onValueChange={setAssignNetworkId}>
<SelectTrigger><SelectValue placeholder="Select a network…" /></SelectTrigger>
<SelectContent>
{networks.map((n) => (
<SelectItem key={n.id} value={n.id}>{n.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Justification (optional)</Label>
<Input
placeholder="Engineering team access"
value={assignJustification}
onChange={(e) => setAssignJustification(e.target.value)}
/>
</div>
{assignError && <p className="text-sm text-destructive">{assignError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAssign(false)} disabled={isAssigning}>Cancel</Button>
<Button onClick={handleAssign} disabled={isAssigning}>
{isAssigning && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Assign Access
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Kill Switch Dialog */}
<Dialog open={showKillSwitch} onOpenChange={(open) => { if (!open) setShowKillSwitch(false); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Skull className="w-5 h-5" />
Kill Switch
</DialogTitle>
<DialogDescription>
Instantly deactivate all active sessions for a user across all managed networks. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="p-3 border border-destructive/30 rounded-lg bg-destructive/5">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<p className="text-sm text-destructive">
This will immediately de-authorize all ZeroTier memberships for the selected user across all networks.
</p>
</div>
</div>
<div className="space-y-2">
<Label>Target User *</Label>
<Select value={killTargetUserId} onValueChange={setKillTargetUserId}>
<SelectTrigger><SelectValue placeholder="Select a user…" /></SelectTrigger>
<SelectContent>
{orgMembers.map((m) => (
<SelectItem key={m.user_id} value={m.user_id}>
{m.user?.full_name || m.user?.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Scope</Label>
<Select value={killScope} onValueChange={(v) => setKillScope(v as "organization" | "global")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="organization">Organization only</SelectItem>
<SelectItem value="global">Global (all networks)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason (optional)</Label>
<Input
placeholder="User terminated / device lost"
value={killReason}
onChange={(e) => setKillReason(e.target.value)}
/>
</div>
{killError && <p className="text-sm text-destructive">{killError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowKillSwitch(false)} disabled={isKilling}>Cancel</Button>
<Button variant="destructive" onClick={handleKillSwitch} disabled={isKilling}>
{isKilling && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Trigger Kill Switch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
File diff suppressed because it is too large Load Diff
+645
View File
@@ -0,0 +1,645 @@
import { useState, useEffect, useCallback } from "react";
import {
Network,
Plus,
Loader2,
Search,
MoreHorizontal,
ChevronRight,
Users,
Monitor,
Clock,
Shield,
Trash2,
Pencil,
Eye,
CheckCircle,
XCircle,
Ban,
Zap,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import {
api,
ApiError,
PortalNetwork,
DeviceNetworkMembership,
UserNetworkApproval,
NetworkEnvironment,
NetworkRequestMode,
} from "@/lib/api";
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
const ENVIRONMENTS: { value: NetworkEnvironment; label: string }[] = [
{ value: "production", label: "Production" },
{ value: "staging", label: "Staging" },
{ value: "development", label: "Development" },
{ value: "lab", label: "Lab" },
];
const REQUEST_MODES: { value: NetworkRequestMode; label: string }[] = [
{ value: "open", label: "Open — anyone can join" },
{ value: "approval_required", label: "Approval Required" },
{ value: "invite_only", label: "Invite Only" },
];
function formatDate(d: string | null | undefined) {
if (!d) return "—";
return new Date(d).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
function EnvironmentBadge({ env }: { env: NetworkEnvironment }) {
const colors: Record<NetworkEnvironment, string> = {
production: "bg-red-500/10 text-red-600 border-red-200",
staging: "bg-yellow-500/10 text-yellow-600 border-yellow-200",
development: "bg-green-500/10 text-green-600 border-green-200",
lab: "bg-blue-500/10 text-blue-600 border-blue-200",
};
return (
<Badge className={cn("text-xs", colors[env])}>
{env.charAt(0).toUpperCase() + env.slice(1)}
</Badge>
);
}
function RequestModeBadge({ mode }: { mode: NetworkRequestMode }) {
if (mode === "open") return <Badge variant="outline" className="text-xs text-green-600 border-green-300">Open</Badge>;
if (mode === "approval_required") return <Badge variant="outline" className="text-xs text-yellow-600 border-yellow-300">Approval Required</Badge>;
return <Badge variant="outline" className="text-xs text-purple-600 border-purple-300">Invite Only</Badge>;
}
function cn(...classes: (string | boolean | undefined | null)[]) {
return classes.filter(Boolean).join(" ");
}
export default function NetworksPage() {
const { orgId } = useCurrentOrganizationId();
const { toast } = useToast();
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState("");
const [createZtId, setCreateZtId] = useState("");
const [createDesc, setCreateDesc] = useState("");
const [createEnv, setCreateEnv] = useState<NetworkEnvironment>("development");
const [createMode, setCreateMode] = useState<NetworkRequestMode>("approval_required");
const [createDefaultLifetime, setCreateDefaultLifetime] = useState("480");
const [createMaxLifetime, setCreateMaxLifetime] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [selectedNetwork, setSelectedNetwork] = useState<PortalNetwork | null>(null);
const [networkMembers, setNetworkMembers] = useState<DeviceNetworkMembership[]>([]);
const [networkRequests, setNetworkRequests] = useState<UserNetworkApproval[]>([]);
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
const [editingNetwork, setEditingNetwork] = useState<PortalNetwork | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
const [editEnv, setEditEnv] = useState<NetworkEnvironment>("development");
const [editMode, setEditMode] = useState<NetworkRequestMode>("approval_required");
const [editDefaultLifetime, setEditDefaultLifetime] = useState("480");
const [editMaxLifetime, setEditMaxLifetime] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [deleteNetwork, setDeleteNetwork] = useState<PortalNetwork | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const fetchNetworks = useCallback(async () => {
if (!orgId) { setIsLoading(false); return; }
setIsLoading(true);
setError(null);
try {
const res = await api.zerotier.listNetworks(orgId);
setNetworks(res.networks || []);
} catch (err) {
setError("Failed to load networks. Please try again.");
} finally {
setIsLoading(false);
}
}, [orgId]);
useEffect(() => {
setNetworks([]);
fetchNetworks();
}, [fetchNetworks]);
const openNetworkDrawer = async (network: PortalNetwork) => {
setSelectedNetwork(network);
setIsDrawerLoading(true);
setNetworkMembers([]);
setNetworkRequests([]);
try {
const [membersRes, requestsRes] = await Promise.allSettled([
api.zerotier.getNetworkMembers(orgId!, network.id),
api.zerotier.getNetworkPendingRequests(orgId!, network.id),
]);
if (membersRes.status === "fulfilled") setNetworkMembers(membersRes.value.memberships || []);
if (requestsRes.status === "fulfilled") setNetworkRequests(requestsRes.value.requests || []);
} catch {
// non-fatal
} finally {
setIsDrawerLoading(false);
}
};
const closeDrawer = () => {
setSelectedNetwork(null);
setNetworkMembers([]);
setNetworkRequests([]);
};
const handleCreate = async () => {
if (!orgId) return;
setCreateError(null);
if (!createName.trim()) { setCreateError("Network name is required."); return; }
if (!createZtId.trim()) { setCreateError("ZeroTier Network ID is required."); return; }
setIsCreating(true);
try {
await api.zerotier.createNetwork(orgId, {
name: createName.trim(),
zerotier_network_id: createZtId.trim(),
description: createDesc.trim() || undefined,
environment: createEnv,
request_mode: createMode,
default_activation_lifetime_minutes: parseInt(createDefaultLifetime) || 480,
max_activation_lifetime_minutes: createMaxLifetime ? parseInt(createMaxLifetime) : undefined,
});
toast({ title: "Network created", description: `${createName} has been added.` });
setShowCreate(false);
setCreateName(""); setCreateZtId(""); setCreateDesc("");
setCreateEnv("development"); setCreateMode("approval_required");
setCreateDefaultLifetime("480"); setCreateMaxLifetime("");
fetchNetworks();
} catch (err) {
setCreateError(err instanceof ApiError ? err.message : "Failed to create network.");
} finally {
setIsCreating(false);
}
};
const openEditDialog = (network: PortalNetwork) => {
setEditingNetwork(network);
setEditName(network.name);
setEditDesc(network.description || "");
setEditEnv(network.environment);
setEditMode(network.request_mode);
setEditDefaultLifetime(String(network.default_activation_lifetime_minutes));
setEditMaxLifetime(network.max_activation_lifetime_minutes ? String(network.max_activation_lifetime_minutes) : "");
setEditError(null);
};
const handleEdit = async () => {
if (!orgId || !editingNetwork) return;
setEditError(null);
setIsEditing(true);
try {
await api.zerotier.updateNetwork(orgId, editingNetwork.id, {
name: editName.trim(),
description: editDesc.trim() || undefined,
environment: editEnv,
request_mode: editMode,
default_activation_lifetime_minutes: parseInt(editDefaultLifetime) || 480,
max_activation_lifetime_minutes: editMaxLifetime ? parseInt(editMaxLifetime) : undefined,
});
toast({ title: "Network updated", description: `${editName} has been updated.` });
setEditingNetwork(null);
fetchNetworks();
} catch (err) {
setEditError(err instanceof ApiError ? err.message : "Failed to update network.");
} finally {
setIsEditing(false);
}
};
const handleDelete = async () => {
if (!orgId || !deleteNetwork) return;
setIsDeleting(true);
try {
await api.zerotier.deleteNetwork(orgId, deleteNetwork.id);
toast({ title: "Network deleted", description: `${deleteNetwork.name} has been removed.` });
setDeleteNetwork(null);
fetchNetworks();
} catch (err) {
toast({ variant: "destructive", title: "Failed to delete network", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setIsDeleting(false);
}
};
const filteredNetworks = networks.filter((n) => {
const q = search.toLowerCase();
return (
n.name.toLowerCase().includes(q) ||
n.zerotier_network_id.toLowerCase().includes(q) ||
(n.description?.toLowerCase().includes(q) ?? false)
);
});
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">Networks</h1>
<p className="page-description">Manage ZeroTier portal networks and monitor access</p>
</div>
<div className="mb-4 flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search networks…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button onClick={() => setShowCreate(true)} className="gap-2">
<Plus className="w-4 h-4" /> Add Network
</Button>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Network className="w-4 h-4" />
Portal Networks
{!isLoading && <Badge variant="secondary" className="ml-1">{networks.length}</Badge>}
</CardTitle>
<CardDescription>Click a network to view members, requests, and manage access</CardDescription>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading networks</span>
</div>
) : error ? (
<div className="p-8 text-center text-destructive">{error}</div>
) : filteredNetworks.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
{search ? "No networks match your search." : "No networks configured yet. Add one to get started."}
</div>
) : (
<div className="divide-y">
{filteredNetworks.map((network) => (
<button
key={network.id}
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
onClick={() => openNetworkDrawer(network)}
>
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Network className="w-5 h-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-foreground truncate">{network.name}</p>
<EnvironmentBadge env={network.environment} />
<RequestModeBadge mode={network.request_mode} />
{!network.is_active && (
<Badge variant="outline" className="text-xs text-red-600 border-red-300 bg-red-50">
<Ban className="w-3 h-3 mr-1" />Inactive
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground font-mono">{network.zerotier_network_id}</p>
</div>
<div className="flex items-center gap-6 text-sm text-muted-foreground flex-shrink-0">
<div className="flex items-center gap-1" title="Approved users">
<Users className="w-4 h-4" />
<span>{network.approved_user_count ?? 0}</span>
</div>
<div className="flex items-center gap-1" title="Active devices">
<Zap className="w-4 h-4 text-green-500" />
<span>{network.active_membership_count ?? 0}</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openNetworkDrawer(network); }}>
<Eye className="w-4 h-4 mr-2" /> View details
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEditDialog(network); }}>
<Pencil className="w-4 h-4 mr-2" /> Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => { e.stopPropagation(); setDeleteNetwork(network); }}
>
<Trash2 className="w-4 h-4 mr-2" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</button>
))}
</div>
)}
</CardContent>
</Card>
{/* Create Network Dialog */}
<Dialog open={showCreate} onOpenChange={(open) => { if (!open) setShowCreate(false); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add Portal Network</DialogTitle>
<DialogDescription>Link a ZeroTier network to your organization.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Network Name *</Label>
<Input placeholder="Production VPN" value={createName} onChange={(e) => setCreateName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>ZeroTier Network ID *</Label>
<Input placeholder="d6578dd03c894448" value={createZtId} onChange={(e) => setCreateZtId(e.target.value)} />
<p className="text-xs text-muted-foreground">16-character hexadecimal network ID from your ZeroTier controller.</p>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input placeholder="Production network for engineering" value={createDesc} onChange={(e) => setCreateDesc(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Environment</Label>
<Select value={createEnv} onValueChange={(v) => setCreateEnv(v as NetworkEnvironment)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ENVIRONMENTS.map((e) => <SelectItem key={e.value} value={e.value}>{e.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Request Mode</Label>
<Select value={createMode} onValueChange={(v) => setCreateMode(v as NetworkRequestMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{REQUEST_MODES.map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Default Activation (minutes)</Label>
<Input type="number" placeholder="480" value={createDefaultLifetime} onChange={(e) => setCreateDefaultLifetime(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Max Activation (minutes)</Label>
<Input type="number" placeholder="No limit" value={createMaxLifetime} onChange={(e) => setCreateMaxLifetime(e.target.value)} />
</div>
</div>
{createError && <p className="text-sm text-destructive">{createError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)} disabled={isCreating}>Cancel</Button>
<Button onClick={handleCreate} disabled={isCreating}>
{isCreating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Create Network
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Network Dialog */}
<Dialog open={!!editingNetwork} onOpenChange={(open) => { if (!open) setEditingNetwork(null); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit Network</DialogTitle>
<DialogDescription>Update network settings.</DialogDescription>
</DialogHeader>
{editingNetwork && (
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Network Name *</Label>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input value={editDesc} onChange={(e) => setEditDesc(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Environment</Label>
<Select value={editEnv} onValueChange={(v) => setEditEnv(v as NetworkEnvironment)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ENVIRONMENTS.map((e) => <SelectItem key={e.value} value={e.value}>{e.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Request Mode</Label>
<Select value={editMode} onValueChange={(v) => setEditMode(v as NetworkRequestMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{REQUEST_MODES.map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Default Activation (minutes)</Label>
<Input type="number" value={editDefaultLifetime} onChange={(e) => setEditDefaultLifetime(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Max Activation (minutes)</Label>
<Input type="number" placeholder="No limit" value={editMaxLifetime} onChange={(e) => setEditMaxLifetime(e.target.value)} />
</div>
</div>
{editError && <p className="text-sm text-destructive">{editError}</p>}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingNetwork(null)} disabled={isEditing}>Cancel</Button>
<Button onClick={handleEdit} disabled={isEditing}>
{isEditing && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<Dialog open={!!deleteNetwork} onOpenChange={(open) => { if (!open) setDeleteNetwork(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Network</DialogTitle>
<DialogDescription>
Are you sure you want to remove "{deleteNetwork?.name}"? This does not affect the ZeroTier network itself.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteNetwork(null)} disabled={isDeleting}>Cancel</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Delete Network
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Network Detail Drawer */}
<Sheet open={!!selectedNetwork} onOpenChange={(open) => { if (!open) closeDrawer(); }}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
{selectedNetwork && (
<>
<SheetHeader className="mb-4">
<SheetTitle className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<Network className="w-5 h-5 text-primary" />
</div>
{selectedNetwork.name}
</SheetTitle>
<SheetDescription className="font-mono">{selectedNetwork.zerotier_network_id}</SheetDescription>
</SheetHeader>
<div className="space-y-4 mb-6">
<div className="flex items-center gap-2 flex-wrap">
<EnvironmentBadge env={selectedNetwork.environment} />
<RequestModeBadge mode={selectedNetwork.request_mode} />
</div>
{selectedNetwork.description && (
<p className="text-sm text-muted-foreground">{selectedNetwork.description}</p>
)}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Default activation</span>
<p className="font-medium">{selectedNetwork.default_activation_lifetime_minutes} min</p>
</div>
<div>
<span className="text-muted-foreground">Max activation</span>
<p className="font-medium">{selectedNetwork.max_activation_lifetime_minutes ? `${selectedNetwork.max_activation_lifetime_minutes} min` : "No limit"}</p>
</div>
<div>
<span className="text-muted-foreground">Approved users</span>
<p className="font-medium flex items-center gap-1"><Users className="w-3 h-3" />{selectedNetwork.approved_user_count ?? 0}</p>
</div>
<div>
<span className="text-muted-foreground">Active devices</span>
<p className="font-medium flex items-center gap-1 text-green-600"><Zap className="w-3 h-3" />{selectedNetwork.active_membership_count ?? 0}</p>
</div>
</div>
</div>
{isDrawerLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<Tabs defaultValue="members" className="w-full">
<TabsList className="mb-3">
<TabsTrigger value="members">
Members ({networkMembers.length})
</TabsTrigger>
<TabsTrigger value="requests">
Requests ({networkRequests.length})
</TabsTrigger>
</TabsList>
<TabsContent value="members">
{networkMembers.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">No members yet.</div>
) : (
<div className="space-y-2">
{networkMembers.map((m) => (
<div key={m.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
<Monitor className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{m.device_id}</p>
<p className="text-xs text-muted-foreground">
State: {m.state} · Join seen: {m.join_seen ? "Yes" : "No"}
</p>
</div>
<div className="flex items-center gap-1">
{m.currently_authorized ? (
<><CheckCircle className="w-4 h-4 text-green-500" /><span className="text-xs text-green-600">Authorized</span></>
) : (
<><XCircle className="w-4 h-4 text-muted-foreground" /><span className="text-xs text-muted-foreground">Inactive</span></>
)}
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="requests">
{networkRequests.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">No pending requests.</div>
) : (
<div className="space-y-2">
{networkRequests.map((r) => (
<div key={r.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
<Clock className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{r.user_id}</p>
<p className="text-xs text-muted-foreground">
{r.grant_type} · {r.state}
</p>
{r.justification && <p className="text-xs text-muted-foreground mt-1">"{r.justification}"</p>}
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
)}
</>
)}
</SheetContent>
</Sheet>
</div>
);
}