feat: add network management page and inline accordion device details
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -54,6 +54,7 @@ import PrincipalsPage from "@/pages/org/PrincipalsPage";
|
||||
import ApiKeysPage from "@/pages/org/ApiKeysPage";
|
||||
import MyMembershipsPage from "@/pages/org/MyMembershipsPage";
|
||||
import NetworksPage from "@/pages/org/NetworksPage";
|
||||
import NetworkManagementPage from "@/pages/org/NetworkManagementPage";
|
||||
import DevicesPage from "@/pages/org/DevicesPage";
|
||||
import AccessPage from "@/pages/org/AccessPage";
|
||||
import ZeroTierConfigPage from "@/pages/org/ZeroTierConfigPage";
|
||||
@@ -209,6 +210,7 @@ function AppRoutes() {
|
||||
<Route path="/org/clients" element={<RequireAdmin><OIDCClientsPage /></RequireAdmin>} />
|
||||
<Route path="/org/cas" element={<RequireAdmin><CAsPage /></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/config" element={<RequireAdmin><ZeroTierConfigPage /></RequireAdmin>} />
|
||||
|
||||
|
||||
@@ -177,6 +177,40 @@ export interface AdminLinkedAccount {
|
||||
linked_at: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUserSshCertificate {
|
||||
id: string;
|
||||
ca_id: string;
|
||||
user_id: string;
|
||||
ssh_key_id: string | null;
|
||||
serial: string;
|
||||
key_id: string;
|
||||
cert_type: 'user' | 'host';
|
||||
principals: string[];
|
||||
valid_after: string;
|
||||
valid_before: string;
|
||||
revoked: boolean;
|
||||
revoked_at: string | null;
|
||||
revoke_reason: string | null;
|
||||
status: 'issued' | 'revoked' | 'expired' | 'superseded';
|
||||
request_ip: string | null;
|
||||
request_user_agent: string | null;
|
||||
critical_options: Record<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
|
||||
export type ExternalProviderId = 'google' | 'github' | 'microsoft';
|
||||
|
||||
@@ -698,6 +732,27 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(policy),
|
||||
}, true, requestConfig),
|
||||
|
||||
// Get SSH certificates issued to a user (admin view)
|
||||
getUserSshCertificates: (userId: string, params?: {
|
||||
status?: string;
|
||||
active?: string;
|
||||
cert_type?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}, requestConfig?: RequestConfig) => {
|
||||
const qs = params ? '?' + new URLSearchParams(
|
||||
Object.entries(params).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)])
|
||||
).toString() : '';
|
||||
return request<{
|
||||
user: { id: string; email: string; full_name: string };
|
||||
certificates: AdminUserSshCertificate[];
|
||||
count: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
pages: number;
|
||||
}>(`/admin/users/${userId}/ssh-certificates${qs}`, {}, true, requestConfig);
|
||||
},
|
||||
},
|
||||
|
||||
totp: {
|
||||
@@ -1568,6 +1623,19 @@ export const api = {
|
||||
true, requestConfig,
|
||||
),
|
||||
|
||||
// ── Org Members (for Add New Membership dialog) ────────────────────
|
||||
getOrgMembers: (orgId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ members: OrgMember[]; count: number }>(
|
||||
`/organizations/${orgId}/members`,
|
||||
{}, true, requestConfig,
|
||||
),
|
||||
|
||||
getUserDevices: (orgId: string, userId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ devices: Device[]; count: number }>(
|
||||
`/organizations/${orgId}/users/${userId}/devices`,
|
||||
{}, true, requestConfig,
|
||||
),
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||
listSessions: (orgId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ sessions: ActivationSession[]; count: number }>(
|
||||
@@ -1964,6 +2032,32 @@ export interface AvailableZtNetwork {
|
||||
portal_network_name: string | null;
|
||||
}
|
||||
|
||||
export interface OrgMember {
|
||||
id: string;
|
||||
user_id: string;
|
||||
organization_id: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
invited_at: string | null;
|
||||
invited_by_id: string | null;
|
||||
joined_at: string | null;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
status: string;
|
||||
avatar_url: string | null;
|
||||
activated: boolean;
|
||||
email_verified: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_login_at: string | null;
|
||||
last_login_ip: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
user_id: string;
|
||||
|
||||
@@ -19,8 +19,12 @@ import {
|
||||
KeyRound,
|
||||
Link2,
|
||||
Unlink,
|
||||
Award,
|
||||
ExternalLink,
|
||||
Lock,
|
||||
FileKey,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -56,7 +60,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
|
||||
import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount, AdminUserSshCertificate } from "@/lib/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
@@ -99,6 +103,7 @@ function RoleBadge({ role }: { role: string }) {
|
||||
export default function AdminUsersPage() {
|
||||
const { toast } = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// User list
|
||||
const [users, setUsers] = useState<ApiUser[]>([]);
|
||||
@@ -162,6 +167,11 @@ export default function AdminUsersPage() {
|
||||
const [passwordResetError, setPasswordResetError] = useState<string | null>(null);
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
||||
setIsLoading(true);
|
||||
@@ -203,12 +213,16 @@ export default function AdminUsersPage() {
|
||||
setUserMfaMethods([]);
|
||||
setUserLinkedAccounts([]);
|
||||
setTotalAuthMethods(0);
|
||||
setUserSshCerts([]);
|
||||
setSshCertsCount(0);
|
||||
setIsSshCertsLoading(true);
|
||||
setIsDrawerLoading(true);
|
||||
try {
|
||||
const [userData, mfaData, linkedData] = await Promise.allSettled([
|
||||
const [userData, mfaData, linkedData, certsData] = await Promise.allSettled([
|
||||
api.admin.getUser(user.id),
|
||||
api.admin.getUserMfa(user.id),
|
||||
api.admin.getUserLinkedAccounts(user.id),
|
||||
api.admin.getUserSshCertificates(user.id, { per_page: 5 }),
|
||||
]);
|
||||
if (userData.status === "fulfilled") setUserSshKeys(userData.value.ssh_keys);
|
||||
if (mfaData.status === "fulfilled") setUserMfaMethods(mfaData.value.mfa_methods);
|
||||
@@ -216,10 +230,18 @@ export default function AdminUsersPage() {
|
||||
setUserLinkedAccounts(linkedData.value.linked_accounts);
|
||||
setTotalAuthMethods(linkedData.value.total_auth_methods);
|
||||
}
|
||||
if (certsData.status === "fulfilled") {
|
||||
setUserSshCerts(certsData.value.certificates);
|
||||
setSshCertsCount(certsData.value.count);
|
||||
} else {
|
||||
setUserSshCerts([]);
|
||||
setSshCertsCount(0);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
} finally {
|
||||
setIsDrawerLoading(false);
|
||||
setIsSshCertsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -929,6 +951,70 @@ export default function AdminUsersPage() {
|
||||
)}
|
||||
</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 */}
|
||||
{selectedUser.id !== currentUser?.id && (
|
||||
<div className="mt-6 p-4 border border-destructive/30 rounded-lg space-y-3">
|
||||
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
UserCheck,
|
||||
ShieldOff,
|
||||
Plus,
|
||||
Award,
|
||||
Clock,
|
||||
FileKey,
|
||||
Globe,
|
||||
Terminal,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,6 +33,13 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -38,7 +52,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api, ApiError, User, SSHKey, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
|
||||
import { api, ApiError, User, SSHKey, AdminMfaMethod, AdminLinkedAccount, AdminUserSshCertificate } from "@/lib/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -155,6 +169,233 @@ function UserManagementSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Certificate Status Badge ─────────────────────────────────────────────────
|
||||
|
||||
function CertStatusBadge({ cert }: { cert: AdminUserSshCertificate }) {
|
||||
if (cert.revoked) {
|
||||
return (
|
||||
<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 ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UserManagementPage() {
|
||||
@@ -207,6 +448,16 @@ export default function UserManagementPage() {
|
||||
// Role update state
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleRemoveMfaMethod = async (method: AdminMfaMethod) => {
|
||||
@@ -545,6 +796,38 @@ export default function UserManagementPage() {
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
// ── Fetch SSH Certificates ───────────────────────────────────────────────────
|
||||
// Reset page when filters change
|
||||
useEffect(() => {
|
||||
setCertsPage(1);
|
||||
}, [certStatusFilter, certActiveFilter, certTypeFilter]);
|
||||
|
||||
// Fetch SSH certificates
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
let cancelled = false;
|
||||
const fetchCerts = async () => {
|
||||
setIsCertsLoading(true);
|
||||
try {
|
||||
const params: Record<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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) {
|
||||
@@ -588,6 +871,10 @@ export default function UserManagementPage() {
|
||||
<TabsTrigger value="details">User Details</TabsTrigger>
|
||||
<TabsTrigger value="security">Security</TabsTrigger>
|
||||
<TabsTrigger value="access">Access</TabsTrigger>
|
||||
<TabsTrigger value="certs">
|
||||
<FileKey className="w-3.5 h-3.5 mr-1.5" />
|
||||
SSH Certificates
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── User Details Tab ────────────────────────────────────────────── */}
|
||||
@@ -918,6 +1205,110 @@ export default function UserManagementPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* ── Remove all MFA confirmation dialog ───────────────────────────── */}
|
||||
|
||||
+107
-149
@@ -40,13 +40,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -159,9 +153,7 @@ export default function DevicesPage() {
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const [regError, setRegError] = useState<string | null>(null);
|
||||
|
||||
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
|
||||
const [deviceMemberships, setDeviceMemberships] = useState<DeviceNetworkMembership[]>([]);
|
||||
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
|
||||
const [expandedDeviceId, setExpandedDeviceId] = useState<string | null>(null);
|
||||
|
||||
const [editDevice, setEditDevice] = useState<Device | null>(null);
|
||||
const [editNickname, setEditNickname] = useState("");
|
||||
@@ -218,25 +210,6 @@ export default function DevicesPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const openDeviceDrawer = async (device: Device) => {
|
||||
setSelectedDevice(device);
|
||||
setIsDrawerLoading(true);
|
||||
setDeviceMemberships([]);
|
||||
try {
|
||||
const deviceMem = memberships.filter((m) => m.device_id === device.id);
|
||||
setDeviceMemberships(deviceMem);
|
||||
} catch {
|
||||
// non-fatal
|
||||
} finally {
|
||||
setIsDrawerLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
setSelectedDevice(null);
|
||||
setDeviceMemberships([]);
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!orgId) return;
|
||||
setRegError(null);
|
||||
@@ -477,7 +450,7 @@ export default function DevicesPage() {
|
||||
Registered Devices
|
||||
{!isLoading && <Badge variant="secondary" className="ml-1">{devices.length}</Badge>}
|
||||
</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>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
@@ -497,11 +470,13 @@ export default function DevicesPage() {
|
||||
const activeCount = memberships.filter(
|
||||
(m) => m.device_id === device.id && m.currently_authorized
|
||||
).length;
|
||||
const isExpanded = expandedDeviceId === device.id;
|
||||
const deviceMemberships = memberships.filter((m) => m.device_id === device.id);
|
||||
return (
|
||||
<div key={device.id}>
|
||||
<button
|
||||
key={device.id}
|
||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => openDeviceDrawer(device)}
|
||||
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">
|
||||
<DeviceTypeIcon nickname={device.device_nickname} hostname={device.hostname} />
|
||||
@@ -534,7 +509,7 @@ export default function DevicesPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openDeviceDrawer(device); }}>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); setExpandedDeviceId(isExpanded ? null : device.id); }}>
|
||||
<ChevronRight className="w-4 h-4 mr-2" /> View memberships
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
@@ -554,8 +529,106 @@ export default function DevicesPage() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<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>
|
||||
@@ -1038,122 +1111,7 @@ export default function DevicesPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Device Detail Drawer */}
|
||||
<Sheet open={!!selectedDevice} onOpenChange={(open) => { if (!open) closeDrawer(); }}>
|
||||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||
{selectedDevice && (
|
||||
<>
|
||||
<SheetHeader className="mb-4">
|
||||
<SheetTitle className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<DeviceTypeIcon nickname={selectedDevice.device_nickname} hostname={selectedDevice.hostname} />
|
||||
</div>
|
||||
{selectedDevice.device_nickname || selectedDevice.node_id}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="font-mono">{selectedDevice.node_id}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
{selectedDevice.hostname && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Hostname</span>
|
||||
<span>{selectedDevice.hostname}</span>
|
||||
</>
|
||||
)}
|
||||
{selectedDevice.asset_tag && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Asset Tag</span>
|
||||
<span>{selectedDevice.asset_tag}</span>
|
||||
</>
|
||||
)}
|
||||
{selectedDevice.serial_number && (
|
||||
<>
|
||||
<span className="text-muted-foreground">Serial</span>
|
||||
<span>{selectedDevice.serial_number}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground">Registered</span>
|
||||
<span>{formatDate(selectedDevice.created_at)}</span>
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<Badge variant={selectedDevice.status === "active" ? "default" : "outline"} className="w-fit">{selectedDevice.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
Network Memberships ({deviceMemberships.length})
|
||||
</h3>
|
||||
|
||||
{isDrawerLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : deviceMemberships.length === 0 ? (
|
||||
<div className="p-6 text-center text-muted-foreground text-sm">
|
||||
No memberships found. Request network access to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{deviceMemberships.map((m) => {
|
||||
const session = getActiveSession(m.id);
|
||||
const network = networks.find((n) => n.id === m.portal_network_id);
|
||||
return (
|
||||
<div key={m.id} className="p-3 border rounded-lg space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{network?.name || m.portal_network_id}</span>
|
||||
<MembershipStateBadge state={m.state} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{m.approved_for_activation && !m.currently_authorized && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowActivateDialog(m.id)}
|
||||
disabled={activatingId === m.id}
|
||||
className="gap-1"
|
||||
>
|
||||
{activatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
{m.currently_authorized && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDeactivate(m.id)}
|
||||
disabled={deactivatingId === m.id}
|
||||
className="gap-1 text-orange-600 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
{deactivatingId === m.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
||||
Deactivate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{session && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
Session expires: {formatExpiry(session.expires_at)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{m.join_seen ? (
|
||||
<><CheckCircle className="w-3 h-3 text-green-500" /> Joined network</>
|
||||
) : (
|
||||
<><XCircle className="w-3 h-3" /> Not yet joined</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Network,
|
||||
Plus,
|
||||
@@ -7,14 +8,10 @@ import {
|
||||
MoreHorizontal,
|
||||
ChevronRight,
|
||||
Users,
|
||||
Monitor,
|
||||
Clock,
|
||||
Shield,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Ban,
|
||||
Zap,
|
||||
Download,
|
||||
@@ -55,7 +52,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
@@ -63,8 +59,6 @@ import {
|
||||
ApiError,
|
||||
AvailableZtNetwork,
|
||||
PortalNetwork,
|
||||
DeviceNetworkMembership,
|
||||
UserNetworkApproval,
|
||||
NetworkEnvironment,
|
||||
NetworkRequestMode,
|
||||
} from "@/lib/api";
|
||||
@@ -119,6 +113,7 @@ function cn(...classes: (string | boolean | undefined | null)[]) {
|
||||
export default function NetworksPage() {
|
||||
const { orgId } = useCurrentOrganizationId();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -136,11 +131,6 @@ export default function NetworksPage() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<PortalNetwork | null>(null);
|
||||
const [networkMembers, setNetworkMembers] = useState<DeviceNetworkMembership[]>([]);
|
||||
const [networkRequests, setNetworkRequests] = useState<UserNetworkApproval[]>([]);
|
||||
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
|
||||
|
||||
const [editingNetwork, setEditingNetwork] = useState<PortalNetwork | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState("");
|
||||
@@ -180,31 +170,6 @@ export default function NetworksPage() {
|
||||
fetchNetworks();
|
||||
}, [fetchNetworks]);
|
||||
|
||||
const openNetworkDrawer = async (network: PortalNetwork) => {
|
||||
setSelectedNetwork(network);
|
||||
setIsDrawerLoading(true);
|
||||
setNetworkMembers([]);
|
||||
setNetworkRequests([]);
|
||||
try {
|
||||
const [membersRes, requestsRes] = await Promise.allSettled([
|
||||
api.zerotier.getNetworkMembers(orgId!, network.id),
|
||||
api.zerotier.getNetworkPendingRequests(orgId!, network.id),
|
||||
]);
|
||||
if (membersRes.status === "fulfilled") setNetworkMembers(membersRes.value.memberships || []);
|
||||
if (requestsRes.status === "fulfilled") setNetworkRequests(requestsRes.value.requests || []);
|
||||
} catch {
|
||||
// non-fatal
|
||||
} finally {
|
||||
setIsDrawerLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
setSelectedNetwork(null);
|
||||
setNetworkMembers([]);
|
||||
setNetworkRequests([]);
|
||||
};
|
||||
|
||||
const openZtPicker = async () => {
|
||||
if (!orgId) return;
|
||||
setShowZtPicker(true);
|
||||
@@ -355,7 +320,7 @@ export default function NetworksPage() {
|
||||
Portal Networks
|
||||
{!isLoading && <Badge variant="secondary" className="ml-1">{networks.length}</Badge>}
|
||||
</CardTitle>
|
||||
<CardDescription>Click a network to view members, requests, and manage access</CardDescription>
|
||||
<CardDescription>Click a network to manage members, devices, and access requests</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
@@ -375,7 +340,7 @@ export default function NetworksPage() {
|
||||
<button
|
||||
key={network.id}
|
||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => openNetworkDrawer(network)}
|
||||
onClick={() => navigate(`/org/zerotier/networks/${network.id}`)}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Network className="w-5 h-5 text-primary" />
|
||||
@@ -410,7 +375,7 @@ export default function NetworksPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openNetworkDrawer(network); }}>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); navigate(`/org/zerotier/networks/${network.id}`); }}>
|
||||
<Eye className="w-4 h-4 mr-2" /> View details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEditDialog(network); }}>
|
||||
@@ -696,118 +661,6 @@ export default function NetworksPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Network Detail Drawer */}
|
||||
<Sheet open={!!selectedNetwork} onOpenChange={(open) => { if (!open) closeDrawer(); }}>
|
||||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||
{selectedNetwork && (
|
||||
<>
|
||||
<SheetHeader className="mb-4">
|
||||
<SheetTitle className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Network className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
{selectedNetwork.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="font-mono">{selectedNetwork.zerotier_network_id}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<EnvironmentBadge env={selectedNetwork.environment} />
|
||||
<RequestModeBadge mode={selectedNetwork.request_mode} />
|
||||
</div>
|
||||
{selectedNetwork.description && (
|
||||
<p className="text-sm text-muted-foreground">{selectedNetwork.description}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Default activation</span>
|
||||
<p className="font-medium">{selectedNetwork.default_activation_lifetime_minutes} min</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Max activation</span>
|
||||
<p className="font-medium">{selectedNetwork.max_activation_lifetime_minutes ? `${selectedNetwork.max_activation_lifetime_minutes} min` : "No limit"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Approved users</span>
|
||||
<p className="font-medium flex items-center gap-1"><Users className="w-3 h-3" />{selectedNetwork.approved_user_count ?? 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Active devices</span>
|
||||
<p className="font-medium flex items-center gap-1 text-green-600"><Zap className="w-3 h-3" />{selectedNetwork.active_membership_count ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDrawerLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="members" className="w-full">
|
||||
<TabsList className="mb-3">
|
||||
<TabsTrigger value="members">
|
||||
Members ({networkMembers.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="requests">
|
||||
Requests ({networkRequests.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="members">
|
||||
{networkMembers.length === 0 ? (
|
||||
<div className="p-6 text-center text-muted-foreground text-sm">No members yet.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{networkMembers.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{m.device_id}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
State: {m.state} · Join seen: {m.join_seen ? "Yes" : "No"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{m.currently_authorized ? (
|
||||
<><CheckCircle className="w-4 h-4 text-green-500" /><span className="text-xs text-green-600">Authorized</span></>
|
||||
) : (
|
||||
<><XCircle className="w-4 h-4 text-muted-foreground" /><span className="text-xs text-muted-foreground">Inactive</span></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="requests">
|
||||
{networkRequests.length === 0 ? (
|
||||
<div className="p-6 text-center text-muted-foreground text-sm">No pending requests.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{networkRequests.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
|
||||
<Clock className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{r.user_id}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{r.grant_type} · {r.state}
|
||||
</p>
|
||||
{r.justification && <p className="text-xs text-muted-foreground mt-1">"{r.justification}"</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user