From d678eb6b9a785494f369a10d7d9694565b4bbb55 Mon Sep 17 00:00:00 2001 From: tarashagarwal Date: Mon, 17 Feb 2025 22:11:03 -0600 Subject: [PATCH 01/24] Adding Siglead Management Option in the Side Menu & Adding a draft Page --- src/common/roles.ts | 1 + src/ui/Router.tsx | 5 + src/ui/components/AppShell/index.tsx | 15 +- src/ui/pages/siglead/ManageSigLeads.page.tsx | 207 +++++++++++++++++++ 4 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 src/ui/pages/siglead/ManageSigLeads.page.tsx diff --git a/src/common/roles.ts b/src/common/roles.ts index c61d572c..a713b930 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -3,6 +3,7 @@ export const runEnvironments = ["dev", "prod"] as const; export type RunEnvironment = (typeof runEnvironments)[number]; export enum AppRoles { EVENTS_MANAGER = "manage:events", + SIGLEAD_MANAGER = "manage:siglead", TICKETS_SCANNER = "scan:tickets", TICKETS_MANAGER = "manage:tickets", IAM_ADMIN = "admin:iam", diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index 5e8528e3..44654cfb 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -19,6 +19,7 @@ import { ViewTicketsPage } from './pages/tickets/ViewTickets.page'; import { ManageIamPage } from './pages/iam/ManageIam.page'; import { ManageProfilePage } from './pages/profile/ManageProfile.page'; import { ManageStripeLinksPage } from './pages/stripe/ViewLinks.page'; +import { ManageSigLeadsPage } from './pages/siglead/ManageSigLeads.page'; const ProfileRediect: React.FC = () => { const location = useLocation(); @@ -162,6 +163,10 @@ const authenticatedRouter = createBrowserRouter([ path: '/stripe', element: , }, + { + path: '/siglead-management', + element: , + }, // Catch-all route for authenticated users shows 404 page { path: '*', diff --git a/src/ui/components/AppShell/index.tsx b/src/ui/components/AppShell/index.tsx index 8b5183ab..9697d673 100644 --- a/src/ui/components/AppShell/index.tsx +++ b/src/ui/components/AppShell/index.tsx @@ -17,6 +17,7 @@ import { IconPizza, IconTicket, IconLock, + IconUsers, } from '@tabler/icons-react'; import { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -37,13 +38,6 @@ export interface AcmAppShellProps { } export const navItems = [ - { - link: '/events/manage', - name: 'Events', - icon: IconCalendar, - description: null, - validRoles: [AppRoles.EVENTS_MANAGER], - }, { link: '/tickets', name: 'Ticketing/Merch', @@ -65,6 +59,13 @@ export const navItems = [ description: null, validRoles: [AppRoles.STRIPE_LINK_CREATOR], }, + { + link: '/siglead-management', + name: 'SigLead', + icon: IconUsers, + description: null, + validRoles: [AppRoles.SIGLEAD_MANAGER], + }, ]; export const extLinks = [ diff --git a/src/ui/pages/siglead/ManageSigLeads.page.tsx b/src/ui/pages/siglead/ManageSigLeads.page.tsx new file mode 100644 index 00000000..521e8885 --- /dev/null +++ b/src/ui/pages/siglead/ManageSigLeads.page.tsx @@ -0,0 +1,207 @@ +import { Title, Box, TextInput, Textarea, Switch, Select, Button, Loader } from '@mantine/core'; +import { DateTimePicker } from '@mantine/dates'; +import { useForm, zodResolver } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; +import dayjs from 'dayjs'; +import React, { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { z } from 'zod'; +import { AuthGuard } from '@ui/components/AuthGuard'; +import { getRunEnvironmentConfig } from '@ui/config'; +import { useApi } from '@ui/util/api'; +import { OrganizationList as orgList } from '@common/orgs'; +import { AppRoles } from '@common/roles'; + +export function capitalizeFirstLetter(string: string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +const repeatOptions = ['weekly', 'biweekly'] as const; + +const baseBodySchema = z.object({ + title: z.string().min(1, 'Title is required'), + description: z.string().min(1, 'Description is required'), + start: z.date(), + end: z.optional(z.date()), + location: z.string().min(1, 'Location is required'), + locationLink: z.optional(z.string().url('Invalid URL')), + host: z.string().min(1, 'Host is required'), + featured: z.boolean().default(false), + paidEventId: z.string().min(1, 'Paid Event ID must be at least 1 character').optional(), +}); + +const requestBodySchema = baseBodySchema + .extend({ + repeats: z.optional(z.enum(repeatOptions)).nullable(), + repeatEnds: z.date().optional(), + }) + .refine((data) => (data.repeatEnds ? data.repeats !== undefined : true), { + message: 'Repeat frequency is required when Repeat End is specified.', + }) + .refine((data) => !data.end || data.end >= data.start, { + message: 'Event end date cannot be earlier than the start date.', + path: ['end'], + }) + .refine((data) => !data.repeatEnds || data.repeatEnds >= data.start, { + message: 'Repeat end date cannot be earlier than the start date.', + path: ['repeatEnds'], + }); + +type EventPostRequest = z.infer; + +export const ManageSigLeadsPage: React.FC = () => { + const [isSubmitting, setIsSubmitting] = useState(false); + const navigate = useNavigate(); + const api = useApi('core'); + + const { eventId } = useParams(); + + const isEditing = eventId !== undefined; + + useEffect(() => { + if (!isEditing) { + return; + } + // Fetch event data and populate form + const getEvent = async () => { + try { + const response = await api.get(`/api/v1/events/${eventId}`); + const eventData = response.data; + const formValues = { + title: eventData.title, + description: eventData.description, + start: new Date(eventData.start), + end: eventData.end ? new Date(eventData.end) : undefined, + location: eventData.location, + locationLink: eventData.locationLink, + host: eventData.host, + featured: eventData.featured, + repeats: eventData.repeats, + repeatEnds: eventData.repeatEnds ? new Date(eventData.repeatEnds) : undefined, + paidEventId: eventData.paidEventId, + }; + form.setValues(formValues); + } catch (error) { + console.error('Error fetching event data:', error); + notifications.show({ + message: 'Failed to fetch event data, please try again.', + }); + } + }; + getEvent(); + }, [eventId, isEditing]); + + const form = useForm({ + validate: zodResolver(requestBodySchema), + initialValues: { + title: '', + description: '', + start: new Date(), + end: new Date(new Date().valueOf() + 3.6e6), // 1 hr later + location: 'ACM Room (Siebel CS 1104)', + locationLink: 'https://maps.app.goo.gl/dwbBBBkfjkgj8gvA8', + host: 'ACM', + featured: false, + repeats: undefined, + repeatEnds: undefined, + paidEventId: undefined, + }, + }); + + const checkPaidEventId = async (paidEventId: string) => { + try { + const merchEndpoint = getRunEnvironmentConfig().ServiceConfiguration.merch.baseEndpoint; + const ticketEndpoint = getRunEnvironmentConfig().ServiceConfiguration.tickets.baseEndpoint; + const paidEventHref = paidEventId.startsWith('merch:') + ? `${merchEndpoint}/api/v1/merch/details?itemid=${paidEventId.slice(6)}` + : `${ticketEndpoint}/api/v1/event/details?eventid=${paidEventId}`; + const response = await api.get(paidEventHref); + return Boolean(response.status < 299 && response.status >= 200); + } catch (error) { + console.error('Error validating paid event ID:', error); + return false; + } + }; + + const handleSubmit = async (values: EventPostRequest) => { + try { + setIsSubmitting(true); + const realValues = { + ...values, + start: dayjs(values.start).format('YYYY-MM-DD[T]HH:mm:00'), + end: values.end ? dayjs(values.end).format('YYYY-MM-DD[T]HH:mm:00') : undefined, + repeatEnds: + values.repeatEnds && values.repeats + ? dayjs(values.repeatEnds).format('YYYY-MM-DD[T]HH:mm:00') + : undefined, + repeats: values.repeats ? values.repeats : undefined, + }; + + const eventURL = isEditing ? `/api/v1/events/${eventId}` : '/api/v1/events'; + const response = await api.post(eventURL, realValues); + notifications.show({ + title: isEditing ? 'Event updated!' : 'Event created!', + message: isEditing ? undefined : `The event ID is "${response.data.id}".`, + }); + navigate('/events/manage'); + } catch (error) { + setIsSubmitting(false); + console.error('Error creating/editing event:', error); + notifications.show({ + message: 'Failed to create/edit event, please try again.', + }); + } + }; + + return ( +
+ + + + + + + + + + + +

Page Under Construction

+ + +
+ ); +}; From a837785035627314a0dd2808d0b84524bf7a7b36 Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Sat, 1 Mar 2025 13:23:56 -0600 Subject: [PATCH 02/24] siglead management screen done --- src/ui/pages/siglead/ManageSigLeads.page.tsx | 54 ++------------------ src/ui/pages/siglead/SigScreenComponents.tsx | 47 +++++++++++++++++ 2 files changed, 52 insertions(+), 49 deletions(-) create mode 100644 src/ui/pages/siglead/SigScreenComponents.tsx diff --git a/src/ui/pages/siglead/ManageSigLeads.page.tsx b/src/ui/pages/siglead/ManageSigLeads.page.tsx index 521e8885..0410ad82 100644 --- a/src/ui/pages/siglead/ManageSigLeads.page.tsx +++ b/src/ui/pages/siglead/ManageSigLeads.page.tsx @@ -11,6 +11,7 @@ import { getRunEnvironmentConfig } from '@ui/config'; import { useApi } from '@ui/util/api'; import { OrganizationList as orgList } from '@common/orgs'; import { AppRoles } from '@common/roles'; +import { ScreenComponent } from './SigScreenComponents'; export function capitalizeFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); @@ -154,54 +155,9 @@ export const ManageSigLeadsPage: React.FC = () => { }; return ( -
- - - - - - - - - - - -

Page Under Construction

- - -
+ + SigLead Management System + + ); }; diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx new file mode 100644 index 00000000..86290b03 --- /dev/null +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -0,0 +1,47 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { z } from 'zod'; +import { OrganizationList } from '@common/orgs'; +import { NavLink } from '@mantine/core'; +import { AuthGuard } from '@ui/components/AuthGuard'; +import { AppRoles } from '@common/roles'; +import { IconUsersGroup } from '@tabler/icons-react'; +import { useLocation } from 'react-router-dom'; + +// use personn icon +// import { IconPlus, IconTrash } from '@tabler/icons-react'; + +// const OrganizationListEnum = z.enum(OrganizationList); + +// const renderTableRow = (org: string) => { +// const count = 50; +// return( +// +// {(styles) => ( +// +// {org} +// {count} +// +// )} +// +// ) +// } + +const renderSigLink = (org: string, index: number) => { + return ( + + MemberCount[{index}] + + + } + /> + ); +}; + +export const ScreenComponent: React.FC = () => { + return <>{OrganizationList.map(renderSigLink)}; +}; From c75ec3c9c6f5532be8b166a19c72b884290b88a7 Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Sat, 8 Mar 2025 16:23:37 -0600 Subject: [PATCH 03/24] column headers and text color --- src/ui/pages/siglead/ManageSigLeads.page.tsx | 3 +- src/ui/pages/siglead/SigScreenComponents.tsx | 109 ++++++++++++++++++- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/ui/pages/siglead/ManageSigLeads.page.tsx b/src/ui/pages/siglead/ManageSigLeads.page.tsx index 0410ad82..741206cb 100644 --- a/src/ui/pages/siglead/ManageSigLeads.page.tsx +++ b/src/ui/pages/siglead/ManageSigLeads.page.tsx @@ -11,7 +11,7 @@ import { getRunEnvironmentConfig } from '@ui/config'; import { useApi } from '@ui/util/api'; import { OrganizationList as orgList } from '@common/orgs'; import { AppRoles } from '@common/roles'; -import { ScreenComponent } from './SigScreenComponents'; +import { ScreenComponent, SigTable } from './SigScreenComponents'; export function capitalizeFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); @@ -158,6 +158,7 @@ export const ManageSigLeadsPage: React.FC = () => { SigLead Management System + {/* */} ); }; diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx index 86290b03..2e4e9488 100644 --- a/src/ui/pages/siglead/SigScreenComponents.tsx +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { z } from 'zod'; import { OrganizationList } from '@common/orgs'; -import { NavLink } from '@mantine/core'; +import { NavLink, Paper } from '@mantine/core'; import { AuthGuard } from '@ui/components/AuthGuard'; import { AppRoles } from '@common/roles'; import { IconUsersGroup } from '@tabler/icons-react'; @@ -31,7 +31,14 @@ const renderSigLink = (org: string, index: number) => { MemberCount[{index}] @@ -43,5 +50,103 @@ const renderSigLink = (org: string, index: number) => { }; export const ScreenComponent: React.FC = () => { - return <>{OrganizationList.map(renderSigLink)}; + return ( + <> + + Organization + Member Count + + {OrganizationList.map(renderSigLink)} + + ); }; + +import { Table } from '@mantine/core'; + +export const SigTable = () => { + const location = useLocation(); + return ( + + {/* Headers */} + + + + + + + + + {OrganizationList.map((org, index) => ( + + {/* Organization Column */} + + + {/* Member Count Column */} + + + ))} + + {/* + {OrganizationList.map((org, index) => ( + + + + ))} + */} +
OrganizationMember Count
+ + + MemberCount[{index}] + +
{renderSigLink(org, index)}
+ ); +}; + +// const navLinks = [ +// { label: "Home", icon: , path: "/" }, +// { label: "Profile", icon: , path: "/profile" }, +// { label: "Settings", icon: , path: "/settings" }, +// ]; + +// export const NavLinkTable = () => { +// return ( +// +// +// +// +// +// +// +// {navLinks.map((link, index) => ( +// +// +// +// ))} +// +//
Navigation
+// +//
+// ); +// } From e89b3017195f00756dc6bca24c06511ab242e3cb Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Thu, 20 Mar 2025 11:58:44 -0700 Subject: [PATCH 04/24] starter --- src/api/routes/siglead.ts | 466 +++++++++++++++++++ src/ui/pages/siglead/SigScreenComponents.tsx | 6 - 2 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 src/api/routes/siglead.ts diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts new file mode 100644 index 00000000..a898b086 --- /dev/null +++ b/src/api/routes/siglead.ts @@ -0,0 +1,466 @@ +import { FastifyPluginAsync } from "fastify"; +import { allAppRoles, AppRoles } from "../../common/roles.js"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { + addToTenant, + getEntraIdToken, + listGroupMembers, + modifyGroup, + patchUserProfile, +} from "../functions/entraId.js"; +import { + BaseError, + DatabaseFetchError, + DatabaseInsertError, + EntraGroupError, + EntraInvitationError, + InternalServerError, + NotFoundError, + UnauthorizedError, +} from "../../common/errors/index.js"; +import { PutItemCommand } from "@aws-sdk/client-dynamodb"; +import { genericConfig } from "../../common/config.js"; +import { marshall } from "@aws-sdk/util-dynamodb"; +import { + InviteUserPostRequest, + invitePostRequestSchema, + GroupMappingCreatePostRequest, + groupMappingCreatePostSchema, + entraActionResponseSchema, + groupModificationPatchSchema, + GroupModificationPatchRequest, + EntraGroupActions, + entraGroupMembershipListResponse, + ProfilePatchRequest, + entraProfilePatchRequest, +} from "../../common/types/iam.js"; +import { + AUTH_DECISION_CACHE_SECONDS, + getGroupRoles, +} from "../functions/authorization.js"; + +const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { + fastify.get<{ + Querystring: { groupId: string }; + }>( + "/groups/:groupId/roles", + { + schema: { + querystring: { + type: "object", + properties: { + groupId: { + type: "string", + }, + }, + }, + }, + onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + }, + }, + async (request, reply) => { + try { + const groupId = (request.params as Record).groupId; + const roles = await getGroupRoles( + fastify.dynamoClient, + fastify, + groupId, + ); + return reply.send(roles); + } catch (e: unknown) { + if (e instanceof BaseError) { + throw e; + } + + request.log.error(e); + throw new DatabaseFetchError({ + message: "An error occurred finding the group role mapping.", + }); + } + }, + ); + + // fastify.patch<{ Body: ProfilePatchRequest }>( + // "/profile", + // { + // preValidation: async (request, reply) => { + // await fastify.zodValidateBody(request, reply, entraProfilePatchRequest); + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, allAppRoles); + // }, + // }, + // async (request, reply) => { + // if (!request.tokenPayload || !request.username) { + // throw new UnauthorizedError({ + // message: "User does not have the privileges for this task.", + // }); + // } + // const userOid = request.tokenPayload["oid"]; + // const entraIdToken = await getEntraIdToken( + // { + // smClient: fastify.secretsManagerClient, + // dynamoClient: fastify.dynamoClient, + // }, + // fastify.environmentConfig.AadValidClientId, + // ); + // await patchUserProfile( + // entraIdToken, + // request.username, + // userOid, + // request.body, + // ); + // reply.send(201); + // }, + // ); + // fastify.get<{ + // Body: undefined; + // Querystring: { groupId: string }; + // }>( + // "/groups/:groupId/roles", + // { + // schema: { + // querystring: { + // type: "object", + // properties: { + // groupId: { + // type: "string", + // }, + // }, + // }, + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + // }, + // }, + // async (request, reply) => { + // try { + // const groupId = (request.params as Record).groupId; + // const roles = await getGroupRoles( + // fastify.dynamoClient, + // fastify, + // groupId, + // ); + // return reply.send(roles); + // } catch (e: unknown) { + // if (e instanceof BaseError) { + // throw e; + // } + + // request.log.error(e); + // throw new DatabaseFetchError({ + // message: "An error occurred finding the group role mapping.", + // }); + // } + // }, + // ); + // fastify.post<{ + // Body: GroupMappingCreatePostRequest; + // Querystring: { groupId: string }; + // }>( + // "/groups/:groupId/roles", + // { + // schema: { + // querystring: { + // type: "object", + // properties: { + // groupId: { + // type: "string", + // }, + // }, + // }, + // }, + // preValidation: async (request, reply) => { + // await fastify.zodValidateBody( + // request, + // reply, + // groupMappingCreatePostSchema, + // ); + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + // }, + // }, + // async (request, reply) => { + // const groupId = (request.params as Record).groupId; + // try { + // const timestamp = new Date().toISOString(); + // const command = new PutItemCommand({ + // TableName: `${genericConfig.IAMTablePrefix}-grouproles`, + // Item: marshall({ + // groupUuid: groupId, + // roles: request.body.roles, + // createdAt: timestamp, + // }), + // }); + // await fastify.dynamoClient.send(command); + // fastify.nodeCache.set( + // `grouproles-${groupId}`, + // request.body.roles, + // AUTH_DECISION_CACHE_SECONDS, + // ); + // } catch (e: unknown) { + // fastify.nodeCache.del(`grouproles-${groupId}`); + // if (e instanceof BaseError) { + // throw e; + // } + + // request.log.error(e); + // throw new DatabaseInsertError({ + // message: "Could not create group role mapping.", + // }); + // } + // reply.send({ message: "OK" }); + // request.log.info( + // { type: "audit", actor: request.username, target: groupId }, + // `set target roles to ${request.body.roles.toString()}`, + // ); + // }, + // ); + // fastify.post<{ Body: InviteUserPostRequest }>( + // "/inviteUsers", + // { + // schema: { + // response: { 202: zodToJsonSchema(entraActionResponseSchema) }, + // }, + // preValidation: async (request, reply) => { + // await fastify.zodValidateBody(request, reply, invitePostRequestSchema); + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, [AppRoles.IAM_INVITE_ONLY]); + // }, + // }, + // async (request, reply) => { + // const emails = request.body.emails; + // const entraIdToken = await getEntraIdToken( + // { + // smClient: fastify.secretsManagerClient, + // dynamoClient: fastify.dynamoClient, + // }, + // fastify.environmentConfig.AadValidClientId, + // ); + // if (!entraIdToken) { + // throw new InternalServerError({ + // message: "Could not get Entra ID token to perform task.", + // }); + // } + // const response: Record[]> = { + // success: [], + // failure: [], + // }; + // const results = await Promise.allSettled( + // emails.map((email) => addToTenant(entraIdToken, email)), + // ); + // for (let i = 0; i < results.length; i++) { + // const result = results[i]; + // if (result.status === "fulfilled") { + // request.log.info( + // { type: "audit", actor: request.username, target: emails[i] }, + // "invited user to Entra ID tenant.", + // ); + // response.success.push({ email: emails[i] }); + // } else { + // request.log.info( + // { type: "audit", actor: request.username, target: emails[i] }, + // "failed to invite user to Entra ID tenant.", + // ); + // if (result.reason instanceof EntraInvitationError) { + // response.failure.push({ + // email: emails[i], + // message: result.reason.message, + // }); + // } else { + // response.failure.push({ + // email: emails[i], + // message: "An unknown error occurred.", + // }); + // } + // } + // } + // reply.status(202).send(response); + // }, + // ); + // fastify.patch<{ + // Body: GroupModificationPatchRequest; + // Querystring: { groupId: string }; + // }>( + // "/groups/:groupId", + // { + // schema: { + // querystring: { + // type: "object", + // properties: { + // groupId: { + // type: "string", + // }, + // }, + // }, + // }, + // preValidation: async (request, reply) => { + // await fastify.zodValidateBody( + // request, + // reply, + // groupModificationPatchSchema, + // ); + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + // }, + // }, + // async (request, reply) => { + // const groupId = (request.params as Record).groupId; + // if (!groupId || groupId === "") { + // throw new NotFoundError({ + // endpointName: request.url, + // }); + // } + // if (genericConfig.ProtectedEntraIDGroups.includes(groupId)) { + // throw new EntraGroupError({ + // code: 403, + // message: + // "This group is protected and cannot be modified by this service. You must log into Entra ID directly to modify this group.", + // group: groupId, + // }); + // } + // const entraIdToken = await getEntraIdToken( + // { + // smClient: fastify.secretsManagerClient, + // dynamoClient: fastify.dynamoClient, + // }, + // fastify.environmentConfig.AadValidClientId, + // ); + // const addResults = await Promise.allSettled( + // request.body.add.map((email) => + // modifyGroup(entraIdToken, email, groupId, EntraGroupActions.ADD), + // ), + // ); + // const removeResults = await Promise.allSettled( + // request.body.remove.map((email) => + // modifyGroup(entraIdToken, email, groupId, EntraGroupActions.REMOVE), + // ), + // ); + // const response: Record[]> = { + // success: [], + // failure: [], + // }; + // for (let i = 0; i < addResults.length; i++) { + // const result = addResults[i]; + // if (result.status === "fulfilled") { + // response.success.push({ email: request.body.add[i] }); + // request.log.info( + // { + // type: "audit", + // actor: request.username, + // target: request.body.add[i], + // }, + // `added target to group ID ${groupId}`, + // ); + // } else { + // request.log.info( + // { + // type: "audit", + // actor: request.username, + // target: request.body.add[i], + // }, + // `failed to add target to group ID ${groupId}`, + // ); + // if (result.reason instanceof EntraGroupError) { + // response.failure.push({ + // email: request.body.add[i], + // message: result.reason.message, + // }); + // } else { + // response.failure.push({ + // email: request.body.add[i], + // message: "An unknown error occurred.", + // }); + // } + // } + // } + // for (let i = 0; i < removeResults.length; i++) { + // const result = removeResults[i]; + // if (result.status === "fulfilled") { + // response.success.push({ email: request.body.remove[i] }); + // request.log.info( + // { + // type: "audit", + // actor: request.username, + // target: request.body.remove[i], + // }, + // `removed target from group ID ${groupId}`, + // ); + // } else { + // request.log.info( + // { + // type: "audit", + // actor: request.username, + // target: request.body.add[i], + // }, + // `failed to remove target from group ID ${groupId}`, + // ); + // if (result.reason instanceof EntraGroupError) { + // response.failure.push({ + // email: request.body.add[i], + // message: result.reason.message, + // }); + // } else { + // response.failure.push({ + // email: request.body.add[i], + // message: "An unknown error occurred.", + // }); + // } + // } + // } + // reply.status(202).send(response); + // }, + // ); + // fastify.get<{ + // Querystring: { groupId: string }; + // }>( + // "/groups/:groupId", + // { + // schema: { + // response: { 200: zodToJsonSchema(entraGroupMembershipListResponse) }, + // querystring: { + // type: "object", + // properties: { + // groupId: { + // type: "string", + // }, + // }, + // }, + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + // }, + // }, + // async (request, reply) => { + // const groupId = (request.params as Record).groupId; + // if (!groupId || groupId === "") { + // throw new NotFoundError({ + // endpointName: request.url, + // }); + // } + // if (genericConfig.ProtectedEntraIDGroups.includes(groupId)) { + // throw new EntraGroupError({ + // code: 403, + // message: + // "This group is protected and cannot be read by this service. You must log into Entra ID directly to read this group.", + // group: groupId, + // }); + // } + // const entraIdToken = await getEntraIdToken( + // { + // smClient: fastify.secretsManagerClient, + // dynamoClient: fastify.dynamoClient, + // }, + // fastify.environmentConfig.AadValidClientId, + // ); + // const response = await listGroupMembers(entraIdToken, groupId); + // reply.status(200).send(response); + // }, + // ); +}; + +export default sigleadRoutes; diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx index 2e4e9488..4d102357 100644 --- a/src/ui/pages/siglead/SigScreenComponents.tsx +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -33,12 +33,6 @@ const renderSigLink = (org: string, index: number) => { label={org} variant="filled" active={index % 2 === 0} - // color="blue" - // style={{ - // // color: "lightgray", - // backgroundColor: "DodgerBlue", - // opacity: 0.5 - // }} rightSection={
MemberCount[{index}] From e251c67a3d1887a0a9ff1ce4df44d9dcc1e0cca1 Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Fri, 28 Mar 2025 15:48:03 -0500 Subject: [PATCH 05/24] stash pop merge conflicts --- src/api/functions/entraId.ts | 2 +- src/api/index.ts | 2 ++ src/api/routes/siglead.ts | 70 +++++++++++++++++------------------- src/common/roles.ts | 21 ++++++----- 4 files changed, 47 insertions(+), 48 deletions(-) diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 44fbe6bf..81a3be97 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -366,7 +366,7 @@ export async function listGroupMembers( * @throws {EntraUserError} If fetching the user profile fails. * @returns {Promise} The user's profile information. */ -export async function getUserProfile( +export async function getUserProflile( token: string, email: string, ): Promise { diff --git a/src/api/index.ts b/src/api/index.ts index 53678935..774c23be 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -26,6 +26,7 @@ import mobileWalletRoute from "./routes/mobileWallet.js"; import stripeRoutes from "./routes/stripe.js"; import membershipPlugin from "./routes/membership.js"; import path from "path"; // eslint-disable-line import/no-nodejs-modules +import sigleadRoutes from "./routes/siglead.js"; dotenv.config(); @@ -133,6 +134,7 @@ async function init(prettyPrint: boolean = false) { api.register(ticketsPlugin, { prefix: "/tickets" }); api.register(mobileWalletRoute, { prefix: "/mobileWallet" }); api.register(stripeRoutes, { prefix: "/stripe" }); + api.register(sigleadRoutes, { prefix: "/siglead" }); if (app.runEnvironment === "dev") { api.register(vendingPlugin, { prefix: "/vending" }); } diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts index a898b086..0d2b07be 100644 --- a/src/api/routes/siglead.ts +++ b/src/api/routes/siglead.ts @@ -1,4 +1,4 @@ -import { FastifyPluginAsync } from "fastify"; +import { FastifyInstance, FastifyPluginAsync } from "fastify"; import { allAppRoles, AppRoles } from "../../common/roles.js"; import { zodToJsonSchema } from "zod-to-json-schema"; import { @@ -38,48 +38,42 @@ import { AUTH_DECISION_CACHE_SECONDS, getGroupRoles, } from "../functions/authorization.js"; +import { OrganizationList } from "common/orgs.js"; +import { z } from "zod"; + +const OrganizationListEnum = z.enum(OrganizationList as [string, ...string[]]); +export type Org = z.infer; + +type Member = { name: string; email: string }; +type OrgMembersResponse = { org: Org; members: Member[] }; + +// const groupMappings = getRunEnvironmentConfig().KnownGroupMappings; +// const groupOptions = Object.entries(groupMappings).map(([key, value]) => ({ +// label: userGroupMappings[key as keyof KnownGroups] || key, +// value: `${key}_${value}`, // to ensure that the same group for multiple roles still renders +// })); const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { fastify.get<{ - Querystring: { groupId: string }; - }>( - "/groups/:groupId/roles", - { - schema: { - querystring: { - type: "object", - properties: { - groupId: { - type: "string", - }, - }, - }, + Reply: OrgMembersResponse[]; + }>("/groups", async (request, reply) => { + const entraIdToken = await getEntraIdToken( + { + smClient: fastify.secretsManagerClient, + dynamoClient: fastify.dynamoClient, }, - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); - }, - }, - async (request, reply) => { - try { - const groupId = (request.params as Record).groupId; - const roles = await getGroupRoles( - fastify.dynamoClient, - fastify, - groupId, - ); - return reply.send(roles); - } catch (e: unknown) { - if (e instanceof BaseError) { - throw e; - } + fastify.environmentConfig.AadValidClientId, + ); + + const data = await Promise.all( + OrganizationList.map(async (org) => { + const members: Member[] = await listGroupMembers(entraIdToken, org); + return { org, members } as OrgMembersResponse; + }), + ); - request.log.error(e); - throw new DatabaseFetchError({ - message: "An error occurred finding the group role mapping.", - }); - } - }, - ); + reply.status(200).send(data); + }); // fastify.patch<{ Body: ProfilePatchRequest }>( // "/profile", diff --git a/src/common/roles.ts b/src/common/roles.ts index a713b930..9b276b9b 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -2,15 +2,18 @@ export const runEnvironments = ["dev", "prod"] as const; export type RunEnvironment = (typeof runEnvironments)[number]; export enum AppRoles { - EVENTS_MANAGER = "manage:events", - SIGLEAD_MANAGER = "manage:siglead", - TICKETS_SCANNER = "scan:tickets", - TICKETS_MANAGER = "manage:tickets", - IAM_ADMIN = "admin:iam", - IAM_INVITE_ONLY = "invite:iam", - STRIPE_LINK_CREATOR = "create:stripeLink", - BYPASS_OBJECT_LEVEL_AUTH = "bypass:ola", + EVENTS_MANAGER = "manage:events", + SIGLEAD_MANAGER = "manage:siglead", + TICKETS_SCANNER = "scan:tickets", + TICKETS_MANAGER = "manage:tickets", + IAM_ADMIN = "admin:iam", + IAM_INVITE_ONLY = "invite:iam", + STRIPE_LINK_CREATOR = "create:stripeLink", + BYPASS_OBJECT_LEVEL_AUTH = "bypass:ola", } export const allAppRoles = Object.values(AppRoles).filter( - (value) => typeof value === "string", + (value) => typeof value === "string", ); + + + \ No newline at end of file From ac54829c02e4c53063362080ab5804ddf2dffc8b Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Fri, 4 Apr 2025 15:09:49 -0500 Subject: [PATCH 06/24] UI updates for the main screen #99 * changed color of alt tabs to be consistent with the theme * increased size of font of all text * reduced width of table for cleaner look --- src/ui/pages/siglead/ManageSigLeads.page.tsx | 22 +++- src/ui/pages/siglead/SigScreenComponents.tsx | 128 +++---------------- 2 files changed, 37 insertions(+), 113 deletions(-) diff --git a/src/ui/pages/siglead/ManageSigLeads.page.tsx b/src/ui/pages/siglead/ManageSigLeads.page.tsx index 741206cb..4b5fda39 100644 --- a/src/ui/pages/siglead/ManageSigLeads.page.tsx +++ b/src/ui/pages/siglead/ManageSigLeads.page.tsx @@ -1,4 +1,14 @@ -import { Title, Box, TextInput, Textarea, Switch, Select, Button, Loader } from '@mantine/core'; +import { + Title, + Box, + TextInput, + Textarea, + Switch, + Select, + Button, + Loader, + Container, +} from '@mantine/core'; import { DateTimePicker } from '@mantine/dates'; import { useForm, zodResolver } from '@mantine/form'; import { notifications } from '@mantine/notifications'; @@ -11,7 +21,7 @@ import { getRunEnvironmentConfig } from '@ui/config'; import { useApi } from '@ui/util/api'; import { OrganizationList as orgList } from '@common/orgs'; import { AppRoles } from '@common/roles'; -import { ScreenComponent, SigTable } from './SigScreenComponents'; +import { ScreenComponent } from './SigScreenComponents'; export function capitalizeFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); @@ -156,9 +166,11 @@ export const ManageSigLeadsPage: React.FC = () => { return ( - SigLead Management System - - {/* */} + + SigLead Management System + + {/* */} + ); }; diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx index 2e4e9488..f34cf995 100644 --- a/src/ui/pages/siglead/SigScreenComponents.tsx +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -1,50 +1,39 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { z } from 'zod'; import { OrganizationList } from '@common/orgs'; import { NavLink, Paper } from '@mantine/core'; -import { AuthGuard } from '@ui/components/AuthGuard'; -import { AppRoles } from '@common/roles'; import { IconUsersGroup } from '@tabler/icons-react'; import { useLocation } from 'react-router-dom'; -// use personn icon -// import { IconPlus, IconTrash } from '@tabler/icons-react'; - -// const OrganizationListEnum = z.enum(OrganizationList); - -// const renderTableRow = (org: string) => { -// const count = 50; -// return( -// -// {(styles) => ( -// -// {org} -// {count} -// -// )} -// -// ) -// } - const renderSigLink = (org: string, index: number) => { + const color = 'light-dark(var(--mantine-color-black), var(--mantine-color-white))'; + const size = '18px'; return ( +
MemberCount[{index}]
} + styles={{ + label: { + color: `${color}`, + fontSize: `${size}`, + }, + }} /> ); }; @@ -60,10 +49,10 @@ export const ScreenComponent: React.FC = () => { justifyContent: 'space-between', alignItems: 'center', fontWeight: 'bold', - // backgroundColor: "#f8f9fa", borderRadius: '8px', padding: '10px 16px', marginBottom: '8px', + fontSize: '22px', }} > Organization @@ -73,80 +62,3 @@ export const ScreenComponent: React.FC = () => { ); }; - -import { Table } from '@mantine/core'; - -export const SigTable = () => { - const location = useLocation(); - return ( - - {/* Headers */} - - - - - - - - - {OrganizationList.map((org, index) => ( - - {/* Organization Column */} - - - {/* Member Count Column */} - - - ))} - - {/* - {OrganizationList.map((org, index) => ( - - - - ))} - */} -
OrganizationMember Count
- - - MemberCount[{index}] - -
{renderSigLink(org, index)}
- ); -}; - -// const navLinks = [ -// { label: "Home", icon: , path: "/" }, -// { label: "Profile", icon: , path: "/profile" }, -// { label: "Settings", icon: , path: "/settings" }, -// ]; - -// export const NavLinkTable = () => { -// return ( -// -// -// -// -// -// -// -// {navLinks.map((link, index) => ( -// -// -// -// ))} -// -//
Navigation
-// -//
-// ); -// } From 9ac6842b36d063d1598ce80d39d7e62b82ae2fdf Mon Sep 17 00:00:00 2001 From: Ethan Ma Date: Tue, 22 Apr 2025 01:27:37 -0500 Subject: [PATCH 07/24] New SigView page --- src/ui/Router.tsx | 5 + src/ui/pages/siglead/ViewSigLead.page.tsx | 204 ++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 src/ui/pages/siglead/ViewSigLead.page.tsx diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index b0091e19..ba324a84 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -21,6 +21,7 @@ import { ManageIamPage } from './pages/iam/ManageIam.page'; import { ManageProfilePage } from './pages/profile/ManageProfile.page'; import { ManageStripeLinksPage } from './pages/stripe/ViewLinks.page'; import { ManageSigLeadsPage } from './pages/siglead/ManageSigLeads.page'; +import { ViewSigLeadPage } from './pages/siglead/ViewSigLead.page'; import { ManageRoomRequestsPage } from './pages/roomRequest/RoomRequestLanding.page'; import { ViewRoomRequest } from './pages/roomRequest/ViewRoomRequest.page'; @@ -182,6 +183,10 @@ const authenticatedRouter = createBrowserRouter([ path: '/siglead-management', element: , }, + { + path: '/siglead-management/:sigId', + element: , + }, { path: '/roomRequests', element: , diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx new file mode 100644 index 00000000..2466856c --- /dev/null +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -0,0 +1,204 @@ +import { + Title, + Box, + TextInput, + Textarea, + Switch, + Select, + Button, + Loader, + Container, + Transition, + useMantineColorScheme, + Table, + Group, + Stack, +} from '@mantine/core'; +import { DateTimePicker } from '@mantine/dates'; +import { useForm, zodResolver } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; +import dayjs from 'dayjs'; +import React, { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { z } from 'zod'; +import { AuthGuard } from '@ui/components/AuthGuard'; +import { getRunEnvironmentConfig } from '@ui/config'; +import { useApi } from '@ui/util/api'; +import { AppRoles } from '@common/roles'; + +const baseSigSchema = z.object({ + sigid: z.string().min(1), + signame: z.string().min(1), + description: z.string().optional(), +}); + +const baseSigMemberSchema = z.object({ + sigGroupId: z.string().min(1), + email: z.string().email('Invalid email'), + designation: z.enum(['L', 'M']), + id: z.string().optional(), + memberName: z.string(), +}); + +type sigDetails = z.infer; +type sigMemberDetails = z.infer; + +export const ViewSigLeadPage: React.FC = () => { + const [isSubmitting, setIsSubmitting] = useState(false); + const navigate = useNavigate(); + const api = useApi('core'); + const { colorScheme } = useMantineColorScheme(); + const { sigId } = useParams(); + const [sigMembers, setSigMembers] = useState([ + { + sigGroupId: sigId || '', + email: 'alice1@illinois.edu', + designation: 'L', + memberName: 'Alice', + }, + { + sigGroupId: sigId || '', + email: 'bob2@illinois.edu', + designation: 'M', + memberName: 'Bob', + }, + ]); + const [sigDetails, setSigDetails] = useState({ + sigid: sigId || '', + signame: 'Default Sig', + description: + 'A cool Sig with a lot of money and members. Founded in 1999 by Sir Charlie of Edinburgh. Focuses on making money and helping others earn more money via education.', + }); + + useEffect(() => { + // Fetch sig data and populate form / for now dummy data... + const getSig = async () => { + try { + /*const formValues = { + }; + form.setValues(formValues);*/ + } catch (error) { + console.error('Error fetching sig data:', error); + notifications.show({ + message: 'Failed to fetch sig data, please try again.', + }); + } + }; + getSig(); + }, [sigId]); + + const renderSigMember = (members: sigMemberDetails, index: number) => { + const shouldShow = true; + return ( + + {(styles) => ( + + {members.memberName} + {members.email} + {members.designation} + + )} + + ); + }; + + /* + const form = useForm({ + validate: zodResolver(requestBodySchema), + initialValues: { + title: '', + description: '', + start: new Date(), + end: new Date(new Date().valueOf() + 3.6e6), // 1 hr later + location: 'ACM Room (Siebel CS 1104)', + locationLink: 'https://maps.app.goo.gl/dwbBBBkfjkgj8gvA8', + host: 'ACM', + featured: false, + repeats: undefined, + repeatEnds: undefined, + paidEventId: undefined, + }, + }); + /* + const handleSubmit = async (values: EventPostRequest) => { + try { + setIsSubmitting(true); + const realValues = { + ...values, + start: dayjs(values.start).format('YYYY-MM-DD[T]HH:mm:00'), + end: values.end ? dayjs(values.end).format('YYYY-MM-DD[T]HH:mm:00') : undefined, + repeatEnds: + values.repeatEnds && values.repeats + ? dayjs(values.repeatEnds).format('YYYY-MM-DD[T]HH:mm:00') + : undefined, + repeats: values.repeats ? values.repeats : undefined, + }; + + const eventURL = isEditing ? `/api/v1/events/${eventId}` : '/api/v1/events'; + const response = await api.post(eventURL, realValues); + notifications.show({ + title: isEditing ? 'Event updated!' : 'Event created!', + message: isEditing ? undefined : `The event ID is "${response.data.id}".`, + }); + navigate('/events/manage'); + } catch (error) { + setIsSubmitting(false); + console.error('Error creating/editing event:', error); + notifications.show({ + message: 'Failed to create/edit event, please try again.', + }); + } + };*/ + + return ( + + + + + {sigDetails.sigid} + {sigDetails.description || ''} + + + + + + + + + + +
+ + + + Name + Email + Roles + + + {sigMembers.map(renderSigMember)} +
+
+
+
+ ); +}; From f42b44a1819782b68f21f2e821197b45b4a18d22 Mon Sep 17 00:00:00 2001 From: Tarash Agarwal Date: Wed, 23 Apr 2025 22:07:57 -0500 Subject: [PATCH 08/24] Update ViewSigLead.page.tsx Updating valid Roles to AppRoles.SIGLEAD_MANAGER --- src/ui/pages/siglead/ViewSigLead.page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx index 2466856c..d0902e83 100644 --- a/src/ui/pages/siglead/ViewSigLead.page.tsx +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -164,7 +164,7 @@ export const ViewSigLeadPage: React.FC = () => { };*/ return ( - + From 5ab43abdce6c4e7ebee1bc951cfea4152f2e7778 Mon Sep 17 00:00:00 2001 From: Tarash Agarwal Date: Mon, 19 May 2025 19:52:20 -0500 Subject: [PATCH 09/24] Fixes --- src/ui/pages/siglead/ManageSigLeads.page.tsx | 113 +++++++++++-------- src/ui/pages/siglead/SigScreenComponents.tsx | 37 +++--- src/ui/pages/siglead/ViewSigLead.page.tsx | 89 ++++++++------- 3 files changed, 131 insertions(+), 108 deletions(-) diff --git a/src/ui/pages/siglead/ManageSigLeads.page.tsx b/src/ui/pages/siglead/ManageSigLeads.page.tsx index 4b5fda39..3555e727 100644 --- a/src/ui/pages/siglead/ManageSigLeads.page.tsx +++ b/src/ui/pages/siglead/ManageSigLeads.page.tsx @@ -8,37 +8,40 @@ import { Button, Loader, Container, -} from '@mantine/core'; -import { DateTimePicker } from '@mantine/dates'; -import { useForm, zodResolver } from '@mantine/form'; -import { notifications } from '@mantine/notifications'; -import dayjs from 'dayjs'; -import React, { useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { z } from 'zod'; -import { AuthGuard } from '@ui/components/AuthGuard'; -import { getRunEnvironmentConfig } from '@ui/config'; -import { useApi } from '@ui/util/api'; -import { OrganizationList as orgList } from '@common/orgs'; -import { AppRoles } from '@common/roles'; -import { ScreenComponent } from './SigScreenComponents'; +} from "@mantine/core"; +import { DateTimePicker } from "@mantine/dates"; +import { useForm, zodResolver } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import dayjs from "dayjs"; +import React, { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { z } from "zod"; +import { AuthGuard } from "@ui/components/AuthGuard"; +import { getRunEnvironmentConfig } from "@ui/config"; +import { useApi } from "@ui/util/api"; +import { OrganizationList as orgList } from "@common/orgs"; +import { AppRoles } from "@common/roles"; +import { ScreenComponent } from "./SigScreenComponents"; export function capitalizeFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); } -const repeatOptions = ['weekly', 'biweekly'] as const; +const repeatOptions = ["weekly", "biweekly"] as const; const baseBodySchema = z.object({ - title: z.string().min(1, 'Title is required'), - description: z.string().min(1, 'Description is required'), + title: z.string().min(1, "Title is required"), + description: z.string().min(1, "Description is required"), start: z.date(), end: z.optional(z.date()), - location: z.string().min(1, 'Location is required'), - locationLink: z.optional(z.string().url('Invalid URL')), - host: z.string().min(1, 'Host is required'), + location: z.string().min(1, "Location is required"), + locationLink: z.optional(z.string().url("Invalid URL")), + host: z.string().min(1, "Host is required"), featured: z.boolean().default(false), - paidEventId: z.string().min(1, 'Paid Event ID must be at least 1 character').optional(), + paidEventId: z + .string() + .min(1, "Paid Event ID must be at least 1 character") + .optional(), }); const requestBodySchema = baseBodySchema @@ -47,15 +50,15 @@ const requestBodySchema = baseBodySchema repeatEnds: z.date().optional(), }) .refine((data) => (data.repeatEnds ? data.repeats !== undefined : true), { - message: 'Repeat frequency is required when Repeat End is specified.', + message: "Repeat frequency is required when Repeat End is specified.", }) .refine((data) => !data.end || data.end >= data.start, { - message: 'Event end date cannot be earlier than the start date.', - path: ['end'], + message: "Event end date cannot be earlier than the start date.", + path: ["end"], }) .refine((data) => !data.repeatEnds || data.repeatEnds >= data.start, { - message: 'Repeat end date cannot be earlier than the start date.', - path: ['repeatEnds'], + message: "Repeat end date cannot be earlier than the start date.", + path: ["repeatEnds"], }); type EventPostRequest = z.infer; @@ -63,7 +66,7 @@ type EventPostRequest = z.infer; export const ManageSigLeadsPage: React.FC = () => { const [isSubmitting, setIsSubmitting] = useState(false); const navigate = useNavigate(); - const api = useApi('core'); + const api = useApi("core"); const { eventId } = useParams(); @@ -88,14 +91,16 @@ export const ManageSigLeadsPage: React.FC = () => { host: eventData.host, featured: eventData.featured, repeats: eventData.repeats, - repeatEnds: eventData.repeatEnds ? new Date(eventData.repeatEnds) : undefined, + repeatEnds: eventData.repeatEnds + ? new Date(eventData.repeatEnds) + : undefined, paidEventId: eventData.paidEventId, }; form.setValues(formValues); } catch (error) { - console.error('Error fetching event data:', error); + console.error("Error fetching event data:", error); notifications.show({ - message: 'Failed to fetch event data, please try again.', + message: "Failed to fetch event data, please try again.", }); } }; @@ -105,13 +110,13 @@ export const ManageSigLeadsPage: React.FC = () => { const form = useForm({ validate: zodResolver(requestBodySchema), initialValues: { - title: '', - description: '', + title: "", + description: "", start: new Date(), end: new Date(new Date().valueOf() + 3.6e6), // 1 hr later - location: 'ACM Room (Siebel CS 1104)', - locationLink: 'https://maps.app.goo.gl/dwbBBBkfjkgj8gvA8', - host: 'ACM', + location: "ACM Room (Siebel CS 1104)", + locationLink: "https://maps.app.goo.gl/dwbBBBkfjkgj8gvA8", + host: "ACM", featured: false, repeats: undefined, repeatEnds: undefined, @@ -121,15 +126,17 @@ export const ManageSigLeadsPage: React.FC = () => { const checkPaidEventId = async (paidEventId: string) => { try { - const merchEndpoint = getRunEnvironmentConfig().ServiceConfiguration.merch.baseEndpoint; - const ticketEndpoint = getRunEnvironmentConfig().ServiceConfiguration.tickets.baseEndpoint; - const paidEventHref = paidEventId.startsWith('merch:') + const merchEndpoint = + getRunEnvironmentConfig().ServiceConfiguration.merch.baseEndpoint; + const ticketEndpoint = + getRunEnvironmentConfig().ServiceConfiguration.tickets.baseEndpoint; + const paidEventHref = paidEventId.startsWith("merch:") ? `${merchEndpoint}/api/v1/merch/details?itemid=${paidEventId.slice(6)}` : `${ticketEndpoint}/api/v1/event/details?eventid=${paidEventId}`; const response = await api.get(paidEventHref); return Boolean(response.status < 299 && response.status >= 200); } catch (error) { - console.error('Error validating paid event ID:', error); + console.error("Error validating paid event ID:", error); return false; } }; @@ -139,33 +146,41 @@ export const ManageSigLeadsPage: React.FC = () => { setIsSubmitting(true); const realValues = { ...values, - start: dayjs(values.start).format('YYYY-MM-DD[T]HH:mm:00'), - end: values.end ? dayjs(values.end).format('YYYY-MM-DD[T]HH:mm:00') : undefined, + start: dayjs(values.start).format("YYYY-MM-DD[T]HH:mm:00"), + end: values.end + ? dayjs(values.end).format("YYYY-MM-DD[T]HH:mm:00") + : undefined, repeatEnds: values.repeatEnds && values.repeats - ? dayjs(values.repeatEnds).format('YYYY-MM-DD[T]HH:mm:00') + ? dayjs(values.repeatEnds).format("YYYY-MM-DD[T]HH:mm:00") : undefined, repeats: values.repeats ? values.repeats : undefined, }; - const eventURL = isEditing ? `/api/v1/events/${eventId}` : '/api/v1/events'; + const eventURL = isEditing + ? `/api/v1/events/${eventId}` + : "/api/v1/events"; const response = await api.post(eventURL, realValues); notifications.show({ - title: isEditing ? 'Event updated!' : 'Event created!', - message: isEditing ? undefined : `The event ID is "${response.data.id}".`, + title: isEditing ? "Event updated!" : "Event created!", + message: isEditing + ? undefined + : `The event ID is "${response.data.id}".`, }); - navigate('/events/manage'); + navigate("/events/manage"); } catch (error) { setIsSubmitting(false); - console.error('Error creating/editing event:', error); + console.error("Error creating/editing event:", error); notifications.show({ - message: 'Failed to create/edit event, please try again.', + message: "Failed to create/edit event, please try again.", }); } }; return ( - + SigLead Management System diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx index f34cf995..53d1c7df 100644 --- a/src/ui/pages/siglead/SigScreenComponents.tsx +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -1,12 +1,13 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { OrganizationList } from '@common/orgs'; -import { NavLink, Paper } from '@mantine/core'; -import { IconUsersGroup } from '@tabler/icons-react'; -import { useLocation } from 'react-router-dom'; +import React, { useEffect, useMemo, useState } from "react"; +import { OrganizationList } from "@common/orgs"; +import { NavLink, Paper } from "@mantine/core"; +import { IconUsersGroup } from "@tabler/icons-react"; +import { useLocation } from "react-router-dom"; const renderSigLink = (org: string, index: number) => { - const color = 'light-dark(var(--mantine-color-black), var(--mantine-color-white))'; - const size = '18px'; + const color = + "light-dark(var(--mantine-color-black), var(--mantine-color-white))"; + const size = "18px"; return ( { rightSection={
{ shadow="xs" p="sm" style={{ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - fontWeight: 'bold', - borderRadius: '8px', - padding: '10px 16px', - marginBottom: '8px', - fontSize: '22px', + display: "flex", + justifyContent: "space-between", + alignItems: "center", + fontWeight: "bold", + borderRadius: "8px", + padding: "10px 16px", + marginBottom: "8px", + fontSize: "22px", }} > Organization diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx index d0902e83..bc3b2c2b 100644 --- a/src/ui/pages/siglead/ViewSigLead.page.tsx +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -13,18 +13,18 @@ import { Table, Group, Stack, -} from '@mantine/core'; -import { DateTimePicker } from '@mantine/dates'; -import { useForm, zodResolver } from '@mantine/form'; -import { notifications } from '@mantine/notifications'; -import dayjs from 'dayjs'; -import React, { useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { z } from 'zod'; -import { AuthGuard } from '@ui/components/AuthGuard'; -import { getRunEnvironmentConfig } from '@ui/config'; -import { useApi } from '@ui/util/api'; -import { AppRoles } from '@common/roles'; +} from "@mantine/core"; +import { DateTimePicker } from "@mantine/dates"; +import { useForm, zodResolver } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import dayjs from "dayjs"; +import React, { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { z } from "zod"; +import { AuthGuard } from "@ui/components/AuthGuard"; +import { getRunEnvironmentConfig } from "@ui/config"; +import { useApi } from "@ui/util/api"; +import { AppRoles } from "@common/roles"; const baseSigSchema = z.object({ sigid: z.string().min(1), @@ -34,8 +34,8 @@ const baseSigSchema = z.object({ const baseSigMemberSchema = z.object({ sigGroupId: z.string().min(1), - email: z.string().email('Invalid email'), - designation: z.enum(['L', 'M']), + email: z.string().email("Invalid email"), + designation: z.enum(["L", "M"]), id: z.string().optional(), memberName: z.string(), }); @@ -46,28 +46,28 @@ type sigMemberDetails = z.infer; export const ViewSigLeadPage: React.FC = () => { const [isSubmitting, setIsSubmitting] = useState(false); const navigate = useNavigate(); - const api = useApi('core'); + const api = useApi("core"); const { colorScheme } = useMantineColorScheme(); const { sigId } = useParams(); const [sigMembers, setSigMembers] = useState([ { - sigGroupId: sigId || '', - email: 'alice1@illinois.edu', - designation: 'L', - memberName: 'Alice', + sigGroupId: sigId || "", + email: "alice1@illinois.edu", + designation: "L", + memberName: "Alice", }, { - sigGroupId: sigId || '', - email: 'bob2@illinois.edu', - designation: 'M', - memberName: 'Bob', + sigGroupId: sigId || "", + email: "bob2@illinois.edu", + designation: "M", + memberName: "Bob", }, ]); const [sigDetails, setSigDetails] = useState({ - sigid: sigId || '', - signame: 'Default Sig', + sigid: sigId || "", + signame: "Default Sig", description: - 'A cool Sig with a lot of money and members. Founded in 1999 by Sir Charlie of Edinburgh. Focuses on making money and helping others earn more money via education.', + "A cool Sig with a lot of money and members. Founded in 1999 by Sir Charlie of Edinburgh. Focuses on making money and helping others earn more money via education.", }); useEffect(() => { @@ -78,9 +78,9 @@ export const ViewSigLeadPage: React.FC = () => { }; form.setValues(formValues);*/ } catch (error) { - console.error('Error fetching sig data:', error); + console.error("Error fetching sig data:", error); notifications.show({ - message: 'Failed to fetch sig data, please try again.', + message: "Failed to fetch sig data, please try again.", }); } }; @@ -90,20 +90,25 @@ export const ViewSigLeadPage: React.FC = () => { const renderSigMember = (members: sigMemberDetails, index: number) => { const shouldShow = true; return ( - + {(styles) => ( {members.memberName} @@ -164,20 +169,22 @@ export const ViewSigLeadPage: React.FC = () => { };*/ return ( - + {sigDetails.sigid} - {sigDetails.description || ''} + {sigDetails.description || ""} - + - + */} + + + + ); +}; From c7873c8bad75815b0b8b09540bb9ec64d76d6f5c Mon Sep 17 00:00:00 2001 From: EthM Date: Sat, 7 Jun 2025 14:15:14 -0500 Subject: [PATCH 12/24] Adding a new sig ui implemented + integration with Ethan's backend functionalities (#139) New page editsiglead registered, navigate by clicking add an sig button on siglead-management, implemented frontend text input and zod validation, checks that signame is not reserved and follows desired regex. All api calls are to backend functions copied over from Ethan Chang's branch. Co-authored-by: Tarash Agarwal --- src/api/functions/siglead.ts | 114 ++++++++++++ src/api/index.ts | 2 + src/api/routes/siglead.ts | 134 +++++++++++++ src/common/config.ts | 4 + src/common/orgs.ts | 2 +- src/common/types/siglead.ts | 24 +++ src/common/utils.ts | 44 +++++ src/ui/Router.tsx | 6 + src/ui/pages/siglead/EditSigLeads.page.tsx | 116 ++++++++++++ src/ui/pages/siglead/ManageSigLeads.page.tsx | 186 ++++--------------- src/ui/pages/siglead/SigScreenComponents.tsx | 21 ++- src/ui/pages/siglead/ViewSigLead.page.tsx | 28 ++- 12 files changed, 504 insertions(+), 177 deletions(-) create mode 100644 src/api/functions/siglead.ts create mode 100644 src/api/routes/siglead.ts create mode 100644 src/common/types/siglead.ts create mode 100644 src/ui/pages/siglead/EditSigLeads.page.tsx diff --git a/src/api/functions/siglead.ts b/src/api/functions/siglead.ts new file mode 100644 index 00000000..b6cbaa38 --- /dev/null +++ b/src/api/functions/siglead.ts @@ -0,0 +1,114 @@ +import { + DynamoDBClient, + QueryCommand, + ScanCommand, +} from "@aws-sdk/client-dynamodb"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { OrganizationList } from "common/orgs.js"; +import { + SigDetailRecord, + SigMemberCount, + SigMemberRecord, +} from "common/types/siglead.js"; +import { transformSigLeadToURI } from "common/utils.js"; +import { string } from "zod"; + +export async function fetchMemberRecords( + sigid: string, + tableName: string, + dynamoClient: DynamoDBClient, +) { + const fetchSigMemberRecords = new QueryCommand({ + TableName: tableName, + KeyConditionExpression: "#sigid = :accessVal", + ExpressionAttributeNames: { + "#sigid": "sigGroupId", + }, + ExpressionAttributeValues: { + ":accessVal": { S: sigid }, + }, + ScanIndexForward: false, + }); + + const result = await dynamoClient.send(fetchSigMemberRecords); + + // Process the results + return (result.Items || []).map((item) => { + const unmarshalledItem = unmarshall(item); + return unmarshalledItem as SigMemberRecord; + }); +} + +export async function fetchSigDetail( + sigid: string, + tableName: string, + dynamoClient: DynamoDBClient, +) { + const fetchSigDetail = new QueryCommand({ + TableName: tableName, + KeyConditionExpression: "#sigid = :accessVal", + ExpressionAttributeNames: { + "#sigid": "sigid", + }, + ExpressionAttributeValues: { + ":accessVal": { S: sigid }, + }, + ScanIndexForward: false, + }); + + const result = await dynamoClient.send(fetchSigDetail); + + // Process the results + return (result.Items || [{}]).map((item) => { + const unmarshalledItem = unmarshall(item); + + // Strip '#' from access field + delete unmarshalledItem.leadGroupId; + delete unmarshalledItem.memberGroupId; + + return unmarshalledItem as SigDetailRecord; + })[0]; +} + +// select count(sigid) +// from table +// groupby sigid +export async function fetchSigCounts( + sigMemberTableName: string, + dynamoClient: DynamoDBClient, +) { + const scan = new ScanCommand({ + TableName: sigMemberTableName, + ProjectionExpression: "sigGroupId", + }); + + const result = await dynamoClient.send(scan); + + const ids2Name: Record = {}; + OrganizationList.forEach((org) => { + const sigid = transformSigLeadToURI(org); + ids2Name[sigid] = org; + }); + + const counts: Record = {}; + (result.Items || []).forEach((item) => { + const sigGroupId = item.sigGroupId?.S; + if (sigGroupId) { + counts[sigGroupId] = (counts[sigGroupId] || 0) + 1; + } + }); + + const joined: Record = {}; + Object.keys(counts).forEach((sigid) => { + joined[sigid] = [ids2Name[sigid], counts[sigid]]; + }); + + const countsArray: SigMemberCount[] = Object.entries(joined).map( + ([sigid, [signame, count]]) => ({ + sigid, + signame, + count, + }), + ); + return countsArray; +} diff --git a/src/api/index.ts b/src/api/index.ts index 5cd222b5..845f8455 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -25,6 +25,7 @@ import * as dotenv from "dotenv"; import iamRoutes from "./routes/iam.js"; import ticketsPlugin from "./routes/tickets.js"; import linkryRoutes from "./routes/linkry.js"; +import sigleadRoutes from "./routes/siglead.js"; import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts"; import NodeCache from "node-cache"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; @@ -287,6 +288,7 @@ async function init(prettyPrint: boolean = false) { api.register(iamRoutes, { prefix: "/iam" }); api.register(ticketsPlugin, { prefix: "/tickets" }); api.register(linkryRoutes, { prefix: "/linkry" }); + api.register(sigleadRoutes, { prefix: "/siglead" }); api.register(mobileWalletRoute, { prefix: "/mobileWallet" }); api.register(stripeRoutes, { prefix: "/stripe" }); api.register(roomRequestRoutes, { prefix: "/roomRequests" }); diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts new file mode 100644 index 00000000..fca95775 --- /dev/null +++ b/src/api/routes/siglead.ts @@ -0,0 +1,134 @@ +import { FastifyPluginAsync } from "fastify"; +import { DatabaseFetchError } from "../../common/errors/index.js"; +import { genericConfig } from "../../common/config.js"; +import { + SigDetailRecord, + SigleadGetRequest, + SigMemberCount, + SigMemberRecord, +} from "common/types/siglead.js"; +import { + fetchMemberRecords, + fetchSigCounts, + fetchSigDetail, +} from "api/functions/siglead.js"; + +const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { + const limitedRoutes: FastifyPluginAsync = async (fastify) => { + /*fastify.register(rateLimiter, { + limit: 30, + duration: 60, + rateLimitIdentifier: "linkry", + });*/ + + fastify.get( + "/sigmembers/:sigid", + { + onRequest: async (request, reply) => { + /*await fastify.authorize(request, reply, [ + AppRoles.LINKS_MANAGER, + AppRoles.LINKS_ADMIN, + ]);*/ + }, + }, + async (request, reply) => { + const { sigid } = request.params; + const tableName = genericConfig.SigleadDynamoSigMemberTableName; + + // First try-catch: Fetch owner records + let memberRecords: SigMemberRecord[]; + try { + memberRecords = await fetchMemberRecords( + sigid, + tableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch member records: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: "Failed to fetch member records from Dynamo table.", + }); + } + + // Send the response + reply.code(200).send(memberRecords); + }, + ); + + fastify.get( + "/sigdetail/:sigid", + { + onRequest: async (request, reply) => { + /*await fastify.authorize(request, reply, [ + AppRoles.LINKS_MANAGER, + AppRoles.LINKS_ADMIN, + ]);*/ + }, + }, + async (request, reply) => { + const { sigid } = request.params; + const tableName = genericConfig.SigleadDynamoSigDetailTableName; + + // First try-catch: Fetch owner records + let sigDetail: SigDetailRecord; + try { + sigDetail = await fetchSigDetail( + sigid, + tableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch sig detail record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: "Failed to fetch sig detail record from Dynamo table.", + }); + } + + // Send the response + reply.code(200).send(sigDetail); + }, + ); + + // fetch sig count + fastify.get( + "/sigcount", + { + onRequest: async (request, reply) => { + /*await fastify.authorize(request, reply, [ + AppRoles.LINKS_MANAGER, + AppRoles.LINKS_ADMIN, + ]);*/ + }, + }, + async (request, reply) => { + // First try-catch: Fetch owner records + let sigMemCounts: SigMemberCount[]; + try { + sigMemCounts = await fetchSigCounts( + genericConfig.SigleadDynamoSigMemberTableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch sig member counts record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: + "Failed to fetch sig member counts record from Dynamo table.", + }); + } + + // Send the response + reply.code(200).send(sigMemCounts); + }, + ); + }; + + fastify.register(limitedRoutes); +}; + +export default sigleadRoutes; diff --git a/src/common/config.ts b/src/common/config.ts index 14937cf5..43608b9e 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -29,6 +29,8 @@ export type GenericConfigType = { EventsDynamoTableName: string; CacheDynamoTableName: string; LinkryDynamoTableName: string; + SigleadDynamoSigDetailTableName: string; + SigleadDynamoSigMemberTableName: string; StripeLinksDynamoTableName: string; ConfigSecretName: string; EntraSecretName: string; @@ -68,6 +70,8 @@ const genericConfig: GenericConfigType = { StripeLinksDynamoTableName: "infra-core-api-stripe-links", CacheDynamoTableName: "infra-core-api-cache", LinkryDynamoTableName: "infra-core-api-linkry", + SigleadDynamoSigDetailTableName: "infra-core-api-sig-details", + SigleadDynamoSigMemberTableName: "infra-core-api-sig-member-details", ConfigSecretName: "infra-core-api-config", EntraSecretName: "infra-core-api-entra", EntraReadOnlySecretName: "infra-core-api-ro-entra", diff --git a/src/common/orgs.ts b/src/common/orgs.ts index 61d570d2..ee84d00e 100644 --- a/src/common/orgs.ts +++ b/src/common/orgs.ts @@ -4,7 +4,7 @@ export const SIGList = [ "GameBuilders", "SIGAIDA", "SIGGRAPH", - "ICPC", + "SIGICPC", "SIGMobile", "SIGMusic", "GLUG", diff --git a/src/common/types/siglead.ts b/src/common/types/siglead.ts new file mode 100644 index 00000000..2822cd4e --- /dev/null +++ b/src/common/types/siglead.ts @@ -0,0 +1,24 @@ +export type SigDetailRecord = { + sigid: string; + signame: string; + description: string; + }; + + export type SigMemberRecord = { + sigGroupId: string; + email: string; + designation: string; + memberName: string; + }; + + export type SigleadGetRequest = { + Params: { sigid: string }; + Querystring: undefined; + Body: undefined; + }; + + export type SigMemberCount = { + sigid: string; + signame: string; + count: number; + }; \ No newline at end of file diff --git a/src/common/utils.ts b/src/common/utils.ts index 786c998f..8959eccd 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -12,3 +12,47 @@ export function transformCommaSeperatedName(name: string) { } return name; } + +const notUnreservedCharsRegex = /[^a-zA-Z0-9\-._~]/g; +const reservedCharsRegex = /[:\/?#\[\]@!$&'()*+,;=]/g; +/** + * Transforms an organization name (sig lead) into a URI-friendly format. + * The function performs the following transformations: + * - Removes characters that are reserved or not unreserved. + * - Adds spaces between camel case words. + * - Converts reserved characters to spaces. + * - Converts all characters to lowercase and replaces all types of whitespace with hyphens. + * - Replaces any sequence of repeated hyphens with a single hyphen. + * - Refer to RFC 3986 https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 + * + * @param {string} org - The organization (sig lead) name to be transformed. + * @returns {string} - The transformed organization name, ready for use as a URL. + */ +export function transformSigLeadToURI(org: string) { + org = org + // change not reserved chars to spaces + .trim() + .replace(notUnreservedCharsRegex, " ") + .trim() + .replace(/\s/g, "-") + + // remove all that is reserved or not unreserved + .replace(reservedCharsRegex, "") + + // convert SIG -> sig for camel case + .replace(/SIG/g, "sig") + + // add hyphen for camel case + .replace(/([a-z])([A-Z])/g, "$1-$2") + + // lower + .toLowerCase() + + // add spaces between chars and numbers (seq2seq -> seq-2-seq) + .replace(/(?<=[a-z])([0-9]+)(?=[a-z])/g, "-$1-") + + // remove duplicate hyphens + .replace(/-{2,}/g, "-"); + + return org === "-" ? "" : org; +} diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index d7864969..92d503c2 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -5,6 +5,7 @@ import { RouterProvider, useLocation, } from "react-router-dom"; + import { AcmAppShell } from "./components/AppShell"; import { useAuth } from "./components/AuthContext"; import AuthCallback from "./components/AuthContext/AuthCallbackHandler.page"; @@ -24,6 +25,7 @@ import { ManageIamPage } from "./pages/iam/ManageIam.page"; import { ManageProfilePage } from "./pages/profile/ManageProfile.page"; import { ManageStripeLinksPage } from "./pages/stripe/ViewLinks.page"; import { ManageRoomRequestsPage } from "./pages/roomRequest/RoomRequestLanding.page"; +import { EditSigLeadsPage } from "./pages/siglead/EditSigLeads.page"; import { ManageSigLeadsPage } from "./pages/siglead/ManageSigLeads.page"; import { ViewSigLeadPage } from "./pages/siglead/ViewSigLead.page"; import { ViewRoomRequest } from "./pages/roomRequest/ViewRoomRequest.page"; @@ -193,6 +195,10 @@ const authenticatedRouter = createBrowserRouter([ path: "/siglead-management", element: , }, + { + path: "/siglead-management/edit", + element: , + }, { path: "/siglead-management/:sigId", element: , diff --git a/src/ui/pages/siglead/EditSigLeads.page.tsx b/src/ui/pages/siglead/EditSigLeads.page.tsx new file mode 100644 index 00000000..c5c03d38 --- /dev/null +++ b/src/ui/pages/siglead/EditSigLeads.page.tsx @@ -0,0 +1,116 @@ +import { Title, TextInput, Button, Container, Group } from "@mantine/core"; +import { useForm, zodResolver } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { z } from "zod"; +import { AuthGuard } from "@ui/components/AuthGuard"; +import { useApi } from "@ui/util/api"; +import { AppRoles } from "@common/roles"; +import { transformSigLeadToURI } from "@common/utils"; + +const baseSigSchema = z.object({ + signame: z + .string() + .min(1, "Title is required") + .regex( + /^[a-zA-Z0-9]+$/, + "Sig name should only contain alphanumeric characters", + ), + description: z.string().min(1, "Description is required"), +}); + +type SigPostRequest = z.infer; + +export const EditSigLeadsPage: React.FC = () => { + const [isSubmitting, setIsSubmitting] = useState(false); + const navigate = useNavigate(); + const api = useApi("core"); + + const form = useForm({ + validate: zodResolver(baseSigSchema), + initialValues: { + signame: "", + description: "", + }, + }); + + const checkSigId = async (signame: string) => { + try { + const sigid = transformSigLeadToURI(signame); + const result = await api.get(`/api/v1/siglead/sigdetail/${sigid}`); + return result.data; + } catch (error) { + console.error("Error validating if sigid already exists", error); + notifications.show({ + message: `Error validating if sigid already exists`, + }); + } + }; + + const handleSubmit = async (sigdetails: SigPostRequest) => { + try { + setIsSubmitting(true); + const found = await checkSigId(sigdetails.signame); + if (found) { + form.setErrors({ + signame: "This signame is reserved already.", + }); + setIsSubmitting(false); + return; + } + notifications.show({ + message: `This will eventually make to a post request with signame: + ${sigdetails.signame} and description: ${sigdetails.description} + `, + }); + //Post... + navigate("/siglead-management"); + } catch (error) { + setIsSubmitting(false); + console.error("Error creating sig:", error); + notifications.show({ + message: "Failed to create sig, please try again.", + }); + } + }; + + return ( + + + Registering a new Sig +
+ + + + + + + +
+
+ ); +}; diff --git a/src/ui/pages/siglead/ManageSigLeads.page.tsx b/src/ui/pages/siglead/ManageSigLeads.page.tsx index 3555e727..ced9bccd 100644 --- a/src/ui/pages/siglead/ManageSigLeads.page.tsx +++ b/src/ui/pages/siglead/ManageSigLeads.page.tsx @@ -1,14 +1,5 @@ -import { - Title, - Box, - TextInput, - Textarea, - Switch, - Select, - Button, - Loader, - Container, -} from "@mantine/core"; +import { Title, Button, Container, Group } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; import { DateTimePicker } from "@mantine/dates"; import { useForm, zodResolver } from "@mantine/form"; import { notifications } from "@mantine/notifications"; @@ -22,168 +13,53 @@ import { useApi } from "@ui/util/api"; import { OrganizationList as orgList } from "@common/orgs"; import { AppRoles } from "@common/roles"; import { ScreenComponent } from "./SigScreenComponents"; - -export function capitalizeFirstLetter(string: string) { - return string.charAt(0).toUpperCase() + string.slice(1); -} - -const repeatOptions = ["weekly", "biweekly"] as const; - -const baseBodySchema = z.object({ - title: z.string().min(1, "Title is required"), - description: z.string().min(1, "Description is required"), - start: z.date(), - end: z.optional(z.date()), - location: z.string().min(1, "Location is required"), - locationLink: z.optional(z.string().url("Invalid URL")), - host: z.string().min(1, "Host is required"), - featured: z.boolean().default(false), - paidEventId: z - .string() - .min(1, "Paid Event ID must be at least 1 character") - .optional(), -}); - -const requestBodySchema = baseBodySchema - .extend({ - repeats: z.optional(z.enum(repeatOptions)).nullable(), - repeatEnds: z.date().optional(), - }) - .refine((data) => (data.repeatEnds ? data.repeats !== undefined : true), { - message: "Repeat frequency is required when Repeat End is specified.", - }) - .refine((data) => !data.end || data.end >= data.start, { - message: "Event end date cannot be earlier than the start date.", - path: ["end"], - }) - .refine((data) => !data.repeatEnds || data.repeatEnds >= data.start, { - message: "Repeat end date cannot be earlier than the start date.", - path: ["repeatEnds"], - }); - -type EventPostRequest = z.infer; +import { transformSigLeadToURI } from "@common/utils"; +import { + SigDetailRecord, + SigleadGetRequest, + SigMemberCount, + SigMemberRecord, +} from "@common/types/siglead"; export const ManageSigLeadsPage: React.FC = () => { - const [isSubmitting, setIsSubmitting] = useState(false); + const [SigMemberCounts, setSigMemberCounts] = useState([]); const navigate = useNavigate(); const api = useApi("core"); - const { eventId } = useParams(); - - const isEditing = eventId !== undefined; - useEffect(() => { - if (!isEditing) { - return; - } - // Fetch event data and populate form - const getEvent = async () => { + const getMemberCounts = async () => { try { - const response = await api.get(`/api/v1/events/${eventId}`); - const eventData = response.data; - const formValues = { - title: eventData.title, - description: eventData.description, - start: new Date(eventData.start), - end: eventData.end ? new Date(eventData.end) : undefined, - location: eventData.location, - locationLink: eventData.locationLink, - host: eventData.host, - featured: eventData.featured, - repeats: eventData.repeats, - repeatEnds: eventData.repeatEnds - ? new Date(eventData.repeatEnds) - : undefined, - paidEventId: eventData.paidEventId, - }; - form.setValues(formValues); + const sigMemberCountsRequest = await api.get( + `/api/v1/siglead/sigcount`, + ); + setSigMemberCounts(sigMemberCountsRequest.data); } catch (error) { - console.error("Error fetching event data:", error); + console.error("Error fetching sig member counts:", error); notifications.show({ - message: "Failed to fetch event data, please try again.", + message: "Failed to fetch sig member counts, please try again.", }); } }; - getEvent(); - }, [eventId, isEditing]); - - const form = useForm({ - validate: zodResolver(requestBodySchema), - initialValues: { - title: "", - description: "", - start: new Date(), - end: new Date(new Date().valueOf() + 3.6e6), // 1 hr later - location: "ACM Room (Siebel CS 1104)", - locationLink: "https://maps.app.goo.gl/dwbBBBkfjkgj8gvA8", - host: "ACM", - featured: false, - repeats: undefined, - repeatEnds: undefined, - paidEventId: undefined, - }, - }); - - const checkPaidEventId = async (paidEventId: string) => { - try { - const merchEndpoint = - getRunEnvironmentConfig().ServiceConfiguration.merch.baseEndpoint; - const ticketEndpoint = - getRunEnvironmentConfig().ServiceConfiguration.tickets.baseEndpoint; - const paidEventHref = paidEventId.startsWith("merch:") - ? `${merchEndpoint}/api/v1/merch/details?itemid=${paidEventId.slice(6)}` - : `${ticketEndpoint}/api/v1/event/details?eventid=${paidEventId}`; - const response = await api.get(paidEventHref); - return Boolean(response.status < 299 && response.status >= 200); - } catch (error) { - console.error("Error validating paid event ID:", error); - return false; - } - }; - - const handleSubmit = async (values: EventPostRequest) => { - try { - setIsSubmitting(true); - const realValues = { - ...values, - start: dayjs(values.start).format("YYYY-MM-DD[T]HH:mm:00"), - end: values.end - ? dayjs(values.end).format("YYYY-MM-DD[T]HH:mm:00") - : undefined, - repeatEnds: - values.repeatEnds && values.repeats - ? dayjs(values.repeatEnds).format("YYYY-MM-DD[T]HH:mm:00") - : undefined, - repeats: values.repeats ? values.repeats : undefined, - }; - - const eventURL = isEditing - ? `/api/v1/events/${eventId}` - : "/api/v1/events"; - const response = await api.post(eventURL, realValues); - notifications.show({ - title: isEditing ? "Event updated!" : "Event created!", - message: isEditing - ? undefined - : `The event ID is "${response.data.id}".`, - }); - navigate("/events/manage"); - } catch (error) { - setIsSubmitting(false); - console.error("Error creating/editing event:", error); - notifications.show({ - message: "Failed to create/edit event, please try again.", - }); - } - }; + getMemberCounts(); + }, []); return ( - SigLead Management System - + + SigLead Management System + + + + {/* */} diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx index 53d1c7df..c5d5e362 100644 --- a/src/ui/pages/siglead/SigScreenComponents.tsx +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -3,16 +3,20 @@ import { OrganizationList } from "@common/orgs"; import { NavLink, Paper } from "@mantine/core"; import { IconUsersGroup } from "@tabler/icons-react"; import { useLocation } from "react-router-dom"; +import { SigMemberCount } from "@common/types/siglead"; -const renderSigLink = (org: string, index: number) => { +const renderSigLink = (sigMemCount: SigMemberCount, index: number) => { const color = "light-dark(var(--mantine-color-black), var(--mantine-color-white))"; const size = "18px"; + const name = sigMemCount.signame; + const id = sigMemCount.sigid; + const count = sigMemCount.count; return ( { fontSize: `${size}`, }} > - MemberCount[{index}] + MemberCount: {count}
} @@ -39,7 +43,11 @@ const renderSigLink = (org: string, index: number) => { ); }; -export const ScreenComponent: React.FC = () => { +type props = { + SigMemberCounts: SigMemberCount[]; +}; + +export const ScreenComponent: React.FC = ({ SigMemberCounts }) => { return ( <> { Organization Member Count - {OrganizationList.map(renderSigLink)} + {/* {OrganizationList.map(renderSigLink)} */} + {SigMemberCounts.map(renderSigLink)} ); }; diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx index bc3b2c2b..d752e111 100644 --- a/src/ui/pages/siglead/ViewSigLead.page.tsx +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -1,12 +1,7 @@ import { Title, Box, - TextInput, - Textarea, - Switch, - Select, Button, - Loader, Container, Transition, useMantineColorScheme, @@ -14,17 +9,15 @@ import { Group, Stack, } from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; -import { useForm, zodResolver } from "@mantine/form"; + import { notifications } from "@mantine/notifications"; -import dayjs from "dayjs"; import React, { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { z } from "zod"; import { AuthGuard } from "@ui/components/AuthGuard"; -import { getRunEnvironmentConfig } from "@ui/config"; import { useApi } from "@ui/util/api"; import { AppRoles } from "@common/roles"; +import { IconUsersGroup } from "@tabler/icons-react"; const baseSigSchema = z.object({ sigid: z.string().min(1), @@ -44,7 +37,6 @@ type sigDetails = z.infer; type sigMemberDetails = z.infer; export const ViewSigLeadPage: React.FC = () => { - const [isSubmitting, setIsSubmitting] = useState(false); const navigate = useNavigate(); const api = useApi("core"); const { colorScheme } = useMantineColorScheme(); @@ -74,9 +66,14 @@ export const ViewSigLeadPage: React.FC = () => { // Fetch sig data and populate form / for now dummy data... const getSig = async () => { try { - /*const formValues = { - }; - form.setValues(formValues);*/ + const sigDetailsData = await api.get( + `/api/v1/siglead/sigdetail/${sigId}`, + ); + setSigDetails(sigDetailsData.data); + const sigMembersData = await api.get( + `/api/v1/siglead/sigmembers/${sigId}`, + ); + setSigMembers(sigMembersData.data); } catch (error) { console.error("Error fetching sig data:", error); notifications.show({ @@ -180,8 +177,9 @@ export const ViewSigLeadPage: React.FC = () => {
- - + */} +
); }; From 22bf9dd14520f20cb619e903a75dbbd2d031e508 Mon Sep 17 00:00:00 2001 From: Ethan Chang <70039253+eeen17@users.noreply.github.com> Date: Tue, 10 Jun 2025 14:01:31 -0700 Subject: [PATCH 14/24] Eeen17/siglead mainscreen api (#152) merging to deploy to qa for testing azure api calls (p2) --- cloudformation/iam.yml | 8 + cloudformation/main.yml | 24 +++ src/api/functions/entraId.ts | 4 +- src/api/functions/siglead.ts | 90 +++++++++-- src/api/index.ts | 2 +- src/api/routes/siglead.ts | 55 ++++--- src/common/config.ts | 14 +- src/common/orgs.ts | 9 ++ src/common/roles.ts | 5 +- src/common/types/siglead.ts | 56 ++++--- src/common/utils.ts | 13 ++ src/ui/Router.tsx | 9 +- src/ui/components/AppShell/index.tsx | 2 +- src/ui/pages/siglead/SigScreenComponents.tsx | 8 +- src/ui/pages/siglead/ViewSigLead.page.tsx | 150 ++++++++++++------- tests/unit/common/utils.test.ts | 145 +++++++++++++++++- 16 files changed, 471 insertions(+), 123 deletions(-) diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index 11b8e05a..cdc88052 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -99,6 +99,14 @@ Resources: Resource: - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache + - Sid: DynamoDBRateLimitTableAccess + Effect: Allow + Action: + - dynamodb:DescribeTable + - dynamodb:UpdateItem + Resource: + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-rate-limiter + - Sid: DynamoDBAuditLogTableAccess Effect: Allow Action: diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 99922545..4e5ea85c 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -408,6 +408,30 @@ Resources: - AttributeName: userEmail KeyType: HASH + RateLimiterTable: + Type: "AWS::DynamoDB::Table" + DeletionPolicy: "Delete" + UpdateReplacePolicy: "Delete" + Properties: + BillingMode: "PAY_PER_REQUEST" + TableName: infra-core-api-rate-limiter + DeletionProtectionEnabled: true + PointInTimeRecoverySpecification: + PointInTimeRecoveryEnabled: false + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + EventRecordsTable: Type: "AWS::DynamoDB::Table" DeletionPolicy: "Retain" diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 0aa45327..35b81130 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -29,7 +29,7 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { checkPaidMembershipFromTable } from "./membership.js"; -function validateGroupId(groupId: string): boolean { +export function validateGroupId(groupId: string): boolean { const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed return groupIdPattern.test(groupId); } @@ -368,7 +368,7 @@ export async function listGroupMembers( * @throws {EntraUserError} If fetching the user profile fails. * @returns {Promise} The user's profile information. */ -export async function getUserProfile( +export async function getUserProflile( token: string, email: string, ): Promise { diff --git a/src/api/functions/siglead.ts b/src/api/functions/siglead.ts index b6cbaa38..83316c96 100644 --- a/src/api/functions/siglead.ts +++ b/src/api/functions/siglead.ts @@ -1,16 +1,23 @@ import { + AttributeValue, DynamoDBClient, + GetItemCommand, + PutItemCommand, + PutItemCommandInput, QueryCommand, ScanCommand, } from "@aws-sdk/client-dynamodb"; import { unmarshall } from "@aws-sdk/util-dynamodb"; -import { OrganizationList } from "common/orgs.js"; +import { DatabaseInsertError } from "common/errors/index.js"; +import { OrganizationList, orgIds2Name } from "common/orgs.js"; import { SigDetailRecord, SigMemberCount, SigMemberRecord, + SigMemberUpdateRecord, } from "common/types/siglead.js"; import { transformSigLeadToURI } from "common/utils.js"; +import { KeyObject } from "crypto"; import { string } from "zod"; export async function fetchMemberRecords( @@ -84,13 +91,11 @@ export async function fetchSigCounts( const result = await dynamoClient.send(scan); - const ids2Name: Record = {}; - OrganizationList.forEach((org) => { - const sigid = transformSigLeadToURI(org); - ids2Name[sigid] = org; - }); - const counts: Record = {}; + // Object.entries(orgIds2Name).forEach(([id, _]) => { + // counts[id] = 0; + // }); + (result.Items || []).forEach((item) => { const sigGroupId = item.sigGroupId?.S; if (sigGroupId) { @@ -98,17 +103,70 @@ export async function fetchSigCounts( } }); - const joined: Record = {}; - Object.keys(counts).forEach((sigid) => { - joined[sigid] = [ids2Name[sigid], counts[sigid]]; - }); - - const countsArray: SigMemberCount[] = Object.entries(joined).map( - ([sigid, [signame, count]]) => ({ - sigid, - signame, + const countsArray: SigMemberCount[] = Object.entries(counts).map( + ([id, count]) => ({ + sigid: id, + signame: orgIds2Name[id], count, }), ); + console.log(countsArray); return countsArray; } + +export async function addMemberToSigDynamo( + sigMemberTableName: string, + sigMemberUpdateRequest: SigMemberUpdateRecord, + dynamoClient: DynamoDBClient, +) { + const item: Record = {}; + Object.entries(sigMemberUpdateRequest).forEach(([k, v]) => { + item[k] = { S: v }; + }); + + // put into table + const put = new PutItemCommand({ + Item: item, + ReturnConsumedCapacity: "TOTAL", + TableName: sigMemberTableName, + }); + try { + const response = await dynamoClient.send(put); + console.log(response); + } catch (e) { + console.error("Put to dynamo db went wrong."); + throw e; + } + + // fetch from db and check if fetched item update time = input item update time + const validatePutQuery = new GetItemCommand({ + TableName: sigMemberTableName, + Key: { + sigGroupId: { S: sigMemberUpdateRequest.sigGroupId }, + email: { S: sigMemberUpdateRequest.email }, + }, + ProjectionExpression: "updatedAt", + }); + + try { + const response = await dynamoClient.send(validatePutQuery); + const item = response.Item; + + if (!item || !item.updatedAt?.S) { + throw new Error("Item not found or missing 'updatedAt'"); + } + + if (item.updatedAt.S !== sigMemberUpdateRequest.updatedAt) { + throw new DatabaseInsertError({ + message: "The member exists, but was updated by someone else!", + }); + } + } catch (e) { + console.error("Validate DynamoDB get went wrong.", e); + throw e; + } +} + +export async function addMemberToSigEntra() { + // uuid validation not implemented yet +} diff --git a/src/api/index.ts b/src/api/index.ts index 845f8455..bc1e682a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -288,9 +288,9 @@ async function init(prettyPrint: boolean = false) { api.register(iamRoutes, { prefix: "/iam" }); api.register(ticketsPlugin, { prefix: "/tickets" }); api.register(linkryRoutes, { prefix: "/linkry" }); - api.register(sigleadRoutes, { prefix: "/siglead" }); api.register(mobileWalletRoute, { prefix: "/mobileWallet" }); api.register(stripeRoutes, { prefix: "/stripe" }); + api.register(sigleadRoutes, { prefix: "/siglead" }); api.register(roomRequestRoutes, { prefix: "/roomRequests" }); api.register(logsPlugin, { prefix: "/logs" }); api.register(apiKeyRoute, { prefix: "/apiKey" }); diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts index fca95775..1b27a3e8 100644 --- a/src/api/routes/siglead.ts +++ b/src/api/routes/siglead.ts @@ -1,17 +1,23 @@ import { FastifyPluginAsync } from "fastify"; import { DatabaseFetchError } from "../../common/errors/index.js"; + import { genericConfig } from "../../common/config.js"; + import { SigDetailRecord, SigleadGetRequest, SigMemberCount, SigMemberRecord, + SigMemberUpdateRecord, } from "common/types/siglead.js"; import { + addMemberToSigDynamo, fetchMemberRecords, fetchSigCounts, fetchSigDetail, } from "api/functions/siglead.js"; +import { intersection } from "api/plugins/auth.js"; +import { request } from "http"; const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { const limitedRoutes: FastifyPluginAsync = async (fastify) => { @@ -94,36 +100,47 @@ const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { ); // fetch sig count - fastify.get( - "/sigcount", - { - onRequest: async (request, reply) => { - /*await fastify.authorize(request, reply, [ - AppRoles.LINKS_MANAGER, - AppRoles.LINKS_ADMIN, - ]);*/ - }, - }, + fastify.get("/sigcount", async (request, reply) => { + // First try-catch: Fetch owner records + let sigMemCounts: SigMemberCount[]; + try { + sigMemCounts = await fetchSigCounts( + genericConfig.SigleadDynamoSigMemberTableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch sig member counts record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: + "Failed to fetch sig member counts record from Dynamo table.", + }); + } + + // Send the response + reply.code(200).send(sigMemCounts); + }); + + // add member + fastify.post<{ Body: SigMemberUpdateRecord }>( + "/addMember", async (request, reply) => { - // First try-catch: Fetch owner records - let sigMemCounts: SigMemberCount[]; try { - sigMemCounts = await fetchSigCounts( + await addMemberToSigDynamo( genericConfig.SigleadDynamoSigMemberTableName, + request.body, fastify.dynamoClient, ); } catch (error) { request.log.error( - `Failed to fetch sig member counts record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + `Failed to add member: ${error instanceof Error ? error.toString() : "Unknown error"}`, ); throw new DatabaseFetchError({ - message: - "Failed to fetch sig member counts record from Dynamo table.", + message: "Failed to add sig member record to Dynamo table.", }); } - - // Send the response - reply.code(200).send(sigMemCounts); + reply.code(200); }, ); }; diff --git a/src/common/config.ts b/src/common/config.ts index 43608b9e..70447d88 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -29,8 +29,6 @@ export type GenericConfigType = { EventsDynamoTableName: string; CacheDynamoTableName: string; LinkryDynamoTableName: string; - SigleadDynamoSigDetailTableName: string; - SigleadDynamoSigMemberTableName: string; StripeLinksDynamoTableName: string; ConfigSecretName: string; EntraSecretName: string; @@ -50,6 +48,10 @@ export type GenericConfigType = { EntraReadOnlySecretName: string; AuditLogTable: string; ApiKeyTable: string; + + RateLimiterDynamoTableName: string; + SigleadDynamoSigDetailTableName: string; + SigleadDynamoSigMemberTableName: string; }; type EnvironmentConfigType = { @@ -65,13 +67,13 @@ export const commChairsTestingGroupId = "d714adb7-07bb-4d4d-a40a-b035bc2a35a3"; export const commChairsGroupId = "105e7d32-7289-435e-a67a-552c7f215507"; export const miscTestingGroupId = "ff25ec56-6a33-420d-bdb0-51d8a3920e46"; +export const orgsGroupId = "0b3be7c2-748e-46ce-97e7-cf86f9ca7337"; + const genericConfig: GenericConfigType = { EventsDynamoTableName: "infra-core-api-events", StripeLinksDynamoTableName: "infra-core-api-stripe-links", CacheDynamoTableName: "infra-core-api-cache", LinkryDynamoTableName: "infra-core-api-linkry", - SigleadDynamoSigDetailTableName: "infra-core-api-sig-details", - SigleadDynamoSigMemberTableName: "infra-core-api-sig-member-details", ConfigSecretName: "infra-core-api-config", EntraSecretName: "infra-core-api-entra", EntraReadOnlySecretName: "infra-core-api-ro-entra", @@ -90,6 +92,10 @@ const genericConfig: GenericConfigType = { RoomRequestsStatusTableName: "infra-core-api-room-requests-status", AuditLogTable: "infra-core-api-audit-log", ApiKeyTable: "infra-core-api-keys", + + RateLimiterDynamoTableName: "infra-core-api-rate-limiter", + SigleadDynamoSigDetailTableName: "infra-core-api-sig-details", + SigleadDynamoSigMemberTableName: "infra-core-api-sig-member-details", } as const; const environmentConfig: EnvironmentConfigType = { diff --git a/src/common/orgs.ts b/src/common/orgs.ts index ee84d00e..becff6fc 100644 --- a/src/common/orgs.ts +++ b/src/common/orgs.ts @@ -1,3 +1,5 @@ +import { transformSigLeadToURI } from "./utils.js"; + export const SIGList = [ "SIGPwny", "SIGCHI", @@ -28,3 +30,10 @@ export const CommitteeList = [ "Marketing Committee", ] as [string, ...string[]]; export const OrganizationList = ["ACM", ...SIGList, ...CommitteeList] as [string, ...string[]]; + +const orgIds2Name: Record = {}; +OrganizationList.forEach((org) => { + const sigid = transformSigLeadToURI(org); + orgIds2Name[sigid] = org; +}); +export { orgIds2Name }; \ No newline at end of file diff --git a/src/common/roles.ts b/src/common/roles.ts index c397d69e..acbeee73 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -18,5 +18,8 @@ export enum AppRoles { MANAGE_ORG_API_KEYS = "manage:orgApiKey" } export const allAppRoles = Object.values(AppRoles).filter( - (value) => typeof value === "string", + (value) => typeof value === "string", ); + + + \ No newline at end of file diff --git a/src/common/types/siglead.ts b/src/common/types/siglead.ts index 2822cd4e..9da5b696 100644 --- a/src/common/types/siglead.ts +++ b/src/common/types/siglead.ts @@ -1,24 +1,34 @@ export type SigDetailRecord = { - sigid: string; - signame: string; - description: string; - }; - - export type SigMemberRecord = { - sigGroupId: string; - email: string; - designation: string; - memberName: string; - }; - - export type SigleadGetRequest = { - Params: { sigid: string }; - Querystring: undefined; - Body: undefined; - }; - - export type SigMemberCount = { - sigid: string; - signame: string; - count: number; - }; \ No newline at end of file + sigid: string; + signame: string; + description: string; +}; + +export type SigMemberRecord = { + sigGroupId: string; + email: string; + designation: string; + memberName: string; +}; + +export type SigleadGetRequest = { + Params: { sigid: string }; + Querystring: undefined; + Body: undefined; +}; + +export type SigMemberCount = { + sigid: string; + signame: string; + count: number; +}; + +export type SigMemberUpdateRecord = { + sigGroupId: string; + email: string; + id: string; + memberName: string; + designation: string; + createdAt: string; + updatedAt: string; +} \ No newline at end of file diff --git a/src/common/utils.ts b/src/common/utils.ts index 8959eccd..5efddccc 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -29,6 +29,7 @@ const reservedCharsRegex = /[:\/?#\[\]@!$&'()*+,;=]/g; * @returns {string} - The transformed organization name, ready for use as a URL. */ export function transformSigLeadToURI(org: string) { + // console.log(`org\t${org}`) org = org // change not reserved chars to spaces .trim() @@ -56,3 +57,15 @@ export function transformSigLeadToURI(org: string) { return org === "-" ? "" : org; } + +export function getTimeInFormat() { + const date = new Date(); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`; +} diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index 92d503c2..28927859 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -27,7 +27,10 @@ import { ManageStripeLinksPage } from "./pages/stripe/ViewLinks.page"; import { ManageRoomRequestsPage } from "./pages/roomRequest/RoomRequestLanding.page"; import { EditSigLeadsPage } from "./pages/siglead/EditSigLeads.page"; import { ManageSigLeadsPage } from "./pages/siglead/ManageSigLeads.page"; -import { ViewSigLeadPage } from "./pages/siglead/ViewSigLead.page"; +import { + AddMemberToSigPage, + ViewSigLeadPage, +} from "./pages/siglead/ViewSigLead.page"; import { ViewRoomRequest } from "./pages/roomRequest/ViewRoomRequest.page"; import { ViewLogsPage } from "./pages/logs/ViewLogs.page"; import { TermsOfService } from "./pages/tos/TermsOfService.page"; @@ -203,6 +206,10 @@ const authenticatedRouter = createBrowserRouter([ path: "/siglead-management/:sigId", element: , }, + { + path: "/siglead-management/:sigId/addMember", + element: , + }, { path: "/roomRequests", element: , diff --git a/src/ui/components/AppShell/index.tsx b/src/ui/components/AppShell/index.tsx index d2720ed5..59ca6bfe 100644 --- a/src/ui/components/AppShell/index.tsx +++ b/src/ui/components/AppShell/index.tsx @@ -18,10 +18,10 @@ import { IconPizza, IconTicket, IconLock, - IconUsers, IconDoor, IconHistory, IconKey, + IconUsers, } from "@tabler/icons-react"; import { ReactNode } from "react"; import { useNavigate } from "react-router-dom"; diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx index c5d5e362..50d45eaf 100644 --- a/src/ui/pages/siglead/SigScreenComponents.tsx +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -2,10 +2,12 @@ import React, { useEffect, useMemo, useState } from "react"; import { OrganizationList } from "@common/orgs"; import { NavLink, Paper } from "@mantine/core"; import { IconUsersGroup } from "@tabler/icons-react"; -import { useLocation } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { SigMemberCount } from "@common/types/siglead"; const renderSigLink = (sigMemCount: SigMemberCount, index: number) => { + const navigate = useNavigate(); + const color = "light-dark(var(--mantine-color-black), var(--mantine-color-white))"; const size = "18px"; @@ -14,7 +16,7 @@ const renderSigLink = (sigMemCount: SigMemberCount, index: number) => { const count = sigMemCount.count; return ( navigate(`./${id}`)} active={index % 2 === 0} label={name} color="var(--mantine-color-blue-light)" @@ -29,7 +31,7 @@ const renderSigLink = (sigMemCount: SigMemberCount, index: number) => { fontSize: `${size}`, }} > - MemberCount: {count} + {count}
} diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx index d752e111..afcf6906 100644 --- a/src/ui/pages/siglead/ViewSigLead.page.tsx +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -11,51 +11,26 @@ import { } from "@mantine/core"; import { notifications } from "@mantine/notifications"; -import React, { useEffect, useState } from "react"; +import React, { FC, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { z } from "zod"; import { AuthGuard } from "@ui/components/AuthGuard"; import { useApi } from "@ui/util/api"; import { AppRoles } from "@common/roles"; -import { IconUsersGroup } from "@tabler/icons-react"; - -const baseSigSchema = z.object({ - sigid: z.string().min(1), - signame: z.string().min(1), - description: z.string().optional(), -}); - -const baseSigMemberSchema = z.object({ - sigGroupId: z.string().min(1), - email: z.string().email("Invalid email"), - designation: z.enum(["L", "M"]), - id: z.string().optional(), - memberName: z.string(), -}); - -type sigDetails = z.infer; -type sigMemberDetails = z.infer; +import { + SigDetailRecord, + SigMemberRecord, + SigMemberUpdateRecord, +} from "@common/types/siglead.js"; +import { getTimeInFormat } from "@common/utils"; +import { orgIds2Name } from "@common/orgs"; export const ViewSigLeadPage: React.FC = () => { const navigate = useNavigate(); const api = useApi("core"); const { colorScheme } = useMantineColorScheme(); const { sigId } = useParams(); - const [sigMembers, setSigMembers] = useState([ - { - sigGroupId: sigId || "", - email: "alice1@illinois.edu", - designation: "L", - memberName: "Alice", - }, - { - sigGroupId: sigId || "", - email: "bob2@illinois.edu", - designation: "M", - memberName: "Bob", - }, - ]); - const [sigDetails, setSigDetails] = useState({ + const [sigMembers, setSigMembers] = useState([]); + const [sigDetails, setSigDetails] = useState({ sigid: sigId || "", signame: "Default Sig", description: @@ -63,17 +38,21 @@ export const ViewSigLeadPage: React.FC = () => { }); useEffect(() => { - // Fetch sig data and populate form / for now dummy data... + // Fetch sig data and populate form const getSig = async () => { try { - const sigDetailsData = await api.get( - `/api/v1/siglead/sigdetail/${sigId}`, - ); - setSigDetails(sigDetailsData.data); - const sigMembersData = await api.get( + /*const formValues = { + }; + form.setValues(formValues);*/ + const sigMemberRequest = await api.get( `/api/v1/siglead/sigmembers/${sigId}`, ); - setSigMembers(sigMembersData.data); + setSigMembers(sigMemberRequest.data); + + const sigDetailRequest = await api.get( + `/api/v1/siglead/sigdetail/${sigId}`, + ); + setSigDetails(sigDetailRequest.data); } catch (error) { console.error("Error fetching sig data:", error); notifications.show({ @@ -84,7 +63,7 @@ export const ViewSigLeadPage: React.FC = () => { getSig(); }, [sigId]); - const renderSigMember = (members: sigMemberDetails, index: number) => { + const renderSigMember = (member: SigMemberRecord, index: number) => { const shouldShow = true; return ( { : "#ffffff", }} > - {members.memberName} - {members.email} - {members.designation} + {member.memberName} + {member.email} + {member.designation} )} @@ -172,15 +151,16 @@ export const ViewSigLeadPage: React.FC = () => { - {sigDetails.sigid} + {sigDetails.signame} {sigDetails.description || ""} - + + - */} + + + + + ); +}; diff --git a/tests/unit/common/utils.test.ts b/tests/unit/common/utils.test.ts index 15177175..e22d642c 100644 --- a/tests/unit/common/utils.test.ts +++ b/tests/unit/common/utils.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from "vitest"; -import { transformCommaSeperatedName } from "../../../src/common/utils.js"; +import { transformCommaSeperatedName, transformSigLeadToURI } from "../../../src/common/utils.js"; describe("Comma-seperated name transformer tests", () => { test("Already-transformed names are returned as-is", () => { @@ -27,3 +27,146 @@ describe("Comma-seperated name transformer tests", () => { expect(output).toEqual(", Test"); }); }); + +describe("transformSigLeadToURI tests", () => { + + // Basic Functionality Tests + test("should convert simple names with spaces to lowercase hyphenated", () => { + const output = transformSigLeadToURI("SIG Network"); + expect(output).toEqual("sig-network"); + }); + + test("should convert simple names to lowercase", () => { + const output = transformSigLeadToURI("Testing"); + expect(output).toEqual("testing"); + }); + + test("should handle names already in the desired format", () => { + const output = transformSigLeadToURI("already-transformed-name"); + expect(output).toEqual("already-transformed-name"); + }); + + // Camel Case Tests + test("should add hyphens between camelCase words", () => { + const output = transformSigLeadToURI("SIGAuth"); + expect(output).toEqual("sig-auth"); + }); + + test("should handle multiple camelCase words", () => { + const output = transformSigLeadToURI("SuperCamelCaseProject"); + expect(output).toEqual("super-camel-case-project"); + }); + + test("should handle mixed camelCase and spaces", () => { + const output = transformSigLeadToURI("SIG ContribEx"); // SIG Contributor Experience + expect(output).toEqual("sig-contrib-ex"); + }); + + test("should handle camelCase starting with lowercase", () => { + const output = transformSigLeadToURI("myCamelCaseName"); + expect(output).toEqual("my-camel-case-name"); + }); + + // Reserved Character Tests (RFC 3986 gen-delims and sub-delims) + test("should convert reserved characters like & to hyphens", () => { + const output = transformSigLeadToURI("SIG Storage & Backup"); + expect(output).toEqual("sig-storage-backup"); // & -> space -> hyphen + }); + + test("should convert reserved characters like / and : to hyphens", () => { + const output = transformSigLeadToURI("Project:Alpha/Beta"); + expect(output).toEqual("project-alpha-beta"); // : -> space, / -> space, space+space -> hyphen + }); + + test("should convert reserved characters like () and + to hyphens", () => { + const output = transformSigLeadToURI("My Project (Test+Alpha)"); + expect(output).toEqual("my-project-test-alpha"); + }); + + test("should convert various reserved characters #[]@?$, to hyphens", () => { + const output = transformSigLeadToURI("Special#Chars[Test]?@Value,$"); + expect(output).toEqual("special-chars-test-value"); + }); + + // Non-Allowed Character Removal Tests + test("should remove characters not unreserved or reserved (e.g., ™, ©)", () => { + const output = transformSigLeadToURI("MyOrg™ With © Symbols"); + expect(output).toEqual("my-org-with-symbols"); + }); + + test("should remove emoji", () => { + const output = transformSigLeadToURI("Project ✨ Fun"); + expect(output).toEqual("project-fun"); + }); + + + // Whitespace and Hyphen Collapsing Tests + test("should handle multiple spaces between words", () => { + const output = transformSigLeadToURI("SIG UI Project"); + expect(output).toEqual("sig-ui-project"); + }); + + test("should handle leading/trailing whitespace", () => { + const output = transformSigLeadToURI(" Leading and Trailing "); + expect(output).toEqual("leading-and-trailing"); + }); + + test("should handle mixed whitespace (tabs, newlines)", () => { + const output = transformSigLeadToURI("Mix\tOf\nWhite Space"); + expect(output).toEqual("mix-of-white-space"); + }); + + test("should collapse multiple hyphens resulting from transformations", () => { + const output = transformSigLeadToURI("Test--Multiple / Spaces"); + expect(output).toEqual("test-multiple-spaces"); + }); + + test("should collapse hyphens from start/end after transformations", () => { + const output = transformSigLeadToURI("&Another Test!"); + expect(output).toEqual("another-test"); + }); + + // Unreserved Character Tests (RFC 3986) + test("should keep unreserved characters: hyphen, period, underscore, tilde", () => { + const output = transformSigLeadToURI("Keep.These-Chars_Okay~123"); + expect(output).toEqual("keep.these-chars_okay~123"); + }); + + test("should handle unreserved chars next to reserved chars", () => { + const output = transformSigLeadToURI("Test._~&Stuff"); + expect(output).toEqual("test._~-stuff"); + }); + + + // Edge Case Tests + test("should return an empty string for an empty input", () => { + const output = transformSigLeadToURI(""); + expect(output).toEqual(""); + }); + + test("should return an empty string for input with only spaces", () => { + const output = transformSigLeadToURI(" "); + expect(output).toEqual(""); + }); + + test("should return an empty string for input with only reserved/non-allowed chars and spaces", () => { + const output = transformSigLeadToURI(" & / # ™ © "); + expect(output).toEqual(""); + }); + + test("should handle numbers correctly", () => { + const output = transformSigLeadToURI("ProjectApollo11"); + expect(output).toEqual("project-apollo11"); // Number doesn't trigger camel case break after letter + }); + + test("should handle numbers triggering camel case break", () => { + const output = transformSigLeadToURI("Project11Apollo"); + expect(output).toEqual("project-11-apollo"); // Letter after number triggers camel case break + }); + + test("should handle names starting with lowercase", () => { + const output = transformSigLeadToURI("myOrg"); + expect(output).toEqual("my-org"); + }); + +}); From 965a1717aeb25fa28be5ad039195b71a33cd008b Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Tue, 10 Jun 2025 19:28:33 -0700 Subject: [PATCH 15/24] undo mistake on entraId --- src/api/functions/entraId.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 35b81130..c29044a1 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -29,7 +29,7 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { checkPaidMembershipFromTable } from "./membership.js"; -export function validateGroupId(groupId: string): boolean { +function validateGroupId(groupId: string): boolean { const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed return groupIdPattern.test(groupId); } From 58cc39e5e53816c7ef83656fbce86b565a96a345 Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Tue, 10 Jun 2025 19:53:07 -0700 Subject: [PATCH 16/24] fixed typo oops --- src/api/functions/entraId.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index c29044a1..0aa45327 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -368,7 +368,7 @@ export async function listGroupMembers( * @throws {EntraUserError} If fetching the user profile fails. * @returns {Promise} The user's profile information. */ -export async function getUserProflile( +export async function getUserProfile( token: string, email: string, ): Promise { From 9e2d2eb1d9f17475df85cf4aa8e527972dbd96fa Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Wed, 11 Jun 2025 16:21:55 -0700 Subject: [PATCH 17/24] give permissions to access sig details and sig member dynamodb tables --- cloudformation/iam.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index cdc88052..38651036 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -78,6 +78,8 @@ Resources: - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests-status - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-linkry - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-keys + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-sig-member-details + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-sig-details # Index accesses - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links/index/* - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-events/index/* From e48c149ed2f1e363eb8e909f27a6b43f2dd7335e Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Wed, 11 Jun 2025 17:03:03 -0700 Subject: [PATCH 18/24] testing iam patch for sigs --- src/ui/pages/siglead/ViewSigLead.page.tsx | 73 +++++++++++++---------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx index afcf6906..53a9dda1 100644 --- a/src/ui/pages/siglead/ViewSigLead.page.tsx +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -191,67 +191,74 @@ export const ViewSigLeadPage: React.FC = () => { }; export const AddMemberToSigPage: FC = () => { - const { sigId } = useParams(); + // const { sigId } = useParams(); const api = useApi("core"); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); const formData = new FormData(event.currentTarget); // console.log(formData) - const data = Object.fromEntries( - formData.entries(), - ) as SigMemberUpdateRecord; - data.designation = "M"; - data.sigGroupId = sigId || ""; - data.createdAt = getTimeInFormat(); - data.updatedAt = data.createdAt; - // console.log(data) - await api.post(`/api/v1/siglead/addMember`, data); - } + const data = Object.fromEntries(formData.entries()) as { + groupid: string; + aid: string; + rid: string; + }; - async function testAddGroup() { - await api.patch( - `/api/v1/iam/groups/:e37a2420-1030-48da-9d17-f7e201b446e1`, - { add: ["d115c8cb-2520-4ba4-bc36-dd55af69c590"], remove: [] }, - ); + await api.patch(`/api/v1/iam/groups/:${data.groupid}`, { + add: [data.aid], + remove: [data.rid], + }); + // console.log( + // `/api/v1/iam/groups/:${data.groupid}`, + // { add: [data.aid], remove: [data.rid] }, + // ); + // ) as SigMemberUpdateRecord; + // data.designation = "M"; + // data.sigGroupId = sigId || ""; + // data.createdAt = getTimeInFormat(); + // data.updatedAt = data.createdAt; + // // console.log(data) + // await api.post(`/api/v1/siglead/addMember`, data); } + // async function testAddGroup() { + // await api.patch( + // `/api/v1/iam/groups/:e37a2420-1030-48da-9d17-f7e201b446e1`, + // { add: ["d115c8cb-2520-4ba4-bc36-dd55af69c590"], remove: [] }, + // ); + // } + return ( -

Add Member to {orgIds2Name[sigId || "acm"]}

- +
- +
- +
- {/* */}
-
); }; From 2437ec1d0c5b7959fbbf6f1ca042c9bdf933889d Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Wed, 11 Jun 2025 17:59:47 -0700 Subject: [PATCH 19/24] log iam patch response --- src/ui/pages/siglead/ViewSigLead.page.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx index 53a9dda1..8994106b 100644 --- a/src/ui/pages/siglead/ViewSigLead.page.tsx +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -204,10 +204,24 @@ export const AddMemberToSigPage: FC = () => { rid: string; }; - await api.patch(`/api/v1/iam/groups/:${data.groupid}`, { - add: [data.aid], - remove: [data.rid], - }); + try { + const response = await api.patch(`/api/v1/iam/groups/:${data.groupid}`, { + add: [data.aid], + remove: [data.rid], + }); + + console.warn(`GRAPH API RESPONSE: ${response}`); + notifications.show({ + message: JSON.stringify(response), + }); + } catch (error) { + notifications.show({ + message: JSON.stringify(error), + }); + } + + // console.log(response); + // console.log( // `/api/v1/iam/groups/:${data.groupid}`, // { add: [data.aid], remove: [data.rid] }, From d0ea8e53b293159c61d8bb6d44906d32110a704c Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Thu, 12 Jun 2025 10:01:44 -0700 Subject: [PATCH 20/24] remove colon in request and check for empty string --- src/ui/pages/siglead/ViewSigLead.page.tsx | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx index 8994106b..5e5db651 100644 --- a/src/ui/pages/siglead/ViewSigLead.page.tsx +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -205,9 +205,9 @@ export const AddMemberToSigPage: FC = () => { }; try { - const response = await api.patch(`/api/v1/iam/groups/:${data.groupid}`, { - add: [data.aid], - remove: [data.rid], + const response = await api.patch(`/api/v1/iam/groups/${data.groupid}`, { + add: data.aid !== "" ? [data.aid] : [], + remove: data.rid !== "" ? [data.rid] : [], }); console.warn(`GRAPH API RESPONSE: ${response}`); @@ -221,18 +221,6 @@ export const AddMemberToSigPage: FC = () => { } // console.log(response); - - // console.log( - // `/api/v1/iam/groups/:${data.groupid}`, - // { add: [data.aid], remove: [data.rid] }, - // ); - // ) as SigMemberUpdateRecord; - // data.designation = "M"; - // data.sigGroupId = sigId || ""; - // data.createdAt = getTimeInFormat(); - // data.updatedAt = data.createdAt; - // // console.log(data) - // await api.post(`/api/v1/siglead/addMember`, data); } // async function testAddGroup() { From e7c77d92b25e07460bf07f9f5f0bcb884ec6a53e Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 15 Jun 2025 12:18:19 -0400 Subject: [PATCH 21/24] remove rate limiter table --- cloudformation/main.yml | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 4e5ea85c..99922545 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -408,30 +408,6 @@ Resources: - AttributeName: userEmail KeyType: HASH - RateLimiterTable: - Type: "AWS::DynamoDB::Table" - DeletionPolicy: "Delete" - UpdateReplacePolicy: "Delete" - Properties: - BillingMode: "PAY_PER_REQUEST" - TableName: infra-core-api-rate-limiter - DeletionProtectionEnabled: true - PointInTimeRecoverySpecification: - PointInTimeRecoveryEnabled: false - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - EventRecordsTable: Type: "AWS::DynamoDB::Table" DeletionPolicy: "Retain" From 173b54128a3178232aaeca001cd0197e3185a4c4 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 15 Jun 2025 13:06:39 -0400 Subject: [PATCH 22/24] remove orgs file --- src/common/orgs.ts | 39 --------------------------------------- 1 file changed, 39 deletions(-) delete mode 100644 src/common/orgs.ts diff --git a/src/common/orgs.ts b/src/common/orgs.ts deleted file mode 100644 index becff6fc..00000000 --- a/src/common/orgs.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { transformSigLeadToURI } from "./utils.js"; - -export const SIGList = [ - "SIGPwny", - "SIGCHI", - "GameBuilders", - "SIGAIDA", - "SIGGRAPH", - "SIGICPC", - "SIGMobile", - "SIGMusic", - "GLUG", - "SIGNLL", - "SIGma", - "SIGQuantum", - "SIGecom", - "SIGPLAN", - "SIGPolicy", - "SIGARCH", - "SIGRobotics", - "SIGtricity", -] as [string, ...string[]]; - -export const CommitteeList = [ - "Infrastructure Committee", - "Social Committee", - "Mentorship Committee", - "Academic Committee", - "Corporate Committee", - "Marketing Committee", -] as [string, ...string[]]; -export const OrganizationList = ["ACM", ...SIGList, ...CommitteeList] as [string, ...string[]]; - -const orgIds2Name: Record = {}; -OrganizationList.forEach((org) => { - const sigid = transformSigLeadToURI(org); - orgIds2Name[sigid] = org; -}); -export { orgIds2Name }; \ No newline at end of file From dffe2df2505e1e211aa8c0b58f5b381a5837baa8 Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Sat, 28 Jun 2025 10:17:31 -0700 Subject: [PATCH 23/24] fix typo --- src/api/functions/entraId.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index f636ad74..f65bff89 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -369,7 +369,7 @@ export async function listGroupMembers( * @throws {EntraUserError} If fetching the user profile fails. * @returns {Promise} The user's profile information. */ -export async function getUserProflile( +export async function getUserProfile( token: string, email: string, ): Promise { From 3db099b63b4f7f98c205f05089f1bcc60ef84ca4 Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Sat, 28 Jun 2025 14:07:08 -0700 Subject: [PATCH 24/24] fixed paths and added siglead test --- src/api/functions/siglead.ts | 4 +- src/api/routes/siglead.ts | 4 +- tests/unit/siglead.test.ts | 228 +++++++++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 tests/unit/siglead.test.ts diff --git a/src/api/functions/siglead.ts b/src/api/functions/siglead.ts index 83316c96..e803ae64 100644 --- a/src/api/functions/siglead.ts +++ b/src/api/functions/siglead.ts @@ -8,8 +8,8 @@ import { ScanCommand, } from "@aws-sdk/client-dynamodb"; import { unmarshall } from "@aws-sdk/util-dynamodb"; -import { DatabaseInsertError } from "common/errors/index.js"; -import { OrganizationList, orgIds2Name } from "common/orgs.js"; +import { DatabaseInsertError } from "../../common/errors/index.js"; +import { OrganizationList, orgIds2Name } from "../../common/orgs.js"; import { SigDetailRecord, SigMemberCount, diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts index 1b27a3e8..4b5283ee 100644 --- a/src/api/routes/siglead.ts +++ b/src/api/routes/siglead.ts @@ -15,9 +15,7 @@ import { fetchMemberRecords, fetchSigCounts, fetchSigDetail, -} from "api/functions/siglead.js"; -import { intersection } from "api/plugins/auth.js"; -import { request } from "http"; +} from "../../api/functions/siglead.js"; const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { const limitedRoutes: FastifyPluginAsync = async (fastify) => { diff --git a/tests/unit/siglead.test.ts b/tests/unit/siglead.test.ts new file mode 100644 index 00000000..a205b005 --- /dev/null +++ b/tests/unit/siglead.test.ts @@ -0,0 +1,228 @@ +import { test, describe, expect, vi, beforeEach, afterEach } from "vitest"; +import Fastify, { FastifyInstance } from "fastify"; +import sigleadRoutes from "../../src/api/routes/siglead.js"; +import * as sigleadFunctions from "../../src/api/functions/siglead.js"; +import { + SigDetailRecord, + SigMemberCount, + SigMemberRecord, +} from "../../src/common/types/siglead.js"; + +// Mock the entire module of siglead functions +vi.mock("../../src/api/functions/siglead.js"); + +// A helper function to build our Fastify app for each test +const build = async (t: any): Promise => { + const app = Fastify(); + // Register the routes we are testing, with a prefix + app.register(sigleadRoutes, { prefix: "/siglead" }); + + // Add a cleanup hook + // t.after(() => app.close()); + + // Make the app available for injection-based testing + await app.ready(); + return app; +}; + +describe("SIGLead Routes", () => { + let app: FastifyInstance; + + // Before each test, build a new Fastify instance and mock the functions + beforeEach(async (t) => { + app = await build(t); + }); + + // After each test, close the server and restore the mocks to their original state + // afterEach(async () => { + // await app.close(); + // vi.restoreAllMocks(); + // }); + + // --- Tests for GET /sigmembers/:sigid --- + describe("GET /siglead/sigmembers/:sigid", () => { + test("should return 200 and member records on success", async () => { + const mockSigId = "sig-awesome"; + const mockMembers: SigMemberRecord[] = [ + { + sigGroupId: mockSigId, + email: "test1@example.com", + designation: "M", + memberName: "test1", + }, + { + sigGroupId: mockSigId, + email: "test2@example.com", + designation: "L", + memberName: "test2", + }, + ]; + + // Control the mock: make fetchMemberRecords return our fake data + vi.mocked(sigleadFunctions.fetchMemberRecords).mockResolvedValue( + mockMembers, + ); + + const response = await app.inject({ + method: "GET", + url: `/siglead/sigmembers/${mockSigId}`, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload)).toEqual(mockMembers); + }); + + test("should return 500 when fetchMemberRecords fails", async () => { + // Control the mock: make it throw an error + vi.mocked(sigleadFunctions.fetchMemberRecords).mockRejectedValue( + new Error("DynamoDB dyed :("), + ); + + const response = await app.inject({ + method: "GET", + url: "/siglead/sigmembers/sig-fail", + }); + + expect(response.statusCode).toBe(500); + const payload = JSON.parse(response.payload); + expect(payload.message).toBe( + "Failed to fetch member records from Dynamo table.", + ); + }); + }); + + // --- Tests for GET /sigdetail/:sigid --- + describe("GET /siglead/sigdetail/:sigid", () => { + test("should return 200 and sig detail on success", async () => { + const mockSigId = "sig-details"; + const mockDetail: SigDetailRecord = { + sigid: mockSigId, + signame: "The Awesome SIG", + description: "A SIG for testing.", + }; + + vi.mocked(sigleadFunctions.fetchSigDetail).mockResolvedValue(mockDetail); + + const response = await app.inject({ + method: "GET", + url: `/siglead/sigdetail/${mockSigId}`, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload)).toEqual(mockDetail); + }); + + test("should return 500 when fetchSigDetail fails", async () => { + vi.mocked(sigleadFunctions.fetchSigDetail).mockRejectedValue( + new Error("Database connection lost"), + ); + + const response = await app.inject({ + method: "GET", + url: "/siglead/sigdetail/sig-fail", + }); + + expect(response.statusCode).toBe(500); + const payload = JSON.parse(response.payload); + expect(payload.message).toBe( + "Failed to fetch sig detail record from Dynamo table.", + ); + }); + }); + + // --- Tests for GET /sigcount --- + describe("GET /siglead/sigcount", () => { + test("should return 200 and sig member counts on success", async () => { + const mockCounts: SigMemberCount[] = [ + { + sigid: "sig-a", + count: 10, + signame: "a", + }, + { + sigid: "sig-b", + count: 25, + signame: "b", + }, + ]; + + vi.mocked(sigleadFunctions.fetchSigCounts).mockResolvedValue(mockCounts); + + const response = await app.inject({ + method: "GET", + url: "/siglead/sigcount", + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload)).toEqual(mockCounts); + }); + + test("should return 500 when fetchSigCounts fails", async () => { + vi.mocked(sigleadFunctions.fetchSigCounts).mockRejectedValue( + new Error("Could not count"), + ); + + const response = await app.inject({ + method: "GET", + url: "/siglead/sigcount", + }); + + expect(response.statusCode).toBe(500); + const payload = JSON.parse(response.payload); + expect(payload.message).toBe( + "Failed to fetch sig member counts record from Dynamo table.", + ); + }); + }); + + // --- Tests for POST /addMember --- + describe("POST /siglead/addMember", () => { + test("should return 200 on successful member addition", async () => { + const newMember = { + sigGroupId: "sig-new", + email: "new.member@example.com", + }; + + // For functions that don't return anything, we just resolve with void + vi.mocked(sigleadFunctions.addMemberToSigDynamo).mockResolvedValue( + undefined, + ); + + const response = await app.inject({ + method: "POST", + url: "/siglead/addMember", + payload: newMember, // Send the data in the request body + }); + + expect(response.statusCode).toBe(200); + + // Verify that our mock was called with the correct data + expect(sigleadFunctions.addMemberToSigDynamo).toHaveBeenCalledWith( + "infra-core-api-sig-member-details", // We don't need to test the table name config + newMember, + undefined, // We don't need to test the dynamoClient instance + ); + }); + + test("should return 500 when addMemberToSigDynamo fails", async () => { + vi.mocked(sigleadFunctions.addMemberToSigDynamo).mockRejectedValue( + new Error("Insert failed"), + ); + + const response = await app.inject({ + method: "POST", + url: "/siglead/addMember", + payload: { + sigGroupId: "sig-fail", + email: "fail@example.com", + }, + }); + + expect(response.statusCode).toBe(500); + const payload = JSON.parse(response.payload); + expect(payload.message).toBe( + "Failed to add sig member record to Dynamo table.", + ); + }); + }); +});