Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/ui/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -181,7 +181,7 @@ const authenticatedRouter = createBrowserRouter([
element: <ManageIamPage />,
},
{
path: "/externalMembership",
path: "/membershipLists",
element: <ManageExternalMembershipPage />,
},
{
Expand Down
7 changes: 4 additions & 3 deletions src/ui/components/AppShell/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
IconHistory,
IconKey,
IconExternalLink,
IconUser,
} from "@tabler/icons-react";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
Expand Down Expand Up @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions src/ui/pages/membershipLists/InternalMembershipQuery.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<MantineProvider
withGlobalClasses
withCssVariables
forceColorScheme="light"
>
<InternalMembershipQuery
queryInternalMembership={queryInternalMembershipMock}
/>
</MantineProvider>
</MemoryRouter>,
);
});
};

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();
});
});
85 changes: 85 additions & 0 deletions src/ui/pages/membershipLists/InternalMembershipQuery.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}

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 (
<Stack gap="md">
<TextInput
label="NetID"
placeholder="e.g., jdoe2"
value={netId}
onChange={(event) => setNetId(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleQuery();
}
}}
/>
<Button
onClick={handleQuery}
loading={isLoading}
disabled={!netId.trim()}
>
Query Membership
</Button>

{result && (
<Box
p="md"
mt="sm"
style={{ borderRadius: "var(--mantine-radius-md)" }}
bg={result.isMember ? "green.1" : "red.1"}
>
<Group>
{result.isMember ? (
<IconCircleCheck
style={{ color: "var(--mantine-color-green-7)" }}
/>
) : (
<IconCircleX style={{ color: "var(--mantine-color-red-7)" }} />
)}
<Text c={result.isMember ? "green.9" : "red.9"} fw={500} size="sm">
<Text span fw={700} inherit>
{result.netId}
</Text>{" "}
is {result.isMember ? "" : "not "}a paid member.
</Text>
</Group>
</Box>
)}
</Stack>
);
};

export default InternalMembershipQuery;
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -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: <IconAlertCircle size={16} />,
});
throw error;
}
};

const handleListCreated = (listId: string) => {
setValidLists((prevLists) => [...(prevLists || []), listId]);
};
Expand Down Expand Up @@ -95,24 +117,45 @@ export const ManageExternalMembershipPage = () => {
return <FullScreenLoader />;
}
return (
<AuthGuard
resourceDef={{
service: "core",
validRoles: [
AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST,
AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST,
],
}}
>
<Container>
<Title order={2}>Manage External Membership Lists</Title>
<ExternalMemberListManagement
fetchMembers={fetchMembers}
updateMembers={handlePatchMembers}
validLists={validLists}
onListCreated={handleListCreated}
/>
</Container>
</AuthGuard>
<Container fluid m="lg">
<Grid>
<Grid.Col span={{ base: 12, lg: 6 }}>
<AuthGuard
resourceDef={{
service: "core",
validRoles: [AppRoles.VIEW_INTERNAL_MEMBERSHIP_LIST],
}}
>
<Stack>
<Title order={2}>Query ACM Paid Membership List</Title>
<InternalMembershipQuery
queryInternalMembership={queryInternalMembership}
/>
</Stack>
</AuthGuard>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 6 }}>
<AuthGuard
resourceDef={{
service: "core",
validRoles: [
AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST,
AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST,
],
}}
>
<Stack>
<Title order={2}>Manage External Membership Lists</Title>
<ExternalMemberListManagement
fetchMembers={fetchMembers}
updateMembers={handlePatchMembers}
validLists={validLists}
onListCreated={handleListCreated}
/>
</Stack>
</AuthGuard>
</Grid.Col>
</Grid>
</Container>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,41 @@ 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,
becomeUser,
}) => {
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();
Expand All @@ -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 [email protected]" })
Expand All @@ -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
Expand Down
Loading