diff --git a/.env.example b/.env.example index 292d6dd..68a264b 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,7 @@ # =========================================== # Secuird UI Configuration # =========================================== -# Copy this file to .env.local for local development -# or use mode-specific env files (.env.development, .env.staging, .env.production) +# Copy this file to .env for your environment # API Configuration VITE_API_BASE_URL=https://api.gatehouse.local/api/v1 diff --git a/src/lib/api.ts b/src/lib/api.ts index d77ff66..8736692 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2106,6 +2106,8 @@ export interface UserNetworkApproval { id: string; organization_id: string; user_id: string; + user_name: string | null; + user_email: string | null; portal_network_id: string; granted_by_user_id: string | null; grant_type: ApprovalGrantType; @@ -2121,7 +2123,11 @@ export interface DeviceNetworkMembership { id: string; organization_id: string; user_id: string; + user_name: string | null; + user_email: string | null; device_id: string; + device_name: string | null; + device_node_id: string | null; portal_network_id: string; user_network_approval_id: string | null; active: boolean; diff --git a/src/pages/org/NetworkManagementPage.tsx b/src/pages/org/NetworkManagementPage.tsx index 35fc853..1331e84 100644 --- a/src/pages/org/NetworkManagementPage.tsx +++ b/src/pages/org/NetworkManagementPage.tsx @@ -621,7 +621,10 @@ export default function NetworkManagementPage() {
-

{userId}

+

{userMemberships[0]?.user_name || userId}

+ {userMemberships[0]?.user_email && ( +

{userMemberships[0].user_email}

+ )}

{userMemberships.length} device{userMemberships.length !== 1 ? "s" : ""} {activeCount > 0 && {activeCount} active} @@ -652,7 +655,10 @@ export default function NetworkManagementPage() {

-

{m.device_id}

+

{m.device_name || m.device_id}

+ {m.device_node_id && ( +

{m.device_node_id}

+ )}
@@ -782,10 +788,13 @@ export default function NetworkManagementPage() {
-

{r.user_id}

+

{r.user_name || r.user_id}

{r.grant_type}
+ {r.user_email && ( +

{r.user_email}

+ )} {r.justification && (

"{r.justification}"

)} @@ -1021,7 +1030,7 @@ export default function NetworkManagementPage() { Remove all devices for this user? - This will remove all memberships for user {confirmRemoveUser}. All of their devices will lose access to this network. + This will remove all memberships for user {members.find(m => m.user_id === confirmRemoveUser)?.user_name || confirmRemoveUser}. All of their devices will lose access to this network. diff --git a/tests/NetworkManagementPage.test.tsx b/tests/NetworkManagementPage.test.tsx index 7cf495c..4c5d36d 100644 --- a/tests/NetworkManagementPage.test.tsx +++ b/tests/NetworkManagementPage.test.tsx @@ -658,7 +658,11 @@ describe("NetworkManagementPage", () => { id: "mem-auth-1", organization_id: "org-1", user_id: "user-a", + user_name: "Alice Smith", + user_email: "alice@test.com", device_id: "dev-laptop-1", + device_name: "Alice Laptop", + device_node_id: "abc123", portal_network_id: "net-abc", user_network_approval_id: null, active: true, @@ -692,7 +696,11 @@ describe("NetworkManagementPage", () => { id: "mem-unauth-1", organization_id: "org-1", user_id: "user-a", + user_name: "Alice Smith", + user_email: "alice@test.com", device_id: "dev-desktop-1", + device_name: "Alice Desktop", + device_node_id: "def456", portal_network_id: "net-abc", user_network_approval_id: null, active: false, @@ -711,7 +719,11 @@ describe("NetworkManagementPage", () => { id: "mem-auth-2", organization_id: "org-1", user_id: "user-b", + user_name: "Bob Jones", + user_email: "bob@test.com", device_id: "dev-phone-1", + device_name: "Bob Phone", + device_node_id: "ghi789", portal_network_id: "net-abc", user_network_approval_id: null, active: false, @@ -730,7 +742,11 @@ describe("NetworkManagementPage", () => { id: "mem-pending-1", organization_id: "org-1", user_id: "user-c", + user_name: "Charlie Brown", + user_email: "charlie@test.com", device_id: "dev-server-1", + device_name: "Charlie Server", + device_node_id: "jkl012", portal_network_id: "net-abc", user_network_approval_id: null, active: false, @@ -874,8 +890,8 @@ describe("NetworkManagementPage", () => { test("groups memberships by user_id and renders user sections", async () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); - // Both memberships belong to user-a → one user section - expect(screen.getByText("user-a")).toBeDefined(); + // Both memberships belong to Alice Smith → one user section + expect(screen.getByText("Alice Smith")).toBeDefined(); // Should show "2 devices" expect(screen.getByText(/2 devices/)).toBeDefined(); }); @@ -883,15 +899,14 @@ describe("NetworkManagementPage", () => { test("renders multiple user sections for different user_ids", async () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_SECOND_USER]); - expect(screen.getByText("user-a")).toBeDefined(); - expect(screen.getByText("user-b")).toBeDefined(); + expect(screen.getByText("Alice Smith")).toBeDefined(); + expect(screen.getByText("Bob Jones")).toBeDefined(); }); - test("displays user_id in monospace font (font-mono class)", async () => { + test("displays user_name when available", async () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); - const userIdElement = screen.getByText("user-a"); - expect(userIdElement.className).toContain("font-mono"); + expect(screen.getByText("Alice Smith")).toBeDefined(); }); // ── Expand / Collapse ─────────────────────────────────────────────────────── @@ -900,31 +915,31 @@ describe("NetworkManagementPage", () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); // Devices should NOT be visible before expanding - expect(screen.queryByText("dev-laptop-1")).toBeNull(); - expect(screen.queryByText("dev-desktop-1")).toBeNull(); + expect(screen.queryByText("Alice Laptop")).toBeNull(); + expect(screen.queryByText("Alice Desktop")).toBeNull(); // Click the user section to expand - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); expect(userButton).not.toBeNull(); fireEvent.click(userButton!); // Now devices should be visible await waitFor(() => { - expect(screen.getByText("dev-laptop-1")).toBeDefined(); + expect(screen.getByText("Alice Laptop")).toBeDefined(); }); - expect(screen.getByText("dev-desktop-1")).toBeDefined(); + expect(screen.getByText("Alice Desktop")).toBeDefined(); }); test("clicking expanded user section collapses devices", async () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); expect(userButton).not.toBeNull(); fireEvent.click(userButton!); // Devices should be visible await waitFor(() => { - expect(screen.getByText("dev-laptop-1")).toBeDefined(); + expect(screen.getByText("Alice Laptop")).toBeDefined(); }); // Click again to collapse @@ -932,36 +947,36 @@ describe("NetworkManagementPage", () => { // Devices should disappear await waitFor(() => { - expect(screen.queryByText("dev-laptop-1")).toBeNull(); + expect(screen.queryByText("Alice Laptop")).toBeNull(); }); }); test("expand/collapse is independent per user section", async () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_SECOND_USER]); - const userAButton = screen.getByText("user-a").closest("button")!; - const userBButton = screen.getByText("user-b").closest("button")!; + const userAButton = screen.getByText("Alice Smith").closest("button")!; + const userBButton = screen.getByText("Bob Jones").closest("button")!; - // Expand user-a only + // Expand Alice Smith only fireEvent.click(userAButton); await waitFor(() => { - expect(screen.getByText("dev-laptop-1")).toBeDefined(); + expect(screen.getByText("Alice Laptop")).toBeDefined(); }); - // user-b's device should still be hidden - expect(screen.queryByText("dev-phone-1")).toBeNull(); + // Bob Jones' device should still be hidden + expect(screen.queryByText("Bob Phone")).toBeNull(); - // Expand user-b too + // Expand Bob Jones too fireEvent.click(userBButton); await waitFor(() => { - expect(screen.getByText("dev-phone-1")).toBeDefined(); + expect(screen.getByText("Bob Phone")).toBeDefined(); }); - // Collapse user-a — user-b should remain expanded + // Collapse Alice Smith — Bob Jones should remain expanded fireEvent.click(userAButton); await waitFor(() => { - expect(screen.queryByText("dev-laptop-1")).toBeNull(); + expect(screen.queryByText("Alice Laptop")).toBeNull(); }); - expect(screen.getByText("dev-phone-1")).toBeDefined(); + expect(screen.getByText("Bob Phone")).toBeDefined(); }); // ── Device Details ────────────────────────────────────────────────────────── @@ -969,7 +984,7 @@ describe("NetworkManagementPage", () => { test("renders Active badge for active_authorized membership", async () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -980,7 +995,7 @@ describe("NetworkManagementPage", () => { test("renders Inactive badge for approved_inactive membership", async () => { await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -991,7 +1006,7 @@ describe("NetworkManagementPage", () => { test("renders Inactive badge for pending_request membership", async () => { await setupMembersTab([MEMBERSHIP_PENDING_REQUEST]); - const userButton = screen.getByText("user-c").closest("button"); + const userButton = screen.getByText("Charlie Brown").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -1002,7 +1017,7 @@ describe("NetworkManagementPage", () => { test("renders Inactive badge for second user (approved_inactive)", async () => { await setupMembersTab([MEMBERSHIP_SECOND_USER]); - const userButton = screen.getByText("user-b").closest("button"); + const userButton = screen.getByText("Bob Jones").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -1015,7 +1030,7 @@ describe("NetworkManagementPage", () => { test("shows 'Active' badge when membership is active", async () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -1027,7 +1042,7 @@ describe("NetworkManagementPage", () => { test("shows 'Inactive' badge when membership is not active", async () => { await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -1040,7 +1055,7 @@ describe("NetworkManagementPage", () => { 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"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -1051,7 +1066,7 @@ describe("NetworkManagementPage", () => { 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"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -1066,7 +1081,7 @@ describe("NetworkManagementPage", () => { test("shows join_seen as 'Yes' when true", async () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -1077,7 +1092,7 @@ describe("NetworkManagementPage", () => { test("shows join_seen as 'No' when false", async () => { await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -1090,7 +1105,7 @@ describe("NetworkManagementPage", () => { test("renders Deactivate button for active memberships", async () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -1102,7 +1117,7 @@ describe("NetworkManagementPage", () => { test("renders Activate button for approved-but-inactive memberships", async () => { await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); await waitFor(() => { @@ -1120,7 +1135,7 @@ describe("NetworkManagementPage", () => { await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); const activateBtn = await screen.findByRole("button", { name: "Activate" }); @@ -1146,7 +1161,7 @@ describe("NetworkManagementPage", () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" }); @@ -1164,7 +1179,7 @@ describe("NetworkManagementPage", () => { await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); const activateBtn = await screen.findByRole("button", { name: "Activate" }); @@ -1190,7 +1205,7 @@ describe("NetworkManagementPage", () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" }); @@ -1207,7 +1222,7 @@ describe("NetworkManagementPage", () => { await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); const activateBtn = await screen.findByRole("button", { name: "Activate" }); @@ -1234,7 +1249,7 @@ describe("NetworkManagementPage", () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" }); @@ -1255,7 +1270,7 @@ describe("NetworkManagementPage", () => { await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); const activateBtn = await screen.findByRole("button", { name: "Activate" }); @@ -1290,7 +1305,7 @@ describe("NetworkManagementPage", () => { await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); const activateBtn = await screen.findByRole("button", { name: "Activate" }); @@ -1324,7 +1339,7 @@ describe("NetworkManagementPage", () => { await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]); - const userButton = screen.getByText("user-a").closest("button"); + const userButton = screen.getByText("Alice Smith").closest("button"); fireEvent.click(userButton!); const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" }); @@ -1385,7 +1400,9 @@ describe("NetworkManagementPage", () => { ...MEMBERSHIP_AUTHORIZED_WITH_SESSION, id: "mem-uni-1", user_id: "user-äéîøü-中文", + user_name: "Unicode User", device_id: "dev-utf8", + device_name: null, }; H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); H.mockGetNetworkMembers.mockResolvedValue({ memberships: [unicodeMembership], count: 1 }); @@ -1401,7 +1418,7 @@ describe("NetworkManagementPage", () => { fireEvent.keyDown(membersTab, { key: "Enter" }); await waitFor(() => { - expect(screen.getByText("user-äéîøü-中文")).toBeDefined(); + expect(screen.getByText("Unicode User")).toBeDefined(); }); }); @@ -1412,7 +1429,9 @@ describe("NetworkManagementPage", () => { ...MEMBERSHIP_AUTHORIZED_WITH_SESSION, id: "mem-xss-1", user_id: "user-xss", + user_name: null, device_id: "", + device_name: null, }; H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); H.mockGetNetworkMembers.mockResolvedValue({ memberships: [xssMembership], count: 1 }); @@ -1444,7 +1463,10 @@ describe("NetworkManagementPage", () => { ...MEMBERSHIP_AUTHORIZED_WITH_SESSION, id: `mem-many-${i}`, user_id: `user-${i}`, + user_name: `User ${i}`, + user_email: `user${i}@test.com`, device_id: `dev-${i}`, + device_name: `Device ${i}`, active_session: null, })); H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); @@ -1465,8 +1487,8 @@ describe("NetworkManagementPage", () => { }); // Should find at least the first and last user - expect(screen.getByText("user-0")).toBeDefined(); - expect(screen.getByText("user-49")).toBeDefined(); + expect(screen.getByText("User 0")).toBeDefined(); + expect(screen.getByText("User 49")).toBeDefined(); }); }); @@ -1479,6 +1501,8 @@ describe("NetworkManagementPage", () => { id: "req-pending-1", organization_id: "org-1", user_id: "user-requestor", + user_name: "Requestor User", + user_email: "requestor@test.com", portal_network_id: "net-abc", granted_by_user_id: null, grant_type: "requested" as const, @@ -1493,6 +1517,8 @@ describe("NetworkManagementPage", () => { id: "req-approved-1", organization_id: "org-1", user_id: "user-approved", + user_name: "Approved User", + user_email: "approved@test.com", portal_network_id: "net-abc", granted_by_user_id: "user-admin", grant_type: "assigned" as const, @@ -1507,6 +1533,8 @@ describe("NetworkManagementPage", () => { id: "req-rejected-1", organization_id: "org-1", user_id: "user-rejected", + user_name: "Rejected User", + user_email: "rejected@test.com", portal_network_id: "net-abc", granted_by_user_id: null, grant_type: "requested" as const, @@ -1670,12 +1698,16 @@ describe("NetworkManagementPage", () => { // ── Request list display ──────────────────────────────────────────────────── - test("displays user_id in monospace font", async () => { + test("displays user_name when available", async () => { await setupRequestsTab([PENDING_REQUEST]); - const userIdElement = screen.getByText("user-requestor"); - expect(userIdElement).toBeDefined(); - expect(userIdElement.className).toContain("font-mono"); + expect(screen.getByText("Requestor User")).toBeDefined(); + }); + + test("displays grant_type badge", async () => { + await setupRequestsTab([PENDING_REQUEST]); + + expect(screen.getByText("requested")).toBeDefined(); }); test("displays grant_type badge", async () => { @@ -1694,7 +1726,7 @@ describe("NetworkManagementPage", () => { await setupRequestsTab([APPROVED_REQUEST]); await waitFor(() => { - expect(screen.getByText("user-approved")).toBeDefined(); + expect(screen.getByText("Approved User")).toBeDefined(); }); expect(screen.queryByText(/"/)).toBeNull(); @@ -2019,15 +2051,15 @@ describe("NetworkManagementPage", () => { test("renders multiple requests in the list", async () => { await setupRequestsTab([PENDING_REQUEST, APPROVED_REQUEST]); - expect(screen.getByText("user-requestor")).toBeDefined(); - expect(screen.getByText("user-approved")).toBeDefined(); + expect(screen.getByText("Requestor User")).toBeDefined(); + expect(screen.getByText("Approved User")).toBeDefined(); }); test("mixed list: only pending requests have action buttons", async () => { await setupRequestsTab([PENDING_REQUEST, APPROVED_REQUEST]); - // user-requestor (pending) should have action buttons - // user-approved (approved) should not + // Requestor User (pending) should have action buttons + // Approved User (approved) should not const approveButtons = screen.getAllByRole("button", { name: "Approve" }); expect(approveButtons.length).toBe(1); @@ -2042,10 +2074,11 @@ describe("NetworkManagementPage", () => { ...PENDING_REQUEST, id: "req-uni-1", user_id: "user-äéîøü-中文-requestor", + user_name: "Unicode Requestor", }; await setupRequestsTab([unicodeRequest]); - expect(screen.getByText("user-äéîøü-中文-requestor")).toBeDefined(); + expect(screen.getByText("Unicode Requestor")).toBeDefined(); }); // ── Adversarial: XSS-safe justification ────────────────────────────────────── @@ -2069,6 +2102,7 @@ describe("NetworkManagementPage", () => { ...PENDING_REQUEST, id: `req-many-${i}`, user_id: `user-req-${i}`, + user_name: `Requestor ${i}`, })); H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK }); H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: manyRequests, count: 50 }); @@ -2087,8 +2121,8 @@ describe("NetworkManagementPage", () => { expect(screen.getByText("50 pending")).toBeDefined(); }); - expect(screen.getByText("user-req-0")).toBeDefined(); - expect(screen.getByText("user-req-49")).toBeDefined(); + expect(screen.getByText("Requestor 0")).toBeDefined(); + expect(screen.getByText("Requestor 49")).toBeDefined(); }); }); });