diff --git a/src/api/functions/uin.ts b/src/api/functions/uin.ts index 251fd22d..b3130bcc 100644 --- a/src/api/functions/uin.ts +++ b/src/api/functions/uin.ts @@ -1,10 +1,13 @@ import { + BatchGetItemCommand, DynamoDBClient, PutItemCommand, QueryCommand, UpdateItemCommand, } from "@aws-sdk/client-dynamodb"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; +import { ValidLoggers } from "api/types.js"; +import { retryDynamoTransactionWithBackoff } from "api/utils.js"; import { argon2id, hash } from "argon2"; import { genericConfig } from "common/config.js"; import { @@ -235,3 +238,80 @@ export async function getUserIdByUin({ const data = unmarshall(response.Items[0]) as { id: string }; return data; } + +export async function batchGetUserInfo({ + emails, + dynamoClient, + logger, +}: { + emails: string[]; + dynamoClient: DynamoDBClient; + logger: ValidLoggers; +}) { + const results: Record< + string, + { + firstName?: string; + lastName?: string; + } + > = {}; + + // DynamoDB BatchGetItem has a limit of 100 items per request + const BATCH_SIZE = 100; + + for (let i = 0; i < emails.length; i += BATCH_SIZE) { + const batch = emails.slice(i, i + BATCH_SIZE); + + try { + await retryDynamoTransactionWithBackoff( + async () => { + const response = await dynamoClient.send( + new BatchGetItemCommand({ + RequestItems: { + [genericConfig.UserInfoTable]: { + Keys: batch.map((email) => ({ + id: { S: email }, + })), + ProjectionExpression: "id, firstName, lastName", + }, + }, + }), + ); + + // Process responses + const items = response.Responses?.[genericConfig.UserInfoTable] || []; + for (const item of items) { + const email = item.id?.S; + if (email) { + results[email] = { + firstName: item.firstName?.S, + lastName: item.lastName?.S, + }; + } + } + + // If there are unprocessed keys, throw to trigger retry + if ( + response.UnprocessedKeys && + Object.keys(response.UnprocessedKeys).length > 0 + ) { + const error = new Error( + "UnprocessedKeys present - triggering retry", + ); + error.name = "TransactionCanceledException"; + throw error; + } + }, + logger, + `batchGetUserInfo (batch ${i / BATCH_SIZE + 1})`, + ); + } catch (error) { + logger.warn( + `Failed to fetch batch ${i / BATCH_SIZE + 1} after retries, returning partial results`, + { error }, + ); + } + } + + return results; +} diff --git a/src/api/routes/user.ts b/src/api/routes/user.ts index b14a3b29..e15abd23 100644 --- a/src/api/routes/user.ts +++ b/src/api/routes/user.ts @@ -9,10 +9,16 @@ import { } from "common/errors/index.js"; import * as z from "zod/v4"; import { + batchResolveUserInfoRequest, + batchResolveUserInfoResponse, searchUserByUinRequest, searchUserByUinResponse, } from "common/types/user.js"; -import { getUinHash, getUserIdByUin } from "api/functions/uin.js"; +import { + batchGetUserInfo, + getUinHash, + getUserIdByUin, +} from "api/functions/uin.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { QueryCommand } from "@aws-sdk/client-dynamodb"; import { genericConfig } from "common/config.js"; @@ -58,6 +64,38 @@ const userRoute: FastifyPluginAsync = async (fastify, _options) => { ); }, ); + fastify.withTypeProvider().post( + "/batchResolveInfo", + { + schema: withRoles( + [], + withTags(["Generic"], { + summary: "Resolve user emails to user info.", + body: batchResolveUserInfoRequest, + response: { + 200: { + description: "The search was performed.", + content: { + "application/json": { + schema: batchResolveUserInfoResponse, + }, + }, + }, + }, + }), + ), + onRequest: fastify.authorizeFromSchema, + }, + async (request, reply) => { + return reply.send( + await batchGetUserInfo({ + dynamoClient: fastify.dynamoClient, + emails: request.body.emails, + logger: request.log, + }), + ); + }, + ); }; export default userRoute; diff --git a/src/common/types/user.ts b/src/common/types/user.ts index baee5293..f66b240a 100644 --- a/src/common/types/user.ts +++ b/src/common/types/user.ts @@ -8,3 +8,18 @@ export const searchUserByUinRequest = z.object({ export const searchUserByUinResponse = z.object({ email: z.email(), }); + +export const batchResolveUserInfoRequest = z.object({ + emails: z.array(z.email()).min(1) +}) + + +export const batchResolveUserInfoResponse = z.object({ +}).catchall( + z.object({ + firstName: z.string().optional(), + lastName: z.string().optional() + }) +); + +export type BatchResolveUserInfoResponse = z.infer; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 5f2253e7..d04e6395 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -8,6 +8,7 @@ import { Notifications } from "@mantine/notifications"; import ColorSchemeContext from "./ColorSchemeContext"; import { Router } from "./Router"; +import { UserResolverProvider } from "./components/NameOptionalCard"; export default function App() { const preferredColorScheme = useColorScheme(); @@ -25,7 +26,9 @@ export default function App() { forceColorScheme={colorScheme} > - + + + ); diff --git a/src/ui/components/NameOptionalCard/index.tsx b/src/ui/components/NameOptionalCard/index.tsx new file mode 100644 index 00000000..999b5f92 --- /dev/null +++ b/src/ui/components/NameOptionalCard/index.tsx @@ -0,0 +1,275 @@ +import { + createContext, + useContext, + useEffect, + useState, + useRef, + ReactNode, +} from "react"; +import { Avatar, Group, Text, Skeleton, Badge } from "@mantine/core"; +import { useApi } from "@ui/util/api"; +import { BatchResolveUserInfoResponse } from "@common/types/user"; +import { useAuth } from "../AuthContext"; + +// Types +interface UserData { + email: string; + name?: string; +} + +const AVATAR_SIZES = { + xs: 16, + sm: 26, + md: 38, + lg: 56, + xl: 84, +} as const; + +// Basic email validation regex +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +interface UserResolverContextType { + resolveUser: (email: string) => UserData | undefined; + requestUser: (email: string) => void; + isResolving: (email: string) => boolean; + resolutionDisabled: boolean; + cacheVersion: number; +} + +// Context +const UserResolverContext = createContext(null); + +// Provider Props +interface UserResolverProviderProps { + children: ReactNode; + batchDelay?: number; + resolutionDisabled?: boolean; +} + +// Sentinel value to indicate we've checked and there's no name +const NO_NAME_FOUND = Symbol("NO_NAME_FOUND"); + +export function UserResolverProvider({ + children, + batchDelay = 50, + resolutionDisabled = false, +}: UserResolverProviderProps) { + const api = useApi("core"); + const [userCache, setUserCache] = useState< + Record + >({}); + const [cacheVersion, setCacheVersion] = useState(0); + const pendingRequests = useRef>(new Set()); + const batchTimeout = useRef(null); + + const fetchUsers = async (emailsToFetch: string[]) => { + const response = await api.post( + "/api/v1/users/batchResolveInfo", + { + emails: emailsToFetch, + }, + ); + + const emailToName: Record = {}; + for (const email of emailsToFetch) { + const userData = response.data[email]; + if (userData?.firstName || userData?.lastName) { + const nameParts = [userData.firstName, userData.lastName].filter( + Boolean, + ); + emailToName[email] = nameParts.join(" "); + } else { + emailToName[email] = NO_NAME_FOUND; + } + } + + return emailToName; + }; + + const executeBatch = async () => { + if (pendingRequests.current.size === 0) { + return; + } + + const emailsToFetch = Array.from(pendingRequests.current); + pendingRequests.current.clear(); + + try { + const results = await fetchUsers(emailsToFetch); + setUserCache((prev) => { + setCacheVersion((v) => v + 1); + return { ...prev, ...results }; + }); + } catch (error) { + console.error("Failed to fetch users:", error); + const failedCache: Record = {}; + emailsToFetch.forEach((email) => { + failedCache[email] = NO_NAME_FOUND; + }); + setUserCache((prev) => { + setCacheVersion((v) => v + 1); + return { ...prev, ...failedCache }; + }); + } + }; + + const requestUser = (email: string) => { + // If resolution is disabled, mark as NO_NAME_FOUND immediately + if (resolutionDisabled) { + setUserCache((prev) => { + setCacheVersion((v) => v + 1); + return { ...prev, [email]: NO_NAME_FOUND }; + }); + return; + } + + // Skip if already cached (including NO_NAME_FOUND sentinel) + if (email in userCache) { + return; + } + + // Validate email format - if invalid, mark as NO_NAME_FOUND immediately + if (!EMAIL_REGEX.test(email)) { + setUserCache((prev) => { + setCacheVersion((v) => v + 1); + return { ...prev, [email]: NO_NAME_FOUND }; + }); + return; + } + + pendingRequests.current.add(email); + + // Clear existing timeout and set new one + if (batchTimeout.current) { + clearTimeout(batchTimeout.current); + } + + batchTimeout.current = setTimeout(() => { + executeBatch(); + }, batchDelay); + }; + + const isResolving = (email: string): boolean => { + return !(email in userCache); + }; + + const resolveUser = (email: string): UserData | undefined => { + const cached = userCache[email]; + if (!cached || cached === NO_NAME_FOUND) { + return undefined; + } + return { email, name: cached }; + }; + + return ( + + {children} + + ); +} + +// Hook +function useUserResolver() { + const context = useContext(UserResolverContext); + if (!context) { + throw new Error("useUserResolver must be used within UserResolverProvider"); + } + return context; +} + +// Component Props +interface NameOptionalUserCardProps { + email: string; + name?: string; + size?: "xs" | "sm" | "md" | "lg" | "xl"; + fallback?: (email: string) => ReactNode; + resolutionDisabled?: boolean; +} + +// Component +export function NameOptionalUserCard({ + name: providedName, + email, + size = "sm", + fallback, + resolutionDisabled: resolutionDisabledProp, +}: NameOptionalUserCardProps) { + const { + resolveUser, + requestUser, + isResolving, + resolutionDisabled: contextResolutionDisabled, + cacheVersion, + } = useUserResolver(); + + const resolutionDisabled = + typeof resolutionDisabledProp === "boolean" + ? resolutionDisabledProp + : contextResolutionDisabled; + + const [resolvedUser, setResolvedUser] = useState(); + const { userData } = useAuth(); + + const isValidEmail = EMAIL_REGEX.test(email); + + useEffect(() => { + if (resolutionDisabled || providedName || !isValidEmail) { + return; + } + + requestUser(email); + const user = resolveUser(email); + if (user) { + setResolvedUser(user); + } + }, [ + email, + providedName, + isValidEmail, + resolutionDisabled, + cacheVersion, + resolveUser, + requestUser, + ]); + + if (!isValidEmail) { + return fallback ? <>{fallback(email)} : {email}; + } + + const displayName = providedName || resolvedUser?.name || email; + const isLoading = !resolutionDisabled && !providedName && isResolving(email); + const isCurrentUser = !!userData && userData.email === email; + + return ( + + {isLoading ? ( + + ) : ( + + )} +
+ + + {isLoading ? : displayName} + + {isCurrentUser && !isLoading && ( + + You + + )} + + + {isLoading ? : email} + +
+
+ ); +} diff --git a/src/ui/pages/apiKeys/ManageKeys.page.tsx b/src/ui/pages/apiKeys/ManageKeys.page.tsx index 80b62a51..60def845 100644 --- a/src/ui/pages/apiKeys/ManageKeys.page.tsx +++ b/src/ui/pages/apiKeys/ManageKeys.page.tsx @@ -16,7 +16,7 @@ export const ManageApiKeysPage: React.FC = () => { }} showSidebar > - + API Keys Manage organization API keys. diff --git a/src/ui/pages/apiKeys/ManageKeysTable.test.tsx b/src/ui/pages/apiKeys/ManageKeysTable.test.tsx index 6bec2c1e..04e8f73b 100644 --- a/src/ui/pages/apiKeys/ManageKeysTable.test.tsx +++ b/src/ui/pages/apiKeys/ManageKeysTable.test.tsx @@ -8,6 +8,7 @@ import { OrgApiKeyTable } from "./ManageKeysTable"; import { MemoryRouter } from "react-router-dom"; import { ApiKeyMaskedEntry, ApiKeyPostBody } from "@common/types/apiKey"; import { AppRoles } from "@common/roles"; +import { UserResolverProvider } from "@ui/components/NameOptionalCard"; // Mock the notifications module vi.mock("@mantine/notifications", () => ({ @@ -83,11 +84,13 @@ describe("OrgApiKeyTable Tests", () => { withCssVariables forceColorScheme="light" > - + + + , ); @@ -129,8 +132,8 @@ describe("OrgApiKeyTable Tests", () => { }); expect(screen.getByText("Test API Key 1")).toBeInTheDocument(); - expect(screen.getByText("You")).toBeInTheDocument(); // Current user's key - expect(screen.getByText("other@example.com")).toBeInTheDocument(); + expect(screen.getAllByText("test@example.com")).toHaveLength(2); + expect(screen.getAllByText("other@example.com")).toHaveLength(2); expect(screen.getByText("Never")).toBeInTheDocument(); // For key that never expires }); diff --git a/src/ui/pages/apiKeys/ManageKeysTable.tsx b/src/ui/pages/apiKeys/ManageKeysTable.tsx index f8762aef..3c7e0e49 100644 --- a/src/ui/pages/apiKeys/ManageKeysTable.tsx +++ b/src/ui/pages/apiKeys/ManageKeysTable.tsx @@ -38,6 +38,7 @@ import { AppRoles } from "@common/roles"; import { BlurredTextDisplay } from "../../components/BlurredTextDisplay"; import * as z from "zod/v4"; import { ResponsiveTable, Column } from "@ui/components/ResponsiveTable"; +import { NameOptionalUserCard } from "@ui/components/NameOptionalCard"; const HumanFriendlyDate = ({ date }: { date: number }) => { return ( @@ -208,11 +209,7 @@ export const OrgApiKeyTable: React.FC = ({ { key: "owner", label: "Owner", - render: (key) => ( - - {key.owner === userData?.email ? "You" : key.owner} - - ), + render: (key) => , }, { key: "created", diff --git a/src/ui/pages/iam/GroupMemberManagement.test.tsx b/src/ui/pages/iam/GroupMemberManagement.test.tsx index e0c554b4..7b8b2554 100644 --- a/src/ui/pages/iam/GroupMemberManagement.test.tsx +++ b/src/ui/pages/iam/GroupMemberManagement.test.tsx @@ -5,6 +5,7 @@ import GroupMemberManagement from "./GroupMemberManagement"; import { MantineProvider } from "@mantine/core"; import { notifications } from "@mantine/notifications"; import userEvent from "@testing-library/user-event"; +import { UserResolverProvider } from "@ui/components/NameOptionalCard"; describe("Exec Group Management Panel tests", () => { const renderComponent = async ( @@ -19,10 +20,12 @@ describe("Exec Group Management Panel tests", () => { withCssVariables forceColorScheme="light" > - + + + , ); diff --git a/src/ui/pages/iam/GroupMemberManagement.tsx b/src/ui/pages/iam/GroupMemberManagement.tsx index 005c223e..8ad04e8b 100644 --- a/src/ui/pages/iam/GroupMemberManagement.tsx +++ b/src/ui/pages/iam/GroupMemberManagement.tsx @@ -22,6 +22,7 @@ import { import { notifications } from "@mantine/notifications"; import { GroupMemberGetResponse, EntraActionResponse } from "@common/types/iam"; import { ResponsiveTable, Column } from "@ui/components/ResponsiveTable"; +import { NameOptionalUserCard } from "@ui/components/NameOptionalCard"; interface GroupMemberManagementProps { fetchMembers: () => Promise; @@ -188,21 +189,7 @@ const GroupMemberManagement: React.FC = ({ label: "Member", isPrimaryColumn: true, render: (member) => ( - - -
- - {member.name} - - - {member.email} - -
-
+ ), }, { diff --git a/src/ui/pages/logs/LogRenderer.test.tsx b/src/ui/pages/logs/LogRenderer.test.tsx index 75514224..399b6dc9 100644 --- a/src/ui/pages/logs/LogRenderer.test.tsx +++ b/src/ui/pages/logs/LogRenderer.test.tsx @@ -7,13 +7,13 @@ import { notifications } from "@mantine/notifications"; import { LogRenderer } from "./LogRenderer"; import { Modules, ModulesToHumanName } from "@common/modules"; import { MemoryRouter } from "react-router-dom"; +import { UserResolverProvider } from "@ui/components/NameOptionalCard"; describe("LogRenderer Tests", () => { const getLogsMock = vi.fn(); // Mock date for consistent testing const mockCurrentDate = new Date("2023-01-15T12:00:00Z"); - const mockPastDate = new Date("2023-01-14T12:00:00Z"); // Sample log data for testing const sampleLogs = [ @@ -46,7 +46,9 @@ describe("LogRenderer Tests", () => { withCssVariables forceColorScheme="light" > - + + + , ); @@ -112,7 +114,7 @@ describe("LogRenderer Tests", () => { // Verify logs are displayed await screen.findByText("User created"); expect(screen.getByText("admin")).toBeInTheDocument(); - expect(screen.getByText("user@example.com")).toBeInTheDocument(); + expect(screen.getAllByText("user@example.com")).length(2); expect(screen.getByText("req-123")).toBeInTheDocument(); }); diff --git a/src/ui/pages/logs/LogRenderer.tsx b/src/ui/pages/logs/LogRenderer.tsx index 0753f105..7f4ed471 100644 --- a/src/ui/pages/logs/LogRenderer.tsx +++ b/src/ui/pages/logs/LogRenderer.tsx @@ -24,6 +24,7 @@ import { import { Modules, ModulesToHumanName } from "@common/modules"; import { notifications } from "@mantine/notifications"; import { ResponsiveTable, Column } from "@ui/components/ResponsiveTable"; +import { NameOptionalUserCard } from "@ui/components/NameOptionalCard"; interface LogEntry { actor: string; @@ -211,7 +212,7 @@ export const LogRenderer: React.FC = ({ getLogs }) => { { key: "actor", label: "Actor", - render: (log) => {log.actor}, + render: (log) => , }, { key: "action", @@ -231,14 +232,16 @@ export const LogRenderer: React.FC = ({ getLogs }) => { { key: "target", label: "Target", - render: (log) => ( - - {selectedModule === Modules.AUDIT_LOG && - Object.values(Modules).includes(log.target as Modules) - ? ModulesToHumanName[log.target as Modules] - : log.target} - - ), + render: (log) => + selectedModule === Modules.AUDIT_LOG && + Object.values(Modules).includes(log.target as Modules) ? ( + ModulesToHumanName[log.target as Modules] + ) : ( + <>{email}} + /> + ), }, { key: "requestId", diff --git a/src/ui/pages/membershipLists/ExternalMemberListManagement.tsx b/src/ui/pages/membershipLists/ExternalMemberListManagement.tsx index 2054e308..4a1046a8 100644 --- a/src/ui/pages/membershipLists/ExternalMemberListManagement.tsx +++ b/src/ui/pages/membershipLists/ExternalMemberListManagement.tsx @@ -23,6 +23,7 @@ import { illinoisNetId } from "@common/types/generic"; import { AuthGuard } from "@ui/components/AuthGuard"; import { AppRoles } from "@common/roles"; import pluralize from "pluralize"; +import { NameOptionalUserCard } from "@ui/components/NameOptionalCard"; interface ExternalMemberListManagementProps { fetchMembers: (listId: string) => Promise; @@ -327,12 +328,7 @@ const ExternalMemberListManagement: React.FC< return ( - - - - {member} - - + {statusBadge} {actionButton} @@ -379,7 +375,7 @@ const ExternalMemberListManagement: React.FC< - Member NetID + Member Status Actions diff --git a/src/ui/pages/roomRequest/ViewRoomRequest.page.tsx b/src/ui/pages/roomRequest/ViewRoomRequest.page.tsx index d0f20917..9ebec954 100644 --- a/src/ui/pages/roomRequest/ViewRoomRequest.page.tsx +++ b/src/ui/pages/roomRequest/ViewRoomRequest.page.tsx @@ -49,6 +49,7 @@ import { downloadFromS3PresignedUrl, uploadToS3PresignedUrl, } from "@ui/util/s3"; +import { NameOptionalUserCard } from "@ui/components/NameOptionalCard"; export const ViewRoomRequest: React.FC = () => { const { semesterId, requestId } = useParams(); @@ -446,17 +447,19 @@ export const ViewRoomRequest: React.FC = () => { <> {data.updates.map((x) => ( {formatStatus(x.status)}} > - {x.createdBy && {x.createdBy}} + {x.createdBy && ( + + )} {x.notes && ( - + {x.notes} )} @@ -464,6 +467,7 @@ export const ViewRoomRequest: React.FC = () => {