feat: add network management page and inline accordion device details
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Network,
|
||||
Plus,
|
||||
@@ -7,14 +8,10 @@ import {
|
||||
MoreHorizontal,
|
||||
ChevronRight,
|
||||
Users,
|
||||
Monitor,
|
||||
Clock,
|
||||
Shield,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Ban,
|
||||
Zap,
|
||||
Download,
|
||||
@@ -55,7 +52,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
@@ -63,8 +59,6 @@ import {
|
||||
ApiError,
|
||||
AvailableZtNetwork,
|
||||
PortalNetwork,
|
||||
DeviceNetworkMembership,
|
||||
UserNetworkApproval,
|
||||
NetworkEnvironment,
|
||||
NetworkRequestMode,
|
||||
} from "@/lib/api";
|
||||
@@ -119,6 +113,7 @@ function cn(...classes: (string | boolean | undefined | null)[]) {
|
||||
export default function NetworksPage() {
|
||||
const { orgId } = useCurrentOrganizationId();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -136,11 +131,6 @@ export default function NetworksPage() {
|
||||
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("");
|
||||
@@ -180,31 +170,6 @@ export default function NetworksPage() {
|
||||
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 openZtPicker = async () => {
|
||||
if (!orgId) return;
|
||||
setShowZtPicker(true);
|
||||
@@ -355,7 +320,7 @@ export default function NetworksPage() {
|
||||
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>
|
||||
<CardDescription>Click a network to manage members, devices, and access requests</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
@@ -375,7 +340,7 @@ export default function NetworksPage() {
|
||||
<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)}
|
||||
onClick={() => navigate(`/org/zerotier/networks/${network.id}`)}
|
||||
>
|
||||
<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" />
|
||||
@@ -410,7 +375,7 @@ export default function NetworksPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openNetworkDrawer(network); }}>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); navigate(`/org/zerotier/networks/${network.id}`); }}>
|
||||
<Eye className="w-4 h-4 mr-2" /> View details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEditDialog(network); }}>
|
||||
@@ -696,118 +661,6 @@ export default function NetworksPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user