diff --git a/src/api/routes/v2/membership.ts b/src/api/routes/v2/membership.ts index d10dc457..b0bce7aa 100644 --- a/src/api/routes/v2/membership.ts +++ b/src/api/routes/v2/membership.ts @@ -28,8 +28,9 @@ import { getEntraIdToken } from "api/functions/entraId.js"; import { genericConfig, roleArns } from "common/config.js"; import { getRoleCredentials } from "api/functions/sts.js"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; -import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { BatchGetItemCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { AppRoles } from "common/roles.js"; +import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => { const getAuthorizedClients = async () => { @@ -160,6 +161,213 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => { ); }, ); + fastify.withTypeProvider().post( + "/verifyBatchOfMembers", + { + schema: withRoles( + [ + AppRoles.VIEW_INTERNAL_MEMBERSHIP_LIST, + AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST, + ], + withTags(["Membership"], { + body: z.array(illinoisNetId).nonempty().max(500), + querystring: z.object({ + list: z.string().min(1).optional().meta({ + example: "built", + description: + "Membership list to check from (defaults to ACM Paid Member list).", + }), + }), + summary: + "Check a batch of NetIDs for ACM @ UIUC paid membership (or partner organization membership) status.", + response: { + 200: { + description: "List membership status.", + content: { + "application/json": { + schema: z + .object({ + members: z.array(illinoisNetId), + notMembers: z.array(illinoisNetId), + list: z.optional(z.string().min(1)), + }) + .meta({ + example: { + members: ["rjjones"], + notMembers: ["isbell"], + list: "built", + }, + }), + }, + }, + }, + }, + }), + ), + onRequest: async (request, reply) => { + await fastify.authorizeFromSchema(request, reply); + if (!request.userRoles) { + throw new InternalServerError({}); + } + const list = request.query.list || "acmpaid"; + if ( + list === "acmpaid" && + !request.userRoles.has(AppRoles.VIEW_INTERNAL_MEMBERSHIP_LIST) + ) { + throw new UnauthorizedError({}); + } + if ( + list !== "acmpaid" && + !request.userRoles.has(AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST) + ) { + throw new UnauthorizedError({}); + } + }, + }, + async (request, reply) => { + const list = request.query.list || "acmpaid"; + let netIdsToCheck = [ + ...new Set(request.body.map((id) => id.toLowerCase())), + ]; + + const members = new Set(); + const notMembers = new Set(); + + const cacheKeys = netIdsToCheck.map((id) => `membership:${id}:${list}`); + if (cacheKeys.length > 0) { + const cachedResults = await fastify.redisClient.mget(cacheKeys); + const remainingNetIds: string[] = []; + cachedResults.forEach((result, index) => { + const netId = netIdsToCheck[index]; + if (result) { + const { isMember } = JSON.parse(result) as { isMember: boolean }; + if (isMember) { + members.add(netId); + } else { + notMembers.add(netId); + } + } else { + remainingNetIds.push(netId); + } + }); + netIdsToCheck = remainingNetIds; + } + + if (netIdsToCheck.length === 0) { + return reply.send({ + members: [...members].sort(), + notMembers: [...notMembers].sort(), + list: list === "acmpaid" ? undefined : list, + }); + } + + const cachePipeline = fastify.redisClient.pipeline(); + + if (list !== "acmpaid") { + // can't do batch get on an index. + const checkPromises = netIdsToCheck.map(async (netId) => { + const isMember = await checkExternalMembership( + netId, + list, + fastify.dynamoClient, + ); + if (isMember) { + members.add(netId); + } else { + notMembers.add(netId); + } + cachePipeline.set( + `membership:${netId}:${list}`, + JSON.stringify({ isMember }), + "EX", + MEMBER_CACHE_SECONDS, + ); + }); + await Promise.all(checkPromises); + } else { + const BATCH_SIZE = 100; + const foundInDynamo = new Set(); + for (let i = 0; i < netIdsToCheck.length; i += BATCH_SIZE) { + const batch = netIdsToCheck.slice(i, i + BATCH_SIZE); + const command = new BatchGetItemCommand({ + RequestItems: { + [genericConfig.MembershipTableName]: { + Keys: batch.map((netId) => + marshall({ email: `${netId}@illinois.edu` }), + ), + }, + }, + }); + const { Responses } = await fastify.dynamoClient.send(command); + const items = Responses?.[genericConfig.MembershipTableName] ?? []; + for (const item of items) { + const { email } = unmarshall(item); + const netId = email.split("@")[0]; + members.add(netId); + foundInDynamo.add(netId); + cachePipeline.set( + `membership:${netId}:${list}`, + JSON.stringify({ isMember: true }), + "EX", + MEMBER_CACHE_SECONDS, + ); + } + } + + // 3. Fallback to Entra ID for remaining paid members + const netIdsForEntra = netIdsToCheck.filter( + (id) => !foundInDynamo.has(id), + ); + if (netIdsForEntra.length > 0) { + const entraIdToken = await getEntraIdToken({ + clients: await getAuthorizedClients(), + clientId: fastify.environmentConfig.AadValidClientId, + secretName: genericConfig.EntraSecretName, + logger: request.log, + }); + const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId; + const entraCheckPromises = netIdsForEntra.map(async (netId) => { + const isMember = await checkPaidMembershipFromEntra( + netId, + entraIdToken, + paidMemberGroup, + ); + if (isMember) { + members.add(netId); + // Fire-and-forget writeback to DynamoDB to warm it up + setPaidMembershipInTable(netId, fastify.dynamoClient).catch( + (err) => + request.log.error( + err, + `Failed to write back Entra membership for ${netId}`, + ), + ); + } else { + notMembers.add(netId); + } + cachePipeline.set( + `membership:${netId}:${list}`, + JSON.stringify({ isMember }), + "EX", + MEMBER_CACHE_SECONDS, + ); + }); + await Promise.all(entraCheckPromises); + } + } + + if (cachePipeline.length > 0) { + await cachePipeline.exec(); + } + + return reply.send({ + members: [...members].sort(), + notMembers: [...notMembers].sort(), + list: list === "acmpaid" ? undefined : list, + }); + }, + ); + fastify.withTypeProvider().get( "/:netId", { diff --git a/src/ui/pages/membershipLists/InternalMembershipQuery.test.tsx b/src/ui/pages/membershipLists/InternalMembershipQuery.test.tsx index 13703c34..532d8d0b 100644 --- a/src/ui/pages/membershipLists/InternalMembershipQuery.test.tsx +++ b/src/ui/pages/membershipLists/InternalMembershipQuery.test.tsx @@ -1,78 +1,101 @@ import React from "react"; -import { render, screen, act } from "@testing-library/react"; +import { render, screen } 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"; +import { MembershipListQuery } from "./InternalMembershipQuery"; -describe("InternalMembershipQuery Tests", () => { - const validNetIds = ["rjjones", "test2"]; - const queryInternalMembershipMock = vi +// Mock the useClipboard hook from @mantine/hooks +vi.mock("@mantine/hooks", async (importOriginal) => { + const originalModule = + await importOriginal(); + return { + ...originalModule, + useClipboard: vi.fn(() => ({ + copy: vi.fn(), + copied: false, + })), + }; +}); + +// Mock implementation for the clipboard API +Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, +}); + +describe("MembershipListQuery Tests", () => { + const queryFunctionMock = vi .fn() - .mockImplementation((netId) => validNetIds.includes(netId)); - const renderComponent = async () => { - await act(async () => { - render( - - - - - , - ); + .mockImplementation(async (netIds: string[]) => { + const validNetIds = ["rjjones", "test2"]; + const members = netIds.filter((id) => validNetIds.includes(id)); + const nonMembers = netIds.filter((id) => !validNetIds.includes(id)); + return { members, nonMembers }; }); + + const renderComponent = () => { + render( + + + , + ); }; beforeEach(() => { vi.clearAllMocks(); - // Reset notification spy - vi.spyOn(notifications, "show"); }); - it("renders the component correctly", async () => { - await renderComponent(); - - expect(screen.getByText("NetID")).toBeInTheDocument(); + it("renders the component correctly", () => { + renderComponent(); expect( - screen.getByRole("button", { name: /Query Membership/i }), + screen.getByLabelText(/Enter NetIDs or Illinois Emails/i), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Query Memberships/i }), ).toBeInTheDocument(); }); - it("disables query button when no NetID is provided", async () => { - await renderComponent(); + it("disables the query button when the input is empty", () => { + renderComponent(); expect( - screen.getByRole("button", { name: /Query Membership/i }), + screen.getByRole("button", { name: /Query Memberships/i }), ).toBeDisabled(); - expect(queryInternalMembershipMock).not.toHaveBeenCalled(); }); - it("correctly renders members", async () => { - await renderComponent(); + + it("enables the query button when input is provided", async () => { + 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(); + const textarea = screen.getByLabelText(/Enter NetIDs or Illinois Emails/i); + await user.type(textarea, "test"); + expect( + screen.getByRole("button", { name: /Query Memberships/i }), + ).toBeEnabled(); }); - it("correctly renders non-members", async () => { - await renderComponent(); + + it("correctly processes input and displays members and non-members", async () => { + 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( + const textarea = screen.getByLabelText(/Enter NetIDs or Illinois Emails/i); + const queryButton = screen.getByRole("button", { + name: /Query Memberships/i, + }); + const inputText = "rjjones, invalid, TEST2@illinois.edu, rjjones"; + + await user.type(textarea, inputText); + await user.click(queryButton); + + expect(queryFunctionMock).toHaveBeenCalledTimes(1); + expect(queryFunctionMock).toHaveBeenCalledWith([ + "rjjones", "invalid", - ); - expect(screen.getByText("is not a paid member.")).toBeVisible(); + "test2", + ]); + + expect(await screen.findByText(/Paid Members \(2\)/i)).toBeVisible(); + + expect(screen.getByText("rjjones")).toBeVisible(); + expect(screen.getByText("test2")).toBeVisible(); }); }); diff --git a/src/ui/pages/membershipLists/InternalMembershipQuery.tsx b/src/ui/pages/membershipLists/InternalMembershipQuery.tsx index e882fde4..50105bea 100644 --- a/src/ui/pages/membershipLists/InternalMembershipQuery.tsx +++ b/src/ui/pages/membershipLists/InternalMembershipQuery.tsx @@ -1,33 +1,173 @@ import { useState } from "react"; -import { TextInput, Button, Stack, Box, Text, Group } from "@mantine/core"; -import { IconCircleCheck, IconCircleX } from "@tabler/icons-react"; +import { + Textarea, + Button, + Stack, + Box, + Group, + Title, + Code, + ActionIcon, + Tooltip, +} from "@mantine/core"; +import { useClipboard } from "@mantine/hooks"; +import { + IconCircleCheck, + IconCircleX, + IconCopy, + IconCheck, + IconMail, +} from "@tabler/icons-react"; -interface InternalMembershipQueryProps { - queryInternalMembership: (netId: string) => Promise; +interface ResultSectionProps { + title: string; + items: string[]; + color: "green" | "red"; + icon: React.ReactNode; + domain?: string; } -export const InternalMembershipQuery = ({ - queryInternalMembership, -}: InternalMembershipQueryProps) => { - const [netId, setNetId] = useState(""); +const ResultSection = ({ + title, + items, + color, + icon, + domain, +}: ResultSectionProps) => { + const clipboardIds = useClipboard({ timeout: 1000 }); + const clipboardEmails = useClipboard({ timeout: 1000 }); + + const handleCopyEmails = () => { + if (domain) { + const emails = items.map((item) => `${item}@${domain}`).join(", "); + clipboardEmails.copy(emails); + } + }; + + if (!items || items.length === 0) { + return null; + } + + return ( + + + + {icon} + + {title} ({items.length}) + + + + + clipboardIds.copy(items.join(", "))} + > + {clipboardIds.copied ? ( + + ) : ( + + )} + + + {domain && ( + + + {clipboardEmails.copied ? ( + + ) : ( + + )} + + + )} + + + + {items.map((item) => ( + + {item} + + ))} + + + ); +}; + +interface MembershipListQueryProps { + queryFunction: (items: string[]) => Promise<{ + members: string[]; + notMembers: string[]; + }>; + + domain?: string; + inputLabel?: string; + inputDescription?: string; + inputPlaceholder?: string; + ctaText?: string; +} + +export const MembershipListQuery = ({ + queryFunction, + domain = "illinois.edu", + inputLabel = "Enter NetIDs or Illinois Emails", + inputDescription = "Enter items separated by commas, semicolons, spaces, or newlines.", + inputPlaceholder = "e.g., rjjones, isbell@illinois.edu, ...", + ctaText = "Query Memberships", +}: MembershipListQueryProps) => { + const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [result, setResult] = useState<{ - netId: string; - isMember: boolean; + members: string[]; + notMembers: string[]; } | null>(null); const handleQuery = async () => { - if (!netId.trim()) { + // Input processing logic remains the same + const domainRegex = domain ? new RegExp(`@${domain}$`, "i") : null; + const processedItems = input + .split(/[;,\s\n]+/) + .map((item) => { + let cleanItem = item.trim().toLowerCase(); + if (domainRegex) { + cleanItem = cleanItem.replace(domainRegex, ""); + } + return cleanItem; + }) + .filter(Boolean); + + const uniqueItems = [...new Set(processedItems)]; + if (uniqueItems.length === 0) { return; } setIsLoading(true); setResult(null); + try { - const isMember = await queryInternalMembership( - netId.trim().toLowerCase(), - ); - setResult({ netId: netId.trim().toLowerCase(), isMember }); + const queryResult = await queryFunction(uniqueItems); + + setResult(queryResult); + } catch (error) { + console.error("An error occurred during the query:", error); } finally { setIsLoading(false); } @@ -35,51 +175,50 @@ export const InternalMembershipQuery = ({ return ( - setNetId(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - handleQuery(); - } - }} +