diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index b1f3b29a..30bbfc53 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -28,7 +28,7 @@ import { ViewRoomRequest } from "./pages/roomRequest/ViewRoomRequest.page"; import { ViewLogsPage } from "./pages/logs/ViewLogs.page"; import { TermsOfService } from "./pages/tos/TermsOfService.page"; import { ManageApiKeysPage } from "./pages/apiKeys/ManageKeys.page"; -import { ManageExternalMembershipPage } from "./pages/externalMembership/ManageExternalMembership.page"; +import { ManageExternalMembershipPage } from "./pages/membershipLists/MembershipListsPage"; const ProfileRediect: React.FC = () => { const location = useLocation(); @@ -181,7 +181,7 @@ const authenticatedRouter = createBrowserRouter([ element: , }, { - path: "/externalMembership", + path: "/membershipLists", element: , }, { diff --git a/src/ui/components/AppShell/index.tsx b/src/ui/components/AppShell/index.tsx index d7fcd3f1..117f2026 100644 --- a/src/ui/components/AppShell/index.tsx +++ b/src/ui/components/AppShell/index.tsx @@ -22,6 +22,7 @@ import { IconHistory, IconKey, IconExternalLink, + IconUser, } from "@tabler/icons-react"; import { ReactNode } from "react"; import { useNavigate } from "react-router-dom"; @@ -99,9 +100,9 @@ export const navItems = [ validRoles: [AppRoles.MANAGE_ORG_API_KEYS], }, { - link: "/externalMembership", - name: "External Membership", - icon: IconExternalLink, + link: "/membershipLists", + name: "Membership Lists", + icon: IconUser, description: null, validRoles: [ AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST, diff --git a/src/ui/pages/externalMembership/ExternalMemberListManagement.tsx b/src/ui/pages/membershipLists/ExternalMemberListManagement.tsx similarity index 100% rename from src/ui/pages/externalMembership/ExternalMemberListManagement.tsx rename to src/ui/pages/membershipLists/ExternalMemberListManagement.tsx diff --git a/src/ui/pages/membershipLists/InternalMembershipQuery.test.tsx b/src/ui/pages/membershipLists/InternalMembershipQuery.test.tsx new file mode 100644 index 00000000..13703c34 --- /dev/null +++ b/src/ui/pages/membershipLists/InternalMembershipQuery.test.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { render, screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import { MantineProvider } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import InternalMembershipQuery from "./InternalMembershipQuery"; +import { Modules, ModulesToHumanName } from "@common/modules"; +import { MemoryRouter } from "react-router-dom"; + +describe("InternalMembershipQuery Tests", () => { + const validNetIds = ["rjjones", "test2"]; + const queryInternalMembershipMock = vi + .fn() + .mockImplementation((netId) => validNetIds.includes(netId)); + const renderComponent = async () => { + await act(async () => { + render( + + + + + , + ); + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset notification spy + vi.spyOn(notifications, "show"); + }); + + it("renders the component correctly", async () => { + await renderComponent(); + + expect(screen.getByText("NetID")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Query Membership/i }), + ).toBeInTheDocument(); + }); + + it("disables query button when no NetID is provided", async () => { + await renderComponent(); + expect( + screen.getByRole("button", { name: /Query Membership/i }), + ).toBeDisabled(); + expect(queryInternalMembershipMock).not.toHaveBeenCalled(); + }); + it("correctly renders members", async () => { + await renderComponent(); + const user = userEvent.setup(); + const textbox = screen.getByRole("textbox", { name: /NetID/i }); + await user.type(textbox, "rjjones"); + await user.click(screen.getByRole("button", { name: /Query Membership/i })); + expect(queryInternalMembershipMock).toHaveBeenCalledExactlyOnceWith( + "rjjones", + ); + expect(screen.getByText("is a paid member.")).toBeVisible(); + }); + it("correctly renders non-members", async () => { + await renderComponent(); + const user = userEvent.setup(); + const textbox = screen.getByRole("textbox", { name: /NetID/i }); + await user.type(textbox, "invalid"); + await user.click(screen.getByRole("button", { name: /Query Membership/i })); + expect(queryInternalMembershipMock).toHaveBeenCalledExactlyOnceWith( + "invalid", + ); + expect(screen.getByText("is not a paid member.")).toBeVisible(); + }); +}); diff --git a/src/ui/pages/membershipLists/InternalMembershipQuery.tsx b/src/ui/pages/membershipLists/InternalMembershipQuery.tsx new file mode 100644 index 00000000..e882fde4 --- /dev/null +++ b/src/ui/pages/membershipLists/InternalMembershipQuery.tsx @@ -0,0 +1,85 @@ +import { useState } from "react"; +import { TextInput, Button, Stack, Box, Text, Group } from "@mantine/core"; +import { IconCircleCheck, IconCircleX } from "@tabler/icons-react"; + +interface InternalMembershipQueryProps { + queryInternalMembership: (netId: string) => Promise; +} + +export const InternalMembershipQuery = ({ + queryInternalMembership, +}: InternalMembershipQueryProps) => { + const [netId, setNetId] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [result, setResult] = useState<{ + netId: string; + isMember: boolean; + } | null>(null); + + const handleQuery = async () => { + if (!netId.trim()) { + return; + } + + setIsLoading(true); + setResult(null); + try { + const isMember = await queryInternalMembership( + netId.trim().toLowerCase(), + ); + setResult({ netId: netId.trim().toLowerCase(), isMember }); + } finally { + setIsLoading(false); + } + }; + + return ( + + setNetId(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleQuery(); + } + }} + /> + + + {result && ( + + + {result.isMember ? ( + + ) : ( + + )} + + + {result.netId} + {" "} + is {result.isMember ? "" : "not "}a paid member. + + + + )} + + ); +}; + +export default InternalMembershipQuery; diff --git a/src/ui/pages/externalMembership/ManageExternalMembership.page.tsx b/src/ui/pages/membershipLists/MembershipListsPage.tsx similarity index 56% rename from src/ui/pages/externalMembership/ManageExternalMembership.page.tsx rename to src/ui/pages/membershipLists/MembershipListsPage.tsx index a616b2cd..1b98b4e0 100644 --- a/src/ui/pages/externalMembership/ManageExternalMembership.page.tsx +++ b/src/ui/pages/membershipLists/MembershipListsPage.tsx @@ -1,12 +1,10 @@ import { useState, useEffect } from "react"; import { Title, - SimpleGrid, - Select, Stack, - Text, LoadingOverlay, Container, + Grid, // Import Grid } from "@mantine/core"; import { AuthGuard } from "@ui/components/AuthGuard"; import { useApi } from "@ui/util/api"; @@ -21,6 +19,8 @@ import { notifications } from "@mantine/notifications"; import { IconAlertCircle } from "@tabler/icons-react"; import ExternalMemberListManagement from "./ExternalMemberListManagement"; import FullScreenLoader from "@ui/components/AuthContext/LoadingScreen"; +import InternalMembershipQuery from "./InternalMembershipQuery"; +import { AxiosError } from "axios"; export const ManageExternalMembershipPage = () => { const api = useApi("core"); @@ -48,6 +48,28 @@ export const ManageExternalMembershipPage = () => { fetchLists(); }, [api]); + const queryInternalMembership = async (netId: string) => { + try { + const result = await api.get<{ netId: string; isPaidMember: boolean }>( + `/api/v2/membership/${netId}`, + ); + return result.data.isPaidMember; + } catch (error: any) { + if (error instanceof AxiosError && error.status === 400) { + // Invalid NetID. + return false; + } + console.error("Failed to check internal membership:", error); + notifications.show({ + title: "Failed to get query membership list.", + message: "Please try again or contact support.", + color: "red", + icon: , + }); + throw error; + } + }; + const handleListCreated = (listId: string) => { setValidLists((prevLists) => [...(prevLists || []), listId]); }; @@ -95,24 +117,45 @@ export const ManageExternalMembershipPage = () => { return ; } return ( - - - Manage External Membership Lists - - - + + + + + + Query ACM Paid Membership List + + + + + + + + Manage External Membership Lists + + + + + + ); }; diff --git a/tests/e2e/externalMembership.spec.ts b/tests/e2e/membershipLists.spec.ts similarity index 66% rename from tests/e2e/externalMembership.spec.ts rename to tests/e2e/membershipLists.spec.ts index df4921fd..5a14e637 100644 --- a/tests/e2e/externalMembership.spec.ts +++ b/tests/e2e/membershipLists.spec.ts @@ -3,6 +3,33 @@ import { test } from "./base.js"; import { describe } from "node:test"; import { randomUUID } from "crypto"; +describe("Internal Membership tests", () => { + test("A user can query internal membership", async ({ page, becomeUser }) => { + const uuid = `e2e-${randomUUID()}`; + await becomeUser(page); + await page.locator("a").filter({ hasText: "Membership Lists" }).click(); + await page + .getByRole("textbox", { name: "NetID", exact: true }) + .fill("dsingh14"); + await page + .getByRole("button", { name: "Query Membership", exact: true }) + .click(); + await expect(page.getByText("dsingh14 is a paid member.")).toBeVisible(); + await page.getByRole("textbox", { name: "NetID", exact: true }).fill("z"); + await page + .getByRole("button", { name: "Query Membership", exact: true }) + .click(); + await expect(page.getByText("z is not a paid member.")).toBeVisible(); + await page + .getByRole("textbox", { name: "NetID", exact: true }) + .fill("rjjones"); + await page + .getByRole("button", { name: "Query Membership", exact: true }) + .click(); + await expect(page.getByText("rjjones is not a paid member.")).toBeVisible(); + }); +}); + describe("External Membership tests", () => { test("A user can create, modify, and delete external memberships", async ({ page, @@ -10,7 +37,7 @@ describe("External Membership tests", () => { }) => { const uuid = `e2e-${randomUUID()}`; await becomeUser(page); - await page.locator("a").filter({ hasText: "External Membership" }).click(); + await page.locator("a").filter({ hasText: "Membership Lists" }).click(); await page.getByRole("button", { name: "New List" }).click(); await page.getByRole("textbox", { name: "New List ID" }).fill(uuid); await page.getByRole("textbox", { name: "Initial Member NetID" }).click(); @@ -21,7 +48,7 @@ describe("External Membership tests", () => { await expect(page.getByText("corete5")).toBeVisible(); await expect(page.locator("tbody")).toContainText("corete5"); await expect(page.locator("tbody")).toContainText("Active"); - await expect(page.getByRole("main")).toContainText("Found 1 member."); + await expect(page.getByText("Found 1 member.")).toBeVisible(); await page.getByRole("button", { name: "Replace List" }).click(); await page .getByRole("textbox", { name: "jdoe2 asmith3@illinois.edu" }) @@ -34,7 +61,7 @@ describe("External Membership tests", () => { .getByRole("button", { name: "Save Changes (1 Additions, 0" }) .click(); await page.getByRole("button", { name: "Cancel", exact: true }).click(); - await expect(page.getByRole("main")).toContainText( + await expect(page.getByRole("main").nth(1)).toContainText( "Save Changes (1 Additions, 0 Removals)", ); await page