feat: add network management page and inline accordion device details

This commit is contained in:
Ubuntu
2026-05-07 19:59:21 +00:00
parent 9a5e023ec3
commit 16fb2b4e41
10 changed files with 4301 additions and 357 deletions
+1
View File
@@ -0,0 +1 @@
{}
+2
View File
@@ -54,6 +54,7 @@ import PrincipalsPage from "@/pages/org/PrincipalsPage";
import ApiKeysPage from "@/pages/org/ApiKeysPage"; import ApiKeysPage from "@/pages/org/ApiKeysPage";
import MyMembershipsPage from "@/pages/org/MyMembershipsPage"; import MyMembershipsPage from "@/pages/org/MyMembershipsPage";
import NetworksPage from "@/pages/org/NetworksPage"; import NetworksPage from "@/pages/org/NetworksPage";
import NetworkManagementPage from "@/pages/org/NetworkManagementPage";
import DevicesPage from "@/pages/org/DevicesPage"; import DevicesPage from "@/pages/org/DevicesPage";
import AccessPage from "@/pages/org/AccessPage"; import AccessPage from "@/pages/org/AccessPage";
import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage"; import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage";
@@ -209,6 +210,7 @@ function AppRoutes() {
<Route path="/org/clients" element={<RequireAdmin><OIDCClientsPage /></RequireAdmin>} /> <Route path="/org/clients" element={<RequireAdmin><OIDCClientsPage /></RequireAdmin>} />
<Route path="/org/cas" element={<RequireAdmin><CAsPage /></RequireAdmin>} /> <Route path="/org/cas" element={<RequireAdmin><CAsPage /></RequireAdmin>} />
<Route path="/org/zerotier/networks" element={<RequireAdmin><NetworksPage /></RequireAdmin>} /> <Route path="/org/zerotier/networks" element={<RequireAdmin><NetworksPage /></RequireAdmin>} />
<Route path="/org/zerotier/networks/:networkId" element={<RequireAdmin><NetworkManagementPage /></RequireAdmin>} />
<Route path="/org/zerotier/access" element={<RequireAdmin><AccessPage /></RequireAdmin>} /> <Route path="/org/zerotier/access" element={<RequireAdmin><AccessPage /></RequireAdmin>} />
<Route path="/org/zerotier/config" element={<RequireAdmin><ZeroTierConfigPage /></RequireAdmin>} /> <Route path="/org/zerotier/config" element={<RequireAdmin><ZeroTierConfigPage /></RequireAdmin>} />
+94
View File
@@ -177,6 +177,40 @@ export interface AdminLinkedAccount {
linked_at: string | null; 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<string, string>;
extensions: Record<string, string>;
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 // External Auth Types
export type ExternalProviderId = 'google' | 'github' | 'microsoft'; export type ExternalProviderId = 'google' | 'github' | 'microsoft';
@@ -698,6 +732,27 @@ export const api = {
method: 'PUT', method: 'PUT',
body: JSON.stringify(policy), body: JSON.stringify(policy),
}, true, requestConfig), }, 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: { totp: {
@@ -1568,6 +1623,19 @@ export const api = {
true, requestConfig, 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 ────────────────────────────────────────────────────────────── // ── Sessions ──────────────────────────────────────────────────────────────
listSessions: (orgId: string, requestConfig?: RequestConfig) => listSessions: (orgId: string, requestConfig?: RequestConfig) =>
request<{ sessions: ActivationSession[]; count: number }>( request<{ sessions: ActivationSession[]; count: number }>(
@@ -1964,6 +2032,32 @@ export interface AvailableZtNetwork {
portal_network_name: string | null; 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 { export interface Device {
id: string; id: string;
user_id: string; user_id: string;
+88 -2
View File
@@ -19,8 +19,12 @@ import {
KeyRound, KeyRound,
Link2, Link2,
Unlink, Unlink,
Award,
ExternalLink,
Lock, Lock,
FileKey,
} from "lucide-react"; } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -56,7 +60,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; 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"; import { useAuth } from "@/contexts/AuthContext";
function formatDate(d: string | null) { function formatDate(d: string | null) {
@@ -99,6 +103,7 @@ function RoleBadge({ role }: { role: string }) {
export default function AdminUsersPage() { export default function AdminUsersPage() {
const { toast } = useToast(); const { toast } = useToast();
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const navigate = useNavigate();
// User list // User list
const [users, setUsers] = useState<ApiUser[]>([]); const [users, setUsers] = useState<ApiUser[]>([]);
@@ -162,6 +167,11 @@ export default function AdminUsersPage() {
const [passwordResetError, setPasswordResetError] = useState<string | null>(null); const [passwordResetError, setPasswordResetError] = useState<string | null>(null);
const [isResettingPassword, setIsResettingPassword] = useState(false); const [isResettingPassword, setIsResettingPassword] = useState(false);
// SSH Certificates summary
const [userSshCerts, setUserSshCerts] = useState<AdminUserSshCertificate[]>([]);
const [sshCertsCount, setSshCertsCount] = useState(0);
const [isSshCertsLoading, setIsSshCertsLoading] = useState(false);
// ── Fetch users ───────────────────────────────────────────────────────────── // ── Fetch users ─────────────────────────────────────────────────────────────
const fetchUsers = useCallback(async (q: string, pg: number) => { const fetchUsers = useCallback(async (q: string, pg: number) => {
setIsLoading(true); setIsLoading(true);
@@ -203,12 +213,16 @@ export default function AdminUsersPage() {
setUserMfaMethods([]); setUserMfaMethods([]);
setUserLinkedAccounts([]); setUserLinkedAccounts([]);
setTotalAuthMethods(0); setTotalAuthMethods(0);
setUserSshCerts([]);
setSshCertsCount(0);
setIsSshCertsLoading(true);
setIsDrawerLoading(true); setIsDrawerLoading(true);
try { try {
const [userData, mfaData, linkedData] = await Promise.allSettled([ const [userData, mfaData, linkedData, certsData] = await Promise.allSettled([
api.admin.getUser(user.id), api.admin.getUser(user.id),
api.admin.getUserMfa(user.id), api.admin.getUserMfa(user.id),
api.admin.getUserLinkedAccounts(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 (userData.status === "fulfilled") setUserSshKeys(userData.value.ssh_keys);
if (mfaData.status === "fulfilled") setUserMfaMethods(mfaData.value.mfa_methods); if (mfaData.status === "fulfilled") setUserMfaMethods(mfaData.value.mfa_methods);
@@ -216,10 +230,18 @@ export default function AdminUsersPage() {
setUserLinkedAccounts(linkedData.value.linked_accounts); setUserLinkedAccounts(linkedData.value.linked_accounts);
setTotalAuthMethods(linkedData.value.total_auth_methods); setTotalAuthMethods(linkedData.value.total_auth_methods);
} }
if (certsData.status === "fulfilled") {
setUserSshCerts(certsData.value.certificates);
setSshCertsCount(certsData.value.count);
} else {
setUserSshCerts([]);
setSshCertsCount(0);
}
} catch { } catch {
// Non-fatal // Non-fatal
} finally { } finally {
setIsDrawerLoading(false); setIsDrawerLoading(false);
setIsSshCertsLoading(false);
} }
}; };
@@ -929,6 +951,70 @@ export default function AdminUsersPage() {
)} )}
</div> </div>
{/* SSH Certificates summary */}
{selectedUser.id !== currentUser?.id && (
<div className="mt-6 p-4 border rounded-lg space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Award className="w-4 h-4" />
SSH Certificates
</h3>
<Button
size="sm"
variant="ghost"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
setSelectedUser(null);
navigate(`/org/members/${selectedUser.id}`);
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
Full details
</Button>
</div>
{isDrawerLoading || isSshCertsLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
) : sshCertsCount === 0 ? (
<p className="text-sm text-muted-foreground">No SSH certificates issued.</p>
) : (
<div className="space-y-3">
{/* Total count badge */}
<div className="flex flex-wrap gap-2">
<Badge variant="secondary" className="text-xs">
<FileKey className="w-3 h-3 mr-1" />
{sshCertsCount} certificate{sshCertsCount !== 1 ? "s" : ""}
</Badge>
</div>
{/* Recent certificates (up to 5) */}
<div className="space-y-1.5">
{userSshCerts.slice(0, 5).map((cert) => (
<div key={cert.id} className="flex items-center justify-between p-2 border rounded text-xs">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono truncate">{cert.key_id}</span>
{cert.revoked ? (
<Badge variant="destructive" className="text-[10px] px-1 py-0">Revoked</Badge>
) : !cert.is_valid ? (
<Badge variant="outline" className="text-[10px] px-1 py-0 text-muted-foreground">Expired</Badge>
) : (
<Badge className="bg-green-500/10 text-green-600 border-0 text-[10px] px-1 py-0">Active</Badge>
)}
</div>
<span className="text-muted-foreground flex-shrink-0 ml-2">
{cert.principals.slice(0, 2).join(", ")}
{cert.principals.length > 2 && ` +${cert.principals.length - 2}`}
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Danger zone — Hard delete */} {/* Danger zone — Hard delete */}
{selectedUser.id !== currentUser?.id && ( {selectedUser.id !== currentUser?.id && (
<div className="mt-6 p-4 border border-destructive/30 rounded-lg space-y-3"> <div className="mt-6 p-4 border border-destructive/30 rounded-lg space-y-3">
+392 -1
View File
@@ -19,6 +19,13 @@ import {
UserCheck, UserCheck,
ShieldOff, ShieldOff,
Plus, Plus,
Award,
Clock,
FileKey,
Globe,
Terminal,
ChevronDown,
ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -26,6 +33,13 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -38,7 +52,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; 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"; import { useAuth } from "@/contexts/AuthContext";
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -155,6 +169,233 @@ function UserManagementSkeleton() {
); );
} }
// ── Certificate Status Badge ─────────────────────────────────────────────────
function CertStatusBadge({ cert }: { cert: AdminUserSshCertificate }) {
if (cert.revoked) {
return (
<Badge variant="destructive" className="text-xs">
<ShieldOff className="w-3 h-3 mr-1" />
Revoked
</Badge>
);
}
if (cert.status === "superseded") {
return (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
Superseded
</Badge>
);
}
if (!cert.is_valid) {
return (
<Badge variant="outline" className="text-xs text-muted-foreground">
Expired
</Badge>
);
}
// Active + valid
if (cert.days_until_expiry >= 0 && cert.days_until_expiry <= 7) {
return (
<Badge className="bg-amber-500/10 text-amber-600 border-0 text-xs">
<Clock className="w-3 h-3 mr-1" />
{cert.days_until_expiry === 0 ? "Expires today" : `${cert.days_until_expiry}d left`}
</Badge>
);
}
return (
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">
<CheckCircle className="w-3 h-3 mr-1" />
Active
</Badge>
);
}
// ── Certificate Row ──────────────────────────────────────────────────────────
function CertificateRow({ cert }: { cert: AdminUserSshCertificate }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-start justify-between gap-3 p-4 text-left hover:bg-accent/30 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<div className="flex-1 min-w-0 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium font-mono truncate">
{cert.key_id}
</span>
<CertStatusBadge cert={cert} />
<Badge variant="secondary" className="text-xs font-mono">
{cert.cert_type}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-mono">
Serial #{cert.serial}
</span>
<span>
{cert.principals.length > 0
? cert.principals.join(", ")
: "No principals"}
</span>
</div>
<div className="text-xs text-muted-foreground">
Valid {formatDate(cert.valid_after)} {formatDate(cert.valid_before)}
{cert.days_until_expiry < 0 && (
<span className="text-red-500 ml-1">
(expired {Math.abs(cert.days_until_expiry)}d ago)
</span>
)}
</div>
</div>
<div className="flex-shrink-0 pt-0.5">
{expanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</div>
</button>
{expanded && (
<div className="border-t bg-muted/30 p-4 space-y-3">
{/* Revocation info */}
{cert.revoked && (
<div className="p-3 rounded-md bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 space-y-1">
<p className="text-sm font-medium text-red-700 dark:text-red-300 flex items-center gap-1.5">
<ShieldOff className="w-3.5 h-3.5" />
Certificate Revoked
</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<span className="text-red-600/70 dark:text-red-400/70">Revoked at</span>
<span className="text-red-700 dark:text-red-300">{formatDate(cert.revoked_at)}</span>
{cert.revoke_reason && (
<>
<span className="text-red-600/70 dark:text-red-400/70">Reason</span>
<span className="text-red-700 dark:text-red-300">{cert.revoke_reason}</span>
</>
)}
</div>
</div>
)}
{/* Certificate details grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<span className="text-muted-foreground">Certificate ID</span>
<span className="font-mono truncate">{cert.id}</span>
<span className="text-muted-foreground">CA ID</span>
<span className="font-mono truncate">{cert.ca_id}</span>
{cert.request_ip && (
<>
<span className="text-muted-foreground">Request IP</span>
<span className="font-mono flex items-center gap-1">
<Globe className="w-3 h-3" />
{cert.request_ip}
</span>
</>
)}
{cert.request_user_agent && (
<>
<span className="text-muted-foreground">User Agent</span>
<span className="font-mono truncate">{cert.request_user_agent}</span>
</>
)}
<span className="text-muted-foreground">Created</span>
<span>{formatDate(cert.created_at)}</span>
<span className="text-muted-foreground">Last Updated</span>
<span>{formatDate(cert.updated_at)}</span>
</div>
{/* Extensions */}
{Object.keys(cert.extensions).length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1">
<Terminal className="w-3 h-3" />
Extensions
</p>
<div className="flex flex-wrap gap-1.5">
{Object.entries(cert.extensions).map(([key, val]) => (
<Badge key={key} variant="secondary" className="text-[10px] font-mono">
{key}{val ? `=${val}` : ""}
</Badge>
))}
</div>
</div>
)}
{/* Critical options */}
{Object.keys(cert.critical_options).length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Critical Options
</p>
<div className="flex flex-wrap gap-1.5">
{Object.entries(cert.critical_options).map(([key, val]) => (
<Badge key={key} variant="outline" className="text-[10px] font-mono text-amber-600 border-amber-300">
{key}{val ? `=${val}` : ""}
</Badge>
))}
</div>
</div>
)}
{/* SSH Key info */}
{cert.ssh_key && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground flex items-center gap-1">
<Key className="w-3 h-3" />
SSH Key
</p>
<div className="p-3 rounded-md border bg-background space-y-1.5 text-xs">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Fingerprint</span>
<span className="font-mono truncate">{cert.ssh_key.fingerprint}</span>
<span className="text-muted-foreground">Type</span>
<span className="font-mono">{cert.ssh_key.key_type} ({cert.ssh_key.key_bits} bits)</span>
{cert.ssh_key.description && (
<>
<span className="text-muted-foreground">Description</span>
<span>{cert.ssh_key.description}</span>
</>
)}
{cert.ssh_key.key_comment && (
<>
<span className="text-muted-foreground">Comment</span>
<span className="font-mono">{cert.ssh_key.key_comment}</span>
</>
)}
<span className="text-muted-foreground">Verified</span>
<span className="flex items-center gap-1">
{cert.ssh_key.verified ? (
<><CheckCircle className="w-3 h-3 text-green-500" /> Yes</>
) : (
<><XCircle className="w-3 h-3 text-amber-500" /> No</>
)}
</span>
</div>
</div>
</div>
)}
{!cert.ssh_key && (
<p className="text-xs text-muted-foreground italic">No SSH key linked to this certificate</p>
)}
</div>
)}
</div>
);
}
// ── Main Component ──────────────────────────────────────────────────────────── // ── Main Component ────────────────────────────────────────────────────────────
export default function UserManagementPage() { export default function UserManagementPage() {
@@ -207,6 +448,16 @@ export default function UserManagementPage() {
// Role update state // Role update state
const [selectedRole, setSelectedRole] = useState<string>("member"); const [selectedRole, setSelectedRole] = useState<string>("member");
// SSH Certificates state
const [sshCerts, setSshCerts] = useState<AdminUserSshCertificate[]>([]);
const [isCertsLoading, setIsCertsLoading] = useState(false);
const [certsPage, setCertsPage] = useState(1);
const [certsPages, setCertsPages] = useState(1);
const [certsCount, setCertsCount] = useState(0);
const [certStatusFilter, setCertStatusFilter] = useState<string>("all");
const [certActiveFilter, setCertActiveFilter] = useState<string>("all");
const [certTypeFilter, setCertTypeFilter] = useState<string>("all");
// ── Handlers ───────────────────────────────────────────────────────────────── // ── Handlers ─────────────────────────────────────────────────────────────────
const handleRemoveMfaMethod = async (method: AdminMfaMethod) => { const handleRemoveMfaMethod = async (method: AdminMfaMethod) => {
@@ -545,6 +796,38 @@ export default function UserManagementPage() {
}; };
}, [userId]); }, [userId]);
// ── Fetch SSH Certificates ───────────────────────────────────────────────────
// Reset page when filters change
useEffect(() => {
setCertsPage(1);
}, [certStatusFilter, certActiveFilter, certTypeFilter]);
// Fetch SSH certificates
useEffect(() => {
if (!userId) return;
let cancelled = false;
const fetchCerts = async () => {
setIsCertsLoading(true);
try {
const params: Record<string, string | number> = { page: certsPage, per_page: 20 };
if (certStatusFilter !== "all") params.status = certStatusFilter;
if (certActiveFilter !== "all") params.active = certActiveFilter;
if (certTypeFilter !== "all") params.cert_type = certTypeFilter;
const data = await api.admin.getUserSshCertificates(userId, params);
if (cancelled) return;
setSshCerts(data.certificates);
setCertsPages(data.pages);
setCertsCount(data.count);
} catch {
if (!cancelled) setSshCerts([]);
} finally {
if (!cancelled) setIsCertsLoading(false);
}
};
fetchCerts();
return () => { cancelled = true; };
}, [userId, certsPage, certStatusFilter, certActiveFilter, certTypeFilter]);
// ── Render ─────────────────────────────────────────────────────────────────── // ── Render ───────────────────────────────────────────────────────────────────
if (isLoading) { if (isLoading) {
@@ -588,6 +871,10 @@ export default function UserManagementPage() {
<TabsTrigger value="details">User Details</TabsTrigger> <TabsTrigger value="details">User Details</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger> <TabsTrigger value="security">Security</TabsTrigger>
<TabsTrigger value="access">Access</TabsTrigger> <TabsTrigger value="access">Access</TabsTrigger>
<TabsTrigger value="certs">
<FileKey className="w-3.5 h-3.5 mr-1.5" />
SSH Certificates
</TabsTrigger>
</TabsList> </TabsList>
{/* ── User Details Tab ────────────────────────────────────────────── */} {/* ── User Details Tab ────────────────────────────────────────────── */}
@@ -918,6 +1205,110 @@ export default function UserManagementPage() {
</Card> </Card>
</div> </div>
</TabsContent> </TabsContent>
{/* ── SSH Certificates Tab ──────────────────────────────────────────── */}
<TabsContent value="certs">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Award className="w-5 h-5" />
SSH Certificates
</div>
{!isCertsLoading && (
<Badge variant="secondary">{certsCount}</Badge>
)}
</CardTitle>
<CardDescription>
SSH certificates issued to this user via the organization's Certificate Authority
</CardDescription>
</CardHeader>
<CardContent>
{/* Filter controls */}
<div className="flex flex-wrap gap-2 mb-4">
<Select value={certStatusFilter} onValueChange={setCertStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="issued">Issued</SelectItem>
<SelectItem value="revoked">Revoked</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
<SelectItem value="superseded">Superseded</SelectItem>
</SelectContent>
</Select>
<Select value={certActiveFilter} onValueChange={setCertActiveFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All certs" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All certs</SelectItem>
<SelectItem value="true">Active only</SelectItem>
<SelectItem value="false">Inactive only</SelectItem>
</SelectContent>
</Select>
<Select value={certTypeFilter} onValueChange={setCertTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="host">Host</SelectItem>
</SelectContent>
</Select>
</div>
{isCertsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : sshCerts.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Award className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p className="text-sm font-medium">No certificates found</p>
<p className="text-xs mt-1">This user has not been issued any SSH certificates yet</p>
</div>
) : (
<div className="space-y-3">
{sshCerts.map((cert) => (
<CertificateRow key={cert.id} cert={cert} />
))}
</div>
)}
{/* Pagination */}
{certsPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-xs text-muted-foreground">
Page {certsPage} of {certsPages} · {certsCount} total
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCertsPage((p) => Math.max(1, p - 1))}
disabled={certsPage === 1}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCertsPage((p) => Math.min(certsPages, p + 1))}
disabled={certsPage === certsPages}
>
Next
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs> </Tabs>
{/* ── Remove all MFA confirmation dialog ───────────────────────────── */} {/* ── Remove all MFA confirmation dialog ───────────────────────────── */}
+160 -202
View File
@@ -40,13 +40,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -159,9 +153,7 @@ export default function DevicesPage() {
const [isRegistering, setIsRegistering] = useState(false); const [isRegistering, setIsRegistering] = useState(false);
const [regError, setRegError] = useState<string | null>(null); const [regError, setRegError] = useState<string | null>(null);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null); const [expandedDeviceId, setExpandedDeviceId] = useState<string | null>(null);
const [deviceMemberships, setDeviceMemberships] = useState<DeviceNetworkMembership[]>([]);
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
const [editDevice, setEditDevice] = useState<Device | null>(null); const [editDevice, setEditDevice] = useState<Device | null>(null);
const [editNickname, setEditNickname] = useState(""); const [editNickname, setEditNickname] = useState("");
@@ -218,25 +210,6 @@ export default function DevicesPage() {
fetchData(); fetchData();
}, [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 () => { const handleRegister = async () => {
if (!orgId) return; if (!orgId) return;
setRegError(null); setRegError(null);
@@ -477,7 +450,7 @@ export default function DevicesPage() {
Registered Devices Registered Devices
{!isLoading && <Badge variant="secondary" className="ml-1">{devices.length}</Badge>} {!isLoading && <Badge variant="secondary" className="ml-1">{devices.length}</Badge>}
</CardTitle> </CardTitle>
<CardDescription>Click a device to view memberships and activation status</CardDescription> <CardDescription>Click a device to expand its details and network memberships</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
@@ -497,65 +470,165 @@ export default function DevicesPage() {
const activeCount = memberships.filter( const activeCount = memberships.filter(
(m) => m.device_id === device.id && m.currently_authorized (m) => m.device_id === device.id && m.currently_authorized
).length; ).length;
const isExpanded = expandedDeviceId === device.id;
const deviceMemberships = memberships.filter((m) => m.device_id === device.id);
return ( return (
<button <div key={device.id}>
key={device.id} <button
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors" className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
onClick={() => openDeviceDrawer(device)} onClick={() => setExpandedDeviceId(isExpanded ? null : device.id)}
> >
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<DeviceTypeIcon nickname={device.device_nickname} hostname={device.hostname} /> <DeviceTypeIcon nickname={device.device_nickname} hostname={device.hostname} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-foreground truncate">
{device.device_nickname || device.hostname || device.node_id}
</p>
{device.device_nickname && device.hostname && (
<span className="text-sm text-muted-foreground truncate">{device.hostname}</span>
)}
<Badge variant={device.status === "active" ? "default" : "outline"} className="text-xs">
{device.status}
</Badge>
</div> </div>
<p className="text-sm text-muted-foreground font-mono">{device.node_id}</p> <div className="flex-1 min-w-0">
</div> <div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 text-sm flex-shrink-0"> <p className="font-medium text-foreground truncate">
{activeCount > 0 ? ( {device.device_nickname || device.hostname || device.node_id}
<><Zap className="w-4 h-4 text-green-500" /><span className="text-green-600">{activeCount} active</span></> </p>
) : ( {device.device_nickname && device.hostname && (
<span className="text-muted-foreground">Inactive</span> <span className="text-sm text-muted-foreground truncate">{device.hostname}</span>
)} )}
</div> <Badge variant={device.status === "active" ? "default" : "outline"} className="text-xs">
<DropdownMenu> {device.status}
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> </Badge>
<Button variant="ghost" size="icon" className="flex-shrink-0" onClick={(e) => e.stopPropagation()}> </div>
<MoreHorizontal className="w-4 h-4" /> <p className="text-sm text-muted-foreground font-mono">{device.node_id}</p>
</Button> </div>
</DropdownMenuTrigger> <div className="flex items-center gap-1 text-sm flex-shrink-0">
<DropdownMenuContent align="end"> {activeCount > 0 ? (
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openDeviceDrawer(device); }}> <><Zap className="w-4 h-4 text-green-500" /><span className="text-green-600">{activeCount} active</span></>
<ChevronRight className="w-4 h-4 mr-2" /> View memberships ) : (
</DropdownMenuItem> <span className="text-muted-foreground">Inactive</span>
<DropdownMenuItem onClick={(e) => { )}
e.stopPropagation(); </div>
setEditDevice(device); <DropdownMenu>
setEditNickname(device.device_nickname || ""); <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
setEditHostname(device.hostname || ""); <Button variant="ghost" size="icon" className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
}}> <MoreHorizontal className="w-4 h-4" />
<Pencil className="w-4 h-4 mr-2" /> Edit </Button>
</DropdownMenuItem> </DropdownMenuTrigger>
<DropdownMenuSeparator /> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem onClick={(e) => { e.stopPropagation(); setExpandedDeviceId(isExpanded ? null : device.id); }}>
className="text-destructive" <ChevronRight className="w-4 h-4 mr-2" /> View memberships
onClick={(e) => { e.stopPropagation(); setDeleteDevice(device); }} </DropdownMenuItem>
> <DropdownMenuItem onClick={(e) => {
<Trash2 className="w-4 h-4 mr-2" /> Remove e.stopPropagation();
</DropdownMenuItem> setEditDevice(device);
</DropdownMenuContent> setEditNickname(device.device_nickname || "");
</DropdownMenu> setEditHostname(device.hostname || "");
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" /> }}>
</button> <Pencil className="w-4 h-4 mr-2" /> Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => { e.stopPropagation(); setDeleteDevice(device); }}
>
<Trash2 className="w-4 h-4 mr-2" /> Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChevronRight className={cn("w-4 h-4 text-muted-foreground flex-shrink-0 transition-transform", isExpanded && "rotate-90")} />
</button>
{isExpanded && (
<div className="border-t px-4 pb-4 pt-4 space-y-4">
<div className="grid grid-cols-2 gap-2 text-sm">
{device.hostname && (
<>
<span className="text-muted-foreground">Hostname</span>
<span>{device.hostname}</span>
</>
)}
{device.asset_tag && (
<>
<span className="text-muted-foreground">Asset Tag</span>
<span>{device.asset_tag}</span>
</>
)}
{device.serial_number && (
<>
<span className="text-muted-foreground">Serial</span>
<span>{device.serial_number}</span>
</>
)}
<span className="text-muted-foreground">Registered</span>
<span>{formatDate(device.created_at)}</span>
<span className="text-muted-foreground">Status</span>
<Badge variant={device.status === "active" ? "default" : "outline"} className="w-fit">{device.status}</Badge>
</div>
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Monitor className="w-4 h-4" />
Network Memberships ({deviceMemberships.length})
</h4>
{deviceMemberships.length === 0 ? (
<div className="p-4 text-center text-muted-foreground text-sm border rounded-lg">
No memberships found. Request network access to get started.
</div>
) : (
<div className="space-y-3">
{deviceMemberships.map((m) => {
const session = getActiveSession(m.id);
const network = networks.find((n) => n.id === m.portal_network_id);
return (
<div key={m.id} className="p-3 border rounded-lg space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{network?.name || m.portal_network_id}</span>
<MembershipStateBadge state={m.state} />
</div>
<div className="flex items-center gap-1">
{m.approved_for_activation && !m.currently_authorized && (
<Button
size="sm"
variant="outline"
onClick={() => setShowActivateDialog(m.id)}
disabled={activatingId === m.id}
className="gap-1"
>
{activatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
Activate
</Button>
)}
{m.currently_authorized && (
<Button
size="sm"
variant="outline"
onClick={() => handleDeactivate(m.id)}
disabled={deactivatingId === m.id}
className="gap-1 text-orange-600 border-orange-300 hover:bg-orange-50"
>
{deactivatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
Deactivate
</Button>
)}
</div>
</div>
{session && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
Session expires: {formatExpiry(session.expires_at)}
</div>
)}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{m.join_seen ? (
<><CheckCircle className="w-3 h-3 text-green-500" /> Joined network</>
) : (
<><XCircle className="w-3 h-3" /> Not yet joined</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
)}
</div>
); );
})} })}
</div> </div>
@@ -1038,122 +1111,7 @@ export default function DevicesPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Device Detail Drawer */}
<Sheet open={!!selectedDevice} onOpenChange={(open) => { if (!open) closeDrawer(); }}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
{selectedDevice && (
<>
<SheetHeader className="mb-4">
<SheetTitle className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<DeviceTypeIcon nickname={selectedDevice.device_nickname} hostname={selectedDevice.hostname} />
</div>
{selectedDevice.device_nickname || selectedDevice.node_id}
</SheetTitle>
<SheetDescription className="font-mono">{selectedDevice.node_id}</SheetDescription>
</SheetHeader>
<div className="space-y-3 mb-6">
<div className="grid grid-cols-2 gap-2 text-sm">
{selectedDevice.hostname && (
<>
<span className="text-muted-foreground">Hostname</span>
<span>{selectedDevice.hostname}</span>
</>
)}
{selectedDevice.asset_tag && (
<>
<span className="text-muted-foreground">Asset Tag</span>
<span>{selectedDevice.asset_tag}</span>
</>
)}
{selectedDevice.serial_number && (
<>
<span className="text-muted-foreground">Serial</span>
<span>{selectedDevice.serial_number}</span>
</>
)}
<span className="text-muted-foreground">Registered</span>
<span>{formatDate(selectedDevice.created_at)}</span>
<span className="text-muted-foreground">Status</span>
<Badge variant={selectedDevice.status === "active" ? "default" : "outline"} className="w-fit">{selectedDevice.status}</Badge>
</div>
</div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Monitor className="w-4 h-4" />
Network Memberships ({deviceMemberships.length})
</h3>
{isDrawerLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : deviceMemberships.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">
No memberships found. Request network access to get started.
</div>
) : (
<div className="space-y-3">
{deviceMemberships.map((m) => {
const session = getActiveSession(m.id);
const network = networks.find((n) => n.id === m.portal_network_id);
return (
<div key={m.id} className="p-3 border rounded-lg space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{network?.name || m.portal_network_id}</span>
<MembershipStateBadge state={m.state} />
</div>
<div className="flex items-center gap-1">
{m.approved_for_activation && !m.currently_authorized && (
<Button
size="sm"
variant="outline"
onClick={() => setShowActivateDialog(m.id)}
disabled={activatingId === m.id}
className="gap-1"
>
{activatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
Activate
</Button>
)}
{m.currently_authorized && (
<Button
size="sm"
variant="outline"
onClick={() => handleDeactivate(m.id)}
disabled={deactivatingId === m.id}
className="gap-1 text-orange-600 border-orange-300 hover:bg-orange-50"
>
{deactivatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
Deactivate
</Button>
)}
</div>
</div>
{session && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
Session expires: {formatExpiry(session.expires_at)}
</div>
)}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{m.join_seen ? (
<><CheckCircle className="w-3 h-3 text-green-500" /> Joined network</>
) : (
<><XCircle className="w-3 h-3" /> Not yet joined</>
)}
</div>
</div>
);
})}
</div>
)}
</>
)}
</SheetContent>
</Sheet>
</div> </div>
); );
} }
+810
View File
@@ -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<NetworkEnvironment, string> = {
production: "bg-red-500/10 text-red-600 border-red-200",
staging: "bg-yellow-500/10 text-yellow-600 border-yellow-200",
development: "bg-green-500/10 text-green-600 border-green-200",
lab: "bg-blue-500/10 text-blue-600 border-blue-200",
};
return (
<Badge className={cn("text-xs", colors[env])}>
{env.charAt(0).toUpperCase() + env.slice(1)}
</Badge>
);
}
function RequestModeBadge({ mode }: { mode: NetworkRequestMode }) {
if (mode === "open") return <Badge variant="outline" className="text-xs text-green-600 border-green-300">Open</Badge>;
if (mode === "approval_required") return <Badge variant="outline" className="text-xs text-yellow-600 border-yellow-300">Approval Required</Badge>;
return <Badge variant="outline" className="text-xs text-purple-600 border-purple-300">Invite Only</Badge>;
}
function 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<string, DeviceNetworkMembership[]> {
const grouped = new Map<string, DeviceNetworkMembership[]>();
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<MembershipState, { label: string; className: string }> = {
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 <Badge variant="outline" className={cn("text-xs", className)}>{label}</Badge>;
}
function ApprovalStateBadge({ state }: { state: string }) {
const colors: Record<string, string> = {
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 (
<Badge variant="outline" className={cn("text-xs", colors[state] || "bg-gray-100 text-gray-600")}>
{state.charAt(0).toUpperCase() + state.slice(1)}
</Badge>
);
}
export default function NetworkManagementPage() {
const { networkId } = useParams<{ networkId: string }>();
const { orgId } = useCurrentOrganizationId();
const navigate = useNavigate();
const [network, setNetwork] = useState<PortalNetwork | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
const [members, setMembers] = useState<DeviceNetworkMembership[]>([]);
const [isMembersLoading, setIsMembersLoading] = useState(false);
const [membersError, setMembersError] = useState<string | null>(null);
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set());
const [activatingMembership, setActivatingMembership] = useState<string | null>(null);
const [deactivatingMembership, setDeactivatingMembership] = useState<string | null>(null);
const [requests, setRequests] = useState<UserNetworkApproval[]>([]);
const [isRequestsLoading, setIsRequestsLoading] = useState(false);
const [requestsError, setRequestsError] = useState<string | null>(null);
const [approvingRequest, setApprovingRequest] = useState<string | null>(null);
const [rejectingRequest, setRejectingRequest] = useState<string | null>(null);
// Add New Membership dialog state
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [addStep, setAddStep] = useState<1 | 2 | 3>(1);
const [selectedUser, setSelectedUser] = useState<OrgMember | null>(null);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const [orgMembers, setOrgMembers] = useState<OrgMember[]>([]);
const [userDevices, setUserDevices] = useState<Device[]>([]);
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 (
<div className="page-container">
<div className="page-header">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96 mt-2" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
</div>
);
}
// ── ERROR STATE ─────────────────────────────────────────────────────────────
if (error) {
return (
<div className="page-container">
<Card className="mx-auto max-w-lg">
<CardContent className="flex flex-col items-center justify-center py-12 space-y-4">
<AlertTriangle className="h-12 w-12 text-muted-foreground" />
<p className="text-lg font-medium text-center">{error}</p>
<Button
variant="outline"
onClick={() => navigate("/org/zerotier/networks")}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Networks
</Button>
</CardContent>
</Card>
</div>
);
}
// ── SUCCESS STATE ───────────────────────────────────────────────────────────
return (
<div className="page-container">
<div className="page-header">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => navigate("/org/zerotier/networks")}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Networks
</Button>
</div>
<div className="mt-4">
<h1 className="page-title flex items-center gap-3">
<Network className="h-6 w-6" />
{network?.name ?? "Network"}
</h1>
<p className="page-description">
Manage network members, devices, and access requests
</p>
</div>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="requests">Requests</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Network className="w-5 h-5" />
Network Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Status and badges row */}
<div className="flex items-center gap-2 flex-wrap">
<EnvironmentBadge env={network!.environment} />
<RequestModeBadge mode={network!.request_mode} />
{!network!.is_active && (
<Badge variant="outline" className="text-xs text-red-600 border-red-300 bg-red-50">
<Ban className="w-3 h-3 mr-1" />Inactive
</Badge>
)}
</div>
{/* Description */}
{network!.description && (
<p className="text-sm text-muted-foreground">{network!.description}</p>
)}
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">ZeroTier Network ID</p>
<p className="font-mono text-sm">{network!.zerotier_network_id}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Default Activation</p>
<p className="text-sm font-medium">{network!.default_activation_lifetime_minutes} min</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Max Activation</p>
<p className="text-sm font-medium">{network!.max_activation_lifetime_minutes ? `${network!.max_activation_lifetime_minutes} min` : "No limit"}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Created</p>
<p className="text-sm font-medium">{formatDate(network!.created_at)}</p>
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<Card className="bg-muted/30">
<CardContent className="pt-4 pb-3 px-4">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">Approved Users</p>
</div>
<p className="text-2xl font-bold mt-1">{network!.approved_user_count ?? 0}</p>
</CardContent>
</Card>
<Card className="bg-muted/30">
<CardContent className="pt-4 pb-3 px-4">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-green-500" />
<p className="text-xs text-muted-foreground">Active Devices</p>
</div>
<p className="text-2xl font-bold mt-1 text-green-600">{network!.active_membership_count ?? 0}</p>
</CardContent>
</Card>
<Card className="bg-muted/30">
<CardContent className="pt-4 pb-3 px-4">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">Request Mode</p>
</div>
<p className="text-sm font-medium mt-1 capitalize">{network!.request_mode.replace(/_/g, " ")}</p>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="members">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Users className="w-5 h-5" />
Network Members
</span>
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => { setAddStep(1); setSelectedUser(null); setSelectedDevice(null); setUserSearch(""); setIsAddDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />
Add New Membership
</Button>
<Badge variant="secondary">{members.length} memberships</Badge>
</div>
</CardTitle>
</CardHeader>
<CardContent>
{isMembersLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading members</span>
</div>
) : membersError ? (
<div className="p-6 text-center text-destructive">{membersError}</div>
) : members.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">No members on this network yet.</div>
) : (
<div className="space-y-3">
{Array.from(groupMembersByUser(members).entries()).map(([userId, userMemberships]) => {
const isExpanded = expandedUsers.has(userId);
const activeCount = userMemberships.filter(m => m.currently_authorized).length;
return (
<div key={userId} className="border rounded-lg overflow-hidden">
{/* User header - clickable to expand/collapse */}
<button
className="w-full flex items-center gap-3 p-4 text-left hover:bg-accent/50 transition-colors"
onClick={() => {
setExpandedUsers(prev => {
const next = new Set(prev);
if (next.has(userId)) next.delete(userId);
else next.add(userId);
return next;
});
}}
>
{isExpanded ? <ChevronDown className="w-4 h-4 flex-shrink-0" /> : <ChevronRight className="w-4 h-4 flex-shrink-0" />}
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Users className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate font-mono text-sm">{userId}</p>
<p className="text-xs text-muted-foreground">
{userMemberships.length} device{userMemberships.length !== 1 ? "s" : ""}
{activeCount > 0 && <span className="text-green-600 ml-2">{activeCount} active</span>}
</p>
</div>
</button>
{/* Device list - shown when expanded */}
{isExpanded && (
<div className="border-t divide-y bg-muted/20">
{userMemberships.map((m) => (
<div key={m.id} className="flex items-center gap-3 p-3 pl-11">
<Monitor className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">{m.device_id}</p>
<MembershipStateBadge state={m.state} />
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{m.currently_authorized ? (
<><CheckCircle className="w-3 h-3 text-green-500" /> Authorized</>
) : (
<><XCircle className="w-3 h-3 text-muted-foreground" /> Unauthorized</>
)}
</span>
{m.active_session && m.active_session.is_active && (
<span className="flex items-center gap-1 text-green-600">
<Clock className="w-3 h-3" /> Session active (expires {new Date(m.active_session.expires_at).toLocaleTimeString()})
</span>
)}
<span>Joined: {m.join_seen ? "Yes" : "No"}</span>
</div>
</div>
{/* Activate/Deactivate button */}
{m.currently_authorized ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDeactivate(m.id)}
disabled={deactivatingMembership === m.id}
className="flex-shrink-0"
>
{deactivatingMembership === m.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
Deactivate
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={() => handleActivate(m.id)}
disabled={activatingMembership === m.id}
className="flex-shrink-0"
>
{activatingMembership === m.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
Activate
</Button>
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="requests">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Clock className="w-5 h-5" />
Access Requests
</span>
<Badge variant="secondary">{requests.length} pending</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{isRequestsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading requests</span>
</div>
) : requestsError ? (
<div className="p-6 text-center text-destructive">{requestsError}</div>
) : requests.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">No pending requests for this network.</div>
) : (
<div className="divide-y">
{requests.map((r) => (
<div key={r.id} className="flex items-start gap-4 p-4">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Users className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium font-mono text-sm">{r.user_id}</p>
<ApprovalStateBadge state={r.state} />
<Badge variant="outline" className="text-xs capitalize">{r.grant_type}</Badge>
</div>
{r.justification && (
<p className="text-xs text-muted-foreground mt-1">"{r.justification}"</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Requested: {formatDate(r.created_at)}
</p>
</div>
{r.state === "pending" && (
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="default"
size="sm"
onClick={() => handleApprove(r.id)}
disabled={approvingRequest === r.id}
>
{approvingRequest === r.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleReject(r.id)}
disabled={rejectingRequest === r.id}
>
{rejectingRequest === r.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
Reject
</Button>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Add New Membership Dialog */}
<Dialog open={isAddDialogOpen} onOpenChange={(open) => { setIsAddDialogOpen(open); if (!open) { setAddStep(1); setSelectedUser(null); setSelectedDevice(null); setUserSearch(""); } }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{addStep === 1 && "Add New Membership - Select User"}
{addStep === 2 && "Add New Membership - Select Device"}
{addStep === 3 && "Add New Membership - Confirm"}
</DialogTitle>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>
{addStep === 1 && (
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users by name or email..."
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="max-h-60 overflow-y-auto space-y-1">
{isLoadingMembers ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
) : (
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 => (
<button
key={m.id}
className="w-full text-left px-3 py-2 rounded hover:bg-accent flex items-center gap-3"
onClick={() => setSelectedUser(m)}
>
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Users className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{m.user?.full_name || "Unnamed User"}</p>
<p className="text-xs text-muted-foreground truncate">{m.user?.email || m.user_id}</p>
</div>
</button>
))
)}
</div>
</div>
)}
{addStep === 2 && selectedUser && (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<Button variant="ghost" size="sm" onClick={() => { setAddStep(1); setSelectedUser(null); setSelectedDevice(null); }}>
<ArrowLeft className="w-4 h-4 mr-1" />
Back
</Button>
<span className="text-sm text-muted-foreground">
{selectedUser.user?.full_name || selectedUser.user_id}
</span>
</div>
{isLoadingDevices ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : userDevices.length === 0 ? (
<p className="text-center text-muted-foreground py-4">No devices found for this user.</p>
) : (
<div className="max-h-60 overflow-y-auto space-y-1">
{userDevices.map(d => (
<button
key={d.id}
className={cn(
"w-full text-left px-3 py-2 rounded hover:bg-accent flex items-center gap-3",
selectedDevice?.id === d.id && "bg-accent"
)}
onClick={() => { setSelectedDevice(d); setAddStep(3); }}
>
<Monitor className="w-4 h-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{d.device_nickname || d.hostname || d.node_id}</p>
<p className="text-xs text-muted-foreground font-mono">{d.node_id}</p>
</div>
{selectedDevice?.id === d.id && <CheckCircle className="w-4 h-4 text-primary" />}
</button>
))}
</div>
)}
</div>
)}
{addStep === 3 && selectedUser && selectedDevice && (
<div className="space-y-4">
<div className="bg-muted/50 p-4 rounded-lg space-y-3">
<div>
<p className="text-xs text-muted-foreground">User</p>
<p className="text-sm font-medium">{selectedUser.user?.full_name || "Unnamed User"}</p>
<p className="text-xs text-muted-foreground">{selectedUser.user?.email || selectedUser.user_id}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Device</p>
<p className="text-sm font-medium">{selectedDevice.device_nickname || selectedDevice.hostname || "Unnamed Device"}</p>
<p className="text-xs text-muted-foreground font-mono">{selectedDevice.node_id}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Network</p>
<p className="text-sm font-medium">{network?.name}</p>
</div>
</div>
</div>
)}
<DialogFooter>
{addStep === 1 && (
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
)}
{addStep === 2 && (
<Button variant="outline" onClick={() => { setAddStep(1); setSelectedUser(null); setSelectedDevice(null); }}>Back</Button>
)}
{addStep === 3 && (
<>
<Button variant="outline" onClick={() => { setAddStep(2); setSelectedDevice(null); }}>Back</Button>
<Button onClick={handleAddMembership} disabled={isJoining}>
{isJoining && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Add Membership
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+5 -152
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { import {
Network, Network,
Plus, Plus,
@@ -7,14 +8,10 @@ import {
MoreHorizontal, MoreHorizontal,
ChevronRight, ChevronRight,
Users, Users,
Monitor,
Clock,
Shield,
Trash2, Trash2,
Pencil, Pencil,
Eye, Eye,
CheckCircle, CheckCircle,
XCircle,
Ban, Ban,
Zap, Zap,
Download, Download,
@@ -55,7 +52,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { import {
@@ -63,8 +59,6 @@ import {
ApiError, ApiError,
AvailableZtNetwork, AvailableZtNetwork,
PortalNetwork, PortalNetwork,
DeviceNetworkMembership,
UserNetworkApproval,
NetworkEnvironment, NetworkEnvironment,
NetworkRequestMode, NetworkRequestMode,
} from "@/lib/api"; } from "@/lib/api";
@@ -119,6 +113,7 @@ function cn(...classes: (string | boolean | undefined | null)[]) {
export default function NetworksPage() { export default function NetworksPage() {
const { orgId } = useCurrentOrganizationId(); const { orgId } = useCurrentOrganizationId();
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate();
const [networks, setNetworks] = useState<PortalNetwork[]>([]); const [networks, setNetworks] = useState<PortalNetwork[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -136,11 +131,6 @@ export default function NetworksPage() {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const [selectedNetwork, setSelectedNetwork] = useState<PortalNetwork | null>(null);
const [networkMembers, setNetworkMembers] = useState<DeviceNetworkMembership[]>([]);
const [networkRequests, setNetworkRequests] = useState<UserNetworkApproval[]>([]);
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
const [editingNetwork, setEditingNetwork] = useState<PortalNetwork | null>(null); const [editingNetwork, setEditingNetwork] = useState<PortalNetwork | null>(null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
@@ -180,31 +170,6 @@ export default function NetworksPage() {
fetchNetworks(); fetchNetworks();
}, [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 () => { const openZtPicker = async () => {
if (!orgId) return; if (!orgId) return;
setShowZtPicker(true); setShowZtPicker(true);
@@ -355,7 +320,7 @@ export default function NetworksPage() {
Portal Networks Portal Networks
{!isLoading && <Badge variant="secondary" className="ml-1">{networks.length}</Badge>} {!isLoading && <Badge variant="secondary" className="ml-1">{networks.length}</Badge>}
</CardTitle> </CardTitle>
<CardDescription>Click a network to view members, requests, and manage access</CardDescription> <CardDescription>Click a network to manage members, devices, and access requests</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
@@ -375,7 +340,7 @@ export default function NetworksPage() {
<button <button
key={network.id} key={network.id}
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors" className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
onClick={() => openNetworkDrawer(network)} onClick={() => navigate(`/org/zerotier/networks/${network.id}`)}
> >
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Network className="w-5 h-5 text-primary" /> <Network className="w-5 h-5 text-primary" />
@@ -410,7 +375,7 @@ export default function NetworksPage() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openNetworkDrawer(network); }}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); navigate(`/org/zerotier/networks/${network.id}`); }}>
<Eye className="w-4 h-4 mr-2" /> View details <Eye className="w-4 h-4 mr-2" /> View details
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEditDialog(network); }}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEditDialog(network); }}>
@@ -696,118 +661,6 @@ export default function NetworksPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Network Detail Drawer */}
<Sheet open={!!selectedNetwork} onOpenChange={(open) => { if (!open) closeDrawer(); }}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
{selectedNetwork && (
<>
<SheetHeader className="mb-4">
<SheetTitle className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<Network className="w-5 h-5 text-primary" />
</div>
{selectedNetwork.name}
</SheetTitle>
<SheetDescription className="font-mono">{selectedNetwork.zerotier_network_id}</SheetDescription>
</SheetHeader>
<div className="space-y-4 mb-6">
<div className="flex items-center gap-2 flex-wrap">
<EnvironmentBadge env={selectedNetwork.environment} />
<RequestModeBadge mode={selectedNetwork.request_mode} />
</div>
{selectedNetwork.description && (
<p className="text-sm text-muted-foreground">{selectedNetwork.description}</p>
)}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Default activation</span>
<p className="font-medium">{selectedNetwork.default_activation_lifetime_minutes} min</p>
</div>
<div>
<span className="text-muted-foreground">Max activation</span>
<p className="font-medium">{selectedNetwork.max_activation_lifetime_minutes ? `${selectedNetwork.max_activation_lifetime_minutes} min` : "No limit"}</p>
</div>
<div>
<span className="text-muted-foreground">Approved users</span>
<p className="font-medium flex items-center gap-1"><Users className="w-3 h-3" />{selectedNetwork.approved_user_count ?? 0}</p>
</div>
<div>
<span className="text-muted-foreground">Active devices</span>
<p className="font-medium flex items-center gap-1 text-green-600"><Zap className="w-3 h-3" />{selectedNetwork.active_membership_count ?? 0}</p>
</div>
</div>
</div>
{isDrawerLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<Tabs defaultValue="members" className="w-full">
<TabsList className="mb-3">
<TabsTrigger value="members">
Members ({networkMembers.length})
</TabsTrigger>
<TabsTrigger value="requests">
Requests ({networkRequests.length})
</TabsTrigger>
</TabsList>
<TabsContent value="members">
{networkMembers.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">No members yet.</div>
) : (
<div className="space-y-2">
{networkMembers.map((m) => (
<div key={m.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
<Monitor className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{m.device_id}</p>
<p className="text-xs text-muted-foreground">
State: {m.state} · Join seen: {m.join_seen ? "Yes" : "No"}
</p>
</div>
<div className="flex items-center gap-1">
{m.currently_authorized ? (
<><CheckCircle className="w-4 h-4 text-green-500" /><span className="text-xs text-green-600">Authorized</span></>
) : (
<><XCircle className="w-4 h-4 text-muted-foreground" /><span className="text-xs text-muted-foreground">Inactive</span></>
)}
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="requests">
{networkRequests.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">No pending requests.</div>
) : (
<div className="space-y-2">
{networkRequests.map((r) => (
<div key={r.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
<Clock className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{r.user_id}</p>
<p className="text-xs text-muted-foreground">
{r.grant_type} · {r.state}
</p>
{r.justification && <p className="text-xs text-muted-foreground mt-1">"{r.justification}"</p>}
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
)}
</>
)}
</SheetContent>
</Sheet>
</div> </div>
); );
} }
File diff suppressed because it is too large Load Diff
+678
View File
@@ -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<string, unknown>;
constructor(message: string, code: number, type: string, details: Record<string, unknown> = {}) {
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(
<MemoryRouter>
<NetworksPage />
</MemoryRouter>,
);
}
// 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 <script>alert("xss")</script>',
description: "desc ${injection}",
zerotier_network_id: "../../etc/passwd",
},
],
count: 1,
});
renderPage();
await waitFor(() => {
expect(
screen.getByText('VPN <script>alert("xss")</script>'),
).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);
});
});