diff --git a/src/pages/org/DevicesPage.tsx b/src/pages/org/DevicesPage.tsx
index b8c1157..149d36c 100644
--- a/src/pages/org/DevicesPage.tsx
+++ b/src/pages/org/DevicesPage.tsx
@@ -108,6 +108,64 @@ function MembershipStateBadge({ state }: { state: MembershipState }) {
);
}
+function ApprovedBadge({ approved }: { approved: boolean }) {
+ if (approved) {
+ return (
+
+ Approved
+
+ );
+ }
+ return (
+
+ Not Approved
+
+ );
+}
+
+function ActiveBadge({ active }: { active: boolean }) {
+ if (active) {
+ return (
+
+ Active
+
+ );
+ }
+ return (
+
+ Inactive
+
+ );
+}
+
+function SessionProgress({ session }: { session: ActivationSession }) {
+ const now = Date.now();
+ const expires = new Date(session.expires_at).getTime();
+ const created = new Date(session.authenticated_at).getTime();
+ const total = expires - created;
+ const elapsed = now - created;
+ const ratio = Math.min(Math.max(elapsed / total, 0), 1);
+ const remaining = Math.max(expires - now, 0);
+ const remainingMin = Math.floor(remaining / 60000);
+ const barColor = ratio < 0.5 ? "bg-green-500" : ratio < 0.8 ? "bg-yellow-500" : "bg-red-500";
+
+ const remainingText = remainingMin >= 60
+ ? `${Math.floor(remainingMin / 60)}h ${remainingMin % 60}m remaining`
+ : `${remainingMin}m remaining`;
+
+ return (
+
+ );
+}
+
function ApprovalStateBadge({ state }: { state: ApprovalState }) {
const config: Record
= {
pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: , label: "Pending" },
@@ -468,7 +526,7 @@ export default function DevicesPage() {
{filteredDevices.map((device) => {
const activeCount = memberships.filter(
- (m) => m.device_id === device.id && m.currently_authorized
+ (m) => m.device_id === device.id && m.active
).length;
const isExpanded = expandedDeviceId === device.id;
const deviceMemberships = memberships.filter((m) => m.device_id === device.id);
@@ -576,12 +634,13 @@ export default function DevicesPage() {
return (
-
+
{network?.name || m.portal_network_id}
-
+
+
- {m.approved_for_activation && !m.currently_authorized && (
+ {m.status === "approved" && !m.active && (
{session && (
-
-
- Session expires: {formatExpiry(session.expires_at)}
+
+
+
)}
@@ -734,7 +793,7 @@ export default function DevicesPage() {
)}
- {network.request_mode === "open" && !hasMembership && (
+ {network.request_mode === "open" && (
Join
)}
- {network.request_mode === "approval_required" && !hasMembership && (
+ {network.request_mode === "approval_required" && (
d.id === m.device_id)?.device_nickname ||
devices.find((d) => d.id === m.device_id)?.node_id}
-
+
+
- {m.approved_for_activation && !m.currently_authorized && (
+ {m.status === "approved" && !m.active && (
)}
- {m.currently_authorized && (
+ {m.active && (
d.id === m.device_id);
return (
- {dev?.device_nickname || dev?.node_id}:
+ {dev?.device_nickname || dev?.node_id}: {m.active ? "Active" : "Inactive"}
);
})}
diff --git a/src/pages/org/NetworkManagementPage.tsx b/src/pages/org/NetworkManagementPage.tsx
index e803ac7..35fc853 100644
--- a/src/pages/org/NetworkManagementPage.tsx
+++ b/src/pages/org/NetworkManagementPage.tsx
@@ -1,6 +1,6 @@
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 { Network, ArrowLeft, Loader2, AlertTriangle, Users, Zap, ZapOff, Ban, Shield, Monitor, CheckCircle, Clock, ChevronDown, ChevronRight, Plus, Search, Trash2 } 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";
@@ -8,9 +8,20 @@ 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 { Label } from "@/components/ui/label";
+import { api, PortalNetwork, ApiError, NetworkEnvironment, NetworkRequestMode, DeviceNetworkMembership, UserNetworkApproval, OrgMember, Device, ActivationSession, ApprovalState } from "@/lib/api";
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
import { useToast } from "@/hooks/use-toast";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
function cn(...classes: (string | boolean | undefined | null)[]) {
return classes.filter(Boolean).join(" ");
@@ -55,21 +66,47 @@ function groupMembersByUser(memberships: DeviceNetworkMembership[]): Map = {
- pending_device_registration: { label: "Pending Device", className: "bg-gray-100 text-gray-600 border-gray-200" },
- pending_request: { label: "Pending Request", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
- pending_manager_approval: { label: "Pending Approval", className: "bg-orange-100 text-orange-700 border-orange-200" },
- approved_inactive: { label: "Approved", className: "bg-blue-100 text-blue-700 border-blue-200" },
- joined_deauthorized: { label: "Deauthorized", className: "bg-red-100 text-red-700 border-red-200" },
- active_authorized: { label: "Active", className: "bg-green-100 text-green-700 border-green-200" },
- activation_expired: { label: "Expired", className: "bg-gray-100 text-gray-500 border-gray-200" },
- suspended: { label: "Suspended", className: "bg-red-100 text-red-700 border-red-200" },
- revoked: { label: "Revoked", className: "bg-red-100 text-red-700 border-red-200" },
- rejected: { label: "Rejected", className: "bg-red-100 text-red-700 border-red-200" },
- };
- const { label, className } = config[state] || { label: state, className: "bg-gray-100 text-gray-600" };
- return {label};
+function ActiveBadge({ active }: { active: boolean }) {
+ if (active) {
+ return (
+
+ Active
+
+ );
+ }
+ return (
+
+ Inactive
+
+ );
+}
+
+function SessionProgress({ session }: { session: ActivationSession }) {
+ const now = Date.now();
+ const expires = new Date(session.expires_at).getTime();
+ const created = new Date(session.authenticated_at).getTime();
+ const total = expires - created;
+ const elapsed = now - created;
+ const ratio = Math.min(Math.max(elapsed / total, 0), 1);
+ const remaining = Math.max(expires - now, 0);
+ const remainingMin = Math.floor(remaining / 60000);
+ const barColor = ratio < 0.5 ? "bg-green-500" : ratio < 0.8 ? "bg-yellow-500" : "bg-red-500";
+
+ const remainingText = remainingMin >= 60
+ ? `${Math.floor(remainingMin / 60)}h ${remainingMin % 60}m remaining`
+ : `${remainingMin}m remaining`;
+
+ return (
+
+ );
}
function ApprovalStateBadge({ state }: { state: string }) {
@@ -103,6 +140,13 @@ export default function NetworkManagementPage() {
const [expandedUsers, setExpandedUsers] = useState>(new Set());
const [activatingMembership, setActivatingMembership] = useState(null);
const [deactivatingMembership, setDeactivatingMembership] = useState(null);
+ const [removingMembership, setRemovingMembership] = useState(null);
+ const [removingUserId, setRemovingUserId] = useState(null);
+ const [confirmRemoveDevice, setConfirmRemoveDevice] = useState(null);
+ const [confirmRemoveUser, setConfirmRemoveUser] = useState(null);
+ const [showActivateDialog, setShowActivateDialog] = useState(null);
+ const [activateLifetime, setActivateLifetime] = useState("480");
+ const [activatingAll, setActivatingAll] = useState(false);
const [requests, setRequests] = useState([]);
const [isRequestsLoading, setIsRequestsLoading] = useState(false);
@@ -254,12 +298,18 @@ export default function NetworkManagementPage() {
};
const handleActivate = async (membershipId: string) => {
+ if (!orgId) return;
+ setShowActivateDialog(membershipId);
+ };
+
+ const handleActivateConfirm = async (membershipId: string) => {
if (!orgId) return;
setActivatingMembership(membershipId);
try {
- await api.zerotier.activateMembership(orgId, membershipId);
- toast({ title: "Membership activated" });
- // Refresh members
+ const lifetime = parseInt(activateLifetime);
+ await api.zerotier.activateMembership(orgId, membershipId, lifetime);
+ toast({ title: "Membership activated", description: `Active for ${lifetime} minutes.` });
+ setShowActivateDialog(null);
const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
setMembers(result.memberships || []);
} catch (err) {
@@ -284,6 +334,57 @@ export default function NetworkManagementPage() {
}
};
+ const handleActivateAll = async () => {
+ if (!orgId) return;
+ setActivatingAll(true);
+ try {
+ const lifetime = parseInt(activateLifetime);
+ const res = await api.zerotier.activateAllMemberships(orgId, lifetime);
+ toast({ title: "All memberships activated", description: `${res.count} memberships activated for ${lifetime} minutes.` });
+ const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
+ setMembers(result.memberships || []);
+ } catch (err) {
+ toast({ variant: "destructive", title: "Failed to activate all", description: err instanceof ApiError ? err.message : "Something went wrong." });
+ } finally {
+ setActivatingAll(false);
+ }
+ };
+
+ const handleRemoveDevice = async (membershipId: string) => {
+ if (!orgId) return;
+ setRemovingMembership(membershipId);
+ try {
+ await api.zerotier.adminDeleteMembership(orgId, membershipId);
+ toast({ title: "Device removed from network" });
+ const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
+ setMembers(result.memberships || []);
+ } catch (err) {
+ toast({ variant: "destructive", title: "Failed to remove device", description: err instanceof ApiError ? err.message : "Something went wrong." });
+ } finally {
+ setRemovingMembership(null);
+ setConfirmRemoveDevice(null);
+ }
+ };
+
+ const handleRemoveUserDevices = async (userId: string) => {
+ if (!orgId) return;
+ setRemovingUserId(userId);
+ try {
+ const userMemberships = members.filter(m => m.user_id === userId);
+ await Promise.all(
+ userMemberships.map(m => api.zerotier.adminDeleteMembership(orgId, m.id))
+ );
+ toast({ title: "All devices removed", description: `Removed ${userMemberships.length} device(s) for this user.` });
+ const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
+ setMembers(result.memberships || []);
+ } catch (err) {
+ toast({ variant: "destructive", title: "Failed to remove devices", description: err instanceof ApiError ? err.message : "Something went wrong." });
+ } finally {
+ setRemovingUserId(null);
+ setConfirmRemoveUser(null);
+ }
+ };
+
const handleApprove = async (approvalId: string) => {
if (!orgId) return;
setApprovingRequest(approvalId);
@@ -498,85 +599,112 @@ export default function NetworkManagementPage() {
{Array.from(groupMembersByUser(members).entries()).map(([userId, userMemberships]) => {
const isExpanded = expandedUsers.has(userId);
- const activeCount = userMemberships.filter(m => m.currently_authorized).length;
+ const activeCount = userMemberships.filter(m => m.active).length;
+ const approvedCount = userMemberships.filter(m => m.status === "approved" && !m.active).length;
return (
{/* User header - clickable to expand/collapse */}
-
{
- setExpandedUsers(prev => {
- const next = new Set(prev);
- if (next.has(userId)) next.delete(userId);
- else next.add(userId);
- return next;
- });
- }}
- >
- {isExpanded ? : }
-
-
-
-
-
{userId}
-
- {userMemberships.length} device{userMemberships.length !== 1 ? "s" : ""}
- {activeCount > 0 && {activeCount} active}
-
-
-
+
+
{
+ setExpandedUsers(prev => {
+ const next = new Set(prev);
+ if (next.has(userId)) next.delete(userId);
+ else next.add(userId);
+ return next;
+ });
+ }}
+ >
+ {isExpanded ? : }
+
+
+
+
+
{userId}
+
+ {userMemberships.length} device{userMemberships.length !== 1 ? "s" : ""}
+ {activeCount > 0 && {activeCount} active}
+ {approvedCount > 0 && {approvedCount} ready}
+
+
+
+
setConfirmRemoveUser(userId)}
+ disabled={removingUserId === userId}
+ className="mr-2 text-destructive hover:text-destructive flex-shrink-0"
+ >
+ {removingUserId === userId ? (
+
+ ) : (
+
+ )}
+ Remove All
+
+
{/* Device list - shown when expanded */}
{isExpanded && (
{userMemberships.map((m) => (
-
-
-
-
-
-
- {m.currently_authorized ? (
- <> Authorized>
- ) : (
- <> Unauthorized>
- )}
-
- {m.active_session && m.active_session.is_active && (
-
- Session active (expires {new Date(m.active_session.expires_at).toLocaleTimeString()})
-
- )}
-
Joined: {m.join_seen ? "Yes" : "No"}
+
+
+
+
{m.device_id}
+
+
+
+ Joined: {m.join_seen ? "Yes" : "No"}
+
+ {m.active_session && m.active_session.is_active && (
+
+ )}
- {/* Activate/Deactivate button */}
- {m.currently_authorized ? (
+
+ {m.active ? (
+ handleDeactivate(m.id)}
+ disabled={deactivatingMembership === m.id}
+ className="flex-shrink-0"
+ >
+ {deactivatingMembership === m.id && }
+ Deactivate
+
+ ) : m.status === "approved" ? (
+ handleActivate(m.id)}
+ disabled={activatingMembership === m.id}
+ className="flex-shrink-0"
+ >
+ {activatingMembership === m.id && }
+ Activate
+
+ ) : (
+
+ Not eligible
+
+ )}
handleDeactivate(m.id)}
- disabled={deactivatingMembership === m.id}
- className="flex-shrink-0"
+ onClick={() => setConfirmRemoveDevice(m.id)}
+ disabled={removingMembership === m.id}
+ className="text-destructive hover:text-destructive flex-shrink-0"
>
- {deactivatingMembership === m.id && }
- Deactivate
+ {removingMembership === m.id ? (
+
+ ) : (
+
+ )}
- ) : (
- handleActivate(m.id)}
- disabled={activatingMembership === m.id}
- className="flex-shrink-0"
- >
- {activatingMembership === m.id && }
- Activate
-
- )}
+
))}
@@ -588,6 +716,40 @@ export default function NetworkManagementPage() {
)}
+
+ {/* Activate All section */}
+ {members.filter(m => m.status === "approved" && !m.active).length > 0 && (
+
+
+
+
+
+
+ {members.filter(m => m.active).length} active,{" "}
+ {members.filter(m => m.status === "approved" && !m.active).length} ready to activate
+
+
+
+
+ Duration:
+ setActivateLifetime(e.target.value)}
+ className="h-8 w-20 text-xs"
+ placeholder="480"
+ />
+ min
+
+
+ {activatingAll ? : }
+ Activate All
+
+
+
+
+
+ )}
@@ -805,6 +967,76 @@ export default function NetworkManagementPage() {
+
+ {/* ── Activation Lifetime Dialog ──────────────────────────────────── */}
+
+
+ {/* ── Confirm Remove Single Device ─────────────────────────────────────── */}
+ setConfirmRemoveDevice(null)}>
+
+
+ Remove device from network?
+
+ This will remove this device's membership from the network. The user will need to re-join if they want access again.
+
+
+
+ Cancel
+ confirmRemoveDevice && handleRemoveDevice(confirmRemoveDevice)}
+ >
+ {removingMembership !== null && }
+ Remove
+
+
+
+
+
+ {/* ── Confirm Remove All User Devices ────────────────────────────────── */}
+ setConfirmRemoveUser(null)}>
+
+
+ Remove all devices for this user?
+
+ This will remove all memberships for user {confirmRemoveUser}. All of their devices will lose access to this network.
+
+
+
+ Cancel
+ confirmRemoveUser && handleRemoveUserDevices(confirmRemoveUser)}
+ >
+ {removingUserId !== null && }
+ Remove All
+
+
+
+
);
}
diff --git a/src/pages/org/ca/CADetailCard.tsx b/src/pages/org/ca/CADetailCard.tsx
index 818a21f..20a2768 100644
--- a/src/pages/org/ca/CADetailCard.tsx
+++ b/src/pages/org/ca/CADetailCard.tsx
@@ -47,13 +47,43 @@ export function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardPro
const isSystem = !!ca.is_system;
// ── User CA: server trusts this public key so it accepts user certs ──────
- const userCaServerSnippet = `# On each SSH server — trust Secuird-issued user certificates:
-echo '${ca.public_key.trim()}' >> /etc/ssh/trusted_user_ca
+ const userCaServerSnippet = `#!/usr/bin/env bash
+set -euo pipefail
-# /etc/ssh/sshd_config (add once, then reload sshd):
-TrustedUserCAKeys /etc/ssh/trusted_user_ca
-AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
-# Create /etc/ssh/auth_principals/
containing one principal per line.`;
+CA_KEY='${ca.public_key.trim()}'
+UNIX_USER="ubuntu" # ← change to the server's unix user
+PRINCIPAL="" # ← change to the principal for this user
+
+CA_FILE="/etc/ssh/trusted_user_ca"
+PRINCIPALS_DIR="/etc/ssh/auth_principals"
+SSHD_DROP_IN="/etc/ssh/sshd_config.d/99-ca-auth.conf"
+
+if [[ "\$(id -u)" -ne 0 ]]; then
+ echo "error: must be run as root" >&2
+ exit 1
+fi
+
+install -m 0644 -o root -g root /dev/null "\${CA_FILE}"
+echo "\${CA_KEY}" > "\${CA_FILE}"
+
+install -d -m 0755 -o root -g root "\${PRINCIPALS_DIR}"
+install -m 0644 -o root -g root /dev/null "\${PRINCIPALS_DIR}/\${UNIX_USER}"
+echo "\${PRINCIPAL}" > "\${PRINCIPALS_DIR}/\${UNIX_USER}"
+
+install -d -m 0755 -o root -g root "/etc/ssh/sshd_config.d"
+install -m 0600 -o root -g root /dev/null "\${SSHD_DROP_IN}"
+cat > "\${SSHD_DROP_IN}" </dev/null || systemctl reload sshd
+ echo "done — CA trust and principal '\${PRINCIPAL}' configured for '\${UNIX_USER}'"
+else
+ echo "error: sshd configuration test failed — SSH was NOT reloaded" >&2
+ exit 1
+fi`;
// ── Host CA: clients trust this public key so they can verify server certs ─
const hostCaClientSnippet = `# On SSH clients — trust host certificates signed by this CA:
diff --git a/tests/NetworkManagementPage.test.tsx b/tests/NetworkManagementPage.test.tsx
index ae6acdd..7cf495c 100644
--- a/tests/NetworkManagementPage.test.tsx
+++ b/tests/NetworkManagementPage.test.tsx
@@ -38,31 +38,23 @@ vi.mock("@/hooks/useCurrentOrganization", () => ({
}),
}));
-vi.mock("@/lib/api", () => ({
- api: {
- zerotier: {
- getNetwork: H.mockGetNetwork,
- getNetworkMembers: H.mockGetNetworkMembers,
- activateMembership: H.mockActivateMembership,
- deactivateMembership: H.mockDeactivateMembership,
- getNetworkPendingRequests: H.mockGetNetworkPendingRequests,
- approveRequest: H.mockApproveRequest,
- rejectRequest: H.mockRejectRequest,
+vi.mock("@/lib/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ api: {
+ zerotier: {
+ getNetwork: H.mockGetNetwork,
+ getNetworkMembers: H.mockGetNetworkMembers,
+ activateMembership: H.mockActivateMembership,
+ deactivateMembership: H.mockDeactivateMembership,
+ getNetworkPendingRequests: H.mockGetNetworkPendingRequests,
+ approveRequest: H.mockApproveRequest,
+ rejectRequest: H.mockRejectRequest,
+ },
},
- },
- ApiError: class ApiError extends Error {
- code: number;
- type: string;
- details: Record;
- constructor(message: string, code: number, type: string, details: Record = {}) {
- super(message);
- this.name = "ApiError";
- this.code = code;
- this.type = type;
- this.details = details;
- }
- },
-}));
+ };
+});
vi.mock("@/hooks/use-toast", () => ({
useToast: () => ({
@@ -669,10 +661,12 @@ describe("NetworkManagementPage", () => {
device_id: "dev-laptop-1",
portal_network_id: "net-abc",
user_network_approval_id: null,
- state: "active_authorized",
+ active: true,
+ status: "approved",
+ grant_type: "assigned",
+ granted_by_user_id: null,
+ justification: null,
join_seen: true,
- currently_authorized: true,
- approved_for_activation: true,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
deleted_at: null,
@@ -701,10 +695,12 @@ describe("NetworkManagementPage", () => {
device_id: "dev-desktop-1",
portal_network_id: "net-abc",
user_network_approval_id: null,
- state: "joined_deauthorized",
+ active: false,
+ status: "approved",
+ grant_type: "assigned",
+ granted_by_user_id: null,
+ justification: null,
join_seen: false,
- currently_authorized: false,
- approved_for_activation: false,
created_at: "2025-02-01T00:00:00Z",
updated_at: "2025-02-01T00:00:00Z",
deleted_at: null,
@@ -718,10 +714,12 @@ describe("NetworkManagementPage", () => {
device_id: "dev-phone-1",
portal_network_id: "net-abc",
user_network_approval_id: null,
- state: "approved_inactive",
+ active: false,
+ status: "approved",
+ grant_type: "assigned",
+ granted_by_user_id: null,
+ justification: null,
join_seen: true,
- currently_authorized: false,
- approved_for_activation: true,
created_at: "2025-03-01T00:00:00Z",
updated_at: "2025-03-01T00:00:00Z",
deleted_at: null,
@@ -735,10 +733,12 @@ describe("NetworkManagementPage", () => {
device_id: "dev-server-1",
portal_network_id: "net-abc",
user_network_approval_id: null,
- state: "pending_request",
+ active: false,
+ status: "pending",
+ grant_type: "requested",
+ granted_by_user_id: null,
+ justification: null,
join_seen: false,
- currently_authorized: false,
- approved_for_activation: false,
created_at: "2025-04-01T00:00:00Z",
updated_at: "2025-04-01T00:00:00Z",
deleted_at: null,
@@ -966,7 +966,7 @@ describe("NetworkManagementPage", () => {
// ── Device Details ──────────────────────────────────────────────────────────
- test("renders device state badge for active_authorized membership", async () => {
+ test("renders Active badge for active_authorized membership", async () => {
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
const userButton = screen.getByText("user-a").closest("button");
@@ -975,102 +975,90 @@ describe("NetworkManagementPage", () => {
await waitFor(() => {
expect(screen.getByText("Active")).toBeDefined();
});
- // active_authorized → green badge classes
- const badge = screen.getByText("Active");
- expect(badge.className).toContain("bg-green-100");
- expect(badge.className).toContain("text-green-700");
});
- test("renders device state badge for joined_deauthorized membership", async () => {
+ test("renders Inactive badge for approved_inactive membership", async () => {
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
const userButton = screen.getByText("user-a").closest("button");
fireEvent.click(userButton!);
await waitFor(() => {
- expect(screen.getByText("Deauthorized")).toBeDefined();
+ expect(screen.getByText("Inactive")).toBeDefined();
});
- const badge = screen.getByText("Deauthorized");
- expect(badge.className).toContain("bg-red-100");
- expect(badge.className).toContain("text-red-700");
});
- test("renders device state badge for pending_request membership", async () => {
+ test("renders Inactive badge for pending_request membership", async () => {
await setupMembersTab([MEMBERSHIP_PENDING_REQUEST]);
const userButton = screen.getByText("user-c").closest("button");
fireEvent.click(userButton!);
await waitFor(() => {
- expect(screen.getByText("Pending Request")).toBeDefined();
+ expect(screen.getByText("Inactive")).toBeDefined();
});
- const badge = screen.getByText("Pending Request");
- expect(badge.className).toContain("bg-yellow-100");
- expect(badge.className).toContain("text-yellow-700");
});
- test("renders device state badge for approved_inactive membership", async () => {
+ test("renders Inactive badge for second user (approved_inactive)", async () => {
await setupMembersTab([MEMBERSHIP_SECOND_USER]);
const userButton = screen.getByText("user-b").closest("button");
fireEvent.click(userButton!);
await waitFor(() => {
- expect(screen.getByText("Approved")).toBeDefined();
+ expect(screen.getByText("Inactive")).toBeDefined();
});
- const badge = screen.getByText("Approved");
- expect(badge.className).toContain("bg-blue-100");
- expect(badge.className).toContain("text-blue-700");
});
// ── Authorization Status ────────────────────────────────────────────────────
- test("shows 'Authorized' with green check icon when currently_authorized is true", async () => {
+ test("shows 'Active' badge when membership is active", async () => {
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
const userButton = screen.getByText("user-a").closest("button");
fireEvent.click(userButton!);
await waitFor(() => {
- expect(screen.getByText("Authorized")).toBeDefined();
+ const actives = screen.getAllByText("Active");
+ expect(actives.length).toBeGreaterThanOrEqual(1);
});
});
- test("shows 'Unauthorized' with X icon when currently_authorized is false", async () => {
+ test("shows 'Inactive' badge when membership is not active", async () => {
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
const userButton = screen.getByText("user-a").closest("button");
fireEvent.click(userButton!);
await waitFor(() => {
- expect(screen.getByText("Unauthorized")).toBeDefined();
+ expect(screen.getByText("Inactive")).toBeDefined();
});
});
// ── Active Session Info ─────────────────────────────────────────────────────
- test("shows session info when active_session is present and is_active", async () => {
+ test("shows session progress bar when active_session is present and is_active", async () => {
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
const userButton = screen.getByText("user-a").closest("button");
fireEvent.click(userButton!);
await waitFor(() => {
- expect(screen.getByText(/Session active/)).toBeDefined();
+ expect(screen.getByText(/remaining/)).toBeDefined();
});
});
- test("does NOT show session info when active_session is null", async () => {
+ test("does NOT show session progress when active_session is null", async () => {
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
const userButton = screen.getByText("user-a").closest("button");
fireEvent.click(userButton!);
await waitFor(() => {
- expect(screen.getByText("Deauthorized")).toBeDefined();
+ expect(screen.getByText("Inactive")).toBeDefined();
});
- expect(screen.queryByText(/Session active/)).toBeNull();
+ expect(screen.queryByText(/remaining/)).toBeNull();
});
// ── Join Seen ───────────────────────────────────────────────────────────────
@@ -1099,7 +1087,7 @@ describe("NetworkManagementPage", () => {
// ── Activate / Deactivate Buttons ───────────────────────────────────────────
- test("renders Deactivate button for currently_authorized memberships", async () => {
+ test("renders Deactivate button for active memberships", async () => {
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
const userButton = screen.getByText("user-a").closest("button");
@@ -1111,7 +1099,7 @@ describe("NetworkManagementPage", () => {
expect(screen.queryByRole("button", { name: "Activate" })).toBeNull();
});
- test("renders Activate button for non-currently_authorized memberships", async () => {
+ test("renders Activate button for approved-but-inactive memberships", async () => {
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
const userButton = screen.getByText("user-a").closest("button");
@@ -1123,7 +1111,7 @@ describe("NetworkManagementPage", () => {
expect(screen.queryByRole("button", { name: "Deactivate" })).toBeNull();
});
- test("clicking Activate calls api.zerotier.activateMembership with correct orgId and membershipId", async () => {
+ test("clicking Activate opens dialog then calls api.zerotier.activateMembership with correct orgId, membershipId, and lifetime", async () => {
H.mockActivateMembership.mockResolvedValue({});
// Second call to getNetworkMembers (refresh after activate)
H.mockGetNetworkMembers
@@ -1138,8 +1126,15 @@ describe("NetworkManagementPage", () => {
const activateBtn = await screen.findByRole("button", { name: "Activate" });
fireEvent.click(activateBtn);
+ // Dialog opens; click the Activate button in the dialog
+ await waitFor(() => {
+ expect(screen.getByText("Set Activation Duration")).toBeDefined();
+ });
+ const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
+ fireEvent.click(dialogBtns[dialogBtns.length - 1]);
+
expect(H.mockActivateMembership).toHaveBeenCalledTimes(1);
- expect(H.mockActivateMembership).toHaveBeenCalledWith("org-1", "mem-unauth-1");
+ expect(H.mockActivateMembership).toHaveBeenCalledWith("org-1", "mem-unauth-1", 480);
});
test("clicking Deactivate calls api.zerotier.deactivateMembership with correct orgId and membershipId", async () => {
@@ -1175,6 +1170,13 @@ describe("NetworkManagementPage", () => {
const activateBtn = await screen.findByRole("button", { name: "Activate" });
fireEvent.click(activateBtn);
+ // Confirm in dialog
+ await waitFor(() => {
+ expect(screen.getByText("Set Activation Duration")).toBeDefined();
+ });
+ const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
+ fireEvent.click(dialogBtns[dialogBtns.length - 1]);
+
await waitFor(() => {
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Membership activated" }));
});
@@ -1211,6 +1213,13 @@ describe("NetworkManagementPage", () => {
const activateBtn = await screen.findByRole("button", { name: "Activate" });
fireEvent.click(activateBtn);
+ // Confirm in dialog
+ await waitFor(() => {
+ expect(screen.getByText("Set Activation Duration")).toBeDefined();
+ });
+ const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
+ fireEvent.click(dialogBtns[dialogBtns.length - 1]);
+
await waitFor(() => {
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({
variant: "destructive",
@@ -1252,6 +1261,13 @@ describe("NetworkManagementPage", () => {
const activateBtn = await screen.findByRole("button", { name: "Activate" });
fireEvent.click(activateBtn);
+ // Confirm in dialog
+ await waitFor(() => {
+ expect(screen.getByText("Set Activation Duration")).toBeDefined();
+ });
+ const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
+ fireEvent.click(dialogBtns[dialogBtns.length - 1]);
+
await waitFor(() => {
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({
variant: "destructive",
@@ -1265,8 +1281,8 @@ describe("NetworkManagementPage", () => {
H.mockActivateMembership.mockResolvedValue({});
const updatedMembership = {
...MEMBERSHIP_UNAUTHORIZED_NO_SESSION,
- state: "active_authorized",
- currently_authorized: true,
+ active: true,
+ status: "approved",
};
H.mockGetNetworkMembers
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 })
@@ -1280,6 +1296,13 @@ describe("NetworkManagementPage", () => {
const activateBtn = await screen.findByRole("button", { name: "Activate" });
fireEvent.click(activateBtn);
+ // Confirm in dialog
+ await waitFor(() => {
+ expect(screen.getByText("Set Activation Duration")).toBeDefined();
+ });
+ const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
+ fireEvent.click(dialogBtns[dialogBtns.length - 1]);
+
// Verify getNetworkMembers was called twice: once on mount, once on refresh
await waitFor(() => {
expect(H.mockGetNetworkMembers).toHaveBeenCalledTimes(2);
@@ -1292,8 +1315,8 @@ describe("NetworkManagementPage", () => {
H.mockDeactivateMembership.mockResolvedValue({});
const updatedMembership = {
...MEMBERSHIP_AUTHORIZED_WITH_SESSION,
- state: "joined_deauthorized",
- currently_authorized: false,
+ active: false,
+ status: "approved",
};
H.mockGetNetworkMembers
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 })
@@ -1344,15 +1367,15 @@ describe("NetworkManagementPage", () => {
test("shows active device count in user section header", async () => {
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
- // 1 active (currently_authorized) + 1 inactive → "1 active"
- expect(screen.getByText(/1 active/)).toBeDefined();
+ // 1 active + 1 inactive → "1 active"
+ expect(screen.getByText(/^1 active$/)).toBeDefined();
});
test("does not show active count when no devices are authorized", async () => {
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
- // 0 active devices → no "active" text
- expect(screen.queryByText(/active/)).toBeNull();
+ // 0 active devices → no "active" count in user section
+ expect(screen.queryByText(/^\d+ active$/)).toBeNull();
});
// ── Adversarial: Unicode user_ids ───────────────────────────────────────────