diff --git a/.opencode/opencode-swarm.json b/.opencode/opencode-swarm.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.opencode/opencode-swarm.json @@ -0,0 +1 @@ +{} diff --git a/src/App.tsx b/src/App.tsx index 9371f8a..79319dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,7 @@ import PrincipalsPage from "@/pages/org/PrincipalsPage"; import ApiKeysPage from "@/pages/org/ApiKeysPage"; import MyMembershipsPage from "@/pages/org/MyMembershipsPage"; import NetworksPage from "@/pages/org/NetworksPage"; +import NetworkManagementPage from "@/pages/org/NetworkManagementPage"; import DevicesPage from "@/pages/org/DevicesPage"; import AccessPage from "@/pages/org/AccessPage"; import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage"; @@ -209,6 +210,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/src/lib/api.ts b/src/lib/api.ts index d2a2e78..8c06de0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -177,6 +177,40 @@ export interface AdminLinkedAccount { linked_at: string | null; } +export interface AdminUserSshCertificate { + id: string; + ca_id: string; + user_id: string; + ssh_key_id: string | null; + serial: string; + key_id: string; + cert_type: 'user' | 'host'; + principals: string[]; + valid_after: string; + valid_before: string; + revoked: boolean; + revoked_at: string | null; + revoke_reason: string | null; + status: 'issued' | 'revoked' | 'expired' | 'superseded'; + request_ip: string | null; + request_user_agent: string | null; + critical_options: Record; + extensions: Record; + created_at: string; + updated_at: string; + is_valid: boolean; + days_until_expiry: number; + ssh_key: { + id: string; + fingerprint: string; + key_type: string; + key_bits: number; + key_comment: string | null; + description: string | null; + verified: boolean; + } | null; +} + // External Auth Types export type ExternalProviderId = 'google' | 'github' | 'microsoft'; @@ -698,6 +732,27 @@ export const api = { method: 'PUT', body: JSON.stringify(policy), }, true, requestConfig), + + // Get SSH certificates issued to a user (admin view) + getUserSshCertificates: (userId: string, params?: { + status?: string; + active?: string; + cert_type?: string; + page?: number; + per_page?: number; + }, requestConfig?: RequestConfig) => { + const qs = params ? '?' + new URLSearchParams( + Object.entries(params).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)]) + ).toString() : ''; + return request<{ + user: { id: string; email: string; full_name: string }; + certificates: AdminUserSshCertificate[]; + count: number; + page: number; + per_page: number; + pages: number; + }>(`/admin/users/${userId}/ssh-certificates${qs}`, {}, true, requestConfig); + }, }, totp: { @@ -1568,6 +1623,19 @@ export const api = { true, requestConfig, ), + // ── Org Members (for Add New Membership dialog) ──────────────────── + getOrgMembers: (orgId: string, requestConfig?: RequestConfig) => + request<{ members: OrgMember[]; count: number }>( + `/organizations/${orgId}/members`, + {}, true, requestConfig, + ), + + getUserDevices: (orgId: string, userId: string, requestConfig?: RequestConfig) => + request<{ devices: Device[]; count: number }>( + `/organizations/${orgId}/users/${userId}/devices`, + {}, true, requestConfig, + ), + // ── Sessions ────────────────────────────────────────────────────────────── listSessions: (orgId: string, requestConfig?: RequestConfig) => request<{ sessions: ActivationSession[]; count: number }>( @@ -1964,6 +2032,32 @@ export interface AvailableZtNetwork { portal_network_name: string | null; } +export interface OrgMember { + id: string; + user_id: string; + organization_id: string; + role: string; + created_at: string; + updated_at: string; + deleted_at: string | null; + invited_at: string | null; + invited_by_id: string | null; + joined_at: string | null; + user: { + id: string; + email: string; + full_name: string | null; + status: string; + avatar_url: string | null; + activated: boolean; + email_verified: boolean; + created_at: string; + updated_at: string; + last_login_at: string | null; + last_login_ip: string | null; + } | null; +} + export interface Device { id: string; user_id: string; diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx index efa17e5..619d3cf 100644 --- a/src/pages/admin/AdminUsersPage.tsx +++ b/src/pages/admin/AdminUsersPage.tsx @@ -19,8 +19,12 @@ import { KeyRound, Link2, Unlink, + Award, + ExternalLink, Lock, + FileKey, } from "lucide-react"; +import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -56,7 +60,7 @@ import { SelectValue, } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; -import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api"; +import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount, AdminUserSshCertificate } from "@/lib/api"; import { useAuth } from "@/contexts/AuthContext"; function formatDate(d: string | null) { @@ -99,6 +103,7 @@ function RoleBadge({ role }: { role: string }) { export default function AdminUsersPage() { const { toast } = useToast(); const { user: currentUser } = useAuth(); + const navigate = useNavigate(); // User list const [users, setUsers] = useState([]); @@ -162,6 +167,11 @@ export default function AdminUsersPage() { const [passwordResetError, setPasswordResetError] = useState(null); const [isResettingPassword, setIsResettingPassword] = useState(false); + // SSH Certificates summary + const [userSshCerts, setUserSshCerts] = useState([]); + const [sshCertsCount, setSshCertsCount] = useState(0); + const [isSshCertsLoading, setIsSshCertsLoading] = useState(false); + // ── Fetch users ───────────────────────────────────────────────────────────── const fetchUsers = useCallback(async (q: string, pg: number) => { setIsLoading(true); @@ -203,12 +213,16 @@ export default function AdminUsersPage() { setUserMfaMethods([]); setUserLinkedAccounts([]); setTotalAuthMethods(0); + setUserSshCerts([]); + setSshCertsCount(0); + setIsSshCertsLoading(true); setIsDrawerLoading(true); try { - const [userData, mfaData, linkedData] = await Promise.allSettled([ + const [userData, mfaData, linkedData, certsData] = await Promise.allSettled([ api.admin.getUser(user.id), api.admin.getUserMfa(user.id), api.admin.getUserLinkedAccounts(user.id), + api.admin.getUserSshCertificates(user.id, { per_page: 5 }), ]); if (userData.status === "fulfilled") setUserSshKeys(userData.value.ssh_keys); if (mfaData.status === "fulfilled") setUserMfaMethods(mfaData.value.mfa_methods); @@ -216,10 +230,18 @@ export default function AdminUsersPage() { setUserLinkedAccounts(linkedData.value.linked_accounts); setTotalAuthMethods(linkedData.value.total_auth_methods); } + if (certsData.status === "fulfilled") { + setUserSshCerts(certsData.value.certificates); + setSshCertsCount(certsData.value.count); + } else { + setUserSshCerts([]); + setSshCertsCount(0); + } } catch { // Non-fatal } finally { setIsDrawerLoading(false); + setIsSshCertsLoading(false); } }; @@ -929,6 +951,70 @@ export default function AdminUsersPage() { )} + {/* SSH Certificates summary */} + {selectedUser.id !== currentUser?.id && ( +
+
+

+ + SSH Certificates +

+ +
+ + {isDrawerLoading || isSshCertsLoading ? ( +
+ +
+ ) : sshCertsCount === 0 ? ( +

No SSH certificates issued.

+ ) : ( +
+ {/* Total count badge */} +
+ + + {sshCertsCount} certificate{sshCertsCount !== 1 ? "s" : ""} + +
+ + {/* Recent certificates (up to 5) */} +
+ {userSshCerts.slice(0, 5).map((cert) => ( +
+
+ {cert.key_id} + {cert.revoked ? ( + Revoked + ) : !cert.is_valid ? ( + Expired + ) : ( + Active + )} +
+ + {cert.principals.slice(0, 2).join(", ")} + {cert.principals.length > 2 && ` +${cert.principals.length - 2}`} + +
+ ))} +
+
+ )} +
+ )} + {/* Danger zone — Hard delete */} {selectedUser.id !== currentUser?.id && (
diff --git a/src/pages/admin/UserManagementPage.tsx b/src/pages/admin/UserManagementPage.tsx index 15af94a..596dcac 100644 --- a/src/pages/admin/UserManagementPage.tsx +++ b/src/pages/admin/UserManagementPage.tsx @@ -19,6 +19,13 @@ import { UserCheck, ShieldOff, Plus, + Award, + Clock, + FileKey, + Globe, + Terminal, + ChevronDown, + ChevronRight, } from "lucide-react"; import { useParams, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; @@ -26,6 +33,13 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -38,7 +52,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; -import { api, ApiError, User, SSHKey, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api"; +import { api, ApiError, User, SSHKey, AdminMfaMethod, AdminLinkedAccount, AdminUserSshCertificate } from "@/lib/api"; import { useAuth } from "@/contexts/AuthContext"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -155,6 +169,233 @@ function UserManagementSkeleton() { ); } +// ── Certificate Status Badge ───────────────────────────────────────────────── + +function CertStatusBadge({ cert }: { cert: AdminUserSshCertificate }) { + if (cert.revoked) { + return ( + + + Revoked + + ); + } + if (cert.status === "superseded") { + return ( + + Superseded + + ); + } + if (!cert.is_valid) { + return ( + + Expired + + ); + } + // Active + valid + if (cert.days_until_expiry >= 0 && cert.days_until_expiry <= 7) { + return ( + + + {cert.days_until_expiry === 0 ? "Expires today" : `${cert.days_until_expiry}d left`} + + ); + } + return ( + + + Active + + ); +} + +// ── Certificate Row ────────────────────────────────────────────────────────── + +function CertificateRow({ cert }: { cert: AdminUserSshCertificate }) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + + {expanded && ( +
+ {/* Revocation info */} + {cert.revoked && ( +
+

+ + Certificate Revoked +

+
+ Revoked at + {formatDate(cert.revoked_at)} + {cert.revoke_reason && ( + <> + Reason + {cert.revoke_reason} + + )} +
+
+ )} + + {/* Certificate details grid */} +
+ Certificate ID + {cert.id} + + CA ID + {cert.ca_id} + + {cert.request_ip && ( + <> + Request IP + + + {cert.request_ip} + + + )} + + {cert.request_user_agent && ( + <> + User Agent + {cert.request_user_agent} + + )} + + Created + {formatDate(cert.created_at)} + + Last Updated + {formatDate(cert.updated_at)} +
+ + {/* Extensions */} + {Object.keys(cert.extensions).length > 0 && ( +
+

+ + Extensions +

+
+ {Object.entries(cert.extensions).map(([key, val]) => ( + + {key}{val ? `=${val}` : ""} + + ))} +
+
+ )} + + {/* Critical options */} + {Object.keys(cert.critical_options).length > 0 && ( +
+

+ + Critical Options +

+
+ {Object.entries(cert.critical_options).map(([key, val]) => ( + + {key}{val ? `=${val}` : ""} + + ))} +
+
+ )} + + {/* SSH Key info */} + {cert.ssh_key && ( +
+

+ + SSH Key +

+
+
+ Fingerprint + {cert.ssh_key.fingerprint} + Type + {cert.ssh_key.key_type} ({cert.ssh_key.key_bits} bits) + {cert.ssh_key.description && ( + <> + Description + {cert.ssh_key.description} + + )} + {cert.ssh_key.key_comment && ( + <> + Comment + {cert.ssh_key.key_comment} + + )} + Verified + + {cert.ssh_key.verified ? ( + <> Yes + ) : ( + <> No + )} + +
+
+
+ )} + + {!cert.ssh_key && ( +

No SSH key linked to this certificate

+ )} +
+ )} +
+ ); +} + // ── Main Component ──────────────────────────────────────────────────────────── export default function UserManagementPage() { @@ -207,6 +448,16 @@ export default function UserManagementPage() { // Role update state const [selectedRole, setSelectedRole] = useState("member"); + // SSH Certificates state + const [sshCerts, setSshCerts] = useState([]); + const [isCertsLoading, setIsCertsLoading] = useState(false); + const [certsPage, setCertsPage] = useState(1); + const [certsPages, setCertsPages] = useState(1); + const [certsCount, setCertsCount] = useState(0); + const [certStatusFilter, setCertStatusFilter] = useState("all"); + const [certActiveFilter, setCertActiveFilter] = useState("all"); + const [certTypeFilter, setCertTypeFilter] = useState("all"); + // ── Handlers ───────────────────────────────────────────────────────────────── const handleRemoveMfaMethod = async (method: AdminMfaMethod) => { @@ -545,6 +796,38 @@ export default function UserManagementPage() { }; }, [userId]); + // ── Fetch SSH Certificates ─────────────────────────────────────────────────── + // Reset page when filters change + useEffect(() => { + setCertsPage(1); + }, [certStatusFilter, certActiveFilter, certTypeFilter]); + + // Fetch SSH certificates + useEffect(() => { + if (!userId) return; + let cancelled = false; + const fetchCerts = async () => { + setIsCertsLoading(true); + try { + const params: Record = { page: certsPage, per_page: 20 }; + if (certStatusFilter !== "all") params.status = certStatusFilter; + if (certActiveFilter !== "all") params.active = certActiveFilter; + if (certTypeFilter !== "all") params.cert_type = certTypeFilter; + const data = await api.admin.getUserSshCertificates(userId, params); + if (cancelled) return; + setSshCerts(data.certificates); + setCertsPages(data.pages); + setCertsCount(data.count); + } catch { + if (!cancelled) setSshCerts([]); + } finally { + if (!cancelled) setIsCertsLoading(false); + } + }; + fetchCerts(); + return () => { cancelled = true; }; + }, [userId, certsPage, certStatusFilter, certActiveFilter, certTypeFilter]); + // ── Render ─────────────────────────────────────────────────────────────────── if (isLoading) { @@ -588,6 +871,10 @@ export default function UserManagementPage() { User Details Security Access + + + SSH Certificates + {/* ── User Details Tab ────────────────────────────────────────────── */} @@ -918,6 +1205,110 @@ export default function UserManagementPage() {
+ + {/* ── SSH Certificates Tab ──────────────────────────────────────────── */} + + + + +
+ + SSH Certificates +
+ {!isCertsLoading && ( + {certsCount} + )} +
+ + SSH certificates issued to this user via the organization's Certificate Authority + +
+ + {/* Filter controls */} +
+ + + + + +
+ + {isCertsLoading ? ( +
+ +
+ ) : sshCerts.length === 0 ? ( +
+ +

No certificates found

+

This user has not been issued any SSH certificates yet

+
+ ) : ( +
+ {sshCerts.map((cert) => ( + + ))} +
+ )} + + {/* Pagination */} + {certsPages > 1 && ( +
+

+ Page {certsPage} of {certsPages} · {certsCount} total +

+
+ + +
+
+ )} +
+
+
{/* ── Remove all MFA confirmation dialog ───────────────────────────── */} diff --git a/src/pages/org/DevicesPage.tsx b/src/pages/org/DevicesPage.tsx index 7db8a39..9616995 100644 --- a/src/pages/org/DevicesPage.tsx +++ b/src/pages/org/DevicesPage.tsx @@ -40,13 +40,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; + import { Select, SelectContent, @@ -159,9 +153,7 @@ export default function DevicesPage() { const [isRegistering, setIsRegistering] = useState(false); const [regError, setRegError] = useState(null); - const [selectedDevice, setSelectedDevice] = useState(null); - const [deviceMemberships, setDeviceMemberships] = useState([]); - const [isDrawerLoading, setIsDrawerLoading] = useState(false); + const [expandedDeviceId, setExpandedDeviceId] = useState(null); const [editDevice, setEditDevice] = useState(null); const [editNickname, setEditNickname] = useState(""); @@ -218,25 +210,6 @@ export default function DevicesPage() { 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); @@ -477,7 +450,7 @@ export default function DevicesPage() { Registered Devices {!isLoading && {devices.length}} - Click a device to view memberships and activation status + Click a device to expand its details and network memberships {isLoading ? ( @@ -497,65 +470,165 @@ export default function DevicesPage() { const activeCount = memberships.filter( (m) => m.device_id === device.id && m.currently_authorized ).length; + const isExpanded = expandedDeviceId === device.id; + const deviceMemberships = memberships.filter((m) => m.device_id === device.id); return ( - - - - { e.stopPropagation(); openDeviceDrawer(device); }}> - View memberships - - { - e.stopPropagation(); - setEditDevice(device); - setEditNickname(device.device_nickname || ""); - setEditHostname(device.hostname || ""); - }}> - Edit - - - { e.stopPropagation(); setDeleteDevice(device); }} - > - Remove - - - - - +
+
+

+ {device.device_nickname || device.hostname || device.node_id} +

+ {device.device_nickname && device.hostname && ( + {device.hostname} + )} + + {device.status} + +
+

{device.node_id}

+
+
+ {activeCount > 0 ? ( + <>{activeCount} active + ) : ( + Inactive + )} +
+ + e.stopPropagation()}> + + + + { e.stopPropagation(); setExpandedDeviceId(isExpanded ? null : device.id); }}> + View memberships + + { + e.stopPropagation(); + setEditDevice(device); + setEditNickname(device.device_nickname || ""); + setEditHostname(device.hostname || ""); + }}> + Edit + + + { e.stopPropagation(); setDeleteDevice(device); }} + > + Remove + + + + + + {isExpanded && ( +
+
+ {device.hostname && ( + <> + Hostname + {device.hostname} + + )} + {device.asset_tag && ( + <> + Asset Tag + {device.asset_tag} + + )} + {device.serial_number && ( + <> + Serial + {device.serial_number} + + )} + Registered + {formatDate(device.created_at)} + Status + {device.status} +
+ +
+

+ + Network Memberships ({deviceMemberships.length}) +

+ + {deviceMemberships.length === 0 ? ( +
+ No memberships found. Request network access to get started. +
+ ) : ( +
+ {deviceMemberships.map((m) => { + const session = getActiveSession(m.id); + const network = networks.find((n) => n.id === m.portal_network_id); + return ( +
+
+
+ {network?.name || m.portal_network_id} + +
+
+ {m.approved_for_activation && !m.currently_authorized && ( + + )} + {m.currently_authorized && ( + + )} +
+
+ {session && ( +
+ + Session expires: {formatExpiry(session.expires_at)} +
+ )} +
+ {m.join_seen ? ( + <> Joined network + ) : ( + <> Not yet joined + )} +
+
+ ); + })} +
+ )} +
+
+ )} + ); })} @@ -1038,122 +1111,7 @@ export default function DevicesPage() { - {/* Device Detail Drawer */} - { if (!open) closeDrawer(); }}> - - {selectedDevice && ( - <> - - -
- -
- {selectedDevice.device_nickname || selectedDevice.node_id} -
- {selectedDevice.node_id} -
-
-
- {selectedDevice.hostname && ( - <> - Hostname - {selectedDevice.hostname} - - )} - {selectedDevice.asset_tag && ( - <> - Asset Tag - {selectedDevice.asset_tag} - - )} - {selectedDevice.serial_number && ( - <> - Serial - {selectedDevice.serial_number} - - )} - Registered - {formatDate(selectedDevice.created_at)} - Status - {selectedDevice.status} -
-
- -

- - Network Memberships ({deviceMemberships.length}) -

- - {isDrawerLoading ? ( -
- -
- ) : deviceMemberships.length === 0 ? ( -
- No memberships found. Request network access to get started. -
- ) : ( -
- {deviceMemberships.map((m) => { - const session = getActiveSession(m.id); - const network = networks.find((n) => n.id === m.portal_network_id); - return ( -
-
-
- {network?.name || m.portal_network_id} - -
-
- {m.approved_for_activation && !m.currently_authorized && ( - - )} - {m.currently_authorized && ( - - )} -
-
- {session && ( -
- - Session expires: {formatExpiry(session.expires_at)} -
- )} -
- {m.join_seen ? ( - <> Joined network - ) : ( - <> Not yet joined - )} -
-
- ); - })} -
- )} - - )} -
-
); } diff --git a/src/pages/org/NetworkManagementPage.tsx b/src/pages/org/NetworkManagementPage.tsx new file mode 100644 index 0000000..e803ac7 --- /dev/null +++ b/src/pages/org/NetworkManagementPage.tsx @@ -0,0 +1,810 @@ +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { Network, ArrowLeft, Loader2, AlertTriangle, Users, Zap, Ban, Shield, Monitor, CheckCircle, XCircle, Clock, ChevronDown, ChevronRight, Plus, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { api, PortalNetwork, ApiError, NetworkEnvironment, NetworkRequestMode, DeviceNetworkMembership, MembershipState, UserNetworkApproval, OrgMember, Device } from "@/lib/api"; +import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; +import { useToast } from "@/hooks/use-toast"; + +function cn(...classes: (string | boolean | undefined | null)[]) { + return classes.filter(Boolean).join(" "); +} + +function EnvironmentBadge({ env }: { env: NetworkEnvironment }) { + const colors: Record = { + 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 ( + + {env.charAt(0).toUpperCase() + env.slice(1)} + + ); +} + +function RequestModeBadge({ mode }: { mode: NetworkRequestMode }) { + if (mode === "open") return Open; + if (mode === "approval_required") return Approval Required; + return Invite Only; +} + +function formatDate(d: string | null | undefined) { + if (!d) return "—"; + return new Date(d).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function groupMembersByUser(memberships: DeviceNetworkMembership[]): Map { + const grouped = new Map(); + for (const m of memberships) { + const existing = grouped.get(m.user_id) || []; + existing.push(m); + grouped.set(m.user_id, existing); + } + return grouped; +} + +function MembershipStateBadge({ state }: { state: MembershipState }) { + const config: Record = { + pending_device_registration: { label: "Pending Device", className: "bg-gray-100 text-gray-600 border-gray-200" }, + pending_request: { label: "Pending Request", className: "bg-yellow-100 text-yellow-700 border-yellow-200" }, + pending_manager_approval: { label: "Pending Approval", className: "bg-orange-100 text-orange-700 border-orange-200" }, + approved_inactive: { label: "Approved", className: "bg-blue-100 text-blue-700 border-blue-200" }, + joined_deauthorized: { label: "Deauthorized", className: "bg-red-100 text-red-700 border-red-200" }, + active_authorized: { label: "Active", className: "bg-green-100 text-green-700 border-green-200" }, + activation_expired: { label: "Expired", className: "bg-gray-100 text-gray-500 border-gray-200" }, + suspended: { label: "Suspended", className: "bg-red-100 text-red-700 border-red-200" }, + revoked: { label: "Revoked", className: "bg-red-100 text-red-700 border-red-200" }, + rejected: { label: "Rejected", className: "bg-red-100 text-red-700 border-red-200" }, + }; + const { label, className } = config[state] || { label: state, className: "bg-gray-100 text-gray-600" }; + return {label}; +} + +function ApprovalStateBadge({ state }: { state: string }) { + const colors: Record = { + pending: "bg-yellow-100 text-yellow-700 border-yellow-200", + approved: "bg-green-100 text-green-700 border-green-200", + rejected: "bg-red-100 text-red-700 border-red-200", + revoked: "bg-red-100 text-red-700 border-red-200", + suspended: "bg-orange-100 text-orange-700 border-orange-200", + }; + return ( + + {state.charAt(0).toUpperCase() + state.slice(1)} + + ); +} + +export default function NetworkManagementPage() { + const { networkId } = useParams<{ networkId: string }>(); + const { orgId } = useCurrentOrganizationId(); + const navigate = useNavigate(); + + const [network, setNetwork] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const { toast } = useToast(); + const [members, setMembers] = useState([]); + const [isMembersLoading, setIsMembersLoading] = useState(false); + const [membersError, setMembersError] = useState(null); + const [expandedUsers, setExpandedUsers] = useState>(new Set()); + const [activatingMembership, setActivatingMembership] = useState(null); + const [deactivatingMembership, setDeactivatingMembership] = useState(null); + + const [requests, setRequests] = useState([]); + const [isRequestsLoading, setIsRequestsLoading] = useState(false); + const [requestsError, setRequestsError] = useState(null); + const [approvingRequest, setApprovingRequest] = useState(null); + const [rejectingRequest, setRejectingRequest] = useState(null); + + // Add New Membership dialog state + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [addStep, setAddStep] = useState<1 | 2 | 3>(1); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedDevice, setSelectedDevice] = useState(null); + const [orgMembers, setOrgMembers] = useState([]); + const [userDevices, setUserDevices] = useState([]); + const [isLoadingMembers, setIsLoadingMembers] = useState(false); + const [isLoadingDevices, setIsLoadingDevices] = useState(false); + const [isJoining, setIsJoining] = useState(false); + const [userSearch, setUserSearch] = useState(""); + + useEffect(() => { + async function fetchNetwork() { + if (!orgId || !networkId) { + setError("Organization or network ID is missing."); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + setError(null); + const result = await api.zerotier.getNetwork(orgId, networkId); + setNetwork(result.network); + } catch (err) { + let message = "Failed to load network details."; + if (err instanceof ApiError) { + message = err.message; + } + setError(message); + } finally { + setIsLoading(false); + } + } + + fetchNetwork(); + }, [orgId, networkId]); + + useEffect(() => { + async function fetchMembers() { + if (!orgId || !networkId) return; + setIsMembersLoading(true); + setMembersError(null); + try { + const result = await api.zerotier.getNetworkMembers(orgId, networkId); + setMembers(result.memberships || []); + } catch (err) { + setMembersError(err instanceof ApiError ? err.message : "Failed to load members."); + } finally { + setIsMembersLoading(false); + } + } + fetchMembers(); + }, [orgId, networkId]); + + useEffect(() => { + async function fetchRequests() { + if (!orgId || !networkId) return; + setIsRequestsLoading(true); + setRequestsError(null); + try { + const result = await api.zerotier.getNetworkPendingRequests(orgId, networkId); + setRequests(result.requests || []); + } catch (err) { + setRequestsError(err instanceof ApiError ? err.message : "Failed to load requests."); + } finally { + setIsRequestsLoading(false); + } + } + fetchRequests(); + }, [orgId, networkId]); + + // Fetch org members when dialog opens + useEffect(() => { + if (!isAddDialogOpen || !orgId) return; + async function fetchMembers() { + setIsLoadingMembers(true); + try { + const result = await api.zerotier.getOrgMembers(orgId); + setOrgMembers(result.members || []); + } catch (err) { + toast({ variant: "destructive", title: "Failed to load members" }); + } finally { + setIsLoadingMembers(false); + } + } + fetchMembers(); + }, [isAddDialogOpen, orgId]); + + // Fetch user devices when user is selected + useEffect(() => { + if (!selectedUser || !orgId) return; + async function fetchDevices() { + setIsLoadingDevices(true); + try { + const result = await api.zerotier.getUserDevices(orgId, selectedUser.user_id); + setUserDevices(result.devices || []); + // Auto-select if only one device + if (result.devices?.length === 1) { + setSelectedDevice(result.devices[0]); + setAddStep(3); + } else { + setSelectedDevice(null); + setAddStep(2); + } + } catch (err) { + toast({ variant: "destructive", title: "Failed to load devices" }); + } finally { + setIsLoadingDevices(false); + } + } + fetchDevices(); + }, [selectedUser, orgId]); + + const handleAddMembership = async () => { + if (!orgId || !selectedDevice || !networkId) return; + setIsJoining(true); + try { + // Check for duplicate + const exists = members.some(m => m.user_id === selectedUser?.user_id && m.device_id === selectedDevice.id); + if (exists) { + toast({ variant: "destructive", title: "Duplicate membership", description: "This user/device combination is already a member." }); + return; + } + await api.zerotier.joinNetworkForDevice(orgId, selectedDevice.id, networkId); + toast({ title: "Membership added successfully" }); + // Refresh members + const result = await api.zerotier.getNetworkMembers(orgId, networkId); + setMembers(result.memberships || []); + // Reset dialog + setIsAddDialogOpen(false); + setAddStep(1); + setSelectedUser(null); + setSelectedDevice(null); + setUserSearch(""); + } catch (err) { + toast({ variant: "destructive", title: "Failed to add membership", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setIsJoining(false); + } + }; + + const handleActivate = async (membershipId: string) => { + if (!orgId) return; + setActivatingMembership(membershipId); + try { + await api.zerotier.activateMembership(orgId, membershipId); + toast({ title: "Membership activated" }); + // Refresh members + const result = await api.zerotier.getNetworkMembers(orgId, networkId!); + setMembers(result.memberships || []); + } catch (err) { + toast({ variant: "destructive", title: "Failed to activate", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setActivatingMembership(null); + } + }; + + const handleDeactivate = async (membershipId: string) => { + if (!orgId) return; + setDeactivatingMembership(membershipId); + try { + await api.zerotier.deactivateMembership(orgId, membershipId); + toast({ title: "Membership deactivated" }); + const result = await api.zerotier.getNetworkMembers(orgId, networkId!); + setMembers(result.memberships || []); + } catch (err) { + toast({ variant: "destructive", title: "Failed to deactivate", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setDeactivatingMembership(null); + } + }; + + const handleApprove = async (approvalId: string) => { + if (!orgId) return; + setApprovingRequest(approvalId); + try { + await api.zerotier.approveRequest(orgId, approvalId); + toast({ title: "Request approved" }); + // Refresh requests + const result = await api.zerotier.getNetworkPendingRequests(orgId, networkId!); + setRequests(result.requests || []); + } catch (err) { + toast({ variant: "destructive", title: "Failed to approve", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setApprovingRequest(null); + } + }; + + const handleReject = async (approvalId: string) => { + if (!orgId) return; + setRejectingRequest(approvalId); + try { + await api.zerotier.rejectRequest(orgId, approvalId); + toast({ title: "Request rejected" }); + const result = await api.zerotier.getNetworkPendingRequests(orgId, networkId!); + setRequests(result.requests || []); + } catch (err) { + toast({ variant: "destructive", title: "Failed to reject", description: err instanceof ApiError ? err.message : "Something went wrong." }); + } finally { + setRejectingRequest(null); + } + }; + + // ── SKELETON STATE ────────────────────────────────────────────────────────── + if (isLoading) { + return ( +
+
+ + +
+ + + + + + + + + +
+ ); + } + + // ── ERROR STATE ───────────────────────────────────────────────────────────── + if (error) { + return ( +
+ + + +

{error}

+ +
+
+
+ ); + } + + // ── SUCCESS STATE ─────────────────────────────────────────────────────────── + return ( +
+
+
+ +
+
+

+ + {network?.name ?? "Network"} +

+

+ Manage network members, devices, and access requests +

+
+
+ + + + Overview + Members + Requests + + + + + + + + Network Details + + + + {/* Status and badges row */} +
+ + + {!network!.is_active && ( + + Inactive + + )} +
+ + {/* Description */} + {network!.description && ( +

{network!.description}

+ )} + + {/* Metadata grid */} +
+
+

ZeroTier Network ID

+

{network!.zerotier_network_id}

+
+
+

Default Activation

+

{network!.default_activation_lifetime_minutes} min

+
+
+

Max Activation

+

{network!.max_activation_lifetime_minutes ? `${network!.max_activation_lifetime_minutes} min` : "No limit"}

+
+
+

Created

+

{formatDate(network!.created_at)}

+
+
+ + {/* Stats row */} +
+ + +
+ +

Approved Users

+
+

{network!.approved_user_count ?? 0}

+
+
+ + +
+ +

Active Devices

+
+

{network!.active_membership_count ?? 0}

+
+
+ + +
+ +

Request Mode

+
+

{network!.request_mode.replace(/_/g, " ")}

+
+
+
+
+
+
+ + + + + + + + Network Members + +
+ + {members.length} memberships +
+
+
+ + {isMembersLoading ? ( +
+ + Loading members… +
+ ) : membersError ? ( +
{membersError}
+ ) : members.length === 0 ? ( +
No members on this network yet.
+ ) : ( +
+ {Array.from(groupMembersByUser(members).entries()).map(([userId, userMemberships]) => { + const isExpanded = expandedUsers.has(userId); + const activeCount = userMemberships.filter(m => m.currently_authorized).length; + return ( +
+ {/* User header - clickable to expand/collapse */} + + + {/* Device list - shown when expanded */} + {isExpanded && ( +
+ {userMemberships.map((m) => ( +
+ +
+
+

{m.device_id}

+ +
+
+ + {m.currently_authorized ? ( + <> Authorized + ) : ( + <> Unauthorized + )} + + {m.active_session && m.active_session.is_active && ( + + Session active (expires {new Date(m.active_session.expires_at).toLocaleTimeString()}) + + )} + Joined: {m.join_seen ? "Yes" : "No"} +
+
+ {/* Activate/Deactivate button */} + {m.currently_authorized ? ( + + ) : ( + + )} +
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+
+
+ + + + + + + + Access Requests + + {requests.length} pending + + + + {isRequestsLoading ? ( +
+ + Loading requests… +
+ ) : requestsError ? ( +
{requestsError}
+ ) : requests.length === 0 ? ( +
No pending requests for this network.
+ ) : ( +
+ {requests.map((r) => ( +
+
+ +
+
+
+

{r.user_id}

+ + {r.grant_type} +
+ {r.justification && ( +

"{r.justification}"

+ )} +

+ Requested: {formatDate(r.created_at)} +

+
+ {r.state === "pending" && ( +
+ + +
+ )} +
+ ))} +
+ )} +
+
+
+
+ + {/* Add New Membership Dialog */} + { setIsAddDialogOpen(open); if (!open) { setAddStep(1); setSelectedUser(null); setSelectedDevice(null); setUserSearch(""); } }}> + + + + {addStep === 1 && "Add New Membership - Select User"} + {addStep === 2 && "Add New Membership - Select Device"} + {addStep === 3 && "Add New Membership - Confirm"} + + + {addStep === 1 && "Search and select a user to add to this network."} + {addStep === 2 && selectedUser && `Select a device for ${selectedUser.user?.full_name || selectedUser.user_id}.`} + {addStep === 3 && "Review and confirm the membership details."} + + + + {addStep === 1 && ( +
+
+ + setUserSearch(e.target.value)} + className="pl-8" + /> +
+
+ {isLoadingMembers ? ( +
+ +
+ ) : ( + orgMembers + .filter(m => { + const search = userSearch.toLowerCase(); + if (!search) return true; + const name = m.user?.full_name?.toLowerCase() || ""; + const email = m.user?.email?.toLowerCase() || ""; + const id = m.user_id.toLowerCase(); + return name.includes(search) || email.includes(search) || id.includes(search); + }) + .map(m => ( + + )) + )} +
+
+ )} + + {addStep === 2 && selectedUser && ( +
+
+ + + {selectedUser.user?.full_name || selectedUser.user_id} + +
+ {isLoadingDevices ? ( +
+ +
+ ) : userDevices.length === 0 ? ( +

No devices found for this user.

+ ) : ( +
+ {userDevices.map(d => ( + + ))} +
+ )} +
+ )} + + {addStep === 3 && selectedUser && selectedDevice && ( +
+
+
+

User

+

{selectedUser.user?.full_name || "Unnamed User"}

+

{selectedUser.user?.email || selectedUser.user_id}

+
+
+

Device

+

{selectedDevice.device_nickname || selectedDevice.hostname || "Unnamed Device"}

+

{selectedDevice.node_id}

+
+
+

Network

+

{network?.name}

+
+
+
+ )} + + + {addStep === 1 && ( + + )} + {addStep === 2 && ( + + )} + {addStep === 3 && ( + <> + + + + )} + +
+
+
+ ); +} diff --git a/src/pages/org/NetworksPage.tsx b/src/pages/org/NetworksPage.tsx index 5c92e7d..a333242 100644 --- a/src/pages/org/NetworksPage.tsx +++ b/src/pages/org/NetworksPage.tsx @@ -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([]); const [isLoading, setIsLoading] = useState(true); @@ -136,11 +131,6 @@ export default function NetworksPage() { const [isCreating, setIsCreating] = useState(false); const [createError, setCreateError] = useState(null); - const [selectedNetwork, setSelectedNetwork] = useState(null); - const [networkMembers, setNetworkMembers] = useState([]); - const [networkRequests, setNetworkRequests] = useState([]); - const [isDrawerLoading, setIsDrawerLoading] = useState(false); - const [editingNetwork, setEditingNetwork] = useState(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 && {networks.length}} - Click a network to view members, requests, and manage access + Click a network to manage members, devices, and access requests {isLoading ? ( @@ -375,7 +340,7 @@ export default function NetworksPage() { - { e.stopPropagation(); openNetworkDrawer(network); }}> + { e.stopPropagation(); navigate(`/org/zerotier/networks/${network.id}`); }}> View details { e.stopPropagation(); openEditDialog(network); }}> @@ -696,118 +661,6 @@ export default function NetworksPage() { - - {/* Network Detail Drawer */} - { if (!open) closeDrawer(); }}> - - {selectedNetwork && ( - <> - - -
- -
- {selectedNetwork.name} -
- {selectedNetwork.zerotier_network_id} -
- -
-
- - -
- {selectedNetwork.description && ( -

{selectedNetwork.description}

- )} -
-
- Default activation -

{selectedNetwork.default_activation_lifetime_minutes} min

-
-
- Max activation -

{selectedNetwork.max_activation_lifetime_minutes ? `${selectedNetwork.max_activation_lifetime_minutes} min` : "No limit"}

-
-
- Approved users -

{selectedNetwork.approved_user_count ?? 0}

-
-
- Active devices -

{selectedNetwork.active_membership_count ?? 0}

-
-
-
- - {isDrawerLoading ? ( -
- -
- ) : ( - - - - Members ({networkMembers.length}) - - - Requests ({networkRequests.length}) - - - - - {networkMembers.length === 0 ? ( -
No members yet.
- ) : ( -
- {networkMembers.map((m) => ( -
- -
-

{m.device_id}

-

- State: {m.state} · Join seen: {m.join_seen ? "Yes" : "No"} -

-
-
- {m.currently_authorized ? ( - <>Authorized - ) : ( - <>Inactive - )} -
-
- ))} -
- )} -
- - - {networkRequests.length === 0 ? ( -
No pending requests.
- ) : ( -
- {networkRequests.map((r) => ( -
- -
-

{r.user_id}

-

- {r.grant_type} · {r.state} -

- {r.justification &&

"{r.justification}"

} -
-
- ))} -
- )} -
-
- )} - - )} -
-
); } diff --git a/tests/NetworkManagementPage.test.tsx b/tests/NetworkManagementPage.test.tsx new file mode 100644 index 0000000..ae6acdd --- /dev/null +++ b/tests/NetworkManagementPage.test.tsx @@ -0,0 +1,2071 @@ +// @vitest-environment jsdom +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react"; +import React from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; + +// ── Shared mock state (vi.hoisted avoids TDZ with vi.mock hoisting) ──────────── + +const H = vi.hoisted(() => ({ + mockNavigate: vi.fn(), + mockGetNetwork: vi.fn(), + mockGetNetworkMembers: vi.fn(), + mockActivateMembership: vi.fn(), + mockDeactivateMembership: vi.fn(), + mockGetNetworkPendingRequests: vi.fn(), + mockApproveRequest: vi.fn(), + mockRejectRequest: vi.fn(), + mockToast: vi.fn(), + state: { + orgId: "org-1" as string | null, + networkId: "net-abc" as string | undefined, + }, +})); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => H.mockNavigate, + useParams: () => ({ networkId: H.state.networkId }), + }; +}); + +vi.mock("@/hooks/useCurrentOrganization", () => ({ + useCurrentOrganizationId: () => ({ + orgId: H.state.orgId, + isLoading: false, + }), +})); + +vi.mock("@/lib/api", () => ({ + api: { + zerotier: { + getNetwork: H.mockGetNetwork, + getNetworkMembers: H.mockGetNetworkMembers, + activateMembership: H.mockActivateMembership, + deactivateMembership: H.mockDeactivateMembership, + getNetworkPendingRequests: H.mockGetNetworkPendingRequests, + approveRequest: H.mockApproveRequest, + rejectRequest: H.mockRejectRequest, + }, + }, + ApiError: class ApiError extends Error { + code: number; + type: string; + details: Record; + constructor(message: string, code: number, type: string, details: Record = {}) { + super(message); + this.name = "ApiError"; + this.code = code; + this.type = type; + this.details = details; + } + }, +})); + +vi.mock("@/hooks/use-toast", () => ({ + useToast: () => ({ + toast: H.mockToast, + dismiss: () => {}, + toasts: [], + }), +})); + +import NetworkManagementPage from "../src/pages/org/NetworkManagementPage"; +import { ApiError } from "@/lib/api"; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const DEV_NETWORK = { + id: "net-abc", + organization_id: "org-1", + name: "Dev Network", + description: "Internal dev", + owner_user_id: "user-1", + zerotier_network_id: "zt-xxx", + environment: "development" as const, + request_mode: "open" as const, + default_activation_lifetime_minutes: 1440, + max_activation_lifetime_minutes: null as number | null, + is_active: true, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + deleted_at: null as string | null, + approved_user_count: 42, + active_membership_count: 7, +}; + +function renderWithRoute(networkId = "net-abc") { + return render( + React.createElement( + MemoryRouter, + { initialEntries: [`/org/zerotier/networks/${networkId}`] }, + React.createElement(Routes, null, + React.createElement(Route, { + path: "/org/zerotier/networks/:networkId", + element: React.createElement(NetworkManagementPage), + }) + ) + ) + ); +} + +// ── Setup / Teardown ─────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + H.state.orgId = "org-1"; + H.state.networkId = "net-abc"; + // Default: getNetwork returns a pending promise (loading state) + H.mockGetNetwork.mockImplementation(() => new Promise(() => {})); + // Default: getNetworkPendingRequests returns a pending promise (loading state) + H.mockGetNetworkPendingRequests.mockImplementation(() => new Promise(() => {})); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("NetworkManagementPage", () => { + // ── HAPPY PATH: Loading → Success ──────────────────────────────────────────── + + test("renders skeleton placeholders during loading state", () => { + renderWithRoute(); + + const container = document.querySelector(".page-container"); + expect(container).not.toBeNull(); + + const skeletons = document.querySelectorAll(".animate-pulse"); + expect(skeletons.length).toBeGreaterThanOrEqual(3); + }); + + test("renders success state with network name and tabs when API resolves", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + expect(screen.getByText("Back to Networks")).toBeDefined(); + expect(screen.getByRole("tab", { name: "Overview" })).toBeDefined(); + expect(screen.getByRole("tab", { name: "Members" })).toBeDefined(); + expect(screen.getByRole("tab", { name: "Requests" })).toBeDefined(); + expect( + screen.getByText("Manage network members, devices, and access requests") + ).toBeDefined(); + }); + + test("renders fallback 'Network' when name is null", async () => { + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, name: null as unknown as string }, + }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Network")).toBeDefined(); + }); + }); + + // ── ERROR PATH ─────────────────────────────────────────────────────────────── + + test("renders generic error when API throws a plain Error", async () => { + H.mockGetNetwork.mockRejectedValue(new Error("Network unreachable")); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Failed to load network details.")).toBeDefined(); + }); + + expect(screen.getByText("Back to Networks")).toBeDefined(); + }); + + test("renders specific error message when API throws ApiError", async () => { + const apiError = new ApiError("Network not found in the controller", 404, "not_found"); + + H.mockGetNetwork.mockRejectedValue(apiError); + + renderWithRoute(); + + await waitFor(() => { + expect( + screen.getByText("Network not found in the controller") + ).toBeDefined(); + }); + }); + + test("renders error when orgId is missing", async () => { + H.state.orgId = null; + + renderWithRoute(); + + await waitFor(() => { + expect( + screen.getByText("Organization or network ID is missing.") + ).toBeDefined(); + }); + + expect(H.mockGetNetwork).not.toHaveBeenCalled(); + }); + + test("renders error when networkId is missing from URL", async () => { + H.state.networkId = undefined; + + render( + React.createElement( + MemoryRouter, + { initialEntries: ["/org/zerotier/networks/"] }, + React.createElement(Routes, null, + React.createElement(Route, { + path: "/org/zerotier/networks/", + element: React.createElement(NetworkManagementPage), + }) + ) + ) + ); + + await waitFor(() => { + expect( + screen.getByText("Organization or network ID is missing.") + ).toBeDefined(); + }); + + expect(H.mockGetNetwork).not.toHaveBeenCalled(); + }); + + // ── NAVIGATION ─────────────────────────────────────────────────────────────── + + test("Back to Networks in success state navigates to /org/zerotier/networks", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + fireEvent.click(screen.getByText("Back to Networks")); + + expect(H.mockNavigate).toHaveBeenCalledTimes(1); + expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks"); + }); + + test("Back to Networks in error state navigates to /org/zerotier/networks", async () => { + H.mockGetNetwork.mockRejectedValue(new Error("fail")); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Failed to load network details.")).toBeDefined(); + }); + + fireEvent.click(screen.getByText("Back to Networks")); + + expect(H.mockNavigate).toHaveBeenCalledTimes(1); + expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks"); + }); + + // ── BOUNDARY / STATE ───────────────────────────────────────────────────────── + + test("stops showing loading skeletons after API resolves", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + + renderWithRoute(); + + expect(document.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + expect(document.querySelectorAll(".animate-pulse").length).toBe(0); + }); + + test("stops showing loading skeletons after API rejects", async () => { + H.mockGetNetwork.mockRejectedValue(new Error("fail")); + + renderWithRoute(); + + expect(document.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + + await waitFor(() => { + expect(screen.getByText("Failed to load network details.")).toBeDefined(); + }); + + expect(document.querySelectorAll(".animate-pulse").length).toBe(0); + }); + + test("passes correct orgId and networkId to getNetwork", async () => { + H.state.orgId = "org-2"; + H.state.networkId = "net-specific"; + + H.mockGetNetwork.mockResolvedValue({ + network: { + ...DEV_NETWORK, + id: "net-specific", + organization_id: "org-2", + name: "Specific Network", + }, + }); + + renderWithRoute("net-specific"); + + await waitFor(() => { + expect(screen.getByText("Specific Network")).toBeDefined(); + }); + + expect(H.mockGetNetwork).toHaveBeenCalledTimes(1); + expect(H.mockGetNetwork).toHaveBeenCalledWith("org-2", "net-specific"); + }); + + test("renders exactly 3 tabs", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const tabs = screen.getAllByRole("tab"); + expect(tabs.length).toBe(3); + }); + + test("Overview tab is selected by default", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const overviewTab = screen.getByRole("tab", { name: "Overview" }); + expect(overviewTab.getAttribute("data-state")).toBe("active"); + }); + + // ── ADVERSARIAL ────────────────────────────────────────────────────────────── + + test("handles very long network name (500 chars)", async () => { + const longName = "A".repeat(500); + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, name: longName }, + }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText(longName)).toBeDefined(); + }); + + expect(screen.getByText("Back to Networks")).toBeDefined(); + expect(screen.getByRole("tab", { name: "Overview" })).toBeDefined(); + }); + + test("handles network name with script tags (XSS-safe rendering)", async () => { + const specialName = "Dev/Network & Co."; + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, name: specialName }, + }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText(specialName)).toBeDefined(); + }); + + expect(screen.getByText(specialName).textContent).toBe(specialName); + }); + + // ── OVERVIEW TAB CONTENT ────────────────────────────────────────────────────── + + describe("Overview tab content", () => { + beforeEach(() => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + }); + + test("renders environment badge with correct color classes", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Development")).toBeDefined(); + }); + const badge = screen.getByText("Development"); + expect(badge.className).toContain("bg-green-500/10"); + expect(badge.className).toContain("text-green-600"); + }); + + test("renders request mode badge for 'open' mode", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Open")).toBeDefined(); + }); + const badge = screen.getByText("Open"); + expect(badge.className).toContain("text-green-600"); + }); + + test("renders request mode badge for 'approval_required' mode", async () => { + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, request_mode: "approval_required" }, + }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Approval Required")).toBeDefined(); + }); + }); + + test("renders request mode badge for 'invite_only' mode", async () => { + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, request_mode: "invite_only" }, + }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Invite Only")).toBeDefined(); + }); + }); + + test("renders inactive badge when is_active is false", async () => { + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, is_active: false }, + }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Inactive")).toBeDefined(); + }); + }); + + test("does NOT render inactive badge when is_active is true", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Development")).toBeDefined(); + }); + expect(screen.queryByText("Inactive")).toBeNull(); + }); + + test("renders description when present", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Internal dev")).toBeDefined(); + }); + }); + + test("does NOT render description section when null", async () => { + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, description: null }, + }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Development")).toBeDefined(); + }); + expect(screen.queryByText("Internal dev")).toBeNull(); + }); + + test("renders ZeroTier Network ID", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("zt-xxx")).toBeDefined(); + }); + }); + + test("renders default activation lifetime", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("1440 min")).toBeDefined(); + }); + }); + + test("renders 'No limit' for null max activation lifetime", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("No limit")).toBeDefined(); + }); + }); + + test("renders numeric max activation lifetime when set", async () => { + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, max_activation_lifetime_minutes: 2880 }, + }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("2880 min")).toBeDefined(); + }); + }); + + test("renders approved user count stat card", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Approved Users")).toBeDefined(); + }); + expect(screen.getByText("42")).toBeDefined(); + }); + + test("renders active device count stat card", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Active Devices")).toBeDefined(); + }); + expect(screen.getByText("7")).toBeDefined(); + }); + + test("renders approved user count as 0 when undefined", async () => { + const { approved_user_count, ...withoutApproved } = DEV_NETWORK as any; + H.mockGetNetwork.mockResolvedValue({ + network: withoutApproved, + }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("0")).toBeDefined(); + }); + }); + + test("renders active device count as 0 when undefined", async () => { + const { active_membership_count, ...withoutActive } = DEV_NETWORK as any; + H.mockGetNetwork.mockResolvedValue({ + network: withoutActive, + }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Active Devices")).toBeDefined(); + }); + }); + + test("renders '—' for null created_at date", async () => { + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, created_at: null as unknown as string }, + }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("—")).toBeDefined(); + }); + }); + + test("renders created_at as formatted date", async () => { + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, created_at: "2025-06-15T10:30:00Z" }, + }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Development")).toBeDefined(); + }); + // Just verify it's not "—" (exact date format depends on locale) + const createdLabel = screen.getByText("Created"); + expect(createdLabel).toBeDefined(); + // The value is rendered in a sibling element; verify it exists and is not em-dash + const createdSection = createdLabel.closest("div"); + expect(createdSection).not.toBeNull(); + const valueElement = createdSection!.querySelector(".font-medium"); + expect(valueElement).not.toBeNull(); + expect(valueElement!.textContent).not.toBe("—"); + }); + + test("renders request mode stat card with human-readable mode", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Request Mode")).toBeDefined(); + }); + // "open" → "open" → CSS capitalize → "Open" + expect(screen.getByText("open")).toBeDefined(); + }); + + test("renders all overview metadata labels", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Development")).toBeDefined(); + }); + expect(screen.getByText("ZeroTier Network ID")).toBeDefined(); + expect(screen.getByText("Default Activation")).toBeDefined(); + expect(screen.getByText("Max Activation")).toBeDefined(); + expect(screen.getByText("Created")).toBeDefined(); + }); + + test("renders Network Details card title", async () => { + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Network Details")).toBeDefined(); + }); + }); + }); + + // ── OVERVIEW TAB ENVIRONMENT BADGE VARIANTS ─────────────────────────────────── + + describe("Environment badge variants", () => { + test.each([ + ["production", "Production", "bg-red-500/10", "text-red-600"], + ["staging", "Staging", "bg-yellow-500/10", "text-yellow-600"], + ["development", "Development", "bg-green-500/10", "text-green-600"], + ["lab", "Lab", "bg-blue-500/10", "text-blue-600"], + ])("renders %s environment with label '%s' and correct colors", + async (env, label, expectedBg, expectedText) => { + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, environment: env as any }, + }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText(label)).toBeDefined(); + }); + const badge = screen.getByText(label); + expect(badge.className).toContain(expectedBg); + expect(badge.className).toContain(expectedText); + }); + }); + + // ── OVERVIEW TAB NULL/UNDEFINED HANDLING ────────────────────────────────────── + + describe("Overview tab null edge cases", () => { + test("shows '—' for null created_at", async () => { + H.mockGetNetwork.mockResolvedValue({ + network: { ...DEV_NETWORK, created_at: null as unknown as string }, + }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("—")).toBeDefined(); + }); + }); + + test("shows '—' for undefined created_at", async () => { + const { created_at, ...rest } = DEV_NETWORK; + H.mockGetNetwork.mockResolvedValue({ network: rest as any }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("—")).toBeDefined(); + }); + }); + + test("shows 0 for undefined approved_user_count", async () => { + const { approved_user_count, ...rest } = DEV_NETWORK; + H.mockGetNetwork.mockResolvedValue({ network: rest as any }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Approved Users")).toBeDefined(); + }); + // The count displays as 0 via nullish coalescing: approved_user_count ?? 0 + }); + + test("shows 0 for undefined active_membership_count", async () => { + const { active_membership_count, ...rest } = DEV_NETWORK; + H.mockGetNetwork.mockResolvedValue({ network: rest as any }); + renderWithRoute(); + await waitFor(() => { + expect(screen.getByText("Active Devices")).toBeDefined(); + }); + }); + }); + + // ── MEMBERS TAB ────────────────────────────────────────────────────────────── + + describe("Members tab", () => { + // ── Fixtures ──────────────────────────────────────────────────────────────── + + const MEMBERSHIP_AUTHORIZED_WITH_SESSION = { + id: "mem-auth-1", + organization_id: "org-1", + user_id: "user-a", + device_id: "dev-laptop-1", + portal_network_id: "net-abc", + user_network_approval_id: null, + state: "active_authorized", + join_seen: true, + currently_authorized: true, + approved_for_activation: true, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + deleted_at: null, + active_session: { + id: "session-1", + organization_id: "org-1", + user_id: "user-a", + device_network_membership_id: "mem-auth-1", + authenticated_at: "2025-01-01T00:00:00Z", + expires_at: "2025-12-31T23:59:59Z", + ended_at: null, + end_reason: null, + created_by: "user-a", + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + deleted_at: null, + is_expired: false, + is_active: true, + }, + }; + + const MEMBERSHIP_UNAUTHORIZED_NO_SESSION = { + id: "mem-unauth-1", + organization_id: "org-1", + user_id: "user-a", + device_id: "dev-desktop-1", + portal_network_id: "net-abc", + user_network_approval_id: null, + state: "joined_deauthorized", + join_seen: false, + currently_authorized: false, + approved_for_activation: false, + created_at: "2025-02-01T00:00:00Z", + updated_at: "2025-02-01T00:00:00Z", + deleted_at: null, + active_session: null, + }; + + const MEMBERSHIP_SECOND_USER = { + id: "mem-auth-2", + organization_id: "org-1", + user_id: "user-b", + device_id: "dev-phone-1", + portal_network_id: "net-abc", + user_network_approval_id: null, + state: "approved_inactive", + join_seen: true, + currently_authorized: false, + approved_for_activation: true, + created_at: "2025-03-01T00:00:00Z", + updated_at: "2025-03-01T00:00:00Z", + deleted_at: null, + active_session: null, + }; + + const MEMBERSHIP_PENDING_REQUEST = { + id: "mem-pending-1", + organization_id: "org-1", + user_id: "user-c", + device_id: "dev-server-1", + portal_network_id: "net-abc", + user_network_approval_id: null, + state: "pending_request", + join_seen: false, + currently_authorized: false, + approved_for_activation: false, + created_at: "2025-04-01T00:00:00Z", + updated_at: "2025-04-01T00:00:00Z", + deleted_at: null, + active_session: null, + }; + + // Helper: setup resolved network, then click Members tab + async function setupMembersTab(memberships: Array> = [MEMBERSHIP_AUTHORIZED_WITH_SESSION]) { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkMembers.mockResolvedValue({ memberships, count: memberships.length }); + + renderWithRoute(); + + // Wait for the page to render (network resolves first) + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + // Activate Members tab via keyboard (Radix uses focus + Enter) + const membersTab = screen.getByRole("tab", { name: "Members" }); + membersTab.focus(); + fireEvent.keyDown(membersTab, { key: "Enter" }); + + // Wait for members content + await waitFor(() => { + expect(screen.getByText("Network Members")).toBeDefined(); + }); + } + + // ── Loading / Error / Empty ───────────────────────────────────────────────── + + test("renders loading spinner in Members tab while fetching members", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + // getNetworkMembers stays pending (never resolves) + H.mockGetNetworkMembers.mockImplementation(() => new Promise(() => {})); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const membersTab = screen.getByRole("tab", { name: "Members" }); + membersTab.focus(); + fireEvent.keyDown(membersTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("Network Members")).toBeDefined(); + }); + + expect(screen.getByText("Loading members…")).toBeDefined(); + }); + + test("renders error message when members API fails", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkMembers.mockRejectedValue(new Error("API unavailable")); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const membersTab = screen.getByRole("tab", { name: "Members" }); + membersTab.focus(); + fireEvent.keyDown(membersTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("Failed to load members.")).toBeDefined(); + }); + }); + + test("renders ApiError message when members API throws ApiError", async () => { + const apiError = new ApiError("Membership lookup failed", 500, "internal_error"); + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkMembers.mockRejectedValue(apiError); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const membersTab = screen.getByRole("tab", { name: "Members" }); + membersTab.focus(); + fireEvent.keyDown(membersTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("Membership lookup failed")).toBeDefined(); + }); + }); + + test("renders 'No members' message when memberships array is empty", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkMembers.mockResolvedValue({ memberships: [], count: 0 }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const membersTab = screen.getByRole("tab", { name: "Members" }); + membersTab.focus(); + fireEvent.keyDown(membersTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("No members on this network yet.")).toBeDefined(); + }); + }); + + test("renders 'No members' when memberships field is undefined", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkMembers.mockResolvedValue({ count: 0 }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const membersTab = screen.getByRole("tab", { name: "Members" }); + membersTab.focus(); + fireEvent.keyDown(membersTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("No members on this network yet.")).toBeDefined(); + }); + }); + + // ── Grouping by user_id ───────────────────────────────────────────────────── + + test("groups memberships by user_id and renders user sections", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + // Both memberships belong to user-a → one user section + expect(screen.getByText("user-a")).toBeDefined(); + // Should show "2 devices" + expect(screen.getByText(/2 devices/)).toBeDefined(); + }); + + test("renders multiple user sections for different user_ids", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_SECOND_USER]); + + expect(screen.getByText("user-a")).toBeDefined(); + expect(screen.getByText("user-b")).toBeDefined(); + }); + + test("displays user_id in monospace font (font-mono class)", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); + + const userIdElement = screen.getByText("user-a"); + expect(userIdElement.className).toContain("font-mono"); + }); + + // ── Expand / Collapse ─────────────────────────────────────────────────────── + + test("clicking user section expands to show devices", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + // Devices should NOT be visible before expanding + expect(screen.queryByText("dev-laptop-1")).toBeNull(); + expect(screen.queryByText("dev-desktop-1")).toBeNull(); + + // Click the user section to expand + const userButton = screen.getByText("user-a").closest("button"); + expect(userButton).not.toBeNull(); + fireEvent.click(userButton!); + + // Now devices should be visible + await waitFor(() => { + expect(screen.getByText("dev-laptop-1")).toBeDefined(); + }); + expect(screen.getByText("dev-desktop-1")).toBeDefined(); + }); + + test("clicking expanded user section collapses devices", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + expect(userButton).not.toBeNull(); + fireEvent.click(userButton!); + + // Devices should be visible + await waitFor(() => { + expect(screen.getByText("dev-laptop-1")).toBeDefined(); + }); + + // Click again to collapse + fireEvent.click(userButton!); + + // Devices should disappear + await waitFor(() => { + expect(screen.queryByText("dev-laptop-1")).toBeNull(); + }); + }); + + test("expand/collapse is independent per user section", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_SECOND_USER]); + + const userAButton = screen.getByText("user-a").closest("button")!; + const userBButton = screen.getByText("user-b").closest("button")!; + + // Expand user-a only + fireEvent.click(userAButton); + await waitFor(() => { + expect(screen.getByText("dev-laptop-1")).toBeDefined(); + }); + // user-b's device should still be hidden + expect(screen.queryByText("dev-phone-1")).toBeNull(); + + // Expand user-b too + fireEvent.click(userBButton); + await waitFor(() => { + expect(screen.getByText("dev-phone-1")).toBeDefined(); + }); + + // Collapse user-a — user-b should remain expanded + fireEvent.click(userAButton); + await waitFor(() => { + expect(screen.queryByText("dev-laptop-1")).toBeNull(); + }); + expect(screen.getByText("dev-phone-1")).toBeDefined(); + }); + + // ── Device Details ────────────────────────────────────────────────────────── + + test("renders device state badge for active_authorized membership", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByText("Active")).toBeDefined(); + }); + // active_authorized → green badge classes + const badge = screen.getByText("Active"); + expect(badge.className).toContain("bg-green-100"); + expect(badge.className).toContain("text-green-700"); + }); + + test("renders device state badge for joined_deauthorized membership", async () => { + await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByText("Deauthorized")).toBeDefined(); + }); + const badge = screen.getByText("Deauthorized"); + expect(badge.className).toContain("bg-red-100"); + expect(badge.className).toContain("text-red-700"); + }); + + test("renders device state badge for pending_request membership", async () => { + await setupMembersTab([MEMBERSHIP_PENDING_REQUEST]); + + const userButton = screen.getByText("user-c").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByText("Pending Request")).toBeDefined(); + }); + const badge = screen.getByText("Pending Request"); + expect(badge.className).toContain("bg-yellow-100"); + expect(badge.className).toContain("text-yellow-700"); + }); + + test("renders device state badge for approved_inactive membership", async () => { + await setupMembersTab([MEMBERSHIP_SECOND_USER]); + + const userButton = screen.getByText("user-b").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByText("Approved")).toBeDefined(); + }); + const badge = screen.getByText("Approved"); + expect(badge.className).toContain("bg-blue-100"); + expect(badge.className).toContain("text-blue-700"); + }); + + // ── Authorization Status ──────────────────────────────────────────────────── + + test("shows 'Authorized' with green check icon when currently_authorized is true", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByText("Authorized")).toBeDefined(); + }); + }); + + test("shows 'Unauthorized' with X icon when currently_authorized is false", async () => { + await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByText("Unauthorized")).toBeDefined(); + }); + }); + + // ── Active Session Info ───────────────────────────────────────────────────── + + test("shows session info when active_session is present and is_active", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByText(/Session active/)).toBeDefined(); + }); + }); + + test("does NOT show session info when active_session is null", async () => { + await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByText("Deauthorized")).toBeDefined(); + }); + + expect(screen.queryByText(/Session active/)).toBeNull(); + }); + + // ── Join Seen ─────────────────────────────────────────────────────────────── + + test("shows join_seen as 'Yes' when true", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByText("Joined: Yes")).toBeDefined(); + }); + }); + + test("shows join_seen as 'No' when false", async () => { + await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByText("Joined: No")).toBeDefined(); + }); + }); + + // ── Activate / Deactivate Buttons ─────────────────────────────────────────── + + test("renders Deactivate button for currently_authorized memberships", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Deactivate" })).toBeDefined(); + }); + expect(screen.queryByRole("button", { name: "Activate" })).toBeNull(); + }); + + test("renders Activate button for non-currently_authorized memberships", async () => { + await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Activate" })).toBeDefined(); + }); + expect(screen.queryByRole("button", { name: "Deactivate" })).toBeNull(); + }); + + test("clicking Activate calls api.zerotier.activateMembership with correct orgId and membershipId", async () => { + H.mockActivateMembership.mockResolvedValue({}); + // Second call to getNetworkMembers (refresh after activate) + H.mockGetNetworkMembers + .mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 }) + .mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 }); + + await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + const activateBtn = await screen.findByRole("button", { name: "Activate" }); + fireEvent.click(activateBtn); + + expect(H.mockActivateMembership).toHaveBeenCalledTimes(1); + expect(H.mockActivateMembership).toHaveBeenCalledWith("org-1", "mem-unauth-1"); + }); + + test("clicking Deactivate calls api.zerotier.deactivateMembership with correct orgId and membershipId", async () => { + H.mockDeactivateMembership.mockResolvedValue({}); + // Second call to getNetworkMembers (refresh after deactivate) + H.mockGetNetworkMembers + .mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 }) + .mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 }); + + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" }); + fireEvent.click(deactivateBtn); + + expect(H.mockDeactivateMembership).toHaveBeenCalledTimes(1); + expect(H.mockDeactivateMembership).toHaveBeenCalledWith("org-1", "mem-auth-1"); + }); + + test("shows success toast after successful activation", async () => { + H.mockActivateMembership.mockResolvedValue({}); + H.mockGetNetworkMembers + .mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 }) + .mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 }); + + await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + const activateBtn = await screen.findByRole("button", { name: "Activate" }); + fireEvent.click(activateBtn); + + await waitFor(() => { + expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Membership activated" })); + }); + }); + + test("shows success toast after successful deactivation", async () => { + H.mockDeactivateMembership.mockResolvedValue({}); + H.mockGetNetworkMembers + .mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 }) + .mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 }); + + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" }); + fireEvent.click(deactivateBtn); + + await waitFor(() => { + expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Membership deactivated" })); + }); + }); + + test("shows error toast when activation fails", async () => { + H.mockActivateMembership.mockRejectedValue(new Error("Activation failed")); + H.mockGetNetworkMembers.mockResolvedValue({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 }); + + await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + const activateBtn = await screen.findByRole("button", { name: "Activate" }); + fireEvent.click(activateBtn); + + await waitFor(() => { + expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ + variant: "destructive", + title: "Failed to activate", + })); + }); + }); + + test("shows error toast when deactivation fails", async () => { + H.mockDeactivateMembership.mockRejectedValue(new Error("Deactivation failed")); + H.mockGetNetworkMembers.mockResolvedValue({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 }); + + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" }); + fireEvent.click(deactivateBtn); + + await waitFor(() => { + expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ + variant: "destructive", + title: "Failed to deactivate", + })); + }); + }); + + test("shows ApiError description in error toast when activation fails with ApiError", async () => { + const apiError = new ApiError("Member not found", 404, "not_found"); + H.mockActivateMembership.mockRejectedValue(apiError); + H.mockGetNetworkMembers.mockResolvedValue({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 }); + + await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + const activateBtn = await screen.findByRole("button", { name: "Activate" }); + fireEvent.click(activateBtn); + + await waitFor(() => { + expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ + variant: "destructive", + title: "Failed to activate", + description: "Member not found", + })); + }); + }); + + test("refresh after activate fetches updated members", async () => { + H.mockActivateMembership.mockResolvedValue({}); + const updatedMembership = { + ...MEMBERSHIP_UNAUTHORIZED_NO_SESSION, + state: "active_authorized", + currently_authorized: true, + }; + H.mockGetNetworkMembers + .mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 }) + .mockResolvedValueOnce({ memberships: [updatedMembership], count: 1 }); + + await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + const activateBtn = await screen.findByRole("button", { name: "Activate" }); + fireEvent.click(activateBtn); + + // Verify getNetworkMembers was called twice: once on mount, once on refresh + await waitFor(() => { + expect(H.mockGetNetworkMembers).toHaveBeenCalledTimes(2); + }); + expect(H.mockGetNetworkMembers).toHaveBeenNthCalledWith(1, "org-1", "net-abc"); + expect(H.mockGetNetworkMembers).toHaveBeenNthCalledWith(2, "org-1", "net-abc"); + }); + + test("refresh after deactivate fetches updated members", async () => { + H.mockDeactivateMembership.mockResolvedValue({}); + const updatedMembership = { + ...MEMBERSHIP_AUTHORIZED_WITH_SESSION, + state: "joined_deauthorized", + currently_authorized: false, + }; + H.mockGetNetworkMembers + .mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 }) + .mockResolvedValueOnce({ memberships: [updatedMembership], count: 1 }); + + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); + + const userButton = screen.getByText("user-a").closest("button"); + fireEvent.click(userButton!); + + const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" }); + fireEvent.click(deactivateBtn); + + await waitFor(() => { + expect(H.mockGetNetworkMembers).toHaveBeenCalledTimes(2); + }); + }); + + // ── Members badge count ───────────────────────────────────────────────────── + + test("shows membership count in the Members tab badge", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION, MEMBERSHIP_SECOND_USER]); + + expect(screen.getByText("3 memberships")).toBeDefined(); + }); + + test("shows '0 memberships' when empty", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkMembers.mockResolvedValue({ memberships: [], count: 0 }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const membersTab = screen.getByRole("tab", { name: "Members" }); + membersTab.focus(); + fireEvent.keyDown(membersTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("0 memberships")).toBeDefined(); + }); + }); + + // ── Active device count in user section ───────────────────────────────────── + + test("shows active device count in user section header", async () => { + await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + // 1 active (currently_authorized) + 1 inactive → "1 active" + expect(screen.getByText(/1 active/)).toBeDefined(); + }); + + test("does not show active count when no devices are authorized", async () => { + await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); + + // 0 active devices → no "active" text + expect(screen.queryByText(/active/)).toBeNull(); + }); + + // ── Adversarial: Unicode user_ids ─────────────────────────────────────────── + + test("renders users with Unicode user_ids", async () => { + const unicodeMembership = { + ...MEMBERSHIP_AUTHORIZED_WITH_SESSION, + id: "mem-uni-1", + user_id: "user-äéîøü-中文", + device_id: "dev-utf8", + }; + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkMembers.mockResolvedValue({ memberships: [unicodeMembership], count: 1 }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const membersTab = screen.getByRole("tab", { name: "Members" }); + membersTab.focus(); + fireEvent.keyDown(membersTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("user-äéîøü-中文")).toBeDefined(); + }); + }); + + // ── Adversarial: device_id with special characters ────────────────────────── + + test("renders device_id with HTML-like content safely", async () => { + const xssMembership = { + ...MEMBERSHIP_AUTHORIZED_WITH_SESSION, + id: "mem-xss-1", + user_id: "user-xss", + device_id: "", + }; + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkMembers.mockResolvedValue({ memberships: [xssMembership], count: 1 }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const membersTab = screen.getByRole("tab", { name: "Members" }); + membersTab.focus(); + fireEvent.keyDown(membersTab, { key: "Enter" }); + + // User section should be visible + const userButton = screen.getByText("user-xss").closest("button"); + fireEvent.click(userButton!); + + await waitFor(() => { + // The device_id should be rendered as text, not executed as HTML + expect(screen.getByText("")).toBeDefined(); + }); + }); + + // ── Adversarial: large membership list ────────────────────────────────────── + + test("renders many user sections without crashing", async () => { + const manyMemberships = Array.from({ length: 50 }, (_, i) => ({ + ...MEMBERSHIP_AUTHORIZED_WITH_SESSION, + id: `mem-many-${i}`, + user_id: `user-${i}`, + device_id: `dev-${i}`, + active_session: null, + })); + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkMembers.mockResolvedValue({ memberships: manyMemberships, count: 50 }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const membersTab = screen.getByRole("tab", { name: "Members" }); + membersTab.focus(); + fireEvent.keyDown(membersTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("50 memberships")).toBeDefined(); + }); + + // Should find at least the first and last user + expect(screen.getByText("user-0")).toBeDefined(); + expect(screen.getByText("user-49")).toBeDefined(); + }); + }); + + // ── REQUESTS TAB ───────────────────────────────────────────────────────────── + + describe("Requests tab", () => { + // ── Fixtures ──────────────────────────────────────────────────────────────── + + const PENDING_REQUEST = { + id: "req-pending-1", + organization_id: "org-1", + user_id: "user-requestor", + portal_network_id: "net-abc", + granted_by_user_id: null, + grant_type: "requested" as const, + state: "pending" as const, + justification: "Need access for development work.", + created_at: "2025-06-01T10:00:00Z", + updated_at: "2025-06-01T10:00:00Z", + deleted_at: null, + }; + + const APPROVED_REQUEST = { + id: "req-approved-1", + organization_id: "org-1", + user_id: "user-approved", + portal_network_id: "net-abc", + granted_by_user_id: "user-admin", + grant_type: "assigned" as const, + state: "approved" as const, + justification: null, + created_at: "2025-05-15T08:00:00Z", + updated_at: "2025-05-15T09:00:00Z", + deleted_at: null, + }; + + const REJECTED_REQUEST = { + id: "req-rejected-1", + organization_id: "org-1", + user_id: "user-rejected", + portal_network_id: "net-abc", + granted_by_user_id: null, + grant_type: "requested" as const, + state: "rejected" as const, + justification: "This user should not have access.", + created_at: "2025-04-20T12:00:00Z", + updated_at: "2025-04-21T12:00:00Z", + deleted_at: null, + }; + + const REVOKED_REQUEST = { + id: "req-revoked-1", + organization_id: "org-1", + user_id: "user-revoked", + portal_network_id: "net-abc", + granted_by_user_id: "user-admin", + grant_type: "assigned" as const, + state: "revoked" as const, + justification: null, + created_at: "2025-03-01T00:00:00Z", + updated_at: "2025-03-15T00:00:00Z", + deleted_at: null, + }; + + const SUSPENDED_REQUEST = { + id: "req-suspended-1", + organization_id: "org-1", + user_id: "user-suspended", + portal_network_id: "net-abc", + granted_by_user_id: "user-admin", + grant_type: "assigned" as const, + state: "suspended" as const, + justification: "Policy violation review.", + created_at: "2025-02-10T00:00:00Z", + updated_at: "2025-02-12T00:00:00Z", + deleted_at: null, + }; + + // Helper: setup resolved network, then click Requests tab + async function setupRequestsTab(requestsList: Array> = [PENDING_REQUEST]) { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: requestsList, count: requestsList.length }); + + renderWithRoute(); + + // Wait for the page to render (network resolves first) + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + // Activate Requests tab via keyboard (Radix uses focus + Enter) + const requestsTab = screen.getByRole("tab", { name: "Requests" }); + requestsTab.focus(); + fireEvent.keyDown(requestsTab, { key: "Enter" }); + + // Wait for requests content + await waitFor(() => { + expect(screen.getByText("Access Requests")).toBeDefined(); + }); + } + + // ── Loading / Error / Empty ───────────────────────────────────────────────── + + test("renders loading spinner in Requests tab while fetching requests", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + // getNetworkPendingRequests stays pending (never resolves) + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const requestsTab = screen.getByRole("tab", { name: "Requests" }); + requestsTab.focus(); + fireEvent.keyDown(requestsTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("Access Requests")).toBeDefined(); + }); + + expect(screen.getByText("Loading requests…")).toBeDefined(); + }); + + test("renders error message when requests API fails", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkPendingRequests.mockRejectedValue(new Error("API unavailable")); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const requestsTab = screen.getByRole("tab", { name: "Requests" }); + requestsTab.focus(); + fireEvent.keyDown(requestsTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("Failed to load requests.")).toBeDefined(); + }); + }); + + test("renders ApiError message when requests API throws ApiError", async () => { + const apiError = new ApiError("Pending request lookup failed", 500, "internal_error"); + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkPendingRequests.mockRejectedValue(apiError); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const requestsTab = screen.getByRole("tab", { name: "Requests" }); + requestsTab.focus(); + fireEvent.keyDown(requestsTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("Pending request lookup failed")).toBeDefined(); + }); + }); + + test("renders 'No pending requests' message when requests array is empty", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [], count: 0 }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const requestsTab = screen.getByRole("tab", { name: "Requests" }); + requestsTab.focus(); + fireEvent.keyDown(requestsTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("No pending requests for this network.")).toBeDefined(); + }); + }); + + test("renders 'No pending requests' when requests field is undefined", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkPendingRequests.mockResolvedValue({ count: 0 }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const requestsTab = screen.getByRole("tab", { name: "Requests" }); + requestsTab.focus(); + fireEvent.keyDown(requestsTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("No pending requests for this network.")).toBeDefined(); + }); + }); + + // ── Request list display ──────────────────────────────────────────────────── + + test("displays user_id in monospace font", async () => { + await setupRequestsTab([PENDING_REQUEST]); + + const userIdElement = screen.getByText("user-requestor"); + expect(userIdElement).toBeDefined(); + expect(userIdElement.className).toContain("font-mono"); + }); + + test("displays grant_type badge", async () => { + await setupRequestsTab([PENDING_REQUEST]); + + expect(screen.getByText("requested")).toBeDefined(); + }); + + test("displays justification in quotes when present", async () => { + await setupRequestsTab([PENDING_REQUEST]); + + expect(screen.getByText('"Need access for development work."')).toBeDefined(); + }); + + test("does NOT display justification when null", async () => { + await setupRequestsTab([APPROVED_REQUEST]); + + await waitFor(() => { + expect(screen.getByText("user-approved")).toBeDefined(); + }); + + expect(screen.queryByText(/"/)).toBeNull(); + }); + + test("displays created_at formatted date", async () => { + await setupRequestsTab([PENDING_REQUEST]); + + // "Requested:" is part of a larger text node like "Requested: Jun 1, 2025" + const requestedEl = screen.getByText(/^Requested: /); + expect(requestedEl).toBeDefined(); + // The element should contain text beyond just "Requested:" + expect(requestedEl.textContent?.length).toBeGreaterThan("Requested:".length); + // Verify it is NOT the em-dash fallback (which would mean formatDate failed) + expect(requestedEl.textContent).not.toBe("Requested: —"); + }); + + // ── ApprovalStateBadge colors ──────────────────────────────────────────────── + + test("renders Pending badge with yellow colors for pending state", async () => { + await setupRequestsTab([PENDING_REQUEST]); + + const badge = screen.getByText("Pending"); + expect(badge).toBeDefined(); + expect(badge.className).toContain("bg-yellow-100"); + expect(badge.className).toContain("text-yellow-700"); + }); + + test("renders Approved badge with green colors for approved state", async () => { + await setupRequestsTab([APPROVED_REQUEST]); + + const badge = screen.getByText("Approved"); + expect(badge).toBeDefined(); + expect(badge.className).toContain("bg-green-100"); + expect(badge.className).toContain("text-green-700"); + }); + + test("renders Rejected badge with red colors for rejected state", async () => { + await setupRequestsTab([REJECTED_REQUEST]); + + const badge = screen.getByText("Rejected"); + expect(badge).toBeDefined(); + expect(badge.className).toContain("bg-red-100"); + expect(badge.className).toContain("text-red-700"); + }); + + test("renders Revoked badge with red colors for revoked state", async () => { + await setupRequestsTab([REVOKED_REQUEST]); + + const badge = screen.getByText("Revoked"); + expect(badge).toBeDefined(); + expect(badge.className).toContain("bg-red-100"); + expect(badge.className).toContain("text-red-700"); + }); + + test("renders Suspended badge with orange colors for suspended state", async () => { + await setupRequestsTab([SUSPENDED_REQUEST]); + + const badge = screen.getByText("Suspended"); + expect(badge).toBeDefined(); + expect(badge.className).toContain("bg-orange-100"); + expect(badge.className).toContain("text-orange-700"); + }); + + // ── Approve / Reject button visibility ─────────────────────────────────────── + + test("shows Approve and Reject buttons for pending state request", async () => { + await setupRequestsTab([PENDING_REQUEST]); + + expect(screen.getByRole("button", { name: "Approve" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Reject" })).toBeDefined(); + }); + + test("does NOT show action buttons for approved state request", async () => { + await setupRequestsTab([APPROVED_REQUEST]); + + await waitFor(() => { + expect(screen.getByText("Approved")).toBeDefined(); + }); + + expect(screen.queryByRole("button", { name: "Approve" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Reject" })).toBeNull(); + }); + + test("does NOT show action buttons for rejected state request", async () => { + await setupRequestsTab([REJECTED_REQUEST]); + + await waitFor(() => { + expect(screen.getByText("Rejected")).toBeDefined(); + }); + + expect(screen.queryByRole("button", { name: "Approve" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Reject" })).toBeNull(); + }); + + test("does NOT show action buttons for revoked state request", async () => { + await setupRequestsTab([REVOKED_REQUEST]); + + await waitFor(() => { + expect(screen.getByText("Revoked")).toBeDefined(); + }); + + expect(screen.queryByRole("button", { name: "Approve" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Reject" })).toBeNull(); + }); + + test("does NOT show action buttons for suspended state request", async () => { + await setupRequestsTab([SUSPENDED_REQUEST]); + + await waitFor(() => { + expect(screen.getByText("Suspended")).toBeDefined(); + }); + + expect(screen.queryByRole("button", { name: "Approve" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Reject" })).toBeNull(); + }); + + // ── Approve action ─────────────────────────────────────────────────────────── + + test("clicking Approve calls api.zerotier.approveRequest with correct orgId and approvalId", async () => { + H.mockApproveRequest.mockResolvedValue({}); + // Second call to getNetworkPendingRequests (refresh after approve) + H.mockGetNetworkPendingRequests + .mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 }) + .mockResolvedValueOnce({ requests: [], count: 0 }); + + await setupRequestsTab([PENDING_REQUEST]); + + const approveBtn = screen.getByRole("button", { name: "Approve" }); + fireEvent.click(approveBtn); + + expect(H.mockApproveRequest).toHaveBeenCalledTimes(1); + expect(H.mockApproveRequest).toHaveBeenCalledWith("org-1", "req-pending-1"); + }); + + test("shows success toast after successful approval", async () => { + H.mockApproveRequest.mockResolvedValue({}); + H.mockGetNetworkPendingRequests + .mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 }) + .mockResolvedValueOnce({ requests: [], count: 0 }); + + await setupRequestsTab([PENDING_REQUEST]); + + const approveBtn = screen.getByRole("button", { name: "Approve" }); + fireEvent.click(approveBtn); + + await waitFor(() => { + expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Request approved" })); + }); + }); + + test("shows error toast when approval fails", async () => { + H.mockApproveRequest.mockRejectedValue(new Error("Approval failed")); + H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [PENDING_REQUEST], count: 1 }); + + await setupRequestsTab([PENDING_REQUEST]); + + const approveBtn = screen.getByRole("button", { name: "Approve" }); + fireEvent.click(approveBtn); + + await waitFor(() => { + expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ + variant: "destructive", + title: "Failed to approve", + })); + }); + }); + + test("shows ApiError description in error toast when approval fails with ApiError", async () => { + const apiError = new ApiError("Request not found", 404, "not_found"); + H.mockApproveRequest.mockRejectedValue(apiError); + H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [PENDING_REQUEST], count: 1 }); + + await setupRequestsTab([PENDING_REQUEST]); + + const approveBtn = screen.getByRole("button", { name: "Approve" }); + fireEvent.click(approveBtn); + + await waitFor(() => { + expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ + variant: "destructive", + title: "Failed to approve", + description: "Request not found", + })); + }); + }); + + test("refresh after approve fetches updated requests", async () => { + H.mockApproveRequest.mockResolvedValue({}); + H.mockGetNetworkPendingRequests + .mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 }) + .mockResolvedValueOnce({ requests: [], count: 0 }); + + await setupRequestsTab([PENDING_REQUEST]); + + const approveBtn = screen.getByRole("button", { name: "Approve" }); + fireEvent.click(approveBtn); + + await waitFor(() => { + expect(H.mockGetNetworkPendingRequests).toHaveBeenCalledTimes(2); + }); + expect(H.mockGetNetworkPendingRequests).toHaveBeenNthCalledWith(1, "org-1", "net-abc"); + expect(H.mockGetNetworkPendingRequests).toHaveBeenNthCalledWith(2, "org-1", "net-abc"); + }); + + // ── Reject action ──────────────────────────────────────────────────────────── + + test("clicking Reject calls api.zerotier.rejectRequest with correct orgId and approvalId", async () => { + H.mockRejectRequest.mockResolvedValue({}); + H.mockGetNetworkPendingRequests + .mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 }) + .mockResolvedValueOnce({ requests: [], count: 0 }); + + await setupRequestsTab([PENDING_REQUEST]); + + const rejectBtn = screen.getByRole("button", { name: "Reject" }); + fireEvent.click(rejectBtn); + + expect(H.mockRejectRequest).toHaveBeenCalledTimes(1); + expect(H.mockRejectRequest).toHaveBeenCalledWith("org-1", "req-pending-1"); + }); + + test("shows success toast after successful rejection", async () => { + H.mockRejectRequest.mockResolvedValue({}); + H.mockGetNetworkPendingRequests + .mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 }) + .mockResolvedValueOnce({ requests: [], count: 0 }); + + await setupRequestsTab([PENDING_REQUEST]); + + const rejectBtn = screen.getByRole("button", { name: "Reject" }); + fireEvent.click(rejectBtn); + + await waitFor(() => { + expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Request rejected" })); + }); + }); + + test("shows error toast when rejection fails", async () => { + H.mockRejectRequest.mockRejectedValue(new Error("Rejection failed")); + H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [PENDING_REQUEST], count: 1 }); + + await setupRequestsTab([PENDING_REQUEST]); + + const rejectBtn = screen.getByRole("button", { name: "Reject" }); + fireEvent.click(rejectBtn); + + await waitFor(() => { + expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ + variant: "destructive", + title: "Failed to reject", + })); + }); + }); + + test("shows ApiError description in error toast when rejection fails with ApiError", async () => { + const apiError = new ApiError("Request already processed", 409, "conflict"); + H.mockRejectRequest.mockRejectedValue(apiError); + H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [PENDING_REQUEST], count: 1 }); + + await setupRequestsTab([PENDING_REQUEST]); + + const rejectBtn = screen.getByRole("button", { name: "Reject" }); + fireEvent.click(rejectBtn); + + await waitFor(() => { + expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ + variant: "destructive", + title: "Failed to reject", + description: "Request already processed", + })); + }); + }); + + test("refresh after reject fetches updated requests", async () => { + H.mockRejectRequest.mockResolvedValue({}); + H.mockGetNetworkPendingRequests + .mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 }) + .mockResolvedValueOnce({ requests: [], count: 0 }); + + await setupRequestsTab([PENDING_REQUEST]); + + const rejectBtn = screen.getByRole("button", { name: "Reject" }); + fireEvent.click(rejectBtn); + + await waitFor(() => { + expect(H.mockGetNetworkPendingRequests).toHaveBeenCalledTimes(2); + }); + expect(H.mockGetNetworkPendingRequests).toHaveBeenNthCalledWith(1, "org-1", "net-abc"); + expect(H.mockGetNetworkPendingRequests).toHaveBeenNthCalledWith(2, "org-1", "net-abc"); + }); + + // ── Requests badge count ───────────────────────────────────────────────────── + + test("shows request count in the Requests tab badge", async () => { + await setupRequestsTab([PENDING_REQUEST, APPROVED_REQUEST, REJECTED_REQUEST]); + + expect(screen.getByText("3 pending")).toBeDefined(); + }); + + test("shows '0 pending' when empty", async () => { + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [], count: 0 }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const requestsTab = screen.getByRole("tab", { name: "Requests" }); + requestsTab.focus(); + fireEvent.keyDown(requestsTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("0 pending")).toBeDefined(); + }); + }); + + // ── Multiple requests display ──────────────────────────────────────────────── + + test("renders multiple requests in the list", async () => { + await setupRequestsTab([PENDING_REQUEST, APPROVED_REQUEST]); + + expect(screen.getByText("user-requestor")).toBeDefined(); + expect(screen.getByText("user-approved")).toBeDefined(); + }); + + test("mixed list: only pending requests have action buttons", async () => { + await setupRequestsTab([PENDING_REQUEST, APPROVED_REQUEST]); + + // user-requestor (pending) should have action buttons + // user-approved (approved) should not + const approveButtons = screen.getAllByRole("button", { name: "Approve" }); + expect(approveButtons.length).toBe(1); + + const rejectButtons = screen.getAllByRole("button", { name: "Reject" }); + expect(rejectButtons.length).toBe(1); + }); + + // ── Adversarial: Unicode user_ids in requests ─────────────────────────────── + + test("renders requests with Unicode user_ids", async () => { + const unicodeRequest = { + ...PENDING_REQUEST, + id: "req-uni-1", + user_id: "user-äéîøü-中文-requestor", + }; + await setupRequestsTab([unicodeRequest]); + + expect(screen.getByText("user-äéîøü-中文-requestor")).toBeDefined(); + }); + + // ── Adversarial: XSS-safe justification ────────────────────────────────────── + + test("renders justification with script tags safely", async () => { + const xssRequest = { + ...PENDING_REQUEST, + id: "req-xss-1", + user_id: "user-xss-req", + justification: "", + }; + await setupRequestsTab([xssRequest]); + + expect(screen.getByText('""')).toBeDefined(); + }); + + // ── Adversarial: many requests ─────────────────────────────────────────────── + + test("renders many requests without crashing", async () => { + const manyRequests = Array.from({ length: 50 }, (_, i) => ({ + ...PENDING_REQUEST, + id: `req-many-${i}`, + user_id: `user-req-${i}`, + })); + H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); + H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: manyRequests, count: 50 }); + + renderWithRoute(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const requestsTab = screen.getByRole("tab", { name: "Requests" }); + requestsTab.focus(); + fireEvent.keyDown(requestsTab, { key: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("50 pending")).toBeDefined(); + }); + + expect(screen.getByText("user-req-0")).toBeDefined(); + expect(screen.getByText("user-req-49")).toBeDefined(); + }); + }); +}); diff --git a/tests/NetworksPage.test.tsx b/tests/NetworksPage.test.tsx new file mode 100644 index 0000000..e6edd53 --- /dev/null +++ b/tests/NetworksPage.test.tsx @@ -0,0 +1,678 @@ +// @vitest-environment jsdom +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, within, fireEvent, cleanup } from "@testing-library/react"; +import React from "react"; +import { MemoryRouter } from "react-router-dom"; + +// ── Shared mock state (vi.hoisted avoids TDZ with vi.mock hoisting) ──────────── + +const H = vi.hoisted(() => ({ + mockNavigate: vi.fn(), + mockListNetworks: vi.fn(), + mockListAvailableZtNetworks: vi.fn(), + mockToast: vi.fn(), + state: { + orgId: "org-test-123" as string | null, + }, + navigateCalls: [] as string[], +})); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => { + const fn = (path: string) => { + H.navigateCalls.push(path); + H.mockNavigate(path); + }; + return fn; + }, + useParams: () => ({ orgId: H.state.orgId }), + }; +}); + +vi.mock("@/hooks/useCurrentOrganization", () => ({ + useCurrentOrganizationId: () => ({ + orgId: H.state.orgId, + isLoading: false, + }), + useCurrentOrganization: () => ({ + org: { + id: H.state.orgId, + name: "Test Org", + slug: "test-org", + description: null, + logo_url: null, + is_active: true, + role: "admin", + created_at: "2024-01-01", + updated_at: "2024-01-01", + }, + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-toast", () => ({ + useToast: () => ({ + toast: H.mockToast, + dismiss: () => {}, + toasts: [], + }), +})); + +vi.mock("@/lib/api", () => ({ + api: { + zerotier: { + listNetworks: H.mockListNetworks, + listAvailableZtNetworks: H.mockListAvailableZtNetworks, + createNetwork: vi.fn(), + updateNetwork: vi.fn(), + deleteNetwork: vi.fn(), + }, + }, + ApiError: class ApiError extends Error { + code: number; + type: string; + details: Record; + constructor(message: string, code: number, type: string, details: Record = {}) { + super(message); + this.name = "ApiError"; + this.code = code; + this.type = type; + this.details = details; + } + }, +})); + +import NetworksPage from "../src/pages/org/NetworksPage"; + +// ── Test data ────────────────────────────────────────────────────────────────── + +const MOCK_NETWORKS = [ + { + id: "net-001", + organization_id: "org-test-123", + name: "Production VPN", + description: "Main production network", + owner_user_id: "user-1", + zerotier_network_id: "d6578dd03c894448", + environment: "production" as const, + request_mode: "approval_required" as const, + default_activation_lifetime_minutes: 480, + max_activation_lifetime_minutes: null, + is_active: true, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + deleted_at: null, + approved_user_count: 25, + active_membership_count: 12, + }, + { + id: "net-002", + organization_id: "org-test-123", + name: "Dev Network", + description: "Development and staging", + owner_user_id: "user-1", + zerotier_network_id: "abcdef1234567890", + environment: "development" as const, + request_mode: "open" as const, + default_activation_lifetime_minutes: 240, + max_activation_lifetime_minutes: 1440, + is_active: false, + created_at: "2024-01-02T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + deleted_at: null, + approved_user_count: 5, + active_membership_count: 0, + }, +]; + +const MOCK_ZT_NETWORKS = [ + { + id: "zt-net-001", + name: "External ZeroTier", + description: "An external ZT network", + owner_id: null, + online_member_count: 3, + authorized_member_count: 10, + total_member_count: 10, + already_managed: false, + portal_network_id: null, + portal_network_name: null, + }, +]; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function renderPage() { + return render( + + + , + ); +} + +// Default: all API calls return never-resolving promise (loading state) +// Individual tests override BEFORE calling renderPage(). +beforeEach(() => { + vi.clearAllMocks(); + H.navigateCalls.length = 0; + H.mockListNetworks.mockImplementation(() => new Promise(() => {})); + H.mockListAvailableZtNetworks.mockImplementation(() => new Promise(() => {})); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// HAPPY PATH: Data Loading +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("NetworksPage — Data Loading", () => { + test("renders loading state while fetching networks", () => { + renderPage(); + + expect(screen.getByText("Loading networks…")).toBeDefined(); + }); + + test("renders network data when API resolves", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + expect(screen.getByText("Dev Network")).toBeDefined(); + expect(screen.getByText("d6578dd03c894448")).toBeDefined(); + expect(screen.getByText("abcdef1234567890")).toBeDefined(); + }); + + test("renders error state when API fails", async () => { + H.mockListNetworks.mockRejectedValue(new Error("Network error")); + + renderPage(); + + await waitFor(() => { + expect( + screen.getByText("Failed to load networks. Please try again."), + ).toBeDefined(); + }); + }); + + test("renders empty state when no networks exist", async () => { + H.mockListNetworks.mockResolvedValue({ networks: [], count: 0 }); + + renderPage(); + + await waitFor(() => { + expect( + screen.getByText("No networks configured yet. Add one to get started."), + ).toBeDefined(); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// NAVIGATION: Row Click +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("NetworksPage — Row Click Navigation", () => { + test("clicking a network row navigates to /org/zerotier/networks/{networkId}", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + const productionRow = screen.getByText("Production VPN").closest("button"); + expect(productionRow).not.toBeNull(); + + fireEvent.click(productionRow!); + + expect(H.mockNavigate).toHaveBeenCalledTimes(1); + expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-001"); + expect(H.navigateCalls).toEqual(["/org/zerotier/networks/net-001"]); + }); + + test("clicking second network row navigates to its URL", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const devRow = screen.getByText("Dev Network").closest("button"); + expect(devRow).not.toBeNull(); + + fireEvent.click(devRow!); + + expect(H.mockNavigate).toHaveBeenCalledTimes(1); + expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-002"); + }); + + test("navigate NOT called before any click", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + expect(H.mockNavigate).not.toHaveBeenCalled(); + expect(H.navigateCalls).toEqual([]); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// NAVIGATION: Dropdown "View details" +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("NetworksPage — Dropdown View details Navigation", () => { + test('"View details" dropdown item navigates to /org/zerotier/networks/{networkId}', async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + // The MoreHorizontal button is a CHILD of the row button (nested inside it). + // Find it by looking for the button with the MoreHorizontal icon inside the row. + const productionRow = screen.getByText("Production VPN").closest("button")!; + // Find ALL nested buttons within the row + const nestedButtons = productionRow.querySelectorAll("button"); + // The first nested button should be the MoreHorizontal dropdown trigger + expect(nestedButtons.length).toBeGreaterThan(0); + // Radix DropdownMenu opens on pointerdown + fireEvent.pointerDown(nestedButtons[0]); + + // DropdownMenuContent renders in a portal, screen.getByText searches the whole document + await waitFor(() => { + expect(screen.getByText("View details")).toBeDefined(); + }); + + fireEvent.click(screen.getByText("View details")); + + expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-001"); + }); + + test('"View details" for second network navigates to its URL', async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + const devRow = screen.getByText("Dev Network").closest("button")!; + const nestedButtons = devRow.querySelectorAll("button"); + expect(nestedButtons.length).toBeGreaterThan(0); + fireEvent.pointerDown(nestedButtons[0]); + + await waitFor(() => { + expect(screen.getByText("View details")).toBeDefined(); + }); + + fireEvent.click(screen.getByText("View details")); + + expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-002"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CARD DESCRIPTION TEXT +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("NetworksPage — Card Description", () => { + test("CardDescription reflects page navigation (not old drawer text)", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + const description = screen.getByText( + "Click a network to manage members, devices, and access requests", + ); + expect(description).toBeDefined(); + }); + + test("old drawer-related text is absent", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + // The old Sheet content (Network Details, member list) should NOT be present + expect(screen.queryByText("Network Details")).toBeNull(); + expect(screen.queryByText("Members")).toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// ZERO TIER NETWORK PICKER SHEET +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("NetworksPage — ZeroTier Picker Sheet", () => { + test('"Import from ZeroTier" button is present', async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + const importButton = screen.getByRole("button", { + name: /import from zerotier/i, + }); + expect(importButton).toBeDefined(); + }); + + test('clicking "Import from ZeroTier" opens the ZT Picker Sheet', async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + H.mockListAvailableZtNetworks.mockResolvedValue({ + networks: MOCK_ZT_NETWORKS, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + const importButton = screen.getByRole("button", { + name: /import from zerotier/i, + }); + fireEvent.click(importButton); + + // Wait for the Sheet to render with its content + await waitFor(() => { + expect(screen.getByText("External ZeroTier")).toBeDefined(); + }); + + expect(screen.getByText("zt-net-001")).toBeDefined(); + expect(screen.getByText("Import")).toBeDefined(); + }); + + test("ZT Picker calls API with correct orgId", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + H.mockListAvailableZtNetworks.mockResolvedValue({ + networks: MOCK_ZT_NETWORKS, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + const importButton = screen.getByRole("button", { + name: /import from zerotier/i, + }); + fireEvent.click(importButton); + + await waitFor(() => { + expect(screen.getByText("External ZeroTier")).toBeDefined(); + }); + + expect(H.mockListAvailableZtNetworks).toHaveBeenCalledTimes(1); + expect(H.mockListAvailableZtNetworks).toHaveBeenCalledWith("org-test-123"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// DATA DISPLAY +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("NetworksPage — Data Display", () => { + test("displays network count badge", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + // The CardTitle contains "Portal Networks" and the count badge + expect(screen.getByText("Portal Networks")).toBeDefined(); + // Badge with count "2" should be present + expect(screen.getByText("2")).toBeDefined(); + }); + + test("displays approved user counts", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + expect(screen.getByText("25")).toBeDefined(); + expect(screen.getByText("5")).toBeDefined(); + }); + + test("displays active device counts", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + expect(screen.getByText("12")).toBeDefined(); + expect(screen.getByText("0")).toBeDefined(); + }); + + test("displays environment badges", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + expect(screen.getByText("Production")).toBeDefined(); + expect(screen.getByText("Development")).toBeDefined(); + }); + + test("displays request mode badges", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + expect(screen.getByText("Approval Required")).toBeDefined(); + expect(screen.getByText("Open")).toBeDefined(); + }); + + test('displays "Inactive" badge for inactive networks', async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Dev Network")).toBeDefined(); + }); + + expect(screen.getByText("Inactive")).toBeDefined(); + }); + + test("renders search-empty state when filter matches nothing", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: MOCK_NETWORKS, + count: MOCK_NETWORKS.length, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + const searchInput = screen.getByPlaceholderText("Search networks…"); + fireEvent.change(searchInput, { target: { value: "zzzz_nonexistent" } }); + + await waitFor(() => { + expect( + screen.getByText("No networks match your search."), + ).toBeDefined(); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// EDGE CASES / ADVERSARIAL INPUTS +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("NetworksPage — Adversarial Inputs", () => { + test("handles XSS-like network name as text", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: [ + { + ...MOCK_NETWORKS[0], + name: 'VPN ', + description: "desc ${injection}", + zerotier_network_id: "../../etc/passwd", + }, + ], + count: 1, + }); + + renderPage(); + + await waitFor(() => { + expect( + screen.getByText('VPN '), + ).toBeDefined(); + }); + }); + + test("handles very long network name", async () => { + const longName = "A".repeat(500); + H.mockListNetworks.mockResolvedValue({ + networks: [{ ...MOCK_NETWORKS[0], name: longName, id: "net-long" }], + count: 1, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(longName)).toBeDefined(); + }); + }); + + test("handles Unicode network name", async () => { + const unicodeName = "ネットワーク \u{1F525} 测试"; + H.mockListNetworks.mockResolvedValue({ + networks: [ + { ...MOCK_NETWORKS[0], name: unicodeName, id: "net-unicode" }, + ], + count: 1, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(unicodeName)).toBeDefined(); + }); + }); + + test("handles missing optional counts (undefined)", async () => { + H.mockListNetworks.mockResolvedValue({ + networks: [ + { + ...MOCK_NETWORKS[0], + approved_user_count: undefined, + active_membership_count: undefined, + }, + ], + count: 1, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("Production VPN")).toBeDefined(); + }); + + // Should show "0" for undefined counts (nullish coalescing: ?? 0) + const zeros = screen.getAllByText("0"); + expect(zeros.length).toBeGreaterThanOrEqual(2); + }); +});