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