Files
gatehouse-ui/src/pages/org/DevicesPage.tsx
T

1160 lines
54 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useCallback } from "react";
import {
Monitor,
Plus,
Loader2,
Search,
MoreHorizontal,
ChevronRight,
Zap,
ZapOff,
Clock,
Trash2,
Pencil,
Laptop,
Smartphone,
Server,
CheckCircle,
XCircle,
AlertCircle,
Globe,
Users,
} 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,
Device,
DeviceNetworkMembership,
ActivationSession,
MembershipState,
PortalNetwork,
UserNetworkApproval,
ApprovalState,
} 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 MembershipStateBadge({ state }: { state: MembershipState }) {
const config: Record<MembershipState, { color: string; icon: React.ReactNode; label: string }> = {
pending_device_registration: { color: "bg-gray-500/10 text-gray-600 border-gray-200", icon: <AlertCircle className="w-3 h-3 mr-1" />, label: "Pending Registration" },
pending_request: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: <Clock className="w-3 h-3 mr-1" />, label: "Pending Request" },
pending_manager_approval: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: <Clock className="w-3 h-3 mr-1" />, label: "Pending Approval" },
approved_inactive: { color: "bg-blue-500/10 text-blue-600 border-blue-200", icon: <CheckCircle className="w-3 h-3 mr-1" />, label: "Approved (Inactive)" },
joined_deauthorized: { color: "bg-orange-500/10 text-orange-600 border-orange-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: "Joined (Deauth)" },
active_authorized: { color: "bg-green-500/10 text-green-600 border-green-200", icon: <Zap className="w-3 h-3 mr-1" />, label: "Active" },
activation_expired: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: "Expired" },
suspended: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <AlertCircle className="w-3 h-3 mr-1" />, label: "Suspended" },
revoked: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <ZapOff className="w-3 h-3 mr-1" />, label: "Revoked" },
rejected: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: "Rejected" },
};
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>
);
}
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: <AlertCircle 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>
);
}
function DeviceTypeIcon({ nickname, hostname }: { nickname: string | null; hostname: string | null }) {
const text = (nickname || hostname || "").toLowerCase();
if (text.includes("server") || text.includes("host") || text.includes("node")) return <Server className="w-5 h-5" />;
if (text.includes("phone") || text.includes("mobile")) return <Smartphone className="w-5 h-5" />;
return <Laptop className="w-5 h-5" />;
}
export default function DevicesPage() {
const { orgId } = useCurrentOrganizationId();
const { toast } = useToast();
const [devices, setDevices] = useState<Device[]>([]);
const [memberships, setMemberships] = useState<DeviceNetworkMembership[]>([]);
const [sessions, setSessions] = useState<ActivationSession[]>([]);
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
const [myApprovals, setMyApprovals] = useState<UserNetworkApproval[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [showRegister, setShowRegister] = useState(false);
const [regNodeId, setRegNodeId] = useState("");
const [regNickname, setRegNickname] = useState("");
const [regHostname, setRegHostname] = useState("");
const [regAssetTag, setRegAssetTag] = useState("");
const [regSerial, setRegSerial] = useState("");
const [isRegistering, setIsRegistering] = useState(false);
const [regError, setRegError] = useState<string | null>(null);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const [deviceMemberships, setDeviceMemberships] = useState<DeviceNetworkMembership[]>([]);
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
const [editDevice, setEditDevice] = useState<Device | null>(null);
const [editNickname, setEditNickname] = useState("");
const [editHostname, setEditHostname] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [deleteDevice, setDeleteDevice] = useState<Device | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [activatingId, setActivatingId] = useState<string | null>(null);
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
const [activateLifetime, setActivateLifetime] = useState("480");
const [showActivateDialog, setShowActivateDialog] = useState<string | null>(null);
const [showJoinDialog, setShowJoinDialog] = useState<PortalNetwork | null>(null);
const [joinDeviceId, setJoinDeviceId] = useState("");
const [isJoining, setIsJoining] = useState(false);
const [showRequestDialog, setShowRequestDialog] = useState<PortalNetwork | null>(null);
const [requestDeviceId, setRequestDeviceId] = useState("");
const [requestJustification, setRequestJustification] = useState("");
const [isRequesting, setIsRequesting] = useState(false);
const [requestError, setRequestError] = useState<string | null>(null);
const [deletingMembershipId, setDeletingMembershipId] = useState<string | null>(null);
const fetchData = useCallback(async () => {
if (!orgId) { setIsLoading(false); return; }
setIsLoading(true);
setError(null);
try {
const [devicesRes, membershipsRes, sessionsRes, networksRes, approvalsRes] = await Promise.allSettled([
api.zerotier.listDevices(orgId),
api.zerotier.listMemberships(orgId),
api.zerotier.listSessions(orgId),
api.zerotier.listNetworks(orgId),
api.zerotier.listMyApprovals(orgId),
]);
if (devicesRes.status === "fulfilled") setDevices(devicesRes.value.devices || []);
if (membershipsRes.status === "fulfilled") setMemberships(membershipsRes.value.memberships || []);
if (sessionsRes.status === "fulfilled") setSessions(sessionsRes.value.sessions || []);
if (networksRes.status === "fulfilled") setNetworks(networksRes.value.networks || []);
if (approvalsRes.status === "fulfilled") setMyApprovals(approvalsRes.value.approvals || []);
} catch {
setError("Failed to load data. Please try again.");
} finally {
setIsLoading(false);
}
}, [orgId]);
useEffect(() => {
setDevices([]);
setMemberships([]);
fetchData();
}, [fetchData]);
const openDeviceDrawer = async (device: Device) => {
setSelectedDevice(device);
setIsDrawerLoading(true);
setDeviceMemberships([]);
try {
const deviceMem = memberships.filter((m) => m.device_id === device.id);
setDeviceMemberships(deviceMem);
} catch {
// non-fatal
} finally {
setIsDrawerLoading(false);
}
};
const closeDrawer = () => {
setSelectedDevice(null);
setDeviceMemberships([]);
};
const handleRegister = async () => {
if (!orgId) return;
setRegError(null);
if (!regNodeId.trim()) { setRegError("Node ID is required."); return; }
if (regNodeId.trim().length !== 10) { setRegError("Node ID must be exactly 10 characters."); return; }
setIsRegistering(true);
try {
await api.zerotier.registerDevice(orgId, {
node_id: regNodeId.trim(),
nickname: regNickname.trim() || undefined,
hostname: regHostname.trim() || undefined,
asset_tag: regAssetTag.trim() || undefined,
serial_number: regSerial.trim() || undefined,
});
toast({ title: "Device registered", description: `Node ${regNodeId} has been registered.` });
setShowRegister(false);
setRegNodeId(""); setRegNickname(""); setRegHostname("");
setRegAssetTag(""); setRegSerial("");
fetchData();
} catch (err) {
setRegError(err instanceof ApiError ? err.message : "Failed to register device.");
} finally {
setIsRegistering(false);
}
};
const handleEdit = async () => {
if (!orgId || !editDevice) return;
setIsEditing(true);
try {
await api.zerotier.updateDevice(orgId, editDevice.id, {
nickname: editNickname.trim() || undefined,
hostname: editHostname.trim() || undefined,
});
toast({ title: "Device updated" });
setEditDevice(null);
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to update device", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setIsEditing(false);
}
};
const handleDelete = async () => {
if (!orgId || !deleteDevice) return;
setIsDeleting(true);
try {
await api.zerotier.removeDevice(orgId, deleteDevice.id);
toast({ title: "Device removed", description: `${deleteDevice.node_id} has been removed.` });
setDeleteDevice(null);
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to remove device", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setIsDeleting(false);
}
};
const handleActivate = async (membershipId: string) => {
if (!orgId) return;
setActivatingId(membershipId);
try {
const lifetime = parseInt(activateLifetime);
await api.zerotier.activateMembership(orgId, membershipId, lifetime);
toast({ title: "Membership activated", description: `Active for ${lifetime} minutes.` });
setShowActivateDialog(null);
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to activate", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setActivatingId(null);
}
};
const handleDeactivate = async (membershipId: string) => {
if (!orgId) return;
setDeactivatingId(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 {
setDeactivatingId(null);
}
};
const handleActivateAll = async () => {
if (!orgId) return;
setActivatingId("all");
try {
const lifetime = parseInt(activateLifetime);
const res = await api.zerotier.activateAllMemberships(orgId, lifetime);
toast({ title: "All memberships activated", description: `${res.count} memberships activated for ${lifetime} minutes.` });
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to activate all", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setActivatingId(null);
}
};
const handleJoinNetwork = async () => {
if (!orgId || !showJoinDialog || !joinDeviceId) return;
setIsJoining(true);
setRequestError(null);
try {
await api.zerotier.joinNetworkForDevice(orgId, joinDeviceId, showJoinDialog.id);
toast({ title: "Joined network", description: `Device is now a member of ${showJoinDialog.name}.` });
setShowJoinDialog(null);
setJoinDeviceId("");
fetchData();
} catch (err) {
setRequestError(err instanceof ApiError ? err.message : "Failed to join network.");
} finally {
setIsJoining(false);
}
};
const handleRequestAccess = async () => {
if (!orgId || !showRequestDialog || !requestDeviceId) return;
setIsRequesting(true);
setRequestError(null);
try {
await api.zerotier.requestAccess(orgId, {
portal_network_id: showRequestDialog.id,
device_id: requestDeviceId,
justification: requestJustification.trim() || undefined,
});
toast({ title: "Access requested", description: `Request sent for ${showRequestDialog.name}.` });
setShowRequestDialog(null);
setRequestDeviceId("");
setRequestJustification("");
fetchData();
} catch (err) {
setRequestError(err instanceof ApiError ? err.message : "Failed to request access.");
} finally {
setIsRequesting(false);
}
};
const handleDeleteMembership = async (membershipId: string) => {
if (!orgId) return;
setDeletingMembershipId(membershipId);
try {
await api.zerotier.deleteMembership(orgId, membershipId);
toast({ title: "Membership removed" });
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to remove membership", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setDeletingMembershipId(null);
}
};
const filteredDevices = devices.filter((d) => {
const q = search.toLowerCase();
return (
d.node_id.toLowerCase().includes(q) ||
(d.device_nickname?.toLowerCase().includes(q) ?? false) ||
(d.hostname?.toLowerCase().includes(q) ?? false)
);
});
const getActiveSession = (membershipId: string): ActivationSession | null => {
return sessions.find((s) => s.device_network_membership_id === membershipId && s.is_active) ?? null;
};
const getMembershipForDeviceAndNetwork = (deviceId: string, networkId: string): DeviceNetworkMembership | null => {
return memberships.find((m) => m.device_id === deviceId && m.portal_network_id === networkId) ?? null;
};
const getApprovalForNetwork = (networkId: string): UserNetworkApproval | null => {
return myApprovals.find((a) => a.portal_network_id === networkId) ?? null;
};
const filteredApprovals = myApprovals.filter((a) => {
if (search) {
const q = search.toLowerCase();
const network = networks.find((n) => n.id === a.portal_network_id);
if (!network?.name.toLowerCase().includes(q) && !a.portal_network_id.toLowerCase().includes(q)) return false;
}
return true;
});
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">ZeroTier Access</h1>
<p className="page-description">Manage your devices, networks, and access requests</p>
</div>
<Tabs defaultValue="devices" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="devices" className="gap-2">
<Monitor className="w-4 h-4" /> My Devices
{!isLoading && devices.length > 0 && (
<Badge variant="secondary" className="ml-1">{devices.length}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="networks" className="gap-2">
<Globe className="w-4 h-4" /> My Networks
{!isLoading && networks.length > 0 && (
<Badge variant="secondary" className="ml-1">{networks.length}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="requests" className="gap-2">
<Clock className="w-4 h-4" /> My Requests
{!isLoading && myApprovals.filter((a) => a.state === "pending").length > 0 && (
<Badge variant="secondary" className="ml-1">{myApprovals.filter((a) => a.state === "pending").length}</Badge>
)}
</TabsTrigger>
</TabsList>
{/* ── Tab A: My Devices ── */}
<TabsContent value="devices">
<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 by Node ID or nickname…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button onClick={() => setShowRegister(true)} className="gap-2">
<Plus className="w-4 h-4" /> Register Device
</Button>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Monitor className="w-4 h-4" />
Registered Devices
{!isLoading && <Badge variant="secondary" className="ml-1">{devices.length}</Badge>}
</CardTitle>
<CardDescription>Click a device to view memberships and activation status</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 devices</span>
</div>
) : error ? (
<div className="p-8 text-center text-destructive">{error}</div>
) : filteredDevices.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
{search ? "No devices match your search." : "No devices registered. Register your first ZeroTier node."}
</div>
) : (
<div className="divide-y">
{filteredDevices.map((device) => {
const activeCount = memberships.filter(
(m) => m.device_id === device.id && m.currently_authorized
).length;
return (
<button
key={device.id}
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
onClick={() => openDeviceDrawer(device)}
>
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<DeviceTypeIcon nickname={device.device_nickname} hostname={device.hostname} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-foreground truncate">
{device.device_nickname || device.hostname || device.node_id}
</p>
{device.device_nickname && device.hostname && (
<span className="text-sm text-muted-foreground truncate">{device.hostname}</span>
)}
<Badge variant={device.status === "active" ? "default" : "outline"} className="text-xs">
{device.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground font-mono">{device.node_id}</p>
</div>
<div className="flex items-center gap-1 text-sm flex-shrink-0">
{activeCount > 0 ? (
<><Zap className="w-4 h-4 text-green-500" /><span className="text-green-600">{activeCount} active</span></>
) : (
<span className="text-muted-foreground">Inactive</span>
)}
</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(); openDeviceDrawer(device); }}>
<ChevronRight className="w-4 h-4 mr-2" /> View memberships
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
setEditDevice(device);
setEditNickname(device.device_nickname || "");
setEditHostname(device.hostname || "");
}}>
<Pencil className="w-4 h-4 mr-2" /> Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => { e.stopPropagation(); setDeleteDevice(device); }}
>
<Trash2 className="w-4 h-4 mr-2" /> Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</button>
);
})}
</div>
)}
</CardContent>
</Card>
{sessions.filter((s) => s.is_active).length > 0 && (
<Card className="mt-4">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium">{sessions.filter((s) => s.is_active).length} active session(s)</span>
</div>
<Button size="sm" variant="outline" onClick={handleActivateAll} disabled={activatingId !== null} className="gap-1">
{activatingId === "all" ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
Activate All
</Button>
</div>
<div className="mt-2 space-y-1">
{sessions.filter((s) => s.is_active).map((session) => (
<div key={session.id} className="flex items-center justify-between text-sm p-2 border rounded">
<span className="text-muted-foreground font-mono">{session.device_network_membership_id}</span>
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Expires: {formatExpiry(session.expires_at)}</span>
<Button size="sm" variant="ghost" onClick={() => handleDeactivate(session.id)} disabled={deactivatingId === session.id}>
{deactivatingId === session.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* ── Tab B: My Networks ── */}
<TabsContent value="networks">
<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>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Globe className="w-4 h-4" />
Available Networks
{!isLoading && <Badge variant="secondary" className="ml-1">{networks.length}</Badge>}
</CardTitle>
<CardDescription>Join open networks or request access to approval-required networks</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>
) : networks.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">No networks available in this organization.</div>
) : (
<div className="divide-y">
{networks.filter((n) => {
if (!n.is_active) return false;
const q = search.toLowerCase();
if (q && !n.name.toLowerCase().includes(q) && !n.zerotier_network_id.toLowerCase().includes(q)) return false;
return true;
}).map((network) => {
const approval = getApprovalForNetwork(network.id);
const hasMembership = memberships.some((m) => m.portal_network_id === network.id && !m.deleted_at);
const myDeviceMemberships = devices.map((d) => getMembershipForDeviceAndNetwork(d.id, network.id)).filter(Boolean) as DeviceNetworkMembership[];
return (
<div key={network.id} className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<p className="font-medium truncate">{network.name}</p>
<Badge variant="outline" className="text-xs">{network.environment}</Badge>
<Badge variant={network.request_mode === "open" ? "default" : "secondary"} className="text-xs">
{network.request_mode === "open" ? "Open" : "Approval Required"}
</Badge>
{hasMembership && <Badge className="text-xs bg-green-500/10 text-green-600 border-green-200">Member</Badge>}
</div>
<p className="text-sm text-muted-foreground font-mono">{network.zerotier_network_id}</p>
{approval && (
<div className="flex items-center gap-2 mt-1">
<ApprovalStateBadge state={approval.state} />
{approval.justification && (
<span className="text-xs text-muted-foreground">"{approval.justification}"</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{network.request_mode === "open" && !hasMembership && (
<Button
size="sm"
variant="outline"
onClick={() => {
setShowJoinDialog(network);
setJoinDeviceId(devices[0]?.id || "");
}}
disabled={devices.length === 0}
className="gap-1"
>
<Plus className="w-3 h-3" /> Join
</Button>
)}
{network.request_mode === "approval_required" && !hasMembership && (
<Button
size="sm"
variant="outline"
onClick={() => {
setShowRequestDialog(network);
setRequestDeviceId(devices[0]?.id || "");
setRequestJustification("");
}}
disabled={devices.length === 0}
className="gap-1"
>
<Globe className="w-3 h-3" /> Request Access
</Button>
)}
{hasMembership && (
<div className="flex items-center gap-1 text-sm text-green-600">
<CheckCircle className="w-4 h-4" /> Member
</div>
)}
</div>
</div>
{myDeviceMemberships.length > 0 && (
<div className="mt-3 space-y-2">
{myDeviceMemberships.map((m) => (
<div key={m.id} className="flex items-center justify-between p-2 border rounded-lg bg-muted/30">
<div className="flex items-center gap-2">
<DeviceTypeIcon
nickname={devices.find((d) => d.id === m.device_id)?.device_nickname || null}
hostname={devices.find((d) => d.id === m.device_id)?.hostname || null}
/>
<span className="text-sm font-mono">
{devices.find((d) => d.id === m.device_id)?.device_nickname ||
devices.find((d) => d.id === m.device_id)?.node_id}
</span>
<MembershipStateBadge state={m.state} />
</div>
<div className="flex items-center gap-1">
{m.approved_for_activation && !m.currently_authorized && (
<Button
size="sm"
variant="outline"
onClick={() => setShowActivateDialog(m.id)}
disabled={activatingId === m.id}
className="gap-1"
>
{activatingId === 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={() => handleDeactivate(m.id)}
disabled={deactivatingId === m.id}
className="gap-1 text-orange-600 border-orange-300 hover:bg-orange-50"
>
{deactivatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
Deactivate
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* ── Tab C: My Requests ── */}
<TabsContent value="requests">
<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 by network name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Users className="w-4 h-4" />
My Access Requests
{!isLoading && <Badge variant="secondary" className="ml-1">{myApprovals.length}</Badge>}
</CardTitle>
<CardDescription>Track your network access requests and approvals</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 requests</span>
</div>
) : error ? (
<div className="p-8 text-center text-destructive">{error}</div>
) : filteredApprovals.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
{search ? "No requests match your search." : "No access requests yet. Browse networks to request access."}
</div>
) : (
<div className="divide-y">
{filteredApprovals.map((approval) => {
const network = networks.find((n) => n.id === approval.portal_network_id);
const relatedMemberships = memberships.filter((m) => m.portal_network_id === approval.portal_network_id);
return (
<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 mb-1">
<p className="font-medium truncate">{network?.name || approval.portal_network_id}</p>
<Badge variant="outline" className="text-xs">{network?.environment}</Badge>
<ApprovalStateBadge state={approval.state} />
</div>
<p className="text-sm text-muted-foreground">
{approval.grant_type === "requested" ? "You requested" : "Assigned by admin"}
{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 manager`}
</p>
{relatedMemberships.length > 0 && (
<div className="flex items-center gap-1 mt-1 flex-wrap">
{relatedMemberships.map((m) => {
const dev = devices.find((d) => d.id === m.device_id);
return (
<Badge key={m.id} variant="outline" className="text-xs">
{dev?.device_nickname || dev?.node_id}: <MembershipStateBadge state={m.state} />
</Badge>
);
})}
</div>
)}
</div>
{approval.state === "pending" && (
<Button
size="sm"
variant="outline"
className="text-destructive border-red-300 hover:bg-red-50 gap-1 flex-shrink-0"
onClick={() => {
const firstMem = memberships.find((m) => m.portal_network_id === approval.portal_network_id);
if (firstMem) handleDeleteMembership(firstMem.id);
}}
disabled={!memberships.some((m) => m.portal_network_id === approval.portal_network_id)}
>
Cancel
</Button>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Register Device Dialog */}
<Dialog open={showRegister} onOpenChange={(open) => { if (!open) setShowRegister(false); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Register Device</DialogTitle>
<DialogDescription>Add a ZeroTier node to your account. Find your Node ID in the ZeroTier client.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Node ID *</Label>
<Input placeholder="d6578dd03c" maxLength={10} value={regNodeId} onChange={(e) => setRegNodeId(e.target.value.toLowerCase())} className="font-mono" />
<p className="text-xs text-muted-foreground">10-character ZeroTier Node ID from your client.</p>
</div>
<div className="space-y-2">
<Label>Nickname</Label>
<Input placeholder="Work Laptop" value={regNickname} onChange={(e) => setRegNickname(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Hostname</Label>
<Input placeholder="ubuntu-work" value={regHostname} onChange={(e) => setRegHostname(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Asset Tag</Label>
<Input placeholder="ASSET-001" value={regAssetTag} onChange={(e) => setRegAssetTag(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Serial Number</Label>
<Input placeholder="SN123456" value={regSerial} onChange={(e) => setRegSerial(e.target.value)} />
</div>
</div>
{regError && <p className="text-sm text-destructive">{regError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRegister(false)} disabled={isRegistering}>Cancel</Button>
<Button onClick={handleRegister} disabled={isRegistering}>
{isRegistering && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Register Device
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Device Dialog */}
<Dialog open={!!editDevice} onOpenChange={(open) => { if (!open) setEditDevice(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Device</DialogTitle>
<DialogDescription>Update device nickname or hostname.</DialogDescription>
</DialogHeader>
{editDevice && (
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Node ID</Label>
<Input value={editDevice.node_id} disabled className="font-mono bg-muted" />
</div>
<div className="space-y-2">
<Label>Nickname</Label>
<Input value={editNickname} onChange={(e) => setEditNickname(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Hostname</Label>
<Input value={editHostname} onChange={(e) => setEditHostname(e.target.value)} />
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditDevice(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 Device Confirmation */}
<Dialog open={!!deleteDevice} onOpenChange={(open) => { if (!open) setDeleteDevice(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Device</DialogTitle>
<DialogDescription>
Remove "{deleteDevice?.node_id}" from your account? Active sessions will be terminated.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDevice(null)} disabled={isDeleting}>Cancel</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Remove Device
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Activate Lifetime Dialog */}
<Dialog open={!!showActivateDialog} onOpenChange={(open) => { if (!open) setShowActivateDialog(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Activation Duration</DialogTitle>
<DialogDescription>How long should this membership be active?</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Duration (minutes)</Label>
<Input type="number" value={activateLifetime} onChange={(e) => setActivateLifetime(e.target.value)} placeholder="480" />
<p className="text-xs text-muted-foreground">e.g. 480 = 8 hours, 60 = 1 hour</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowActivateDialog(null)}>Cancel</Button>
<Button onClick={() => { if (showActivateDialog) handleActivate(showActivateDialog); }} disabled={activatingId !== null}>
{activatingId !== null && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Activate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Join Network Dialog */}
<Dialog open={!!showJoinDialog} onOpenChange={(open) => { if (!open) { setShowJoinDialog(null); setJoinDeviceId(""); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Join Network</DialogTitle>
<DialogDescription>Select a registered device to join {showJoinDialog?.name}.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Device *</Label>
<Select value={joinDeviceId} onValueChange={setJoinDeviceId}>
<SelectTrigger><SelectValue placeholder="Select a device…" /></SelectTrigger>
<SelectContent>
{devices.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.device_nickname || d.hostname || d.node_id} ({d.node_id})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{requestError && <p className="text-sm text-destructive">{requestError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowJoinDialog(null); setJoinDeviceId(""); }} disabled={isJoining}>Cancel</Button>
<Button onClick={handleJoinNetwork} disabled={isJoining || !joinDeviceId}>
{isJoining && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Join Network
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Request Access Dialog */}
<Dialog open={!!showRequestDialog} onOpenChange={(open) => { if (!open) { setShowRequestDialog(null); setRequestDeviceId(""); setRequestJustification(""); setRequestError(null); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Request Network Access</DialogTitle>
<DialogDescription>Request access to {showRequestDialog?.name}. A manager will review your request.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Device *</Label>
<Select value={requestDeviceId} onValueChange={setRequestDeviceId}>
<SelectTrigger><SelectValue placeholder="Select a device…" /></SelectTrigger>
<SelectContent>
{devices.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.device_nickname || d.hostname || d.node_id} ({d.node_id})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Justification (optional)</Label>
<Input
placeholder="Engineering team access"
value={requestJustification}
onChange={(e) => setRequestJustification(e.target.value)}
/>
</div>
{requestError && <p className="text-sm text-destructive">{requestError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowRequestDialog(null); setRequestDeviceId(""); setRequestJustification(""); setRequestError(null); }} disabled={isRequesting}>Cancel</Button>
<Button onClick={handleRequestAccess} disabled={isRequesting || !requestDeviceId}>
{isRequesting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Submit Request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Device Detail Drawer */}
<Sheet open={!!selectedDevice} onOpenChange={(open) => { if (!open) closeDrawer(); }}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
{selectedDevice && (
<>
<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">
<DeviceTypeIcon nickname={selectedDevice.device_nickname} hostname={selectedDevice.hostname} />
</div>
{selectedDevice.device_nickname || selectedDevice.node_id}
</SheetTitle>
<SheetDescription className="font-mono">{selectedDevice.node_id}</SheetDescription>
</SheetHeader>
<div className="space-y-3 mb-6">
<div className="grid grid-cols-2 gap-2 text-sm">
{selectedDevice.hostname && (
<>
<span className="text-muted-foreground">Hostname</span>
<span>{selectedDevice.hostname}</span>
</>
)}
{selectedDevice.asset_tag && (
<>
<span className="text-muted-foreground">Asset Tag</span>
<span>{selectedDevice.asset_tag}</span>
</>
)}
{selectedDevice.serial_number && (
<>
<span className="text-muted-foreground">Serial</span>
<span>{selectedDevice.serial_number}</span>
</>
)}
<span className="text-muted-foreground">Registered</span>
<span>{formatDate(selectedDevice.created_at)}</span>
<span className="text-muted-foreground">Status</span>
<Badge variant={selectedDevice.status === "active" ? "default" : "outline"} className="w-fit">{selectedDevice.status}</Badge>
</div>
</div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Monitor className="w-4 h-4" />
Network Memberships ({deviceMemberships.length})
</h3>
{isDrawerLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : deviceMemberships.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">
No memberships found. Request network access to get started.
</div>
) : (
<div className="space-y-3">
{deviceMemberships.map((m) => {
const session = getActiveSession(m.id);
const network = networks.find((n) => n.id === m.portal_network_id);
return (
<div key={m.id} className="p-3 border rounded-lg space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{network?.name || m.portal_network_id}</span>
<MembershipStateBadge state={m.state} />
</div>
<div className="flex items-center gap-1">
{m.approved_for_activation && !m.currently_authorized && (
<Button
size="sm"
variant="outline"
onClick={() => setShowActivateDialog(m.id)}
disabled={activatingId === m.id}
className="gap-1"
>
{activatingId === 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={() => handleDeactivate(m.id)}
disabled={deactivatingId === m.id}
className="gap-1 text-orange-600 border-orange-300 hover:bg-orange-50"
>
{deactivatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
Deactivate
</Button>
)}
</div>
</div>
{session && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
Session expires: {formatExpiry(session.expires_at)}
</div>
)}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{m.join_seen ? (
<><CheckCircle className="w-3 h-3 text-green-500" /> Joined network</>
) : (
<><XCircle className="w-3 h-3" /> Not yet joined</>
)}
</div>
</div>
);
})}
</div>
)}
</>
)}
</SheetContent>
</Sheet>
</div>
);
}