From c6fbec6442c0068d0ec3568fd279c2132b9e9fb4 Mon Sep 17 00:00:00 2001 From: cory Date: Thu, 28 May 2026 05:58:56 +0000 Subject: [PATCH] Various QOL updates --- src/lib/api.ts | 18 +- src/pages/admin/UserManagementPage.tsx | 28 ++ src/pages/marketing/SSHCertificatesPage.tsx | 141 +++++++ src/pages/org/DevicesPage.tsx | 88 ++++- src/pages/org/NetworkManagementPage.tsx | 402 +++++++++++++++----- src/pages/org/ca/CADetailCard.tsx | 42 +- tests/NetworkManagementPage.test.tsx | 177 +++++---- 7 files changed, 711 insertions(+), 185 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 199c4fc..da0fc3f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2123,16 +2123,28 @@ export interface DeviceNetworkMembership { device_id: string; portal_network_id: string; user_network_approval_id: string | null; - state: MembershipState; + active: boolean; + status: ApprovalState; + grant_type: ApprovalGrantType; + granted_by_user_id: string | null; + justification: string | null; join_seen: boolean; - currently_authorized: boolean; - approved_for_activation: boolean; created_at: string; updated_at: string; deleted_at: string | null; active_session: ActivationSession | null; } +export function deriveMembershipState(status: ApprovalState, active: boolean): MembershipState { + if (active) return "active_authorized"; + if (status === "approved") return "approved_inactive"; + if (status === "pending") return "pending_manager_approval"; + if (status === "rejected") return "rejected"; + if (status === "revoked") return "revoked"; + if (status === "suspended") return "suspended"; + return "pending_manager_approval"; +} + export interface EnrichedMembership { id: string; user_id: string; diff --git a/src/pages/admin/UserManagementPage.tsx b/src/pages/admin/UserManagementPage.tsx index 5635ff0..a107eb1 100644 --- a/src/pages/admin/UserManagementPage.tsx +++ b/src/pages/admin/UserManagementPage.tsx @@ -1363,6 +1363,34 @@ export default function UserManagementPage() { + {/* ── Suspend confirmation dialog ─────────────────────────────────────── */} + + + + + + Suspend account? + + + {user?.full_name || user?.email} will be blocked from requesting SSH certificates. You can restore their access at any time. + + + + + + + + + {/* ── Admin add SSH key dialog ──────────────────────────────────────────── */} + {/* Deployment Guide */} +
+
+
+
+ + Deployment Guide +
+

+ Deploy to Your Servers +

+

+ One-time setup per server. The script below installs the CA key, configures + principal-based access, and reloads SSH — all in a single idempotent run. +

+
+ +
+ + +
+
1
+
+

Get your CA public key

+

+ In the Secuird dashboard, go to Certificate Authorities and + copy the User CA public key from the detail card. +

+
+
+
+
+ + + +
+
2
+
+

Decide the Unix user and principal

+

+ Each server has a local Unix user (e.g. ubuntu, deploy, root) + that SSH sessions connect to. Choose which principal (from your Secuird configuration) should be + allowed to log in as that user. +

+
+
+
+
+ + + +
+
3
+
+

Run the setup script

+

+ SSH into the server and run the script below as root. Paste your + CA public key, set the Unix user and principal, then execute. +

+ +
+
+
+
+ deploy.sh +
+ +
+                        
+{`#!/usr/bin/env bash
+set -euo pipefail
+
+CA_KEY=''
+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`}
+                        
+                      
+
+ +
+
+ + + + + +
+
4
+
+

Verify the configuration

+

+ The script validates sshd -t before reloading — if you see + "done" at the end, everything is working. To double-check, run: +

+
+                    {`ssh -T user@your-server    # should succeed without a password prompt`}
+                  
+

+ Repeat on every server. Once the CA key is trusted, any user with a valid + Secuird-signed certificate for the matching principal can connect — no more distributing + individual SSH keys to each server. +

+
+
+
+
+
+
+
+ {/* Features Deep Dive */}
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 ( +
+
+
+
+

{remainingText}

+
+ ); +} + 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" && ( )} - {network.request_mode === "approval_required" && !hasMembership && ( + {network.request_mode === "approval_required" && (
- {m.approved_for_activation && !m.currently_authorized && ( + {m.status === "approved" && !m.active && ( +
+ + +
{/* Device list - shown when expanded */} {isExpanded && (
{userMemberships.map((m) => ( -
- -
-
-

{m.device_id}

- -
-
- - {m.currently_authorized ? ( - <> Authorized - ) : ( - <> Unauthorized - )} - - {m.active_session && m.active_session.is_active && ( - - Session active (expires {new Date(m.active_session.expires_at).toLocaleTimeString()}) - - )} - Joined: {m.join_seen ? "Yes" : "No"} +
+ +
+

{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 ? ( + + ) : m.status === "approved" ? ( + + ) : ( + + Not eligible + + )} - ) : ( - - )} +
))}
@@ -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 +
+ +
+
+
+
+ )} @@ -805,6 +967,76 @@ export default function NetworkManagementPage() {
+ + {/* ── Activation Lifetime Dialog ──────────────────────────────────── */} + { if (!open) setShowActivateDialog(null); }}> + + + Set Activation Duration + How long should this membership be active? + +
+
+ + setActivateLifetime(e.target.value)} placeholder="480" /> +

e.g. 480 = 8 hours, 60 = 1 hour

+
+
+ + + + +
+
+ + {/* ── 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 ───────────────────────────────────────────