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