From 8c018f00074ee50126ea90afb331282f85baf4b5 Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Fri, 24 Jan 2025 22:03:01 +0000 Subject: [PATCH 01/21] users table --- src/common/roles.ts | 1 + src/ui/Router.tsx | 5 + src/ui/pages/screen/Screen.page.tsx | 229 ++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 src/ui/pages/screen/Screen.page.tsx diff --git a/src/common/roles.ts b/src/common/roles.ts index 7fda951e..fbe1697f 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -7,6 +7,7 @@ export enum AppRoles { TICKETS_MANAGER = "manage:tickets", IAM_ADMIN = "admin:iam", IAM_INVITE_ONLY = "invite:iam", + USERS_ADMIN = "admin:users", } export const allAppRoles = Object.values(AppRoles).filter( (value) => typeof value === "string", diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index 1258db86..3eb744d2 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -17,6 +17,7 @@ import { ScanTicketsPage } from './pages/tickets/ScanTickets.page'; import { SelectTicketsPage } from './pages/tickets/SelectEventId.page'; import { ViewTicketsPage } from './pages/tickets/ViewTickets.page'; import { ManageIamPage } from './pages/iam/ManageIam.page'; +import { ScreenPage } from './pages/screen/Screen.page'; // Component to handle redirects to login with return path const LoginRedirect: React.FC = () => { @@ -119,6 +120,10 @@ const authenticatedRouter = createBrowserRouter([ path: '/tickets/manage/:eventId', element: , }, + { + path: '/users', + element: , + }, // Catch-all route for authenticated users shows 404 page { path: '*', diff --git a/src/ui/pages/screen/Screen.page.tsx b/src/ui/pages/screen/Screen.page.tsx new file mode 100644 index 00000000..34d07e0b --- /dev/null +++ b/src/ui/pages/screen/Screen.page.tsx @@ -0,0 +1,229 @@ +import { Text, Button, Table, Modal, Group, Transition, ButtonGroup } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { notifications } from '@mantine/notifications'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; +// import dayjs from 'dayjs'; +import React, { useEffect, useState } from 'react'; +// import { useNavigate } from 'react-router-dom'; +import { z } from 'zod'; + +// import { capitalizeFirstLetter } from './ManageEvent.page.js'; +import FullScreenLoader from '@ui/components/AuthContext/LoadingScreen'; +import { AuthGuard } from '@ui/components/AuthGuard'; +import { useApi } from '@ui/util/api'; +import { AppRoles } from '@common/roles.js'; + +// const repeatOptions = ['weekly', 'biweekly'] as const; + +const userSchema = z.object({ + netid: z.string().min(1), + firstName: z.string().min(1), + middleName: z.string().optional(), + lastName: z.string().min(1), + sig: z.string(), + // location: z.string(), + // locationLink: z.optional(z.string().url()), + // host: z.string(), + // featured: z.boolean().default(false), + // paidEventId: z.optional(z.string().min(1)), +}); + +const usersSchema = z.array(userSchema); + +// const requestSchema = baseSchema.extend({ +// repeats: z.optional(z.enum(repeatOptions)), +// repeatEnds: z.string().optional(), +// }); + +// const getEventSchema = requestSchema.extend({ +// id: z.string(), +// upcoming: z.boolean().optional(), +// }); + +export type User = z.infer; +export type Users = z.infer; + +// export type EventGetResponse = z.infer; +// const getEventsSchema = z.array(getEventSchema); +// export type EventsGetResponse = z.infer; + +export const ScreenPage: React.FC = () => { + const [userList, setUserList] = useState([]); + const api = useApi('core'); + const [opened, { open, close }] = useDisclosure(false); + // const [showPrevious, { toggle: togglePrevious }] = useDisclosure(false); // Changed default to false + const [userRemoved, setRemoveUser] = useState(null); + // const navigate = useNavigate(); + + const renderTableRow = (user: User) => { + // const shouldShow = event.upcoming || (!event.upcoming && showPrevious); + + return ( + // + + {(styles) => ( + // + + {user.netid} + {user.firstName} + {user.middleName} + {user.lastName} + {user.sig} + {/* {dayjs(event.start).format('MMM D YYYY hh:mm')} + {event.end ? dayjs(event.end).format('MMM D YYYY hh:mm') : 'N/A'} + {event.location} + {event.description} + {event.host} + {event.featured ? 'Yes' : 'No'} */} + {/* {capitalizeFirstLetter(event.repeats || 'Never')} */} + {/* + + + + + */} + + )} + + ); + }; + + useEffect(() => { + const getUsers = async () => { + // const response = await api.get('/api/v1/events'); + // const upcomingEvents = await api.get('/api/v1/events?upcomingOnly=true'); + // const upcomingEventsSet = new Set(upcomingEvents.data.map((x: EventGetResponse) => x.id)); + // const events = response.data; + // events.sort((a: User, b: User) => { + // return a.start.localeCompare(b.start); + // }); + // const enrichedResponse = response.data.map((item: EventGetResponse) => { + // if (upcomingEventsSet.has(item.id)) { + // return { ...item, upcoming: true }; + // } + // return { ...item, upcoming: false }; + // }); + + // prettier-ignore + const mockUserResponse: Users = [ + { netid: "ethanc12", firstName: "Ethan", middleName:"Yuting", lastName: "Chang", sig: "Infra"}, + { netid: "johnd01", firstName: "John", lastName: "Doe", sig: "SIGMusic" }, + { netid: "sarahg23", firstName: "Sarah", middleName: "Grace", lastName: "Gonzalez", sig: "SIGQuantum" }, + { netid: "miker44", firstName: "Michael", lastName: "Roberts", sig: "SIGPlan" }, + { netid: "annaw02", firstName: "Anna", middleName: "Marie", lastName: "Williams", sig: "SIGMobile" }, + { netid: "chrisb19", firstName: "Christopher", lastName: "Brown", sig: "SIGCHI" }, + { netid: "laurenp87", firstName: "Lauren", middleName: "Patricia", lastName: "Perez", sig: "SIGPwny" }, + { netid: "ethanw12", firstName: "Ethan", lastName: "Wong", sig: "SIGEcom" }, + { netid: "emilyh54", firstName: "Emily", lastName: "Hernandez", sig: "SIGRobotics" }, + { netid: "kevink11", firstName: "Kevin", middleName: "Lee", lastName: "Kim", sig: "Infra" }, + { netid: "juliel08", firstName: "Julie", lastName: "Lopez", sig: "SIGGRAPH" }, + { netid: "mattt92", firstName: "Matthew", middleName: "Thomas", lastName: "Taylor", sig: "SIGtricity" }, + { netid: "rachelb03", firstName: "Rachel", lastName: "Bell", sig: "SIGSYS" }, + { netid: "stephenj45", firstName: "Stephen", middleName: "James", lastName: "Johnson", sig: "SIGAIDA" }, + { netid: "ashleyc28", firstName: "Ashley", lastName: "Clark", sig: "SIGNLL" }, + { netid: "briand77", firstName: "Brian", lastName: "Davis", sig: "SIGMA" }, + { netid: "meganf65", firstName: "Megan", lastName: "Flores", sig: "SIGPolicy" }, + { netid: "danielh04", firstName: "Daniel", lastName: "Hughes", sig: "SIGARCH" }, + { netid: "victorc16", firstName: "Victor", middleName: "Charles", lastName: "Carter", sig: "SIGGLUG" }, + { netid: "lindam29", firstName: "Linda", lastName: "Martinez", sig: "SIGMobile" }, + { netid: "paulf31", firstName: "Paul", lastName: "Fisher", sig: "SIGMusic" }, + { netid: "susana80", firstName: "Susan", middleName: "Ann", lastName: "Anderson", sig: "SIGPwny" }, + { netid: "markl13", firstName: "Mark", lastName: "Lewis", sig: "SIGCHI" }, + { netid: "carolynb59", firstName: "Carolyn", lastName: "Barnes", sig: "SIGSYS" }, + { netid: "patrickh37", firstName: "Patrick", middleName: "Henry", lastName: "Hill", sig: "SIGQuantum" }, + { netid: "nataliep71", firstName: "Natalie", lastName: "Price", sig: "SIGPolicy" }, + ]; + setUserList(mockUserResponse); + }; + getUsers(); + }, []); + + const removeUser = async (netid: string) => { + try { + // await api.delete(`/api/v1/events/${eventId}`); + setUserList((prevUsers) => prevUsers.filter((u) => u.netid !== netid)); + notifications.show({ + title: 'User removed', + message: 'The user was successfully removed.', + }); + close(); + } catch (error) { + console.error(error); + notifications.show({ + title: 'Error removing user', + message: `${error}`, + color: 'red', + }); + } + }; + + if (userList.length === 0) { + return ; + } + + return ( + // + + {userRemoved && ( + { + setRemoveUser(null); + close(); + }} + title="Confirm action" + > + + Are you sure you want to remove the user {userRemoved?.netid}? + +
+ + + +
+ )} + {/*
+ + +
*/} + + + + NetID + First Name + Middle Name + Last Name + Affiliated Special Interest Group + + + {userList.map(renderTableRow)} +
+
+ ); +}; From 5f3d611cb87cf76cddae9e37b1451c442e2adc4d Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Fri, 24 Jan 2025 22:08:13 +0000 Subject: [PATCH 02/21] added remove user button --- src/ui/pages/screen/Screen.page.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ui/pages/screen/Screen.page.tsx b/src/ui/pages/screen/Screen.page.tsx index 34d07e0b..5c1d6ffe 100644 --- a/src/ui/pages/screen/Screen.page.tsx +++ b/src/ui/pages/screen/Screen.page.tsx @@ -76,22 +76,20 @@ export const ScreenPage: React.FC = () => { {event.host} {event.featured ? 'Yes' : 'No'} */} {/* {capitalizeFirstLetter(event.repeats || 'Never')} */} - {/* + - + {/* */} - */} + )}
@@ -220,6 +218,7 @@ export const ScreenPage: React.FC = () => { Middle Name Last Name Affiliated Special Interest Group + Actions {userList.map(renderTableRow)} From d78f9f29514325333e463e9288d844409a011fea Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Sun, 26 Jan 2025 22:20:15 +0000 Subject: [PATCH 03/21] added dev suggestions --- src/common/orgs.ts | 3 +- src/common/roles.ts | 1 - src/common/types/iam.ts | 29 +++++- src/ui/Router.tsx | 2 +- src/ui/pages/screen/Screen.page.tsx | 131 +++++++++++++++------------- 5 files changed, 101 insertions(+), 65 deletions(-) diff --git a/src/common/orgs.ts b/src/common/orgs.ts index ba1091eb..31d0bb99 100644 --- a/src/common/orgs.ts +++ b/src/common/orgs.ts @@ -27,4 +27,5 @@ export const CommitteeList = [ "Corporate Committee", "Marketing Committee", ] as const; -export const OrganizationList = ["ACM", ...SIGList, ...CommitteeList]; + +export const OrganizationList = ["ACM", ...SIGList, ...CommitteeList] as const; diff --git a/src/common/roles.ts b/src/common/roles.ts index fbe1697f..7fda951e 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -7,7 +7,6 @@ export enum AppRoles { TICKETS_MANAGER = "manage:tickets", IAM_ADMIN = "admin:iam", IAM_INVITE_ONLY = "invite:iam", - USERS_ADMIN = "admin:users", } export const allAppRoles = Object.values(AppRoles).filter( (value) => typeof value === "string", diff --git a/src/common/types/iam.ts b/src/common/types/iam.ts index 86e932f8..601f8779 100644 --- a/src/common/types/iam.ts +++ b/src/common/types/iam.ts @@ -1,3 +1,4 @@ +import { OrganizationList } from "../orgs.js"; import { AppRoles } from "../roles.js"; import { z } from "zod"; @@ -23,7 +24,8 @@ export type InviteUserPostRequest = z.infer; export const groupMappingCreatePostSchema = z.object({ roles: z.union([ - z.array(z.nativeEnum(AppRoles)) + z + .array(z.nativeEnum(AppRoles)) .min(1) .refine((items) => new Set(items).size === items.length, { message: "All roles must be unique, no duplicate values allowed", @@ -32,7 +34,6 @@ export const groupMappingCreatePostSchema = z.object({ ]), }); - export type GroupMappingCreatePostRequest = z.infer< typeof groupMappingCreatePostSchema >; @@ -65,3 +66,27 @@ export const entraGroupMembershipListResponse = z.array( export type GroupMemberGetResponse = z.infer< typeof entraGroupMembershipListResponse >; + +const userOrgSchema = z.object({ + netid: z.string().min(1), + org: z.enum(OrganizationList), +}); +const userOrgsSchema = z.array(userOrgSchema); + +const userNameSchema = z.object({ + netid: z.string().min(1), + firstName: z.string().min(1), + middleName: z.string().optional(), + lastName: z.string().min(1), +}); +const userNamesSchema = z.array(userNameSchema); + +const userSchema = userNameSchema.merge(userOrgSchema); +const usersSchema = z.array(userSchema); + +export type UserOrg = z.infer; +export type UserOrgs = z.infer; +export type UserName = z.infer; +export type UserNames = z.infer; +export type User = z.infer; +export type Users = z.infer; diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index 3eb744d2..c7fbfa57 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -121,7 +121,7 @@ const authenticatedRouter = createBrowserRouter([ element: , }, { - path: '/users', + path: '/iam/leads', element: , }, // Catch-all route for authenticated users shows 404 page diff --git a/src/ui/pages/screen/Screen.page.tsx b/src/ui/pages/screen/Screen.page.tsx index 5c1d6ffe..f629eda8 100644 --- a/src/ui/pages/screen/Screen.page.tsx +++ b/src/ui/pages/screen/Screen.page.tsx @@ -12,37 +12,11 @@ import FullScreenLoader from '@ui/components/AuthContext/LoadingScreen'; import { AuthGuard } from '@ui/components/AuthGuard'; import { useApi } from '@ui/util/api'; import { AppRoles } from '@common/roles.js'; +import { OrganizationList } from '@common/orgs'; +import { User, UserNames, UserOrgs, Users } from '@common/types/iam'; // const repeatOptions = ['weekly', 'biweekly'] as const; -const userSchema = z.object({ - netid: z.string().min(1), - firstName: z.string().min(1), - middleName: z.string().optional(), - lastName: z.string().min(1), - sig: z.string(), - // location: z.string(), - // locationLink: z.optional(z.string().url()), - // host: z.string(), - // featured: z.boolean().default(false), - // paidEventId: z.optional(z.string().min(1)), -}); - -const usersSchema = z.array(userSchema); - -// const requestSchema = baseSchema.extend({ -// repeats: z.optional(z.enum(repeatOptions)), -// repeatEnds: z.string().optional(), -// }); - -// const getEventSchema = requestSchema.extend({ -// id: z.string(), -// upcoming: z.boolean().optional(), -// }); - -export type User = z.infer; -export type Users = z.infer; - // export type EventGetResponse = z.infer; // const getEventsSchema = z.array(getEventSchema); // export type EventsGetResponse = z.infer; @@ -68,7 +42,7 @@ export const ScreenPage: React.FC = () => { {user.firstName} {user.middleName} {user.lastName} - {user.sig} + {user.org} {/* {dayjs(event.start).format('MMM D YYYY hh:mm')} {event.end ? dayjs(event.end).format('MMM D YYYY hh:mm') : 'N/A'} {event.location} @@ -112,36 +86,74 @@ export const ScreenPage: React.FC = () => { // return { ...item, upcoming: false }; // }); - // prettier-ignore - const mockUserResponse: Users = [ - { netid: "ethanc12", firstName: "Ethan", middleName:"Yuting", lastName: "Chang", sig: "Infra"}, - { netid: "johnd01", firstName: "John", lastName: "Doe", sig: "SIGMusic" }, - { netid: "sarahg23", firstName: "Sarah", middleName: "Grace", lastName: "Gonzalez", sig: "SIGQuantum" }, - { netid: "miker44", firstName: "Michael", lastName: "Roberts", sig: "SIGPlan" }, - { netid: "annaw02", firstName: "Anna", middleName: "Marie", lastName: "Williams", sig: "SIGMobile" }, - { netid: "chrisb19", firstName: "Christopher", lastName: "Brown", sig: "SIGCHI" }, - { netid: "laurenp87", firstName: "Lauren", middleName: "Patricia", lastName: "Perez", sig: "SIGPwny" }, - { netid: "ethanw12", firstName: "Ethan", lastName: "Wong", sig: "SIGEcom" }, - { netid: "emilyh54", firstName: "Emily", lastName: "Hernandez", sig: "SIGRobotics" }, - { netid: "kevink11", firstName: "Kevin", middleName: "Lee", lastName: "Kim", sig: "Infra" }, - { netid: "juliel08", firstName: "Julie", lastName: "Lopez", sig: "SIGGRAPH" }, - { netid: "mattt92", firstName: "Matthew", middleName: "Thomas", lastName: "Taylor", sig: "SIGtricity" }, - { netid: "rachelb03", firstName: "Rachel", lastName: "Bell", sig: "SIGSYS" }, - { netid: "stephenj45", firstName: "Stephen", middleName: "James", lastName: "Johnson", sig: "SIGAIDA" }, - { netid: "ashleyc28", firstName: "Ashley", lastName: "Clark", sig: "SIGNLL" }, - { netid: "briand77", firstName: "Brian", lastName: "Davis", sig: "SIGMA" }, - { netid: "meganf65", firstName: "Megan", lastName: "Flores", sig: "SIGPolicy" }, - { netid: "danielh04", firstName: "Daniel", lastName: "Hughes", sig: "SIGARCH" }, - { netid: "victorc16", firstName: "Victor", middleName: "Charles", lastName: "Carter", sig: "SIGGLUG" }, - { netid: "lindam29", firstName: "Linda", lastName: "Martinez", sig: "SIGMobile" }, - { netid: "paulf31", firstName: "Paul", lastName: "Fisher", sig: "SIGMusic" }, - { netid: "susana80", firstName: "Susan", middleName: "Ann", lastName: "Anderson", sig: "SIGPwny" }, - { netid: "markl13", firstName: "Mark", lastName: "Lewis", sig: "SIGCHI" }, - { netid: "carolynb59", firstName: "Carolyn", lastName: "Barnes", sig: "SIGSYS" }, - { netid: "patrickh37", firstName: "Patrick", middleName: "Henry", lastName: "Hill", sig: "SIGQuantum" }, - { netid: "nataliep71", firstName: "Natalie", lastName: "Price", sig: "SIGPolicy" }, + // get request for user orgs + const userOrgsResponse: UserOrgs = [ + { netid: 'johnd01', org: 'SIGMusic' }, + { netid: 'miker44', org: 'SIGPLAN' }, + { netid: 'chrisb19', org: 'SIGCHI' }, + { netid: 'ethanw12', org: 'SIGecom' }, + { netid: 'emilyh54', org: 'SIGRobotics' }, + { netid: 'juliel08', org: 'SIGGRAPH' }, + { netid: 'rachelb03', org: 'GameBuilders' }, + { netid: 'ashleyc28', org: 'SIGNLL' }, + { netid: 'briand77', org: 'SIGma' }, + { netid: 'meganf65', org: 'SIGPolicy' }, + { netid: 'danielh04', org: 'SIGARCH' }, + { netid: 'lindam29', org: 'SIGMobile' }, + { netid: 'paulf31', org: 'SIGMusic' }, + { netid: 'markl13', org: 'SIGCHI' }, + { netid: 'carolynb59', org: 'ACM' }, + { netid: 'nataliep71', org: 'SIGPolicy' }, + + { netid: 'ethanc12', org: 'Infrastructure Committee' }, + { netid: 'sarahg23', org: 'SIGQuantum' }, + { netid: 'annaw02', org: 'SIGMobile' }, + { netid: 'laurenp87', org: 'SIGPwny' }, + { netid: 'kevink11', org: 'Infrastructure Committee' }, + { netid: 'mattt92', org: 'SIGtricity' }, + { netid: 'stephenj45', org: 'SIGAIDA' }, + { netid: 'victorc16', org: 'GLUG' }, + { netid: 'susana80', org: 'SIGPwny' }, + { netid: 'patrickh37', org: 'SIGQuantum' }, + ]; + + // retrieve from azure active directory (aad) + const userNamesResponse: UserNames = [ + { netid: 'johnd01', firstName: 'John', lastName: 'Doe' }, + { netid: 'miker44', firstName: 'Michael', lastName: 'Roberts' }, + { netid: 'chrisb19', firstName: 'Christopher', lastName: 'Brown' }, + { netid: 'ethanw12', firstName: 'Ethan', lastName: 'Wong' }, + { netid: 'emilyh54', firstName: 'Emily', lastName: 'Hernandez' }, + { netid: 'juliel08', firstName: 'Julie', lastName: 'Lopez' }, + { netid: 'rachelb03', firstName: 'Rachel', lastName: 'Bell' }, + { netid: 'ashleyc28', firstName: 'Ashley', lastName: 'Clark' }, + { netid: 'briand77', firstName: 'Brian', lastName: 'Davis' }, + { netid: 'meganf65', firstName: 'Megan', lastName: 'Flores' }, + { netid: 'danielh04', firstName: 'Daniel', lastName: 'Hughes' }, + { netid: 'lindam29', firstName: 'Linda', lastName: 'Martinez' }, + { netid: 'paulf31', firstName: 'Paul', lastName: 'Fisher' }, + { netid: 'markl13', firstName: 'Mark', lastName: 'Lewis' }, + { netid: 'carolynb59', firstName: 'Carolyn', lastName: 'Barnes' }, + { netid: 'nataliep71', firstName: 'Natalie', lastName: 'Price' }, + + { netid: 'ethanc12', firstName: 'Ethan', middleName: 'Yuting', lastName: 'Chang' }, + { netid: 'sarahg23', firstName: 'Sarah', middleName: 'Grace', lastName: 'Gonzalez' }, + { netid: 'annaw02', firstName: 'Anna', middleName: 'Marie', lastName: 'Williams' }, + { netid: 'laurenp87', firstName: 'Lauren', middleName: 'Patricia', lastName: 'Perez' }, + { netid: 'kevink11', firstName: 'Kevin', middleName: 'Lee', lastName: 'Kim' }, + { netid: 'mattt92', firstName: 'Matthew', middleName: 'Thomas', lastName: 'Taylor' }, + { netid: 'stephenj45', firstName: 'Stephen', middleName: 'James', lastName: 'Johnson' }, + { netid: 'victorc16', firstName: 'Victor', middleName: 'Charles', lastName: 'Carter' }, + { netid: 'susana80', firstName: 'Susan', middleName: 'Ann', lastName: 'Anderson' }, + { netid: 'patrickh37', firstName: 'Patrick', middleName: 'Henry', lastName: 'Hill' }, ]; - setUserList(mockUserResponse); + + const mergedResponse: Users = userOrgsResponse.map((orgObj) => { + const nameObj = userNamesResponse.find((name) => name.netid === orgObj.netid); + return { ...orgObj, ...nameObj } as User; + }); + + setUserList(mergedResponse); }; getUsers(); }, []); @@ -170,7 +182,6 @@ export const ScreenPage: React.FC = () => { } return ( - // {userRemoved && ( { First Name Middle Name Last Name - Affiliated Special Interest Group + Organization Actions From bbde4a38bfccfe8fcd03753557f89cbc139e27a2 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Fri, 24 Jan 2025 22:01:07 -0600 Subject: [PATCH 04/21] use arm64 for lambda --- cloudformation/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 84c5e026..1112fc3c 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -121,6 +121,7 @@ Resources: DependsOn: - AppLogGroups Properties: + Architectures: [arm64] CodeUri: ../dist AutoPublishAlias: live Runtime: nodejs20.x From 170c2ca9bb107d9e921aaa0673c26d67acb0df3f Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sat, 25 Jan 2025 17:25:11 -0600 Subject: [PATCH 05/21] Performance optimizations (#39) * use a single client per service * use node 22 * minify bundle, use node 22 * fix build * clear node cache before each test * add dep back --- .github/workflows/deploy-dev.yml | 6 +-- .github/workflows/deploy-prod.yml | 10 ++-- cloudformation/main.yml | 24 ++++++++- package.json | 32 ++--------- src/api/functions/cache.ts | 6 +-- src/api/functions/discord.ts | 4 +- src/api/functions/entraId.ts | 13 ++++- src/api/index.ts | 14 ++++- src/api/package.json | 30 ++++++++++- src/api/plugins/auth.ts | 17 +++--- src/api/routes/events.ts | 52 +++++++++++++----- src/api/routes/iam.ts | 15 +++--- src/api/routes/ics.ts | 8 +-- src/api/routes/tickets.ts | 14 ++--- src/api/tsconfig.json | 2 +- src/api/types.d.ts | 4 ++ tests/unit/auth.test.ts | 71 ++++++++++++++----------- tests/unit/discordEvent.test.ts | 1 + tests/unit/entraGroupManagement.test.ts | 2 + tests/unit/entraInviteUser.test.ts | 1 + tests/unit/eventPost.test.ts | 1 + tests/unit/events.test.ts | 2 +- tests/unit/health.test.ts | 2 +- tests/unit/organizations.test.ts | 5 +- tests/unit/tickets.test.ts | 1 + yarn.lock | 44 +++++++-------- 26 files changed, 228 insertions(+), 153 deletions(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index f45adc94..01dff0df 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -37,7 +37,7 @@ jobs: - name: Set up Node for testing uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -80,7 +80,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 2b1b2f80..85480ffe 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -37,7 +37,7 @@ jobs: - name: Set up Node for testing uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -80,7 +80,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -109,7 +109,7 @@ jobs: - name: Set up Node for testing uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" @@ -152,7 +152,7 @@ jobs: - name: Set up Node for testing uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - uses: actions/checkout@v4 env: HUSKY: "0" diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 1112fc3c..5edf7e32 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -120,14 +120,34 @@ Resources: Type: AWS::Serverless::Function DependsOn: - AppLogGroups + Metadata: + BuildMethod: esbuild + BuildProperties: + Format: esm + Minify: true + OutExtension: + - .js=.mjs + Target: "es2022" + Sourcemap: false + EntryPoints: + - api/lambda.js + External: + - aws-sdk + Banner: + - js=import path from 'path'; + import { fileURLToPath } from 'url'; + import { createRequire as topLevelCreateRequire } from 'module'; + const require = topLevelCreateRequire(import.meta.url); + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); Properties: Architectures: [arm64] CodeUri: ../dist AutoPublishAlias: live - Runtime: nodejs20.x + Runtime: nodejs22.x Description: !Sub "${ApplicationFriendlyName} API Lambda" FunctionName: !Sub ${ApplicationPrefix}-lambda - Handler: api/lambda.handler + Handler: lambda.handler MemorySize: 512 Role: !GetAtt AppSecurityRoles.Outputs.MainFunctionRoleArn Timeout: 60 diff --git a/package.json b/package.json index 5b5825cc..ff230ff1 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "yarn workspaces run build && yarn lockfile-manage", "dev": "concurrently --names 'api,ui' 'yarn workspace infra-core-api run dev' 'yarn workspace infra-core-ui run dev'", - "lockfile-manage": "synp --with-workspace --source-file yarn.lock && cp package-lock.json dist/ && cp package.json dist/ && rm package-lock.json", + "lockfile-manage": "synp --with-workspace --source-file yarn.lock && cp package-lock.json dist/ && cp src/api/package.json dist/ && rm package-lock.json", "prettier": "yarn workspaces run prettier && prettier --check tests/**/*.ts", "prettier:write": "yarn workspaces run prettier:write && prettier --write tests/**/*.ts", "lint": "yarn workspaces run lint", @@ -25,35 +25,11 @@ "test:e2e": "playwright test", "test:e2e-ui": "playwright test --ui" }, - "dependencies": { - "@aws-sdk/client-dynamodb": "^3.624.0", - "@aws-sdk/client-secrets-manager": "^3.624.0", - "@aws-sdk/util-dynamodb": "^3.624.0", - "@azure/msal-node": "^2.16.1", - "@fastify/auth": "^5.0.1", - "@fastify/aws-lambda": "^5.0.0", - "@fastify/caching": "^9.0.1", - "@fastify/cors": "^10.0.1", - "@touch4it/ical-timezones": "^1.9.0", - "discord.js": "^14.15.3", - "dotenv": "^16.4.5", - "fastify": "^5.1.0", - "fastify-plugin": "^4.5.1", - "ical-generator": "^7.2.0", - "jsonwebtoken": "^9.0.2", - "jwks-rsa": "^3.1.0", - "moment": "^2.30.1", - "moment-timezone": "^0.5.45", - "node-cache": "^5.1.2", - "pluralize": "^8.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.23.2", - "zod-validation-error": "^3.3.1" - }, + "dependencies": {}, "devDependencies": { "@eslint/compat": "^1.1.1", "@playwright/test": "^1.49.1", - "@tsconfig/node20": "^20.1.4", + "@tsconfig/node22": "^22.0.0", "@types/node": "^22.1.0", "@types/pluralize": "^0.0.33", "@types/react": "^18.3.3", @@ -82,7 +58,7 @@ "husky": "^9.1.4", "identity-obj-proxy": "^3.0.0", "jsdom": "^24.1.1", - "node-ical": "^0.18.0", + "node-ical": "^0.20.1", "postcss": "^8.4.41", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", diff --git a/src/api/functions/cache.ts b/src/api/functions/cache.ts index 5d007e70..62759889 100644 --- a/src/api/functions/cache.ts +++ b/src/api/functions/cache.ts @@ -6,11 +6,8 @@ import { import { genericConfig } from "../../common/config.js"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); - export async function getItemFromCache( + dynamoClient: DynamoDBClient, key: string, ): Promise> { const currentTime = Math.floor(Date.now() / 1000); @@ -37,6 +34,7 @@ export async function getItemFromCache( } export async function insertItemIntoCache( + dynamoClient: DynamoDBClient, key: string, value: Record, expireAt: Date, diff --git a/src/api/functions/discord.ts b/src/api/functions/discord.ts index 51ed6485..5fb92680 100644 --- a/src/api/functions/discord.ts +++ b/src/api/functions/discord.ts @@ -15,6 +15,7 @@ import { FastifyBaseLogger } from "fastify"; import { DiscordEventError } from "../../common/errors/index.js"; import { getSecretValue } from "../plugins/auth.js"; import { genericConfig } from "../../common/config.js"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; // https://stackoverflow.com/a/3809435/5684541 // https://calendar-buff.acmuiuc.pages.dev/calendar?id=dd7af73a-3df6-4e12-b228-0d2dac34fda7&date=2024-08-30 @@ -24,12 +25,13 @@ export type IUpdateDiscord = EventPostRequest & { id: string }; const urlRegex = /https:\/\/[a-z0-9\.-]+\/calendar\?id=([a-f0-9-]+)/; export const updateDiscord = async ( + smClient: SecretsManagerClient, event: IUpdateDiscord, isDelete: boolean = false, logger: FastifyBaseLogger, ): Promise => { const secretApiConfig = - (await getSecretValue(genericConfig.ConfigSecretName)) || {}; + (await getSecretValue(smClient, genericConfig.ConfigSecretName)) || {}; const client = new Client({ intents: [GatewayIntentBits.Guilds] }); let payload: GuildScheduledEventCreateOptions | null = null; diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 4c5b48d5..30c0331a 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -18,6 +18,7 @@ import { EntraGroupActions, EntraInvitationResponse, } from "../../common/types/iam.js"; +import { FastifyInstance } from "fastify"; function validateGroupId(groupId: string): boolean { const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed @@ -25,11 +26,15 @@ function validateGroupId(groupId: string): boolean { } export async function getEntraIdToken( + fastify: FastifyInstance, clientId: string, scopes: string[] = ["https://graph.microsoft.com/.default"], ) { const secretApiConfig = - (await getSecretValue(genericConfig.ConfigSecretName)) || {}; + (await getSecretValue( + fastify.secretsManagerClient, + genericConfig.ConfigSecretName, + )) || {}; if ( !secretApiConfig.entra_id_private_key || !secretApiConfig.entra_id_thumbprint @@ -42,7 +47,10 @@ export async function getEntraIdToken( secretApiConfig.entra_id_private_key as string, "base64", ).toString("utf8"); - const cachedToken = await getItemFromCache("entra_id_access_token"); + const cachedToken = await getItemFromCache( + fastify.dynamoClient, + "entra_id_access_token", + ); if (cachedToken) { return cachedToken["token"] as string; } @@ -70,6 +78,7 @@ export async function getEntraIdToken( date.setTime(date.getTime() - 30000); if (result?.accessToken) { await insertItemIntoCache( + fastify.dynamoClient, "entra_id_access_token", { token: result?.accessToken }, date, diff --git a/src/api/index.ts b/src/api/index.ts index 4fc737f5..ed7f3497 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -19,12 +19,22 @@ import iamRoutes from "./routes/iam.js"; import ticketsPlugin from "./routes/tickets.js"; import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts"; import NodeCache from "node-cache"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; dotenv.config(); const now = () => Date.now(); async function init() { + const dynamoClient = new DynamoDBClient({ + region: genericConfig.AwsRegion, + }); + + const secretsManagerClient = new SecretsManagerClient({ + region: genericConfig.AwsRegion, + }); + const app: FastifyInstance = fastify({ logger: { level: process.env.LOG_LEVEL || "info", @@ -70,6 +80,8 @@ async function init() { app.environmentConfig = environmentConfig[app.runEnvironment as RunEnvironment]; app.nodeCache = new NodeCache({ checkperiod: 30 }); + app.dynamoClient = dynamoClient; + app.secretsManagerClient = secretsManagerClient; app.addHook("onRequest", (req, _, done) => { req.startTime = now(); const hostname = req.hostname; @@ -108,7 +120,7 @@ async function init() { await app.register(cors, { origin: app.environmentConfig.ValidCorsOrigins, }); - + app.log.info("Initialized new Fastify instance..."); return app; } diff --git a/src/api/package.json b/src/api/package.json index 1141ef62..9f0467c2 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -15,7 +15,33 @@ "prettier:write": "prettier --write *.ts **/*.ts" }, "dependencies": { + "@aws-sdk/client-dynamodb": "^3.624.0", + "@aws-sdk/client-secrets-manager": "^3.624.0", "@aws-sdk/client-sts": "^3.726.0", - "node-cache": "^5.1.2" + "@aws-sdk/util-dynamodb": "^3.624.0", + "@azure/msal-node": "^2.16.1", + "@fastify/auth": "^5.0.1", + "@fastify/aws-lambda": "^5.0.0", + "@fastify/caching": "^9.0.1", + "@fastify/cors": "^10.0.1", + "@touch4it/ical-timezones": "^1.9.0", + "discord.js": "^14.15.3", + "dotenv": "^16.4.5", + "esbuild": "^0.24.2", + "fastify": "^5.1.0", + "fastify-plugin": "^4.5.1", + "ical-generator": "^7.2.0", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.1.0", + "moment": "^2.30.1", + "moment-timezone": "^0.5.45", + "node-cache": "^5.1.2", + "pluralize": "^8.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.2", + "zod-validation-error": "^3.3.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.0" } -} +} \ No newline at end of file diff --git a/src/api/plugins/auth.ts b/src/api/plugins/auth.ts index 2361f7ab..1e5fa54c 100644 --- a/src/api/plugins/auth.ts +++ b/src/api/plugins/auth.ts @@ -53,15 +53,9 @@ export type AadToken = { ver: string; roles?: string[]; }; -const smClient = new SecretsManagerClient({ - region: genericConfig.AwsRegion, -}); - -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); export const getSecretValue = async ( + smClient: SecretsManagerClient, secretId: string, ): Promise | null | SecretConfig> => { const data = await smClient.send( @@ -118,7 +112,10 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { signingKey = process.env.JwtSigningKey || (( - (await getSecretValue(genericConfig.ConfigSecretName)) || { + (await getSecretValue( + fastify.secretsManagerClient, + genericConfig.ConfigSecretName, + )) || { jwt_key: "", } ).jwt_key as string) || @@ -168,7 +165,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { if (verifiedTokenData.groups) { const groupRoles = await Promise.allSettled( verifiedTokenData.groups.map((x) => - getGroupRoles(dynamoClient, fastify, x), + getGroupRoles(fastify.dynamoClient, fastify, x), ), ); for (const result of groupRoles) { @@ -201,7 +198,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { if (request.username) { try { const userAuth = await getUserRoles( - dynamoClient, + fastify.dynamoClient, fastify, request.username, ); diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index d50406ee..2d53cad8 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -5,7 +5,6 @@ import { zodToJsonSchema } from "zod-to-json-schema"; import { OrganizationList } from "../../common/orgs.js"; import { DeleteItemCommand, - DynamoDBClient, GetItemCommand, PutItemCommand, QueryCommand, @@ -27,6 +26,7 @@ import { IUpdateDiscord, updateDiscord } from "../functions/discord.js"; // POST const repeatOptions = ["weekly", "biweekly"] as const; +const EVENT_CACHE_SECONDS = 90; export type EventRepeatOptions = (typeof repeatOptions)[number]; const baseSchema = z.object({ @@ -80,10 +80,6 @@ const getEventsSchema = z.array(getEventSchema); export type EventsGetResponse = z.infer; type EventsGetQueryParams = { upcomingOnly?: boolean }; -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); - const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.post<{ Body: EventPostRequest }>( "/:id?", @@ -106,7 +102,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { ).id; const entryUUID = userProvidedId || randomUUID(); if (userProvidedId) { - const response = await dynamoClient.send( + const response = await fastify.dynamoClient.send( new GetItemCommand({ TableName: genericConfig.EventsDynamoTableName, Key: { id: { S: userProvidedId } }, @@ -128,7 +124,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { : new Date().toISOString(), updatedAt: new Date().toISOString(), }; - await dynamoClient.send( + await fastify.dynamoClient.send( new PutItemCommand({ TableName: genericConfig.EventsDynamoTableName, Item: marshall(entry), @@ -140,18 +136,23 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { } try { if (request.body.featured && !request.body.repeats) { - await updateDiscord(entry, false, request.log); + await updateDiscord( + fastify.secretsManagerClient, + entry, + false, + request.log, + ); } } catch (e: unknown) { // restore original DB status if Discord fails. - await dynamoClient.send( + await fastify.dynamoClient.send( new DeleteItemCommand({ TableName: genericConfig.EventsDynamoTableName, Key: { id: { S: entryUUID } }, }), ); if (userProvidedId) { - await dynamoClient.send( + await fastify.dynamoClient.send( new PutItemCommand({ TableName: genericConfig.EventsDynamoTableName, Item: originalEvent, @@ -198,7 +199,7 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { async (request: FastifyRequest, reply) => { const id = request.params.id; try { - const response = await dynamoClient.send( + const response = await fastify.dynamoClient.send( new QueryCommand({ TableName: genericConfig.EventsDynamoTableName, KeyConditionExpression: "#id = :id", @@ -241,13 +242,18 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { async (request: FastifyRequest, reply) => { const id = request.params.id; try { - await dynamoClient.send( + await fastify.dynamoClient.send( new DeleteItemCommand({ TableName: genericConfig.EventsDynamoTableName, Key: marshall({ id }), }), ); - await updateDiscord({ id } as IUpdateDiscord, true, request.log); + await updateDiscord( + fastify.secretsManagerClient, + { id } as IUpdateDiscord, + true, + request.log, + ); reply.send({ id, resource: `/api/v1/events/${id}`, @@ -285,8 +291,20 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { }, async (request: FastifyRequest, reply) => { const upcomingOnly = request.query?.upcomingOnly || false; + const cachedResponse = fastify.nodeCache.get( + `events-upcoming_only=${upcomingOnly}`, + ); + if (cachedResponse) { + reply + .header( + "cache-control", + "public, max-age=7200, stale-while-revalidate=900, stale-if-error=86400", + ) + .header("acm-cache-status", "hit") + .send(cachedResponse); + } try { - const response = await dynamoClient.send( + const response = await fastify.dynamoClient.send( new ScanCommand({ TableName: genericConfig.EventsDynamoTableName }), ); const items = response.Items?.map((item) => unmarshall(item)); @@ -322,11 +340,17 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { } }); } + fastify.nodeCache.set( + `events-upcoming_only=${upcomingOnly}`, + parsedItems, + EVENT_CACHE_SECONDS, + ); reply .header( "cache-control", "public, max-age=7200, stale-while-revalidate=900, stale-if-error=86400", ) + .header("acm-cache-status", "miss") .send(parsedItems); } catch (e: unknown) { if (e instanceof Error) { diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index 9a99caf6..8c6a77e5 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -39,10 +39,6 @@ import { getGroupRoles, } from "../functions/authorization.js"; -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); - const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { fastify.get<{ Body: undefined; @@ -67,7 +63,11 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { async (request, reply) => { try { const groupId = (request.params as Record).groupId; - const roles = await getGroupRoles(dynamoClient, fastify, groupId); + const roles = await getGroupRoles( + fastify.dynamoClient, + fastify, + groupId, + ); return reply.send(roles); } catch (e: unknown) { if (e instanceof BaseError) { @@ -120,7 +120,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { createdAt: timestamp, }), }); - await dynamoClient.send(command); + await fastify.dynamoClient.send(command); fastify.nodeCache.set( `grouproles-${groupId}`, request.body.roles, @@ -160,6 +160,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { async (request, reply) => { const emails = request.body.emails; const entraIdToken = await getEntraIdToken( + fastify, fastify.environmentConfig.AadValidClientId, ); if (!entraIdToken) { @@ -246,6 +247,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { }); } const entraIdToken = await getEntraIdToken( + fastify, fastify.environmentConfig.AadValidClientId, ); const addResults = await Promise.allSettled( @@ -369,6 +371,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { }); } const entraIdToken = await getEntraIdToken( + fastify, fastify.environmentConfig.AadValidClientId, ); const response = await listGroupMembers(entraIdToken, groupId); diff --git a/src/api/routes/ics.ts b/src/api/routes/ics.ts index 706dc10b..cbe03a7b 100644 --- a/src/api/routes/ics.ts +++ b/src/api/routes/ics.ts @@ -18,10 +18,6 @@ import { getVtimezoneComponent } from "@touch4it/ical-timezones"; import { OrganizationList } from "../../common/orgs.js"; import { EventRepeatOptions } from "./events.js"; -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); - const repeatingIcalMap: Record = { weekly: { freq: ICalEventRepeatingFreq.WEEKLY }, @@ -54,7 +50,7 @@ const icalPlugin: FastifyPluginAsync = async (fastify, _options) => { queryParams = { ...queryParams, }; - response = await dynamoClient.send( + response = await fastify.dynamoClient.send( new QueryCommand({ ...queryParams, ExpressionAttributeValues: { @@ -67,7 +63,7 @@ const icalPlugin: FastifyPluginAsync = async (fastify, _options) => { }), ); } else { - response = await dynamoClient.send(new ScanCommand(queryParams)); + response = await fastify.dynamoClient.send(new ScanCommand(queryParams)); } const dynamoItems = response.Items ? response.Items.map((x) => unmarshall(x)) diff --git a/src/api/routes/tickets.ts b/src/api/routes/tickets.ts index ddf29845..39707806 100644 --- a/src/api/routes/tickets.ts +++ b/src/api/routes/tickets.ts @@ -93,10 +93,6 @@ const postSchema = z.union([postMerchSchema, postTicketSchema]); type VerifyPostRequest = z.infer; -const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, -}); - type TicketsGetRequest = { Params: { id: string }; Querystring: { type: string }; @@ -140,7 +136,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { }); const merchItems: ItemMetadata[] = []; - const response = await dynamoClient.send(merchCommand); + const response = await fastify.dynamoClient.send(merchCommand); const now = new Date(); if (response.Items) { @@ -175,7 +171,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { }); const ticketItems: TicketItemMetadata[] = []; - const ticketResponse = await dynamoClient.send(ticketCommand); + const ticketResponse = await fastify.dynamoClient.send(ticketCommand); if (ticketResponse.Items) { for (const item of ticketResponse.Items.map((x) => unmarshall(x))) { @@ -243,7 +239,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { ":itemId": { S: eventId }, }, }); - const response = await dynamoClient.send(command); + const response = await fastify.dynamoClient.send(command); if (!response.Items) { throw new NotFoundError({ endpointName: `/api/v1/tickets/${eventId}`, @@ -340,7 +336,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { } let purchaserData: PurchaseData; try { - const ticketEntry = await dynamoClient.send(command); + const ticketEntry = await fastify.dynamoClient.send(command); if (!ticketEntry.Attributes) { throw new DatabaseFetchError({ message: "Could not find ticket data", @@ -436,7 +432,7 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { message: `Unknown verification type!`, }); } - await dynamoClient.send(command); + await fastify.dynamoClient.send(command); reply.send(response); request.log.info( { type: "audit", actor: request.username, target: ticketId }, diff --git a/src/api/tsconfig.json b/src/api/tsconfig.json index ae443bbd..7a90b5c6 100644 --- a/src/api/tsconfig.json +++ b/src/api/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@tsconfig/node20/tsconfig.json", + "extends": "@tsconfig/node22/tsconfig.json", "compilerOptions": { "module": "Node16", "rootDir": "../", diff --git a/src/api/types.d.ts b/src/api/types.d.ts index 73770980..7f0e498f 100644 --- a/src/api/types.d.ts +++ b/src/api/types.d.ts @@ -3,6 +3,8 @@ import { AppRoles, RunEnvironment } from "../common/roles.js"; import { AadToken } from "./plugins/auth.js"; import { ConfigType } from "../common/config.js"; import NodeCache from "node-cache"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; declare module "fastify" { interface FastifyInstance { authenticate: ( @@ -22,6 +24,8 @@ declare module "fastify" { runEnvironment: RunEnvironment; environmentConfig: ConfigType; nodeCache: NodeCache; + dynamoClient: DynamoDBClient; + secretsManagerClient: SecretsManagerClient; } interface FastifyRequest { startTime: number; diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index 5008a6a4..f4f50c38 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -13,6 +13,7 @@ import { } from "./secret.testdata.js"; import jwt from "jsonwebtoken"; import { allAppRoles, AppRoles } from "../../src/common/roles.js"; +import { beforeEach, describe } from "node:test"; const ddbMock = mockClient(SecretsManagerClient); @@ -50,40 +51,46 @@ vi.stubEnv("JwtSigningKey", jwt_secret); const testJwt = createJwt(); const testJwtNoGroups = createJwtNoGroups(); -test("Test happy path", async () => { - ddbMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, +describe("Test authentication", () => { + test("Test happy path", async () => { + ddbMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/protected", + headers: { + authorization: `Bearer ${testJwt}`, + }, + }); + expect(response.statusCode).toBe(200); + const jsonBody = await response.json(); + expect(jsonBody).toEqual({ + username: "infra-unit-test@acm.illinois.edu", + roles: allAppRoles, + }); }); - const response = await app.inject({ - method: "GET", - url: "/api/v1/protected", - headers: { - authorization: `Bearer ${testJwt}`, - }, - }); - expect(response.statusCode).toBe(200); - const jsonBody = await response.json(); - expect(jsonBody).toEqual({ - username: "infra-unit-test@acm.illinois.edu", - roles: allAppRoles, - }); -}); -test("Test user-specific role grants", async () => { - ddbMock.on(GetSecretValueCommand).resolves({ - SecretString: secretJson, + test("Test user-specific role grants", async () => { + ddbMock.on(GetSecretValueCommand).resolves({ + SecretString: secretJson, + }); + const response = await app.inject({ + method: "GET", + url: "/api/v1/protected", + headers: { + authorization: `Bearer ${testJwtNoGroups}`, + }, + }); + expect(response.statusCode).toBe(200); + const jsonBody = await response.json(); + expect(jsonBody).toEqual({ + username: "infra-unit-test-nogrp@acm.illinois.edu", + roles: [AppRoles.TICKETS_SCANNER], + }); }); - const response = await app.inject({ - method: "GET", - url: "/api/v1/protected", - headers: { - authorization: `Bearer ${testJwtNoGroups}`, - }, - }); - expect(response.statusCode).toBe(200); - const jsonBody = await response.json(); - expect(jsonBody).toEqual({ - username: "infra-unit-test-nogrp@acm.illinois.edu", - roles: [AppRoles.TICKETS_SCANNER], + + beforeEach(() => { + (app as any).nodeCache.flushAll(); }); }); diff --git a/tests/unit/discordEvent.test.ts b/tests/unit/discordEvent.test.ts index 3d138293..11461e45 100644 --- a/tests/unit/discordEvent.test.ts +++ b/tests/unit/discordEvent.test.ts @@ -85,6 +85,7 @@ describe("Test Events <-> Discord integration", () => { vi.useRealTimers(); }); beforeEach(() => { + (app as any).nodeCache.flushAll(); ddbMock.reset(); smMock.reset(); vi.clearAllMocks(); diff --git a/tests/unit/entraGroupManagement.test.ts b/tests/unit/entraGroupManagement.test.ts index f1d4961a..0f3d37e9 100644 --- a/tests/unit/entraGroupManagement.test.ts +++ b/tests/unit/entraGroupManagement.test.ts @@ -42,6 +42,7 @@ const app = await init(); describe("Test Modify Group and List Group Routes", () => { beforeEach(() => { + (app as any).nodeCache.flushAll(); vi.clearAllMocks(); smMock.on(GetSecretValueCommand).resolves({ SecretString: JSON.stringify({ jwt_key: "test_jwt_key" }), @@ -130,6 +131,7 @@ describe("Test Modify Group and List Group Routes", () => { await app.close(); }); beforeEach(() => { + (app as any).nodeCache.flushAll(); vi.clearAllMocks(); vi.useFakeTimers(); (getEntraIdToken as any).mockImplementation(async () => { diff --git a/tests/unit/entraInviteUser.test.ts b/tests/unit/entraInviteUser.test.ts index 40e7e2ca..59d7e24a 100644 --- a/tests/unit/entraInviteUser.test.ts +++ b/tests/unit/entraInviteUser.test.ts @@ -95,6 +95,7 @@ describe("Test Microsoft Entra ID user invitation", () => { }); beforeEach(() => { + (app as any).nodeCache.flushAll(); vi.clearAllMocks(); vi.useFakeTimers(); // Re-implement the mock diff --git a/tests/unit/eventPost.test.ts b/tests/unit/eventPost.test.ts index f846b4ad..437f127a 100644 --- a/tests/unit/eventPost.test.ts +++ b/tests/unit/eventPost.test.ts @@ -197,6 +197,7 @@ afterAll(async () => { vi.useRealTimers(); }); beforeEach(() => { + (app as any).nodeCache.flushAll(); ddbMock.reset(); smMock.reset(); vi.useFakeTimers(); diff --git a/tests/unit/events.test.ts b/tests/unit/events.test.ts index 4b16227e..6c000d98 100644 --- a/tests/unit/events.test.ts +++ b/tests/unit/events.test.ts @@ -29,7 +29,6 @@ test("Test getting events", async () => { }); test("Test dynamodb error handling", async () => { - ddbMock.on(ScanCommand).rejects("Could not get data."); const response = await app.inject({ method: "GET", url: "/api/v1/events", @@ -64,6 +63,7 @@ afterAll(async () => { vi.useRealTimers(); }); beforeEach(() => { + (app as any).nodeCache.flushAll(); ddbMock.reset(); vi.useFakeTimers(); }); diff --git a/tests/unit/health.test.ts b/tests/unit/health.test.ts index e72e85a1..97658503 100644 --- a/tests/unit/health.test.ts +++ b/tests/unit/health.test.ts @@ -3,7 +3,7 @@ import init from "../../src/api/index.js"; import { EventGetResponse } from "../../src/api/routes/events.js"; const app = await init(); -test("Test getting events", async () => { +test("Test getting health", async () => { const response = await app.inject({ method: "GET", url: "/api/v1/healthz", diff --git a/tests/unit/organizations.test.ts b/tests/unit/organizations.test.ts index 50e9805d..7a605f77 100644 --- a/tests/unit/organizations.test.ts +++ b/tests/unit/organizations.test.ts @@ -1,4 +1,4 @@ -import { afterAll, expect, test } from "vitest"; +import { afterAll, expect, test, beforeEach } from "vitest"; import init from "../../src/api/index.js"; const app = await init(); @@ -13,3 +13,6 @@ test("Test getting the list of organizations succeeds", async () => { afterAll(async () => { await app.close(); }); +beforeEach(() => { + (app as any).nodeCache.flushAll(); +}); diff --git a/tests/unit/tickets.test.ts b/tests/unit/tickets.test.ts index a0565197..662c8e06 100644 --- a/tests/unit/tickets.test.ts +++ b/tests/unit/tickets.test.ts @@ -375,4 +375,5 @@ afterAll(async () => { beforeEach(() => { ddbMock.reset(); vi.useFakeTimers(); + (app as any).nodeCache.flushAll(); }); diff --git a/yarn.lock b/yarn.lock index f956643e..d7df54e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3212,10 +3212,10 @@ resolved "https://registry.yarnpkg.com/@touch4it/ical-timezones/-/ical-timezones-1.9.0.tgz#bbd85014f55b5cc3e9079ed7caccd8649b5170a3" integrity sha512-UAiZMrFlgMdOIaJDPsKu5S7OecyMLr3GGALJTYkRgHmsHAA/8Ixm1qD09ELP2X7U1lqgrctEgvKj9GzMbczC+g== -"@tsconfig/node20@^20.1.4": - version "20.1.4" - resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.4.tgz#3457d42eddf12d3bde3976186ab0cd22b85df928" - integrity sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg== +"@tsconfig/node22@^22.0.0": + version "22.0.0" + resolved "https://registry.yarnpkg.com/@tsconfig/node22/-/node22-22.0.0.tgz#0bdaf702f2b7594383d24d7b2b8d557dcfdca1ed" + integrity sha512-twLQ77zevtxobBOD4ToAtVmuYrpeYUh3qh+TEp+08IWhpsrIflVHqQ1F1CiPxQGL7doCdBIOOCF+1Tm833faNg== "@types/aria-query@^5.0.1": version "5.0.4" @@ -4044,16 +4044,7 @@ axe-core@^4.10.0: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df" integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w== -axios@1.6.7: - version "1.6.7" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7" - integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA== - dependencies: - follow-redirects "^1.15.4" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - -axios@^1.7.3: +axios@^1.7.3, axios@^1.7.7: version "1.7.9" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw== @@ -5012,7 +5003,7 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0": +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0", esbuild@^0.24.2: version "0.24.2" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.2.tgz#b5b55bee7de017bff5fb8a4e3e44f2ebe2c3567d" integrity sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA== @@ -5745,7 +5736,7 @@ flatted@^3.2.9, flatted@^3.3.1: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.2.tgz#adba1448a9841bec72b42c532ea23dbbedef1a27" integrity sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA== -follow-redirects@^1.15.4, follow-redirects@^1.15.6: +follow-redirects@^1.15.6: version "1.15.9" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== @@ -7090,7 +7081,7 @@ mnemonist@0.39.8: dependencies: obliterator "^2.0.1" -moment-timezone@^0.5.44, moment-timezone@^0.5.45: +moment-timezone@^0.5.45: version "0.5.46" resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.46.tgz#a21aa6392b3c6b3ed916cd5e95858a28d893704a" integrity sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw== @@ -7167,15 +7158,15 @@ node-cache@^5.1.2: dependencies: clone "2.x" -node-ical@^0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/node-ical/-/node-ical-0.18.0.tgz#919ab65f43cdfebb4ac9a1c2acca2b5e62cc003f" - integrity sha512-FrOUPztjw9OUgSB9o/ffhl86BiVClQTut97C2NqCwKIgOAcKPEw5UQMuSuNJO/Y4hqTyJdKZh2TCqNHQnE9YFg== +node-ical@^0.20.1: + version "0.20.1" + resolved "https://registry.yarnpkg.com/node-ical/-/node-ical-0.20.1.tgz#3a67319af9be956b3cc81cdf6716d1352eaefaca" + integrity sha512-NrXgzDJd6XcyX9kDMJVA3xYCZmntY7ghA2BOdBeYr3iu8tydHOAb+68jPQhF9V2CRQ0/386X05XhmLzQUN0+Hw== dependencies: - axios "1.6.7" - moment-timezone "^0.5.44" + axios "^1.7.7" + moment-timezone "^0.5.45" rrule "2.8.1" - uuid "^9.0.0" + uuid "^10.0.0" node-releases@^2.0.19: version "2.0.19" @@ -9318,6 +9309,11 @@ uuid-random@^1.3.2: resolved "https://registry.yarnpkg.com/uuid-random/-/uuid-random-1.3.2.tgz#96715edbaef4e84b1dcf5024b00d16f30220e2d0" integrity sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ== +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" From cf5cf30cf970f0acc447b75bda7ec915a9dc960b Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 27 Jan 2025 11:05:12 -0600 Subject: [PATCH 06/21] fix ui performance bug --- package.json | 1 - src/ui/package.json | 6 +- src/ui/vite.config.mjs | 1 + yarn.lock | 278 ++++++++++++++++++++++------------------- 4 files changed, 153 insertions(+), 133 deletions(-) diff --git a/package.json b/package.json index ff230ff1..c1f86a49 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,6 @@ "tsx": "^4.16.5", "typescript": "^5.5.4", "typescript-eslint": "^8.0.1", - "vite": "^5.4.0", "vite-tsconfig-paths": "^5.0.1", "vitest": "^2.0.5", "yarn-upgrade-all": "^0.7.4" diff --git a/src/ui/package.json b/src/ui/package.json index 5bbd9ebd..16b3e616 100644 --- a/src/ui/package.json +++ b/src/ui/package.json @@ -26,7 +26,7 @@ "@mantine/form": "^7.12.0", "@mantine/hooks": "^7.12.0", "@mantine/notifications": "^7.12.0", - "@tabler/icons-react": "^3.12.0", + "@tabler/icons-react": "^3.29.0", "@ungap/with-resolvers": "^0.1.0", "axios": "^1.7.3", "dayjs": "^1.11.12", @@ -84,7 +84,7 @@ "stylelint-config-standard-scss": "^13.1.0", "typescript": "^5.5.4", "typescript-eslint": "^8.0.1", - "vite": "^5.4.0", + "vite": "^6.0.11", "vite-tsconfig-paths": "^5.0.1", "vitest": "^2.0.5", "yarn-upgrade-all": "^0.7.4" @@ -92,4 +92,4 @@ "resolutions": { "pdfjs-dist": "4.5.136" } -} \ No newline at end of file +} diff --git a/src/ui/vite.config.mjs b/src/ui/vite.config.mjs index c2839606..0446075b 100644 --- a/src/ui/vite.config.mjs +++ b/src/ui/vite.config.mjs @@ -13,6 +13,7 @@ export default defineConfig({ alias: { '@ui': path.resolve(__dirname, './'), '@common': path.resolve(__dirname, '../common/'), + '@tabler/icons-react': '@tabler/icons-react/dist/esm/icons/index.mjs', }, }, test: { diff --git a/yarn.lock b/yarn.lock index d7df54e4..90a2d88f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1939,100 +1939,100 @@ estree-walker "^2.0.2" picomatch "^4.0.2" -"@rollup/rollup-android-arm-eabi@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.29.2.tgz#d4c3965f18ebf567a99154f93bcf283fd527e2a9" - integrity sha512-s/8RiF4bdmGnc/J0N7lHAr5ZFJj+NdJqJ/Hj29K+c4lEdoVlukzvWXB9XpWZCdakVT0YAw8iyIqUP2iFRz5/jA== - -"@rollup/rollup-android-arm64@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.29.2.tgz#cbc7e636a7aab984161fc045039bf3c6abb50083" - integrity sha512-mKRlVj1KsKWyEOwR6nwpmzakq6SgZXW4NUHNWlYSiyncJpuXk7wdLzuKdWsRoR1WLbWsZBKvsUCdCTIAqRn9cA== - -"@rollup/rollup-darwin-arm64@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.29.2.tgz#d084c6120f96749a7ddc5ef81d8740f2525abb6e" - integrity sha512-vJX+vennGwygmutk7N333lvQ/yKVAHnGoBS2xMRQgXWW8tvn46YWuTDOpKroSPR9BEW0Gqdga2DHqz8Pwk6X5w== - -"@rollup/rollup-darwin-x64@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.29.2.tgz#1393f12d5722cc39b8c014aedd4b4da8043929a9" - integrity sha512-e2rW9ng5O6+Mt3ht8fH0ljfjgSCC6ffmOipiLUgAnlK86CHIaiCdHCzHzmTkMj6vEkqAiRJ7ss6Ibn56B+RE5w== - -"@rollup/rollup-freebsd-arm64@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.29.2.tgz#1c11650970c4b52d7fb077f5a4a6e16ba5e6db4f" - integrity sha512-/xdNwZe+KesG6XJCK043EjEDZTacCtL4yurMZRLESIgHQdvtNyul3iz2Ab03ZJG0pQKbFTu681i+4ETMF9uE/Q== - -"@rollup/rollup-freebsd-x64@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.29.2.tgz#d3d79a2b96e81475571cb9bb414910450bcebe04" - integrity sha512-eXKvpThGzREuAbc6qxnArHh8l8W4AyTcL8IfEnmx+bcnmaSGgjyAHbzZvHZI2csJ+e0MYddl7DX0X7g3sAuXDQ== - -"@rollup/rollup-linux-arm-gnueabihf@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.29.2.tgz#14a39257111abbc38412805c9162819d3bb248c1" - integrity sha512-h4VgxxmzmtXLLYNDaUcQevCmPYX6zSj4SwKuzY7SR5YlnCBYsmvfYORXgiU8axhkFCDtQF3RW5LIXT8B14Qykg== - -"@rollup/rollup-linux-arm-musleabihf@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.29.2.tgz#64304812643515c0ed83cdaf4fde034c35dbc776" - integrity sha512-EObwZ45eMmWZQ1w4N7qy4+G1lKHm6mcOwDa+P2+61qxWu1PtQJ/lz2CNJ7W3CkfgN0FQ7cBUy2tk6D5yR4KeXw== - -"@rollup/rollup-linux-arm64-gnu@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.29.2.tgz#60d457954c288c168049aadb304c204d8a680236" - integrity sha512-Z7zXVHEXg1elbbYiP/29pPwlJtLeXzjrj4241/kCcECds8Zg9fDfURWbZHRIKrEriAPS8wnVtdl4ZJBvZr325w== - -"@rollup/rollup-linux-arm64-musl@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.29.2.tgz#17deb5058243bc5599164a9e0a899b0902310fca" - integrity sha512-TF4kxkPq+SudS/r4zGPf0G08Bl7+NZcFrUSR3484WwsHgGgJyPQRLCNrQ/R5J6VzxfEeQR9XRpc8m2t7lD6SEQ== - -"@rollup/rollup-linux-loongarch64-gnu@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.29.2.tgz#5c65dd6557fda1f45c285cfeb4c5eda4c868341d" - integrity sha512-kO9Fv5zZuyj2zB2af4KA29QF6t7YSxKrY7sxZXfw8koDQj9bx5Tk5RjH+kWKFKok0wLGTi4bG117h31N+TIBEg== - -"@rollup/rollup-linux-powerpc64le-gnu@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.29.2.tgz#33e45cac222fa6d09891f73bfb2d5d027ec34989" - integrity sha512-gIh776X7UCBaetVJGdjXPFurGsdWwHHinwRnC5JlLADU8Yk0EdS/Y+dMO264OjJFo7MXQ5PX4xVFbxrwK8zLqA== - -"@rollup/rollup-linux-riscv64-gnu@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.29.2.tgz#1edee7a06538597720c4bf8178367d4b5651717d" - integrity sha512-YgikssQ5UNq1GoFKZydMEkhKbjlUq7G3h8j6yWXLBF24KyoA5BcMtaOUAXq5sydPmOPEqB6kCyJpyifSpCfQ0w== - -"@rollup/rollup-linux-s390x-gnu@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.29.2.tgz#0ad4aaae2fd89c3607b743c63514c4561905672b" - integrity sha512-9ouIR2vFWCyL0Z50dfnon5nOrpDdkTG9lNDs7MRaienQKlTyHcDxplmk3IbhFlutpifBSBr2H4rVILwmMLcaMA== - -"@rollup/rollup-linux-x64-gnu@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.29.2.tgz#ae27f8d26c02d8ce6f84275860e99678b9f3e932" - integrity sha512-ckBBNRN/F+NoSUDENDIJ2U9UWmIODgwDB/vEXCPOMcsco1niTkxTXa6D2Y/pvCnpzaidvY2qVxGzLilNs9BSzw== - -"@rollup/rollup-linux-x64-musl@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.29.2.tgz#24ff1a64dddd75c489bd8714bcb5a659769d3e4a" - integrity sha512-jycl1wL4AgM2aBFJFlpll/kGvAjhK8GSbEmFT5v3KC3rP/b5xZ1KQmv0vQQ8Bzb2ieFQ0kZFPRMbre/l3Bu9JA== - -"@rollup/rollup-win32-arm64-msvc@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.29.2.tgz#7c56efd576618db251909e21818d473cbcf96786" - integrity sha512-S2V0LlcOiYkNGlRAWZwwUdNgdZBfvsDHW0wYosYFV3c7aKgEVcbonetZXsHv7jRTTX+oY5nDYT4W6B1oUpMNOg== - -"@rollup/rollup-win32-ia32-msvc@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.29.2.tgz#0b77e11129b04bb5b9bfff4b011d084a975190e0" - integrity sha512-pW8kioj9H5f/UujdoX2atFlXNQ9aCfAxFRaa+mhczwcsusm6gGrSo4z0SLvqLF5LwFqFTjiLCCzGkNK/LE0utQ== - -"@rollup/rollup-win32-x64-msvc@4.29.2": - version "4.29.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.29.2.tgz#dc89d92418ae2efa1d70e071c686cffbcf788147" - integrity sha512-p6fTArexECPf6KnOHvJXRpAEq0ON1CBtzG/EY4zw08kCHk/kivBc5vUEtnCFNCHOpJZ2ne77fxwRLIKD4wuW2Q== +"@rollup/rollup-android-arm-eabi@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.32.0.tgz#42a8e897c7b656adb4edebda3a8b83a57526452f" + integrity sha512-G2fUQQANtBPsNwiVFg4zKiPQyjVKZCUdQUol53R8E71J7AsheRMV/Yv/nB8giOcOVqP7//eB5xPqieBYZe9bGg== + +"@rollup/rollup-android-arm64@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.32.0.tgz#846a73eef25b18ff94bac1e52acab6a7c7ac22fa" + integrity sha512-qhFwQ+ljoymC+j5lXRv8DlaJYY/+8vyvYmVx074zrLsu5ZGWYsJNLjPPVJJjhZQpyAKUGPydOq9hRLLNvh1s3A== + +"@rollup/rollup-darwin-arm64@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.32.0.tgz#014ed37f1f7809fdf3442a6b689d3a074a844058" + integrity sha512-44n/X3lAlWsEY6vF8CzgCx+LQaoqWGN7TzUfbJDiTIOjJm4+L2Yq+r5a8ytQRGyPqgJDs3Rgyo8eVL7n9iW6AQ== + +"@rollup/rollup-darwin-x64@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.32.0.tgz#dde6ed3e56d0b34477fa56c4a199abe5d4b9846b" + integrity sha512-F9ct0+ZX5Np6+ZDztxiGCIvlCaW87HBdHcozUfsHnj1WCUTBUubAoanhHUfnUHZABlElyRikI0mgcw/qdEm2VQ== + +"@rollup/rollup-freebsd-arm64@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.32.0.tgz#8ad634f462a6b7e338257cf64c7baff99618a08e" + integrity sha512-JpsGxLBB2EFXBsTLHfkZDsXSpSmKD3VxXCgBQtlPcuAqB8TlqtLcbeMhxXQkCDv1avgwNjF8uEIbq5p+Cee0PA== + +"@rollup/rollup-freebsd-x64@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.32.0.tgz#9d4d1dbbafcb0354d52ba6515a43c7511dba8052" + integrity sha512-wegiyBT6rawdpvnD9lmbOpx5Sph+yVZKHbhnSP9MqUEDX08G4UzMU+D87jrazGE7lRSyTRs6NEYHtzfkJ3FjjQ== + +"@rollup/rollup-linux-arm-gnueabihf@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.32.0.tgz#3bd5fcbab92a66e032faef1078915d1dbf27de7a" + integrity sha512-3pA7xecItbgOs1A5H58dDvOUEboG5UfpTq3WzAdF54acBbUM+olDJAPkgj1GRJ4ZqE12DZ9/hNS2QZk166v92A== + +"@rollup/rollup-linux-arm-musleabihf@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.32.0.tgz#a77838b9779931ce4fa01326b585eee130f51e60" + integrity sha512-Y7XUZEVISGyge51QbYyYAEHwpGgmRrAxQXO3siyYo2kmaj72USSG8LtlQQgAtlGfxYiOwu+2BdbPjzEpcOpRmQ== + +"@rollup/rollup-linux-arm64-gnu@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.32.0.tgz#ec1b1901b82d57a20184adb61c725dd8991a0bf0" + integrity sha512-r7/OTF5MqeBrZo5omPXcTnjvv1GsrdH8a8RerARvDFiDwFpDVDnJyByYM/nX+mvks8XXsgPUxkwe/ltaX2VH7w== + +"@rollup/rollup-linux-arm64-musl@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.32.0.tgz#7aa23b45bf489b7204b5a542e857e134742141de" + integrity sha512-HJbifC9vex9NqnlodV2BHVFNuzKL5OnsV2dvTw6e1dpZKkNjPG6WUq+nhEYV6Hv2Bv++BXkwcyoGlXnPrjAKXw== + +"@rollup/rollup-linux-loongarch64-gnu@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.32.0.tgz#7bf0ebd8c5ad08719c3b4786be561d67f95654a7" + integrity sha512-VAEzZTD63YglFlWwRj3taofmkV1V3xhebDXffon7msNz4b14xKsz7utO6F8F4cqt8K/ktTl9rm88yryvDpsfOw== + +"@rollup/rollup-linux-powerpc64le-gnu@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.32.0.tgz#e687dfcaf08124aafaaebecef0cc3986675cb9b6" + integrity sha512-Sts5DST1jXAc9YH/iik1C9QRsLcCoOScf3dfbY5i4kH9RJpKxiTBXqm7qU5O6zTXBTEZry69bGszr3SMgYmMcQ== + +"@rollup/rollup-linux-riscv64-gnu@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.32.0.tgz#19fce2594f9ce73d1cb0748baf8cd90a7bedc237" + integrity sha512-qhlXeV9AqxIyY9/R1h1hBD6eMvQCO34ZmdYvry/K+/MBs6d1nRFLm6BOiITLVI+nFAAB9kUB6sdJRKyVHXnqZw== + +"@rollup/rollup-linux-s390x-gnu@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.32.0.tgz#fd99b335bb65c59beb7d15ae82be0aafa9883c19" + integrity sha512-8ZGN7ExnV0qjXa155Rsfi6H8M4iBBwNLBM9lcVS+4NcSzOFaNqmt7djlox8pN1lWrRPMRRQ8NeDlozIGx3Omsw== + +"@rollup/rollup-linux-x64-gnu@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.32.0.tgz#4e8c697bbaa2e2d7212bd42086746c8275721166" + integrity sha512-VDzNHtLLI5s7xd/VubyS10mq6TxvZBp+4NRWoW+Hi3tgV05RtVm4qK99+dClwTN1McA6PHwob6DEJ6PlXbY83A== + +"@rollup/rollup-linux-x64-musl@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.32.0.tgz#0d2f74bd9cfe0553f20f056760a95b293e849ab2" + integrity sha512-qcb9qYDlkxz9DxJo7SDhWxTWV1gFuwznjbTiov289pASxlfGbaOD54mgbs9+z94VwrXtKTu+2RqwlSTbiOqxGg== + +"@rollup/rollup-win32-arm64-msvc@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.32.0.tgz#6534a09fcdd43103645155cedb5bfa65fbf2c23f" + integrity sha512-pFDdotFDMXW2AXVbfdUEfidPAk/OtwE/Hd4eYMTNVVaCQ6Yl8et0meDaKNL63L44Haxv4UExpv9ydSf3aSayDg== + +"@rollup/rollup-win32-ia32-msvc@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.32.0.tgz#8222ccfecffd63a6b0ddbe417d8d959e4f2b11b3" + integrity sha512-/TG7WfrCAjeRNDvI4+0AAMoHxea/USWhAzf9PVDFHbcqrQ7hMMKp4jZIy4VEjk72AAfN5k4TiSMRXRKf/0akSw== + +"@rollup/rollup-win32-x64-msvc@4.32.0": + version "4.32.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.32.0.tgz#1a40b4792c08094b6479c48c90fe7f4b10ec2f54" + integrity sha512-5hqO5S3PTEO2E5VjCePxv40gIgyS2KvO7E7/vvC/NbIW4SIRamkMr1hqj+5Y67fbBWv/bQLB6KelBQmXlyCjWA== "@rtsao/scc@^1.1.0": version "1.1.0" @@ -3129,17 +3129,17 @@ resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.4.7.tgz#c308f6a883999bd35e87826738ab8a76515932b5" integrity sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw== -"@tabler/icons-react@^3.12.0": - version "3.28.1" - resolved "https://registry.yarnpkg.com/@tabler/icons-react/-/icons-react-3.28.1.tgz#6bcd85f3fb924ceeb3caf2ce5be7523e41008266" - integrity sha512-KNBpA2kbxr3/2YK5swt7b/kd/xpDP1FHYZCxDFIw54tX8slELRFEf95VMxsccQHZeIcUbdoojmUUuYSbt/sM5Q== +"@tabler/icons-react@^3.29.0": + version "3.29.0" + resolved "https://registry.yarnpkg.com/@tabler/icons-react/-/icons-react-3.29.0.tgz#5853e1a8318c7ecfffbb6c38a3b1b17862855dba" + integrity sha512-jaa3b3j91CplY7TPgx/Gj/e+PcOnQgYiK6c5qtp1P0ytfKM5WPc1qtXyRLE3NcYlfxS2Pcst4YGy1vUML7SjbQ== dependencies: - "@tabler/icons" "3.28.1" + "@tabler/icons" "3.29.0" -"@tabler/icons@3.28.1": - version "3.28.1" - resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.28.1.tgz#271e6d4107525dbb8622a36a9414487e734606aa" - integrity sha512-h7nqKEvFooLtFxhMOC1/2eiV+KRXhBUuDUUJrJlt6Ft6tuMw2eU/9GLQgrTk41DNmIEzp/LI83K9J9UUU8YBYQ== +"@tabler/icons@3.29.0": + version "3.29.0" + resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.29.0.tgz#f78d0bb098641088ccfe3e727fc296502eb8a930" + integrity sha512-VWNINymdmhay3MDvWVREmRwuWLSrX3YiInKvs5L4AHRF4bAfJabLlEReE0BW/XFsBt22ff8/C8Eam/LXlF97mA== "@testing-library/dom@10.4.0", "@testing-library/dom@^10.4.0": version "10.4.0" @@ -7113,7 +7113,7 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -nanoid@^3.3.7: +nanoid@^3.3.7, nanoid@^3.3.8: version "3.3.8" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== @@ -7607,7 +7607,7 @@ postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.4.41, postcss@^8.4.43, postcss@^8.4.49: +postcss@^8.4.41, postcss@^8.4.49: version "8.4.49" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19" integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== @@ -7616,6 +7616,15 @@ postcss@^8.4.41, postcss@^8.4.43, postcss@^8.4.49: picocolors "^1.1.1" source-map-js "^1.2.1" +postcss@^8.4.43: + version "8.5.1" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.1.tgz#e2272a1f8a807fafa413218245630b5db10a3214" + integrity sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ== + dependencies: + nanoid "^3.3.8" + picocolors "^1.1.1" + source-map-js "^1.2.1" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -8084,32 +8093,32 @@ rollup@^2.67.2: optionalDependencies: fsevents "~2.3.2" -rollup@^4.20.0: - version "4.29.2" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.29.2.tgz#ff1555fd27fc20599a9b8f90527f0f43a1738e7f" - integrity sha512-tJXpsEkzsEzyAKIaB3qv3IuvTVcTN7qBw1jL4SPPXM3vzDrJgiLGFY6+HodgFaUHAJ2RYJ94zV5MKRJCoQzQeA== +rollup@^4.20.0, rollup@^4.23.0: + version "4.32.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.32.0.tgz#c405bf6fca494d1999d9088f7736d7f03e5cac5a" + integrity sha512-JmrhfQR31Q4AuNBjjAX4s+a/Pu/Q8Q9iwjWBsjRH1q52SPFE2NqRMK6fUZKKnvKO6id+h7JIRf0oYsph53eATg== dependencies: "@types/estree" "1.0.6" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.29.2" - "@rollup/rollup-android-arm64" "4.29.2" - "@rollup/rollup-darwin-arm64" "4.29.2" - "@rollup/rollup-darwin-x64" "4.29.2" - "@rollup/rollup-freebsd-arm64" "4.29.2" - "@rollup/rollup-freebsd-x64" "4.29.2" - "@rollup/rollup-linux-arm-gnueabihf" "4.29.2" - "@rollup/rollup-linux-arm-musleabihf" "4.29.2" - "@rollup/rollup-linux-arm64-gnu" "4.29.2" - "@rollup/rollup-linux-arm64-musl" "4.29.2" - "@rollup/rollup-linux-loongarch64-gnu" "4.29.2" - "@rollup/rollup-linux-powerpc64le-gnu" "4.29.2" - "@rollup/rollup-linux-riscv64-gnu" "4.29.2" - "@rollup/rollup-linux-s390x-gnu" "4.29.2" - "@rollup/rollup-linux-x64-gnu" "4.29.2" - "@rollup/rollup-linux-x64-musl" "4.29.2" - "@rollup/rollup-win32-arm64-msvc" "4.29.2" - "@rollup/rollup-win32-ia32-msvc" "4.29.2" - "@rollup/rollup-win32-x64-msvc" "4.29.2" + "@rollup/rollup-android-arm-eabi" "4.32.0" + "@rollup/rollup-android-arm64" "4.32.0" + "@rollup/rollup-darwin-arm64" "4.32.0" + "@rollup/rollup-darwin-x64" "4.32.0" + "@rollup/rollup-freebsd-arm64" "4.32.0" + "@rollup/rollup-freebsd-x64" "4.32.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.32.0" + "@rollup/rollup-linux-arm-musleabihf" "4.32.0" + "@rollup/rollup-linux-arm64-gnu" "4.32.0" + "@rollup/rollup-linux-arm64-musl" "4.32.0" + "@rollup/rollup-linux-loongarch64-gnu" "4.32.0" + "@rollup/rollup-linux-powerpc64le-gnu" "4.32.0" + "@rollup/rollup-linux-riscv64-gnu" "4.32.0" + "@rollup/rollup-linux-s390x-gnu" "4.32.0" + "@rollup/rollup-linux-x64-gnu" "4.32.0" + "@rollup/rollup-linux-x64-musl" "4.32.0" + "@rollup/rollup-win32-arm64-msvc" "4.32.0" + "@rollup/rollup-win32-ia32-msvc" "4.32.0" + "@rollup/rollup-win32-x64-msvc" "4.32.0" fsevents "~2.3.2" rrule@2.8.1: @@ -9368,7 +9377,7 @@ vite-tsconfig-paths@^5.0.1: globrex "^0.1.2" tsconfck "^3.0.3" -vite@^5.0.0, vite@^5.4.0: +vite@^5.0.0: version "5.4.11" resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5" integrity sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q== @@ -9379,6 +9388,17 @@ vite@^5.0.0, vite@^5.4.0: optionalDependencies: fsevents "~2.3.3" +vite@^6.0.11: + version "6.0.11" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.0.11.tgz#224497e93e940b34c3357c9ebf2ec20803091ed8" + integrity sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg== + dependencies: + esbuild "^0.24.2" + postcss "^8.4.49" + rollup "^4.23.0" + optionalDependencies: + fsevents "~2.3.3" + vitest@^2.0.5: version "2.1.8" resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.1.8.tgz#2e6a00bc24833574d535c96d6602fb64163092fa" From 2a29a196083dda654c7823250a4b357f53f08978 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 27 Jan 2025 11:06:36 -0600 Subject: [PATCH 07/21] update docs --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index b18953d8..88055351 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,6 @@ This repository is split into multiple parts: * `src/common/` for common modules between the API and the UI (such as constants, types, errors, etc.) ## Getting Started -You will need node>=20 installed, as well as the AWS CLI and the AWS SAM CLI. The best way to work with all of this is to open the environment in a container within your IDE (VS Code should prompt you to do so: use "Clone in Container" for best performance). This container will have all needed software installed. +You will need node>=22 installed, as well as the AWS CLI and the AWS SAM CLI. The best way to work with all of this is to open the environment in a container within your IDE (VS Code should prompt you to do so: use "Clone in Container" for best performance). This container will have all needed software installed. Then, run `make install` to install all packages, and `make local` to start the UI and API servers! The UI will be accessible on `http://localhost:5173/` and the API on `http://localhost:8080/`. - -**Note: there is currently a known performance issue with running the UI development server in a container. If your requests are timing out, try going to `src/ui` and running `yarn preview` to generate a non development server build.** From 31d46dca3f9646f17b5eeb2ebe52baa5b39d4f7f Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 27 Jan 2025 22:57:50 +0000 Subject: [PATCH 08/21] fix port availability in docker containers --- src/ui/package.json | 4 ++-- src/ui/vite.config.mjs | 4 +++- yarn.lock | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ui/package.json b/src/ui/package.json index 16b3e616..3a63ed13 100644 --- a/src/ui/package.json +++ b/src/ui/package.json @@ -3,8 +3,8 @@ "private": true, "version": "0.0.0", "scripts": { - "dev": "dotenv -e ../../.env cross-env VITE_RUN_ENVIRONMENT=local-dev vite", - "dev:aws": "dotenv -e ../../.env cross-env VITE_RUN_ENVIRONMENT=dev vite", + "dev": "VITE_RUN_ENVIRONMENT=local-dev vite", + "dev:aws": "cross-env VITE_RUN_ENVIRONMENT=dev vite", "build": "tsc && vite build", "preview": "cross-env VITE_RUN_ENVIRONMENT=local-dev yarn build && cross-env VITE_RUN_ENVIRONMENT=local-dev serve -l 5173 -s ../../dist_ui/", "typecheck": "tsc --noEmit", diff --git a/src/ui/vite.config.mjs b/src/ui/vite.config.mjs index 0446075b..33cf6097 100644 --- a/src/ui/vite.config.mjs +++ b/src/ui/vite.config.mjs @@ -21,11 +21,13 @@ export default defineConfig({ environment: 'jsdom', setupFiles: './vitest.setup.mjs', env: { - VITE_RUN_ENVIRONMENT: 'dev' + VITE_RUN_ENVIRONMENT: 'dev', }, }, server: { historyApiFallback: true, + host: '127.0.0.1', + port: 5173 }, build: { outDir: '../../dist_ui', diff --git a/yarn.lock b/yarn.lock index 90a2d88f..26a9a553 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9378,9 +9378,9 @@ vite-tsconfig-paths@^5.0.1: tsconfck "^3.0.3" vite@^5.0.0: - version "5.4.11" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5" - integrity sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q== + version "5.4.14" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.14.tgz#ff8255edb02134df180dcfca1916c37a6abe8408" + integrity sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA== dependencies: esbuild "^0.21.3" postcss "^8.4.43" From 1ac2c548bb62e40f575255f70aac8ca9417cc0de Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 27 Jan 2025 18:46:39 -0600 Subject: [PATCH 09/21] Create a Manage Profile page (#41) * enable edit profile support * make the iam page a little nicer * force reroute to profile if we dont know their first and last name * keep the redir behavior outside of the component * tests that fail * fix the tests * fix test * fix tests part 7! --- src/common/types/msGraphApi.ts | 12 + src/common/utils.ts | 11 + src/ui/Router.tsx | 47 ++- src/ui/components/AppShell/index.tsx | 4 +- .../AuthContext/AuthCallbackHandler.page.tsx | 5 - src/ui/components/AuthContext/index.tsx | 35 ++- src/ui/components/AuthGuard/index.tsx | 25 +- src/ui/components/ProfileDropdown/index.tsx | 12 + src/ui/config.ts | 20 +- src/ui/pages/Login.page.tsx | 24 +- src/ui/pages/events/ViewEvents.page.tsx | 5 +- .../pages/iam/GroupMemberManagement.test.tsx | 227 +++----------- src/ui/pages/iam/GroupMemberManagement.tsx | 290 +++++++++--------- src/ui/pages/iam/ManageIam.page.tsx | 9 +- src/ui/pages/profile/ManageProfile.page.tsx | 61 ++++ .../profile/ManageProfileComponent.test.tsx | 179 +++++++++++ .../pages/profile/ManageProfileComponent.tsx | 142 +++++++++ tests/e2e/events.spec.ts | 4 +- tests/e2e/login.spec.ts | 11 +- 19 files changed, 744 insertions(+), 379 deletions(-) create mode 100644 src/common/types/msGraphApi.ts create mode 100644 src/common/utils.ts create mode 100644 src/ui/pages/profile/ManageProfile.page.tsx create mode 100644 src/ui/pages/profile/ManageProfileComponent.test.tsx create mode 100644 src/ui/pages/profile/ManageProfileComponent.tsx diff --git a/src/common/types/msGraphApi.ts b/src/common/types/msGraphApi.ts new file mode 100644 index 00000000..934034d3 --- /dev/null +++ b/src/common/types/msGraphApi.ts @@ -0,0 +1,12 @@ +export interface UserProfileDataBase { + userPrincipalName: string; + displayName?: string; + givenName?: string; + surname?: string; + mail?: string; + otherMails?: string[] +} + +export interface UserProfileData extends UserProfileDataBase { + discordUsername?: string; +} diff --git a/src/common/utils.ts b/src/common/utils.ts new file mode 100644 index 00000000..a0f02bdb --- /dev/null +++ b/src/common/utils.ts @@ -0,0 +1,11 @@ +export function transformCommaSeperatedName(name: string) { + if (name.includes(",")) { + try { + const split = name.split(",") + return `${split[1].slice(1, split[1].length)} ${split[0]}` + } catch (e) { + return name; + } + } + return name; +} diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index c7fbfa57..3f643aea 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -17,7 +17,29 @@ import { ScanTicketsPage } from './pages/tickets/ScanTickets.page'; import { SelectTicketsPage } from './pages/tickets/SelectEventId.page'; import { ViewTicketsPage } from './pages/tickets/ViewTickets.page'; import { ManageIamPage } from './pages/iam/ManageIam.page'; -import { ScreenPage } from './pages/screen/Screen.page'; +import { ManageProfilePage } from './pages/profile/ManageProfile.page'; + +const ProfileRediect: React.FC = () => { + const location = useLocation(); + + // Don't store login-related paths and ALLOW the callback path + const excludedPaths = [ + '/login', + '/logout', + '/force_login', + '/a', + '/auth/callback', // Add this to excluded paths + ]; + + if (excludedPaths.includes(location.pathname)) { + return ; + } + + // Include search params and hash in the return URL if they exist + const returnPath = location.pathname + location.search + location.hash; + const loginUrl = `/profile?returnTo=${encodeURIComponent(returnPath)}&firstTime=true`; + return ; +}; // Component to handle redirects to login with return path const LoginRedirect: React.FC = () => { @@ -57,6 +79,18 @@ const commonRoutes = [ }, ]; +const profileRouter = createBrowserRouter([ + ...commonRoutes, + { + path: '/profile', + element: , + }, + { + path: '*', + element: , + }, +]); + const unauthenticatedRouter = createBrowserRouter([ ...commonRoutes, { @@ -67,7 +101,6 @@ const unauthenticatedRouter = createBrowserRouter([ path: '/login', element: , }, - // Catch-all route that preserves the attempted path { path: '*', element: , @@ -88,6 +121,10 @@ const authenticatedRouter = createBrowserRouter([ path: '/logout', element: , }, + { + path: '/profile', + element: , + }, { path: '/home', element: , @@ -168,7 +205,11 @@ const ErrorBoundary: React.FC = ({ children }) => { export const Router: React.FC = () => { const { isLoggedIn } = useAuth(); - const router = isLoggedIn ? authenticatedRouter : unauthenticatedRouter; + const router = isLoggedIn + ? authenticatedRouter + : isLoggedIn === null + ? profileRouter + : unauthenticatedRouter; return ( diff --git a/src/ui/components/AppShell/index.tsx b/src/ui/components/AppShell/index.tsx index c5a4abaa..867184f6 100644 --- a/src/ui/components/AppShell/index.tsx +++ b/src/ui/components/AppShell/index.tsx @@ -25,7 +25,7 @@ import { HeaderNavbar } from '../Navbar/index.js'; import { AuthenticatedProfileDropdown } from '../ProfileDropdown/index.js'; import { getCurrentRevision } from '@ui/util/revision.js'; -interface AcmAppShellProps { +export interface AcmAppShellProps { children: ReactNode; active?: string; showLoader?: boolean; @@ -164,7 +164,7 @@ const AcmAppShell: React.FC = ({ padding="md" header={{ height: 60 }} navbar={{ - width: 200, + width: showSidebar ? 200 : 0, breakpoint: 'sm', collapsed: { mobile: !opened }, }} diff --git a/src/ui/components/AuthContext/AuthCallbackHandler.page.tsx b/src/ui/components/AuthContext/AuthCallbackHandler.page.tsx index 5eb6c49e..57ca19e8 100644 --- a/src/ui/components/AuthContext/AuthCallbackHandler.page.tsx +++ b/src/ui/components/AuthContext/AuthCallbackHandler.page.tsx @@ -34,11 +34,6 @@ export const AuthCallback: React.FC = () => { setTimeout(() => { handleCallback(); }, 100); - - // Cleanup function - return () => { - console.log('Callback component unmounting'); // Debug log 8 - }; }, [instance, navigate]); return ; diff --git a/src/ui/components/AuthContext/index.tsx b/src/ui/components/AuthContext/index.tsx index c805b401..1f0251c9 100644 --- a/src/ui/components/AuthContext/index.tsx +++ b/src/ui/components/AuthContext/index.tsx @@ -19,6 +19,8 @@ import { CACHE_KEY_PREFIX } from '../AuthGuard/index.js'; import FullScreenLoader from './LoadingScreen.js'; import { getRunEnvironmentConfig, ValidServices } from '@ui/config.js'; +import { transformCommaSeperatedName } from '@common/utils.js'; +import { useApi } from '@ui/util/api.js'; interface AuthContextDataWrapper { isLoggedIn: boolean; @@ -28,6 +30,7 @@ interface AuthContextDataWrapper { getToken: CallableFunction; logoutCallback: CallableFunction; getApiToken: CallableFunction; + setLoginStatus: CallableFunction; } export type AuthContextData = { @@ -53,7 +56,6 @@ export const clearAuthCache = () => { export const AuthProvider: React.FC = ({ children }) => { const { instance, inProgress, accounts } = useMsal(); - const [userData, setUserData] = useState(null); const [isLoggedIn, setIsLoggedIn] = useState(false); @@ -67,11 +69,9 @@ export const AuthProvider: React.FC = ({ children }) => { if (response) { handleMsalResponse(response); } else if (accounts.length > 0) { - // User is already logged in, set the state - const [lastName, firstName] = accounts[0].name?.split(',') || []; setUserData({ email: accounts[0].username, - name: `${firstName} ${lastName}`, + name: transformCommaSeperatedName(accounts[0].name || ''), }); setIsLoggedIn(true); } @@ -94,10 +94,9 @@ export const AuthProvider: React.FC = ({ children }) => { }) .then((silentResponse) => { if (silentResponse?.account?.name) { - const [lastName, firstName] = silentResponse.account.name.split(','); setUserData({ - email: silentResponse.account.username, - name: `${firstName} ${lastName}`, + email: accounts[0].username, + name: transformCommaSeperatedName(accounts[0].name || ''), }); setIsLoggedIn(true); } @@ -105,18 +104,16 @@ export const AuthProvider: React.FC = ({ children }) => { .catch(console.error); return; } - - // Use response.account instead of accounts[0] - const [lastName, firstName] = response.account.name?.split(',') || []; setUserData({ - email: response.account.username, - name: `${firstName} ${lastName}`, + email: accounts[0].username, + name: transformCommaSeperatedName(accounts[0].name || ''), }); setIsLoggedIn(true); } }, [accounts, instance] ); + const getApiToken = useCallback( async (service: ValidServices) => { if (!userData) { @@ -194,6 +191,9 @@ export const AuthProvider: React.FC = ({ children }) => { }, [instance] ); + const setLoginStatus = useCallback((val: boolean) => { + setIsLoggedIn(val); + }, []); const logout = useCallback(async () => { try { @@ -209,7 +209,16 @@ export const AuthProvider: React.FC = ({ children }) => { }; return ( {inProgress !== InteractionStatus.None ? ( diff --git a/src/ui/components/AuthGuard/index.tsx b/src/ui/components/AuthGuard/index.tsx index 0634013f..070aa631 100644 --- a/src/ui/components/AuthGuard/index.tsx +++ b/src/ui/components/AuthGuard/index.tsx @@ -1,7 +1,7 @@ import { Card, Text, Title } from '@mantine/core'; import React, { ReactNode, useEffect, useState } from 'react'; -import { AcmAppShell } from '@ui/components/AppShell'; +import { AcmAppShell, AcmAppShellProps } from '@ui/components/AppShell'; import FullScreenLoader from '@ui/components/AuthContext/LoadingScreen'; import { getRunEnvironmentConfig, ValidService } from '@ui/config'; import { useApi } from '@ui/util/api'; @@ -60,11 +60,13 @@ export const clearAuthCache = () => { } }; -export const AuthGuard: React.FC<{ - resourceDef: ResourceDefinition; - children: ReactNode; - isAppShell?: boolean; -}> = ({ resourceDef, children, isAppShell = true }) => { +export const AuthGuard: React.FC< + { + resourceDef: ResourceDefinition; + children: ReactNode; + isAppShell?: boolean; + } & AcmAppShellProps +> = ({ resourceDef, children, isAppShell = true, ...appShellProps }) => { const { service, validRoles } = resourceDef; const { baseEndpoint, authCheckRoute, friendlyName } = getRunEnvironmentConfig().ServiceConfiguration[service]; @@ -80,6 +82,10 @@ export const AuthGuard: React.FC<{ setIsAuthenticated(true); return; } + if (validRoles.length === 0) { + setIsAuthenticated(true); + return; + } // Check for cached response first const cachedData = getCachedResponse(service, authCheckRoute); @@ -163,12 +169,7 @@ export const AuthGuard: React.FC<{ } if (isAppShell) { - return ( - - {friendlyName} - {children} - - ); + return {children}; } return <>{children}; diff --git a/src/ui/components/ProfileDropdown/index.tsx b/src/ui/components/ProfileDropdown/index.tsx index 1fe8f0e6..71760e8b 100644 --- a/src/ui/components/ProfileDropdown/index.tsx +++ b/src/ui/components/ProfileDropdown/index.tsx @@ -18,6 +18,7 @@ import { useState } from 'react'; import { AuthContextData, useAuth } from '../AuthContext/index.js'; import classes from '../Navbar/index.module.css'; +import { useNavigate } from 'react-router-dom'; interface ProfileDropdownProps { userData?: AuthContextData; @@ -26,6 +27,7 @@ interface ProfileDropdownProps { const AuthenticatedProfileDropdown: React.FC = ({ userData }) => { const [opened, setOpened] = useState(false); const theme = useMantineTheme(); + const navigate = useNavigate(); const { logout } = useAuth(); if (!userData) { return null; @@ -111,6 +113,16 @@ const AuthenticatedProfileDropdown: React.FC = ({ userData + + + + )), + ...toAdd.map((email) => ( + + + + +
+ + {email.split('@')[0]} + + + {email} + +
+
+
+ + + Queued for addition + + + + + +
+ )), + ]; + return ( - - +
+ Exec Council Group Management - {/* Member List */} - - - Current Members - - - {isLoading && } - {!isLoading && ( - - {members.map((member) => ( - - - - - {member.name} ({member.email}) - - {toRemove.includes(member.email) && ( - - Queued for removal - - )} - - handleRemoveMember(member.email)} - data-testid={`remove-exec-member-${member.email}`} - > - - - - - ))} - {toAdd.map((member) => ( - - - - {member} - - Queued for addition - - - - - ))} - - )} - - + {isLoading ? ( + + ) : ( + + + + Member + Status + Actions + + + {rows} +
+ )} - {/* Add Member */} - - setEmail(e.currentTarget.value)} - placeholder="Enter email" - label="Add Member" - /> - - + setEmail(e.currentTarget.value)} + placeholder="Enter email" + label="Add Member" + mt="md" + /> + - {/* Save Changes Button */} - {/* Confirmation Modal */} setConfirmationModal(false)} title="Confirm Changes" > - +
{toAdd.length > 0 && ( - - +
+ Members to Add: - - {toAdd.map((email) => ( - {email} - ))} - - + {toAdd.map((email) => ( + {email} + ))} +
)} {toRemove.length > 0 && ( - - +
+ Members to Remove: - - {toRemove.map((email) => ( - {email} - ))} - - + {toRemove.map((email) => ( + {email} + ))} +
)} -
+
- +
); }; diff --git a/src/ui/pages/iam/ManageIam.page.tsx b/src/ui/pages/iam/ManageIam.page.tsx index e0e3b6a4..5a60d2e4 100644 --- a/src/ui/pages/iam/ManageIam.page.tsx +++ b/src/ui/pages/iam/ManageIam.page.tsx @@ -10,6 +10,7 @@ import { GroupMemberGetResponse, GroupModificationPatchRequest, } from '@common/types/iam'; +import { transformCommaSeperatedName } from '@common/utils'; import { getRunEnvironmentConfig } from '@ui/config'; export const ManageIamPage = () => { @@ -37,7 +38,13 @@ export const ManageIamPage = () => { const getExecMembers = async () => { try { const response = await api.get(`/api/v1/iam/groups/${groupId}`); - return response.data as GroupMemberGetResponse; + const responseMapped = response.data + .map((x: any) => ({ + ...x, + name: transformCommaSeperatedName(x.name), + })) + .sort((x: any, y: any) => (x.name > y.name ? 1 : x.name < y.name ? -1 : 0)); + return responseMapped as GroupMemberGetResponse; } catch (error: any) { console.error('Failed to get users:', error); return []; diff --git a/src/ui/pages/profile/ManageProfile.page.tsx b/src/ui/pages/profile/ManageProfile.page.tsx new file mode 100644 index 00000000..b22da761 --- /dev/null +++ b/src/ui/pages/profile/ManageProfile.page.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Container, Title } from '@mantine/core'; +import { AuthGuard } from '@ui/components/AuthGuard'; +import { useApi } from '@ui/util/api'; +import { UserProfileData, UserProfileDataBase } from '@common/types/msGraphApi'; +import { ManageProfileComponent } from './ManageProfileComponent'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '@ui/components/AuthContext'; + +export const ManageProfilePage: React.FC = () => { + const api = useApi('msGraphApi'); + const { setLoginStatus } = useAuth(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const returnTo = searchParams.get('returnTo') || undefined; + const firstTime = searchParams.get('firstTime') === 'true' || false; + const getProfile = async () => { + const raw = ( + await api.get( + '/v1.0/me?$select=userPrincipalName,givenName,surname,displayName,otherMails,mail' + ) + ).data as UserProfileDataBase; + const discordUsername = raw.otherMails?.filter((x) => x.endsWith('@discord')); + const enhanced = raw as UserProfileData; + if (discordUsername?.length === 1) { + enhanced.discordUsername = discordUsername[0].replace('@discord', ''); + enhanced.otherMails = enhanced.otherMails?.filter((x) => !x.endsWith('@discord')); + } + return enhanced; + }; + + const setProfile = async (data: UserProfileData) => { + const newOtherEmails = [data.mail || data.userPrincipalName]; + if (data.discordUsername && data.discordUsername !== '') { + newOtherEmails.push(`${data.discordUsername}@discord`); + } + data.otherMails = newOtherEmails; + delete data.discordUsername; + const response = await api.patch('/v1.0/me', data); + if (response.status < 299 && firstTime) { + setLoginStatus(true); + } + if (returnTo) { + return navigate(returnTo); + } + return response.data; + }; + + return ( + + + Edit Profile + + + + ); +}; diff --git a/src/ui/pages/profile/ManageProfileComponent.test.tsx b/src/ui/pages/profile/ManageProfileComponent.test.tsx new file mode 100644 index 00000000..f715f0f1 --- /dev/null +++ b/src/ui/pages/profile/ManageProfileComponent.test.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MantineProvider } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; +import { ManageProfileComponent } from './ManageProfileComponent'; + +describe('ManageProfileComponent tests', () => { + const renderComponent = async ( + getProfile: () => Promise, + setProfile: (data: any) => Promise, + firstTime: boolean = false + ) => { + await act(async () => { + render( + + + + + + ); + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading overlay when fetching profile', async () => { + const getProfile = vi.fn().mockResolvedValue(new Promise(() => {})); // Never resolves + const setProfile = vi.fn(); + + await renderComponent(getProfile, setProfile); + + expect(screen.getByTestId('profile-loading')).toBeInTheDocument(); + }); + + it('renders profile form after successfully fetching profile', async () => { + const getProfile = vi.fn().mockResolvedValue({ + displayName: 'John Doe', + givenName: 'John', + surname: 'Doe', + mail: 'john.doe@example.com', + discordUsername: 'johndoe#1234', + }); + const setProfile = vi.fn(); + + await renderComponent(getProfile, setProfile); + + expect(screen.getByTestId('edit-displayName')).toHaveValue('John Doe'); + expect(screen.getByTestId('edit-firstName')).toHaveValue('John'); + expect(screen.getByTestId('edit-lastName')).toHaveValue('Doe'); + expect(screen.getByTestId('edit-email')).toHaveValue('john.doe@example.com'); + expect(screen.getByTestId('edit-discordUsername')).toHaveValue('johndoe#1234'); + }); + + it('handles profile fetch failure gracefully', async () => { + const notificationsMock = vi.spyOn(notifications, 'show'); + const getProfile = vi.fn().mockRejectedValue(new Error('Failed to fetch profile')); + const setProfile = vi.fn(); + + await renderComponent(getProfile, setProfile); + + expect(notificationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to load user profile', + color: 'red', + }) + ); + + notificationsMock.mockRestore(); + }); + + it('allows editing profile fields and saving changes', async () => { + const notificationsMock = vi.spyOn(notifications, 'show'); + const getProfile = vi.fn().mockResolvedValue({ + displayName: 'John Doe', + givenName: 'John', + surname: 'Doe', + mail: 'john.doe@example.com', + discordUsername: '', + }); + const setProfile = vi.fn().mockResolvedValue({}); + + await renderComponent(getProfile, setProfile); + + const user = userEvent.setup(); + + // Edit fields + await user.clear(screen.getByTestId('edit-displayName')); + await user.type(screen.getByTestId('edit-displayName'), 'Jane Doe'); + await user.type(screen.getByTestId('edit-discordUsername'), 'janedoe#5678'); + + // Save changes + const saveButton = screen.getByRole('button', { name: 'Save' }); + await user.click(saveButton); + + expect(setProfile).toHaveBeenCalledWith({ + displayName: 'Jane Doe', + givenName: 'John', + surname: 'Doe', + mail: 'john.doe@example.com', + discordUsername: 'janedoe#5678', + }); + + expect(notificationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Profile updated successfully', + message: 'Changes may take some time to reflect.', + color: 'green', + }) + ); + + notificationsMock.mockRestore(); + }); + + it('shows first-time user alert when `firstTime` is true', async () => { + const getProfile = vi.fn().mockResolvedValue({ + displayName: '', + givenName: '', + surname: '', + mail: 'new.user@example.com', + discordUsername: '', + }); + const setProfile = vi.fn(); + + await renderComponent(getProfile, setProfile, true); + + expect( + screen.getByText( + 'Your profile is incomplete. Please provide us with the information below and click Save.' + ) + ).toBeInTheDocument(); + }); + + it('handles profile update failure gracefully', async () => { + const notificationsMock = vi.spyOn(notifications, 'show'); + const getProfile = vi.fn().mockResolvedValue({ + displayName: 'John Doe', + givenName: 'John', + surname: 'Doe', + mail: 'john.doe@example.com', + discordUsername: '', + }); + const setProfile = vi.fn().mockRejectedValue(new Error('Failed to update profile')); + + await renderComponent(getProfile, setProfile); + + const user = userEvent.setup(); + + // Attempt to save without any changes + const saveButton = screen.getByRole('button', { name: 'Save' }); + await user.click(saveButton); + + expect(notificationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to update profile', + color: 'red', + }) + ); + + notificationsMock.mockRestore(); + }); + + it('disables the save button when no profile data is loaded', async () => { + const getProfile = vi.fn().mockResolvedValue(null); + const setProfile = vi.fn(); + + await renderComponent(getProfile, setProfile); + + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); +}); diff --git a/src/ui/pages/profile/ManageProfileComponent.tsx b/src/ui/pages/profile/ManageProfileComponent.tsx new file mode 100644 index 00000000..9f4423bd --- /dev/null +++ b/src/ui/pages/profile/ManageProfileComponent.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useState } from 'react'; +import { TextInput, Button, Group, Box, LoadingOverlay, Alert } from '@mantine/core'; +import { UserProfileData } from '@common/types/msGraphApi'; +import { notifications } from '@mantine/notifications'; +import { useNavigate } from 'react-router-dom'; +import { IconMoodSmileBeam } from '@tabler/icons-react'; + +interface ManageProfileComponentProps { + getProfile: () => Promise; + setProfile: (data: UserProfileData) => Promise; + firstTime: boolean; +} + +export const ManageProfileComponent: React.FC = ({ + getProfile, + setProfile, + firstTime, +}) => { + const navigate = useNavigate(); + const [userProfile, setUserProfile] = useState(undefined); + const [loading, setLoading] = useState(false); + const fetchProfile = async () => { + setLoading(true); + try { + const profile = await getProfile(); + setUserProfile(profile); + } catch (e) { + console.error(e); + setUserProfile(null); + notifications.show({ + color: 'red', + message: 'Failed to load user profile', + }); + } finally { + setLoading(false); + } + }; + useEffect(() => { + fetchProfile(); + }, [getProfile]); + + const handleSubmit = async () => { + if (!userProfile) return; + setLoading(true); + try { + await setProfile(userProfile); + notifications.show({ + color: 'green', + title: 'Profile updated successfully', + message: 'Changes may take some time to reflect.', + }); + await fetchProfile(); + } catch (e) { + console.error(e); + notifications.show({ + color: 'red', + message: 'Failed to update profile', + }); + } finally { + setLoading(false); + } + }; + + if (userProfile === undefined) { + return ; + } + + return ( + <> + {firstTime && ( + } + title="Welcome to ACM @ UIUC Management Portal" + color="yellow" + > + Your profile is incomplete. Please provide us with the information below and click Save. + + )} + +
{ + e.preventDefault(); + handleSubmit(); + }} + > + + setUserProfile((prev) => prev && { ...prev, displayName: e.target.value }) + } + placeholder={userProfile?.displayName} + required + data-testId="edit-displayName" + /> + + setUserProfile((prev) => prev && { ...prev, givenName: e.target.value }) + } + placeholder={userProfile?.givenName} + required + data-testId="edit-firstName" + /> + setUserProfile((prev) => prev && { ...prev, surname: e.target.value })} + placeholder={userProfile?.surname} + required + data-testId="edit-lastName" + /> + setUserProfile((prev) => prev && { ...prev, mail: e.target.value })} + placeholder={userProfile?.mail} + required + disabled + data-testId="edit-email" + /> + + + setUserProfile((prev) => prev && { ...prev, discordUsername: e.target.value }) + } + data-testId="edit-discordUsername" + /> + + + + + +
+ + ); +}; diff --git a/tests/e2e/events.spec.ts b/tests/e2e/events.spec.ts index 2b6bad0b..c1b28ac7 100644 --- a/tests/e2e/events.spec.ts +++ b/tests/e2e/events.spec.ts @@ -9,9 +9,7 @@ describe("Events tests", () => { }) => { await becomeUser(page); await page.locator("a").filter({ hasText: "Events" }).click(); - await expect(page.getByRole("heading")).toContainText( - "Core Management Service (NonProd)", - ); + await expect(page.getByRole("heading")).toContainText("Event Management"); await expect( page.getByRole("button", { name: "New Calendar Event" }), ).toBeVisible(); diff --git a/tests/e2e/login.spec.ts b/tests/e2e/login.spec.ts index e0aeab52..b0fa3b27 100644 --- a/tests/e2e/login.spec.ts +++ b/tests/e2e/login.spec.ts @@ -20,14 +20,11 @@ describe("Login tests", () => { page.getByRole("link", { name: "ACM Logo Management Portal" }), ).toBeVisible(); await expect( - page.getByRole("link", { name: "P", exact: true }), + page.getByRole("link", { name: "PC", exact: true }), ).toBeVisible(); - await page.getByRole("link", { name: "P", exact: true }).click(); - await expect(page.getByLabel("PMy Account")).toContainText( - "Name Playwright Core User", - ); - await expect(page.getByLabel("PMy Account")).toContainText( - "Emailcore-e2e-testing@acm.illinois.edu", + await page.getByRole("link", { name: "PC", exact: true }).click(); + await expect(page.getByLabel("PCMy Account")).toContainText( + "NamePlaywright Core UserEmailcore-e2e-testing@acm.illinois.eduEdit ProfileLog Out", ); expect(page.url()).toEqual("https://manage.qa.acmuiuc.org/home"); }); From 4788c1dbc359f656d230ac36e6a8bca31a5f3a0a Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Mon, 27 Jan 2025 20:10:56 -0600 Subject: [PATCH 10/21] add python feature to devcontainer --- .devcontainer/devcontainer.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 301547f0..061cacfc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,9 +6,10 @@ "image": "mcr.microsoft.com/devcontainers/base:jammy", "features": { "ghcr.io/devcontainers/features/node:1": {}, - "ghcr.io/devcontainers/features/aws-cli:1": {}, + "ghcr.io/devcontainers/features/aws-cli:1": {}, "ghcr.io/jungaretti/features/make:1": {}, - "ghcr.io/customink/codespaces-features/sam-cli:1": {} + "ghcr.io/customink/codespaces-features/sam-cli:1": {}, + "ghcr.io/devcontainers/features/python:1": {} }, // Features to add to the dev container. More info: https://containers.dev/features. From b65f9183c73ca0367426f462bd2169029e766604 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Tue, 28 Jan 2025 20:37:12 -0600 Subject: [PATCH 11/21] Mobile Wallet Membership Pass (#44) * tooling updates * update gitignore * functionality * fix route integration? * fix? * weird hack around build process * fix lockfile * add tests * mock something * testing * fix ses mock * add one more live test * use moment-timezone build --- .gitignore | 1 + Makefile | 2 + cloudformation/iam.yml | 19 +- cloudformation/main.yml | 25 +- generate_jwt.js | 2 +- package.json | 2 +- src/api/build.js | 39 ++ src/api/esbuild.config.js | 47 ++ src/api/functions/entraId.ts | 46 ++ src/api/functions/membership.ts | 18 + src/api/functions/mobileWallet.ts | 117 ++++ src/api/functions/ses.ts | 130 ++++ src/api/index.ts | 8 + src/api/package.json | 12 +- src/api/package.lambda.json | 15 + .../resources/MembershipPass.pkpass/icon.png | Bin 0 -> 8318 bytes .../resources/MembershipPass.pkpass/logo.png | Bin 0 -> 28139 bytes .../resources/MembershipPass.pkpass/pass.ts | 18 + .../resources/MembershipPass.pkpass/strip.png | Bin 0 -> 44831 bytes src/api/resources/types.d.ts | 9 + src/api/routes/mobileWallet.ts | 91 +++ src/api/types.d.ts | 2 + src/common/config.ts | 15 + src/common/errors/index.ts | 13 + tests/live/ical.test.ts | 1 - tests/live/mobileWallet.test.ts | 18 + tests/unit/mobileWallet.test.ts | 88 +++ tests/unit/secret.testdata.ts | 3 + yarn.lock | 658 +++++++++++++++++- 29 files changed, 1356 insertions(+), 43 deletions(-) create mode 100644 src/api/build.js create mode 100644 src/api/esbuild.config.js create mode 100644 src/api/functions/membership.ts create mode 100644 src/api/functions/mobileWallet.ts create mode 100644 src/api/functions/ses.ts create mode 100644 src/api/package.lambda.json create mode 100644 src/api/resources/MembershipPass.pkpass/icon.png create mode 100644 src/api/resources/MembershipPass.pkpass/logo.png create mode 100644 src/api/resources/MembershipPass.pkpass/pass.ts create mode 100644 src/api/resources/MembershipPass.pkpass/strip.png create mode 100644 src/api/resources/types.d.ts create mode 100644 src/api/routes/mobileWallet.ts create mode 100644 tests/live/mobileWallet.test.ts create mode 100644 tests/unit/mobileWallet.test.ts diff --git a/.gitignore b/.gitignore index 4be0d91e..4ce03d78 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,4 @@ __pycache__ /playwright-report/ /blob-report/ /playwright/.cache/ +dist_devel/ diff --git a/Makefile b/Makefile index af53f386..d0a1af8e 100644 --- a/Makefile +++ b/Makefile @@ -48,10 +48,12 @@ clean: rm -rf src/ui/node_modules/ rm -rf dist/ rm -rf dist_ui/ + rm -rf dist_devel/ build: src/ cloudformation/ docs/ yarn -D VITE_BUILD_HASH=$(GIT_HASH) yarn build + cp -r src/api/resources/ dist/api/resources sam build --template-file cloudformation/main.yml local: diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index 756c9706..c468b871 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -10,6 +10,8 @@ Parameters: LambdaFunctionName: Type: String AllowedPattern: ^[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+$ + SesEmailDomain: + Type: String Resources: ApiLambdaIAMRole: Type: AWS::IAM::Role @@ -24,6 +26,21 @@ Resources: Service: - lambda.amazonaws.com Policies: + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Action: + - ses:SendEmail + - ses:SendRawEmail + Effect: Allow + Resource: "*" + Condition: + StringEquals: + ses:FromAddress: !Sub "membership@${SesEmailDomain}" + ForAllValues:StringLike: + ses:Recipients: + - "*@illinois.edu" + PolicyName: ses-membership - PolicyDocument: Version: '2012-10-17' Statement: @@ -85,4 +102,4 @@ Outputs: Value: Fn::GetAtt: - ApiLambdaIAMRole - - Arn \ No newline at end of file + - Arn diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 5edf7e32..09e64220 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -32,8 +32,10 @@ Mappings: General: dev: LogRetentionDays: 7 + SesDomain: "aws.qa.acmuiuc.org" prod: LogRetentionDays: 365 + SesDomain: "acm.illinois.edu" ApiGwConfig: dev: ApiCertificateArn: arn:aws:acm:us-east-1:427040638965:certificate/63ccdf0b-d2b5-44f0-b589-eceffb935c23 @@ -71,6 +73,7 @@ Resources: Parameters: RunEnvironment: !Ref RunEnvironment LambdaFunctionName: !Sub ${ApplicationPrefix}-lambda + SesEmailDomain: !FindInMap [General, !Ref RunEnvironment, SesDomain] AppLogGroups: Type: AWS::Serverless::Application @@ -120,29 +123,9 @@ Resources: Type: AWS::Serverless::Function DependsOn: - AppLogGroups - Metadata: - BuildMethod: esbuild - BuildProperties: - Format: esm - Minify: true - OutExtension: - - .js=.mjs - Target: "es2022" - Sourcemap: false - EntryPoints: - - api/lambda.js - External: - - aws-sdk - Banner: - - js=import path from 'path'; - import { fileURLToPath } from 'url'; - import { createRequire as topLevelCreateRequire } from 'module'; - const require = topLevelCreateRequire(import.meta.url); - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); Properties: Architectures: [arm64] - CodeUri: ../dist + CodeUri: ../dist/lambda AutoPublishAlias: live Runtime: nodejs22.x Description: !Sub "${ApplicationFriendlyName} API Lambda" diff --git a/generate_jwt.js b/generate_jwt.js index 02f58a07..38776a93 100644 --- a/generate_jwt.js +++ b/generate_jwt.js @@ -18,7 +18,7 @@ const payload = { groups: ["0"], idp: "https://login.microsoftonline.com", ipaddr: "192.168.1.1", - name: "John Doe", + name: "Doe, John", oid: "00000000-0000-0000-0000-000000000000", rh: "rh-value", scp: "user_impersonation", diff --git a/package.json b/package.json index c1f86a49..16c772bf 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "yarn workspaces run build && yarn lockfile-manage", "dev": "concurrently --names 'api,ui' 'yarn workspace infra-core-api run dev' 'yarn workspace infra-core-ui run dev'", - "lockfile-manage": "synp --with-workspace --source-file yarn.lock && cp package-lock.json dist/ && cp src/api/package.json dist/ && rm package-lock.json", + "lockfile-manage": "synp --with-workspace --source-file yarn.lock && cp package-lock.json dist/lambda/ && cp src/api/package.lambda.json dist/lambda/package.json && rm package-lock.json", "prettier": "yarn workspaces run prettier && prettier --check tests/**/*.ts", "prettier:write": "yarn workspaces run prettier:write && prettier --write tests/**/*.ts", "lint": "yarn workspaces run lint", diff --git a/src/api/build.js b/src/api/build.js new file mode 100644 index 00000000..f24c8100 --- /dev/null +++ b/src/api/build.js @@ -0,0 +1,39 @@ +import esbuild from "esbuild"; +import { resolve } from "path"; + +esbuild + .build({ + entryPoints: ["api/lambda.js"], // Entry file + bundle: true, + format: "esm", + minify: true, + outdir: "../../dist/lambda/", + outExtension: { ".js": ".mjs" }, + loader: { + ".png": "file", + ".pkpass": "file", + ".json": "file", + }, // File loaders + target: "es2022", // Target ES2022 + sourcemap: false, + platform: "node", + external: ["aws-sdk", "moment-timezone", "passkit-generator", "fastify"], + alias: { + 'moment-timezone': resolve(process.cwd(), '../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js') + }, + banner: { + js: ` + import path from 'path'; + import { fileURLToPath } from 'url'; + import { createRequire as topLevelCreateRequire } from 'module'; + const require = topLevelCreateRequire(import.meta.url); + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + `.trim(), + }, // Banner for compatibility with CommonJS + }) + .then(() => console.log("Build completed successfully!")) + .catch((error) => { + console.error("Build failed:", error); + process.exit(1); + }); diff --git a/src/api/esbuild.config.js b/src/api/esbuild.config.js new file mode 100644 index 00000000..c48615aa --- /dev/null +++ b/src/api/esbuild.config.js @@ -0,0 +1,47 @@ +import { build, context } from 'esbuild'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const isWatching = !!process.argv.includes('--watch') +const nodePackage = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')); + +const buildOptions = { + entryPoints: [resolve(process.cwd(), 'index.ts')], + outfile: resolve(process.cwd(), '../', '../', 'dist_devel', 'index.js'), + bundle: true, + platform: 'node', + format: 'esm', + external: [ + Object.keys(nodePackage.dependencies ?? {}), + Object.keys(nodePackage.peerDependencies ?? {}), + Object.keys(nodePackage.devDependencies ?? {}), + ].flat(), + loader: { + '.png': 'file', // Add this line to specify a loader for .png files + }, + alias: { + 'moment-timezone': resolve(process.cwd(), '../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js') + }, + banner: { + js: ` + import path from 'path'; + import { fileURLToPath } from 'url'; + import { createRequire as topLevelCreateRequire } from 'module'; + const require = topLevelCreateRequire(import.meta.url); + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + `.trim(), + }, // Banner for compatibility with CommonJS +}; + +if (isWatching) { + context(buildOptions).then(ctx => { + if (isWatching) { + ctx.watch(); + } else { + ctx.rebuild(); + } + }); +} else { + build(buildOptions) +} diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 30c0331a..6524c53b 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -7,6 +7,7 @@ import { } from "../../common/config.js"; import { BaseError, + EntraFetchError, EntraGroupError, EntraInvitationError, InternalServerError, @@ -19,6 +20,7 @@ import { EntraInvitationResponse, } from "../../common/types/iam.js"; import { FastifyInstance } from "fastify"; +import { UserProfileDataBase } from "common/types/msGraphApi.js"; function validateGroupId(groupId: string): boolean { const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed @@ -351,3 +353,47 @@ export async function listGroupMembers( }); } } + +/** + * Retrieves the profile of a user from Entra ID. + * @param token - Entra ID token authorized to perform this action. + * @param userId - The user ID to fetch the profile for. + * @throws {EntraUserError} If fetching the user profile fails. + * @returns {Promise} The user's profile information. + */ +export async function getUserProfile( + token: string, + email: string, +): Promise { + const userId = await resolveEmailToOid(token, email); + try { + const url = `https://graph.microsoft.com/v1.0/users/${userId}?$select=userPrincipalName,givenName,surname,displayName,otherMails,mail`; + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { + error?: { message?: string }; + }; + throw new EntraFetchError({ + message: errorData?.error?.message ?? response.statusText, + email, + }); + } + return (await response.json()) as UserProfileDataBase; + } catch (error) { + if (error instanceof EntraFetchError) { + throw error; + } + + throw new EntraFetchError({ + message: error instanceof Error ? error.message : String(error), + email, + }); + } +} diff --git a/src/api/functions/membership.ts b/src/api/functions/membership.ts new file mode 100644 index 00000000..1f309b3f --- /dev/null +++ b/src/api/functions/membership.ts @@ -0,0 +1,18 @@ +import { FastifyBaseLogger, FastifyInstance } from "fastify"; + +export async function checkPaidMembership( + endpoint: string, + log: FastifyBaseLogger, + netId: string, +) { + const membershipApiPayload = (await ( + await fetch(`${endpoint}?netId=${netId}`) + ).json()) as { netId: string; isPaidMember: boolean }; + log.trace(`Got Membership API Payload for ${netId}: ${membershipApiPayload}`); + try { + return membershipApiPayload["isPaidMember"]; + } catch (e: any) { + log.error(`Failed to get response from membership API: ${e.toString()}`); + throw e; + } +} diff --git a/src/api/functions/mobileWallet.ts b/src/api/functions/mobileWallet.ts new file mode 100644 index 00000000..b16f99b3 --- /dev/null +++ b/src/api/functions/mobileWallet.ts @@ -0,0 +1,117 @@ +import { getSecretValue } from "../plugins/auth.js"; +import { genericConfig, SecretConfig } from "../../common/config.js"; +import { + InternalServerError, + UnauthorizedError, +} from "../../common/errors/index.js"; +import { FastifyInstance, FastifyRequest } from "fastify"; +// these make sure that esbuild includes the files +import icon from "../resources/MembershipPass.pkpass/icon.png"; +import logo from "../resources/MembershipPass.pkpass/logo.png"; +import strip from "../resources/MembershipPass.pkpass/strip.png"; +import pass from "../resources/MembershipPass.pkpass/pass.js"; +import { PKPass } from "passkit-generator"; +import { promises as fs } from "fs"; + +function trim(s: string) { + return (s || "").replace(/^\s+|\s+$/g, ""); +} + +function convertName(name: string): string { + if (!name.includes(",")) { + return name; + } + return `${trim(name.split(",")[1])} ${name.split(",")[0]}`; +} + +export async function issueAppleWalletMembershipCard( + app: FastifyInstance, + request: FastifyRequest, + email: string, + name?: string, +) { + if (!email.endsWith("@illinois.edu")) { + throw new UnauthorizedError({ + message: + "Cannot issue membership pass for emails not on the illinois.edu domain.", + }); + } + const secretApiConfig = (await getSecretValue( + app.secretsManagerClient, + genericConfig.ConfigSecretName, + )) as SecretConfig; + if (!secretApiConfig) { + throw new InternalServerError({ + message: "Could not retrieve signing data", + }); + } + const signerCert = Buffer.from( + secretApiConfig.acm_passkit_signerCert_base64, + "base64", + ).toString("utf-8"); + const signerKey = Buffer.from( + secretApiConfig.acm_passkit_signerKey_base64, + "base64", + ).toString("utf-8"); + const wwdr = Buffer.from( + secretApiConfig.apple_signing_cert_base64, + "base64", + ).toString("utf-8"); + pass["passTypeIdentifier"] = app.environmentConfig["PasskitIdentifier"]; + + const pkpass = new PKPass( + { + "icon.png": await fs.readFile(icon), + "logo.png": await fs.readFile(logo), + "strip.png": await fs.readFile(strip), + "pass.json": Buffer.from(JSON.stringify(pass)), + }, + { + wwdr, + signerCert, + signerKey, + }, + { + // logoText: app.runEnvironment === "dev" ? "INVALID Membership Pass" : "Membership Pass", + serialNumber: app.environmentConfig["PasskitSerialNumber"], + }, + ); + pkpass.setBarcodes({ + altText: email.split("@")[0], + format: "PKBarcodeFormatPDF417", + message: app.runEnvironment === "dev" ? `INVALID${email}INVALID` : email, + }); + const iat = new Date().toLocaleDateString("en-US", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + if (name && name !== "") { + pkpass.secondaryFields.push({ + label: "Member Name", + key: "name", + value: convertName(name), + }); + } + if (app.runEnvironment === "prod") { + pkpass.backFields.push({ + label: "Verification URL", + key: "iss", + value: `https://membership.acm.illinois.edu/verify/${email.split("@")[0]}`, + }); + } else { + pkpass.backFields.push({ + label: "TESTING ONLY Pass", + key: "iss", + value: `Do not honor!`, + }); + } + pkpass.backFields.push({ label: "Pass Created On", key: "iat", value: iat }); + pkpass.backFields.push({ label: "Membership ID", key: "id", value: email }); + const buffer = pkpass.getAsBuffer(); + request.log.info( + { type: "audit", actor: email, target: email }, + "Created membership verification pass", + ); + return buffer; +} diff --git a/src/api/functions/ses.ts b/src/api/functions/ses.ts new file mode 100644 index 00000000..0ce5ff22 --- /dev/null +++ b/src/api/functions/ses.ts @@ -0,0 +1,130 @@ +import { SendRawEmailCommand } from "@aws-sdk/client-ses"; +import { encode } from "base64-arraybuffer"; + +/** + * Generates a SendRawEmailCommand for SES to send an email with an attached membership pass. + * + * @param recipientEmail - The email address of the recipient. + * * @param recipientEmail - The email address of the sender with a verified identity in SES. + * @param attachmentBuffer - The membership pass in ArrayBufferLike format. + * @returns The command to send the email via SES. + */ +export function generateMembershipEmailCommand( + recipientEmail: string, + senderEmail: string, + attachmentBuffer: ArrayBufferLike, +): SendRawEmailCommand { + const encodedAttachment = encode(attachmentBuffer); + const boundary = "----BoundaryForEmail"; + + const emailTemplate = ` + + + + Your ACM @ UIUC Membership + + + + + + +
 
+ +
+
+

Welcome

+

+ Thank you for becoming a member of ACM @ UIUC! Attached is your membership pass. + You can add it to your Apple or Google Wallet for easy access. +

+

+ If you have any questions, feel free to contact us at + infra@acm.illinois.edu. +

+ +
+ + + + `; + + const rawEmail = ` +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="${boundary}" +From: ACM @ UIUC <${senderEmail}> +To: ${recipientEmail} +Subject: Your ACM @ UIUC Membership + +--${boundary} +Content-Type: text/html; charset="UTF-8" + +${emailTemplate} + +--${boundary} +Content-Type: application/vnd.apple.pkpass +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="membership.pkpass" + +${encodedAttachment} +--${boundary}--`.trim(); + return new SendRawEmailCommand({ + RawMessage: { + Data: new TextEncoder().encode(rawEmail), + }, + }); +} diff --git a/src/api/index.ts b/src/api/index.ts index ed7f3497..ebe512b6 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -21,6 +21,8 @@ import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts"; import NodeCache from "node-cache"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { SESClient } from "@aws-sdk/client-ses"; +import mobileWalletRoute from "./routes/mobileWallet.js"; dotenv.config(); @@ -35,6 +37,10 @@ async function init() { region: genericConfig.AwsRegion, }); + const sesClient = new SESClient({ + region: genericConfig.AwsRegion, + }); + const app: FastifyInstance = fastify({ logger: { level: process.env.LOG_LEVEL || "info", @@ -82,6 +88,7 @@ async function init() { app.nodeCache = new NodeCache({ checkperiod: 30 }); app.dynamoClient = dynamoClient; app.secretsManagerClient = secretsManagerClient; + app.sesClient = sesClient; app.addHook("onRequest", (req, _, done) => { req.startTime = now(); const hostname = req.hostname; @@ -111,6 +118,7 @@ async function init() { api.register(icalPlugin, { prefix: "/ical" }); api.register(iamRoutes, { prefix: "/iam" }); api.register(ticketsPlugin, { prefix: "/tickets" }); + api.register(mobileWalletRoute, { prefix: "/mobileWallet" }); if (app.runEnvironment === "dev") { api.register(vendingPlugin, { prefix: "/vending" }); } diff --git a/src/api/package.json b/src/api/package.json index 9f0467c2..765b03b7 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -7,8 +7,8 @@ "license": "BSD-3-Clause", "type": "module", "scripts": { - "build": "tsc", - "dev": "cross-env LOG_LEVEL=debug tsx watch index.ts", + "build": "tsc && node build.js", + "dev": "cross-env LOG_LEVEL=debug concurrently --names 'esbuild,server' 'node esbuild.config.js --watch' 'cd ../../dist_devel && nodemon index.js'", "typecheck": "tsc --noEmit", "lint": "eslint . --ext .ts --cache", "prettier": "prettier --check *.ts **/*.ts", @@ -17,6 +17,7 @@ "dependencies": { "@aws-sdk/client-dynamodb": "^3.624.0", "@aws-sdk/client-secrets-manager": "^3.624.0", + "@aws-sdk/client-ses": "^3.734.0", "@aws-sdk/client-sts": "^3.726.0", "@aws-sdk/util-dynamodb": "^3.624.0", "@azure/msal-node": "^2.16.1", @@ -25,6 +26,7 @@ "@fastify/caching": "^9.0.1", "@fastify/cors": "^10.0.1", "@touch4it/ical-timezones": "^1.9.0", + "base64-arraybuffer": "^1.0.2", "discord.js": "^14.15.3", "dotenv": "^16.4.5", "esbuild": "^0.24.2", @@ -36,12 +38,14 @@ "moment": "^2.30.1", "moment-timezone": "^0.5.45", "node-cache": "^5.1.2", + "passkit-generator": "^3.3.1", "pluralize": "^8.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.2", "zod-validation-error": "^3.3.1" }, "devDependencies": { - "@tsconfig/node22": "^22.0.0" + "@tsconfig/node22": "^22.0.0", + "nodemon": "^3.1.9" } -} \ No newline at end of file +} diff --git a/src/api/package.lambda.json b/src/api/package.lambda.json new file mode 100644 index 00000000..ae609ba8 --- /dev/null +++ b/src/api/package.lambda.json @@ -0,0 +1,15 @@ +{ + "name": "infra-core-api", + "version": "1.0.0", + "description": "ACM@UIUC Infra - Sample AWS Lambda in Node", + "main": "index.js", + "author": "ACM@UIUC", + "license": "BSD-3-Clause", + "type": "module", + "dependencies": { + "moment-timezone": "^0.5.45", + "passkit-generator": "^3.3.1", + "fastify": "^5.1.0" + }, + "devDependencies": {} +} diff --git a/src/api/resources/MembershipPass.pkpass/icon.png b/src/api/resources/MembershipPass.pkpass/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3800ba0836799d62a56431c62333e1a9256b3317 GIT binary patch literal 8318 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 u*Z!8*OteY~7=ZrcJ);_*HEBRi^C^-gFmtBTU8q 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 + export default value; +} diff --git a/src/api/routes/mobileWallet.ts b/src/api/routes/mobileWallet.ts new file mode 100644 index 00000000..b4d7e882 --- /dev/null +++ b/src/api/routes/mobileWallet.ts @@ -0,0 +1,91 @@ +import { FastifyPluginAsync } from "fastify"; +import { issueAppleWalletMembershipCard } from "../functions/mobileWallet.js"; +import { + EntraFetchError, + UnauthenticatedError, + UnauthorizedError, + ValidationError, +} from "../../common/errors/index.js"; +import { generateMembershipEmailCommand } from "../functions/ses.js"; +import { z } from "zod"; +import { getEntraIdToken, getUserProfile } from "../functions/entraId.js"; +import { checkPaidMembership } from "../functions/membership.js"; + +const mobileWalletRoute: FastifyPluginAsync = async (fastify, _options) => { + fastify.get<{ Querystring: { email: string } }>( + "/membership", + { + schema: { + querystring: { + type: "object", + properties: { + email: { type: "string", format: "email" }, + }, + required: ["email"], + }, + }, + }, + async (request, reply) => { + if (!request.query.email) { + throw new UnauthenticatedError({ message: "Could not find user." }); + } + try { + await z + .string() + .email() + .refine( + (email) => email.endsWith("@illinois.edu"), + "Email must be on the illinois.edu domain.", + ) + .parseAsync(request.query.email); + } catch { + throw new ValidationError({ + message: "Email query parameter is not a valid email", + }); + } + const isPaidMember = await checkPaidMembership( + fastify.environmentConfig.MembershipApiEndpoint, + request.log, + request.query.email.replace("@illinois.edu", ""), + ); + if (!isPaidMember) { + throw new UnauthenticatedError({ + message: `${request.query.email} is not a paid member.`, + }); + } + const entraIdToken = await getEntraIdToken( + fastify, + fastify.environmentConfig.AadValidClientId, + ); + + const userProfile = await getUserProfile( + entraIdToken, + request.query.email, + ); + + const item = await issueAppleWalletMembershipCard( + fastify, + request, + request.query.email, + userProfile.displayName, + ); + const emailCommand = generateMembershipEmailCommand( + request.query.email, + `membership@${fastify.environmentConfig.EmailDomain}`, + item, + ); + if ( + fastify.runEnvironment === "dev" && + request.query.email === "testinguser@illinois.edu" + ) { + return reply + .status(202) + .send({ message: "OK (skipped sending email)" }); + } + await fastify.sesClient.send(emailCommand); + reply.status(202).send({ message: "OK" }); + }, + ); +}; + +export default mobileWalletRoute; diff --git a/src/api/types.d.ts b/src/api/types.d.ts index 7f0e498f..252b40bf 100644 --- a/src/api/types.d.ts +++ b/src/api/types.d.ts @@ -5,6 +5,7 @@ import { ConfigType } from "../common/config.js"; import NodeCache from "node-cache"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; +import { SESClient } from "@aws-sdk/client-ses"; declare module "fastify" { interface FastifyInstance { authenticate: ( @@ -25,6 +26,7 @@ declare module "fastify" { environmentConfig: ConfigType; nodeCache: NodeCache; dynamoClient: DynamoDBClient; + sesClient: SESClient; secretsManagerClient: SecretsManagerClient; } interface FastifyRequest { diff --git a/src/common/config.ts b/src/common/config.ts index b0609d60..3800c37f 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -12,6 +12,10 @@ export type ConfigType = { AzureRoleMapping: AzureRoleMapping; ValidCorsOrigins: ValueOrArray | OriginFunction; AadValidClientId: string; + PasskitIdentifier: string; + PasskitSerialNumber: string; + MembershipApiEndpoint: string; + EmailDomain: string; }; type GenericConfigType = { @@ -66,6 +70,10 @@ const environmentConfig: EnvironmentConfigType = { /^https:\/\/(?:.*\.)?acmuiuc\.pages\.dev$/, ], AadValidClientId: "39c28870-94e4-47ee-b4fb-affe0bf96c9f", + PasskitIdentifier: "pass.org.acmuiuc.qa.membership", + PasskitSerialNumber: "0", + MembershipApiEndpoint: "https://infra-membership-api.aws.qa.acmuiuc.org/api/v1/checkMembership", + EmailDomain: "aws.qa.acmuiuc.org", }, prod: { AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] }, @@ -76,6 +84,10 @@ const environmentConfig: EnvironmentConfigType = { /^https:\/\/(?:.*\.)?acmuiuc\.pages\.dev$/, ], AadValidClientId: "5e08cf0f-53bb-4e09-9df2-e9bdc3467296", + PasskitIdentifier: "pass.edu.illinois.acm.membership", + PasskitSerialNumber: "0", + MembershipApiEndpoint: "https://infra-membership-api.aws.acmuiuc.org/api/v1/checkMembership", + EmailDomain: "acm.illinois.edu", } }; @@ -85,6 +97,9 @@ export type SecretConfig = { discord_bot_token: string; entra_id_private_key: string; entra_id_thumbprint: string; + acm_passkit_signerCert_base64: string; + acm_passkit_signerKey_base64: string; + apple_signing_cert_base64: string; }; export { genericConfig, environmentConfig }; diff --git a/src/common/errors/index.ts b/src/common/errors/index.ts index 632669f4..b03d2a72 100644 --- a/src/common/errors/index.ts +++ b/src/common/errors/index.ts @@ -201,3 +201,16 @@ export class EntraGroupError extends BaseError<"EntraGroupError"> { this.group = group; } } + +export class EntraFetchError extends BaseError<"EntraFetchError"> { + email: string; + constructor({ message, email }: { message?: string; email: string }) { + super({ + name: "EntraFetchError", + id: 509, + message: message || "Could not get data from Entra ID.", + httpStatusCode: 500, + }); + this.email = email; + } +} diff --git a/tests/live/ical.test.ts b/tests/live/ical.test.ts index b6b87e3f..5663a896 100644 --- a/tests/live/ical.test.ts +++ b/tests/live/ical.test.ts @@ -1,5 +1,4 @@ import { expect, test } from "vitest"; -import { InternalServerError } from "../../src/common/errors/index.js"; import { describe } from "node:test"; import { OrganizationList } from "../../src/common/orgs.js"; import ical from "node-ical"; diff --git a/tests/live/mobileWallet.test.ts b/tests/live/mobileWallet.test.ts new file mode 100644 index 00000000..c17aff3c --- /dev/null +++ b/tests/live/mobileWallet.test.ts @@ -0,0 +1,18 @@ +import { expect, test, describe } from "vitest"; + +const baseEndpoint = `https://infra-core-api.aws.qa.acmuiuc.org`; + +describe("Mobile pass issuance", async () => { + test("Test that passes will not be issued for non-members", async () => { + const response = await fetch( + `${baseEndpoint}/api/v1/mobileWallet/membership?email=notamemberatall@illinois.edu`, + ); + expect(response.status).toBe(403); + }); + test("Test that passes will be issued for members", async () => { + const response = await fetch( + `${baseEndpoint}/api/v1/mobileWallet/membership?email=testinguser@illinois.edu`, + ); + expect(response.status).toBe(202); + }); +}); diff --git a/tests/unit/mobileWallet.test.ts b/tests/unit/mobileWallet.test.ts new file mode 100644 index 00000000..937281de --- /dev/null +++ b/tests/unit/mobileWallet.test.ts @@ -0,0 +1,88 @@ +import { afterAll, expect, test, beforeEach, vi } from "vitest"; +import init from "../../src/api/index.js"; +import { describe } from "node:test"; +import { EntraFetchError } from "../../src/common/errors/index.js"; +import { mockClient } from "aws-sdk-client-mock"; +import { issueAppleWalletMembershipCard } from "../../src/api/functions/mobileWallet.js"; +import { SendRawEmailCommand, SESClient } from "@aws-sdk/client-ses"; + +const sesMock = mockClient(SESClient); + +vi.mock("../../src/api/functions/membership.js", () => { + return { + checkPaidMembership: vi.fn( + (_endpoint: string, _log: any, netId: string) => { + if (netId === "valid") { + return true; + } + return false; + }, + ), + }; +}); + +vi.mock("../../src/api/functions/entraId.js", () => { + return { + getEntraIdToken: vi.fn().mockImplementation(async () => { + return "atokenofalltime"; + }), + getUserProfile: vi + .fn() + .mockImplementation(async (_token: string, email: string) => { + if (email === "valid@illinois.edu") { + return { displayName: "John Doe" }; + } + throw new EntraFetchError({ + message: "User not found", + email, + }); + }), + resolveEmailToOid: vi.fn().mockImplementation(async () => { + return "12345"; + }), + }; +}); + +vi.mock("../../src/api/functions/mobileWallet.js", () => { + return { + issueAppleWalletMembershipCard: vi.fn().mockImplementation(async () => { + return new ArrayBuffer(); + }), + }; +}); + +const app = await init(); +describe("Mobile wallet pass issuance", async () => { + test("Test that passes will not be issued for non-emails", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/mobileWallet/membership?email=notanemail", + }); + expect(response.statusCode).toBe(400); + await response.json(); + }); + test("Test that passes will not be issued for non-members", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/mobileWallet/membership?email=notamember@illinois.edu", + }); + expect(response.statusCode).toBe(403); + await response.json(); + }); + test("Test that passes will be issued for members", async () => { + sesMock.on(SendRawEmailCommand).resolvesOnce({}).rejects(); + const response = await app.inject({ + method: "GET", + url: "/api/v1/mobileWallet/membership?email=valid@illinois.edu", + }); + expect(response.statusCode).toBe(202); + expect(issueAppleWalletMembershipCard).toHaveBeenCalledOnce(); + }); + afterAll(async () => { + await app.close(); + }); + beforeEach(() => { + (app as any).nodeCache.flushAll(); + vi.clearAllMocks(); + }); +}); diff --git a/tests/unit/secret.testdata.ts b/tests/unit/secret.testdata.ts index 33efb5c8..978bc25f 100644 --- a/tests/unit/secret.testdata.ts +++ b/tests/unit/secret.testdata.ts @@ -6,6 +6,9 @@ const secretObject = { discord_bot_token: "12345", entra_id_private_key: "", entra_id_thumbprint: "", + acm_passkit_signerCert_base64: "", + acm_passkit_signerKey_base64: "", + apple_signing_cert_base64: "", } as SecretConfig & { jwt_key: string }; const secretJson = JSON.stringify(secretObject); diff --git a/yarn.lock b/yarn.lock index 26a9a553..02fc88e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -153,6 +153,52 @@ tslib "^2.6.2" uuid "^9.0.1" +"@aws-sdk/client-ses@^3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-ses/-/client-ses-3.734.0.tgz#4546c9a0c11d63ba390b52d30b942fcc8bcb83db" + integrity sha512-UivyDyjEriXtBin5KMjqFVUZiTJkibLT6Td62GgABKuAItJh19kQU3EcJ6nyYCAqEmpAapwTZWJzFLf27QyauQ== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "3.734.0" + "@aws-sdk/credential-provider-node" "3.734.0" + "@aws-sdk/middleware-host-header" "3.734.0" + "@aws-sdk/middleware-logger" "3.734.0" + "@aws-sdk/middleware-recursion-detection" "3.734.0" + "@aws-sdk/middleware-user-agent" "3.734.0" + "@aws-sdk/region-config-resolver" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@aws-sdk/util-endpoints" "3.734.0" + "@aws-sdk/util-user-agent-browser" "3.734.0" + "@aws-sdk/util-user-agent-node" "3.734.0" + "@smithy/config-resolver" "^4.0.1" + "@smithy/core" "^3.1.1" + "@smithy/fetch-http-handler" "^5.0.1" + "@smithy/hash-node" "^4.0.1" + "@smithy/invalid-dependency" "^4.0.1" + "@smithy/middleware-content-length" "^4.0.1" + "@smithy/middleware-endpoint" "^4.0.2" + "@smithy/middleware-retry" "^4.0.3" + "@smithy/middleware-serde" "^4.0.1" + "@smithy/middleware-stack" "^4.0.1" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/node-http-handler" "^4.0.2" + "@smithy/protocol-http" "^5.0.1" + "@smithy/smithy-client" "^4.1.2" + "@smithy/types" "^4.1.0" + "@smithy/url-parser" "^4.0.1" + "@smithy/util-base64" "^4.0.0" + "@smithy/util-body-length-browser" "^4.0.0" + "@smithy/util-body-length-node" "^4.0.0" + "@smithy/util-defaults-mode-browser" "^4.0.3" + "@smithy/util-defaults-mode-node" "^4.0.3" + "@smithy/util-endpoints" "^3.0.1" + "@smithy/util-middleware" "^4.0.1" + "@smithy/util-retry" "^4.0.1" + "@smithy/util-utf8" "^4.0.0" + "@smithy/util-waiter" "^4.0.2" + tslib "^2.6.2" + "@aws-sdk/client-sso-oidc@3.721.0": version "3.721.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.721.0.tgz#a53b954e5b0112cd253d82b0f68264827e7d36ca" @@ -331,6 +377,50 @@ "@smithy/util-utf8" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/client-sso@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso/-/client-sso-3.734.0.tgz#789c98267f07aaa7155b404d0bfd4059c4b4deb9" + integrity sha512-oerepp0mut9VlgTwnG5Ds/lb0C0b2/rQ+hL/rF6q+HGKPfGsCuPvFx1GtwGKCXd49ase88/jVgrhcA9OQbz3kg== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "3.734.0" + "@aws-sdk/middleware-host-header" "3.734.0" + "@aws-sdk/middleware-logger" "3.734.0" + "@aws-sdk/middleware-recursion-detection" "3.734.0" + "@aws-sdk/middleware-user-agent" "3.734.0" + "@aws-sdk/region-config-resolver" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@aws-sdk/util-endpoints" "3.734.0" + "@aws-sdk/util-user-agent-browser" "3.734.0" + "@aws-sdk/util-user-agent-node" "3.734.0" + "@smithy/config-resolver" "^4.0.1" + "@smithy/core" "^3.1.1" + "@smithy/fetch-http-handler" "^5.0.1" + "@smithy/hash-node" "^4.0.1" + "@smithy/invalid-dependency" "^4.0.1" + "@smithy/middleware-content-length" "^4.0.1" + "@smithy/middleware-endpoint" "^4.0.2" + "@smithy/middleware-retry" "^4.0.3" + "@smithy/middleware-serde" "^4.0.1" + "@smithy/middleware-stack" "^4.0.1" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/node-http-handler" "^4.0.2" + "@smithy/protocol-http" "^5.0.1" + "@smithy/smithy-client" "^4.1.2" + "@smithy/types" "^4.1.0" + "@smithy/url-parser" "^4.0.1" + "@smithy/util-base64" "^4.0.0" + "@smithy/util-body-length-browser" "^4.0.0" + "@smithy/util-body-length-node" "^4.0.0" + "@smithy/util-defaults-mode-browser" "^4.0.3" + "@smithy/util-defaults-mode-node" "^4.0.3" + "@smithy/util-endpoints" "^3.0.1" + "@smithy/util-middleware" "^4.0.1" + "@smithy/util-retry" "^4.0.1" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + "@aws-sdk/client-sts@3.721.0": version "3.721.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-sts/-/client-sts-3.721.0.tgz#701de8e0877aec3974291e19cd1361feda742680" @@ -457,6 +547,23 @@ fast-xml-parser "4.4.1" tslib "^2.6.2" +"@aws-sdk/core@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.734.0.tgz#fa2289750efd75f4fb8c45719a4a4ea7e7755160" + integrity sha512-SxnDqf3vobdm50OLyAKfqZetv6zzwnSqwIwd3jrbopxxHKqNIM/I0xcYjD6Tn+mPig+u7iRKb9q3QnEooFTlmg== + dependencies: + "@aws-sdk/types" "3.734.0" + "@smithy/core" "^3.1.1" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/property-provider" "^4.0.1" + "@smithy/protocol-http" "^5.0.1" + "@smithy/signature-v4" "^5.0.1" + "@smithy/smithy-client" "^4.1.2" + "@smithy/types" "^4.1.0" + "@smithy/util-middleware" "^4.0.1" + fast-xml-parser "4.4.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-env@3.716.0": version "3.716.0" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.716.0.tgz#10ab93c5806f5e1b29dde8dae38307c766b99197" @@ -479,6 +586,17 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/credential-provider-env@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.734.0.tgz#6c0b1734764a7fb1616455836b1c3dacd99e50a3" + integrity sha512-gtRkzYTGafnm1FPpiNO8VBmJrYMoxhDlGPYDVcijzx3DlF8dhWnowuSBCxLSi+MJMx5hvwrX2A+e/q0QAeHqmw== + dependencies: + "@aws-sdk/core" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@smithy/property-provider" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-http@3.716.0": version "3.716.0" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.716.0.tgz#6d02e3c8b67069a30f51cd3fa761a1e939940da4" @@ -511,6 +629,22 @@ "@smithy/util-stream" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/credential-provider-http@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.734.0.tgz#21c5fbb380d1dd503491897b346e1e0b1d06ae41" + integrity sha512-JFSL6xhONsq+hKM8xroIPhM5/FOhiQ1cov0lZxhzZWj6Ai3UAjucy3zyIFDr9MgP1KfCYNdvyaUq9/o+HWvEDg== + dependencies: + "@aws-sdk/core" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@smithy/fetch-http-handler" "^5.0.1" + "@smithy/node-http-handler" "^4.0.2" + "@smithy/property-provider" "^4.0.1" + "@smithy/protocol-http" "^5.0.1" + "@smithy/smithy-client" "^4.1.2" + "@smithy/types" "^4.1.0" + "@smithy/util-stream" "^4.0.2" + tslib "^2.6.2" + "@aws-sdk/credential-provider-ini@3.721.0": version "3.721.0" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.721.0.tgz#6b28d36fb3409099eb2f8e6222a6b8064516ab32" @@ -547,6 +681,25 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/credential-provider-ini@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.734.0.tgz#5769ae28cd255d4fc946799c0273b4af6f2f12bb" + integrity sha512-HEyaM/hWI7dNmb4NhdlcDLcgJvrilk8G4DQX6qz0i4pBZGC2l4iffuqP8K6ZQjUfz5/6894PzeFuhTORAMd+cg== + dependencies: + "@aws-sdk/core" "3.734.0" + "@aws-sdk/credential-provider-env" "3.734.0" + "@aws-sdk/credential-provider-http" "3.734.0" + "@aws-sdk/credential-provider-process" "3.734.0" + "@aws-sdk/credential-provider-sso" "3.734.0" + "@aws-sdk/credential-provider-web-identity" "3.734.0" + "@aws-sdk/nested-clients" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@smithy/credential-provider-imds" "^4.0.1" + "@smithy/property-provider" "^4.0.1" + "@smithy/shared-ini-file-loader" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-node@3.721.0": version "3.721.0" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.721.0.tgz#a52dc78efebfa566711e12b53e01a9e7216cba8a" @@ -583,6 +736,24 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/credential-provider-node@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.734.0.tgz#86d54171c11cab5b64bfa55ab0def5e807440ad2" + integrity sha512-9NOSNbkPVb91JwaXOhyfahkzAwWdMsbWHL6fh5/PHlXYpsDjfIfT23I++toepNF2nODAJNLnOEHGYIxgNgf6jQ== + dependencies: + "@aws-sdk/credential-provider-env" "3.734.0" + "@aws-sdk/credential-provider-http" "3.734.0" + "@aws-sdk/credential-provider-ini" "3.734.0" + "@aws-sdk/credential-provider-process" "3.734.0" + "@aws-sdk/credential-provider-sso" "3.734.0" + "@aws-sdk/credential-provider-web-identity" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@smithy/credential-provider-imds" "^4.0.1" + "@smithy/property-provider" "^4.0.1" + "@smithy/shared-ini-file-loader" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-process@3.716.0": version "3.716.0" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.716.0.tgz#a8a7b9416cb28c0e2ef601a2713342533619ce4c" @@ -607,6 +778,18 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/credential-provider-process@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.734.0.tgz#eb1de678a9c3d2d7b382e74a670fa283327f9c45" + integrity sha512-zvjsUo+bkYn2vjT+EtLWu3eD6me+uun+Hws1IyWej/fKFAqiBPwyeyCgU7qjkiPQSXqk1U9+/HG9IQ6Iiz+eBw== + dependencies: + "@aws-sdk/core" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@smithy/property-provider" "^4.0.1" + "@smithy/shared-ini-file-loader" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-sso@3.721.0": version "3.721.0" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.721.0.tgz#14350ec1ccdb612af36f35e4383067ecfb99f8e6" @@ -635,6 +818,20 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/credential-provider-sso@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.734.0.tgz#68a9d678319e9743d65cf59e2d29c0c440d8975c" + integrity sha512-cCwwcgUBJOsV/ddyh1OGb4gKYWEaTeTsqaAK19hiNINfYV/DO9r4RMlnWAo84sSBfJuj9shUNsxzyoe6K7R92Q== + dependencies: + "@aws-sdk/client-sso" "3.734.0" + "@aws-sdk/core" "3.734.0" + "@aws-sdk/token-providers" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@smithy/property-provider" "^4.0.1" + "@smithy/shared-ini-file-loader" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-web-identity@3.716.0": version "3.716.0" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.716.0.tgz#dfde14b78a311c0d5ef974f42049c41bef604a83" @@ -657,6 +854,18 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/credential-provider-web-identity@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.734.0.tgz#666b61cc9f498a3aaecd8e38c9ae34aef37e2e64" + integrity sha512-t4OSOerc+ppK541/Iyn1AS40+2vT/qE+MFMotFkhCgCJbApeRF2ozEdnDN6tGmnl4ybcUuxnp9JWLjwDVlR/4g== + dependencies: + "@aws-sdk/core" "3.734.0" + "@aws-sdk/nested-clients" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@smithy/property-provider" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/endpoint-cache@3.693.0": version "3.693.0" resolved "https://registry.yarnpkg.com/@aws-sdk/endpoint-cache/-/endpoint-cache-3.693.0.tgz#4b3f0bbc16dc2907e1b977e3d8ddfc7ba008fd12" @@ -697,6 +906,16 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/middleware-host-header@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.734.0.tgz#a9a02c055352f5c435cc925a4e1e79b7ba41b1b5" + integrity sha512-LW7RRgSOHHBzWZnigNsDIzu3AiwtjeI2X66v+Wn1P1u+eXssy1+up4ZY/h+t2sU4LU36UvEf+jrZti9c6vRnFw== + dependencies: + "@aws-sdk/types" "3.734.0" + "@smithy/protocol-http" "^5.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/middleware-logger@3.714.0": version "3.714.0" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.714.0.tgz#c059e1aabf28fdfc647db6a3dba625a9813787cd" @@ -715,6 +934,15 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/middleware-logger@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.734.0.tgz#d31e141ae7a78667e372953a3b86905bc6124664" + integrity sha512-mUMFITpJUW3LcKvFok176eI5zXAUomVtahb9IQBwLzkqFYOrMJvWAvoV4yuxrJ8TlQBG8gyEnkb9SnhZvjg67w== + dependencies: + "@aws-sdk/types" "3.734.0" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/middleware-recursion-detection@3.714.0": version "3.714.0" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.714.0.tgz#c2d20d335c035196ac1cd5cdf3f58c5f31b01bdb" @@ -735,6 +963,16 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/middleware-recursion-detection@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.734.0.tgz#4fa1deb9887455afbb39130f7d9bc89ccee17168" + integrity sha512-CUat2d9ITsFc2XsmeiRQO96iWpxSKYFjxvj27Hc7vo87YUHRnfMfnc8jw1EpxEwMcvBD7LsRa6vDNky6AjcrFA== + dependencies: + "@aws-sdk/types" "3.734.0" + "@smithy/protocol-http" "^5.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/middleware-user-agent@3.721.0": version "3.721.0" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.721.0.tgz#2a5fbfb63d42a79b4f4b9d94e5aefa66b4e57ddd" @@ -761,6 +999,63 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/middleware-user-agent@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.734.0.tgz#12d400ccb98593f2b02e4fb08239cb9835d41d3a" + integrity sha512-MFVzLWRkfFz02GqGPjqSOteLe5kPfElUrXZft1eElnqulqs6RJfVSpOV7mO90gu293tNAeggMWAVSGRPKIYVMg== + dependencies: + "@aws-sdk/core" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@aws-sdk/util-endpoints" "3.734.0" + "@smithy/core" "^3.1.1" + "@smithy/protocol-http" "^5.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + +"@aws-sdk/nested-clients@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.734.0.tgz#10a116d141522341c446b11783551ef863aabd27" + integrity sha512-iph2XUy8UzIfdJFWo1r0Zng9uWj3253yvW9gljhtu+y/LNmNvSnJxQk1f3D2BC5WmcoPZqTS3UsycT3mLPSzWA== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "3.734.0" + "@aws-sdk/middleware-host-header" "3.734.0" + "@aws-sdk/middleware-logger" "3.734.0" + "@aws-sdk/middleware-recursion-detection" "3.734.0" + "@aws-sdk/middleware-user-agent" "3.734.0" + "@aws-sdk/region-config-resolver" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@aws-sdk/util-endpoints" "3.734.0" + "@aws-sdk/util-user-agent-browser" "3.734.0" + "@aws-sdk/util-user-agent-node" "3.734.0" + "@smithy/config-resolver" "^4.0.1" + "@smithy/core" "^3.1.1" + "@smithy/fetch-http-handler" "^5.0.1" + "@smithy/hash-node" "^4.0.1" + "@smithy/invalid-dependency" "^4.0.1" + "@smithy/middleware-content-length" "^4.0.1" + "@smithy/middleware-endpoint" "^4.0.2" + "@smithy/middleware-retry" "^4.0.3" + "@smithy/middleware-serde" "^4.0.1" + "@smithy/middleware-stack" "^4.0.1" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/node-http-handler" "^4.0.2" + "@smithy/protocol-http" "^5.0.1" + "@smithy/smithy-client" "^4.1.2" + "@smithy/types" "^4.1.0" + "@smithy/url-parser" "^4.0.1" + "@smithy/util-base64" "^4.0.0" + "@smithy/util-body-length-browser" "^4.0.0" + "@smithy/util-body-length-node" "^4.0.0" + "@smithy/util-defaults-mode-browser" "^4.0.3" + "@smithy/util-defaults-mode-node" "^4.0.3" + "@smithy/util-endpoints" "^3.0.1" + "@smithy/util-middleware" "^4.0.1" + "@smithy/util-retry" "^4.0.1" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + "@aws-sdk/region-config-resolver@3.714.0": version "3.714.0" resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.714.0.tgz#26449aeb67daa00560c69bb80cb6cd187ee18dc9" @@ -785,6 +1080,18 @@ "@smithy/util-middleware" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/region-config-resolver@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.734.0.tgz#45ffbc56a3e94cc5c9e0cd596b0fda60f100f70b" + integrity sha512-Lvj1kPRC5IuJBr9DyJ9T9/plkh+EfKLy+12s/mykOy1JaKHDpvj+XGy2YO6YgYVOb8JFtaqloid+5COtje4JTQ== + dependencies: + "@aws-sdk/types" "3.734.0" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/types" "^4.1.0" + "@smithy/util-config-provider" "^4.0.0" + "@smithy/util-middleware" "^4.0.1" + tslib "^2.6.2" + "@aws-sdk/token-providers@3.721.0": version "3.721.0" resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.721.0.tgz#7956b8e88fd995b0fed3716a4d33f0e35f76a598" @@ -807,6 +1114,18 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/token-providers@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.734.0.tgz#8880e94f21457fe5dd7074ecc52fdd43180cbb2c" + integrity sha512-2U6yWKrjWjZO8Y5SHQxkFvMVWHQWbS0ufqfAIBROqmIZNubOL7jXCiVdEFekz6MZ9LF2tvYGnOW4jX8OKDGfIw== + dependencies: + "@aws-sdk/nested-clients" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@smithy/property-provider" "^4.0.1" + "@smithy/shared-ini-file-loader" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/types@3.714.0", "@aws-sdk/types@^3.222.0": version "3.714.0" resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.714.0.tgz#de6afee1436d2d95364efa0663887f3bf0b1303a" @@ -823,6 +1142,14 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/types@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.734.0.tgz#af5e620b0e761918282aa1c8e53cac6091d169a2" + integrity sha512-o11tSPTT70nAkGV1fN9wm/hAIiLPyWX6SuGf+9JyTp7S/rC2cFWhR26MvA69nplcjNaXVzB0f+QFrLXXjOqCrg== + dependencies: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@aws-sdk/util-dynamodb@^3.624.0": version "3.721.0" resolved "https://registry.yarnpkg.com/@aws-sdk/util-dynamodb/-/util-dynamodb-3.721.0.tgz#781723705f5a6c8dd8b3bd163acb5b0a78b7e33b" @@ -850,6 +1177,16 @@ "@smithy/util-endpoints" "^3.0.0" tslib "^2.6.2" +"@aws-sdk/util-endpoints@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.734.0.tgz#43bac42a21a45477a386ccf398028e7f793bc217" + integrity sha512-w2+/E88NUbqql6uCVAsmMxDQKu7vsKV0KqhlQb0lL+RCq4zy07yXYptVNs13qrnuTfyX7uPXkXrlugvK9R1Ucg== + dependencies: + "@aws-sdk/types" "3.734.0" + "@smithy/types" "^4.1.0" + "@smithy/util-endpoints" "^3.0.1" + tslib "^2.6.2" + "@aws-sdk/util-locate-window@^3.0.0": version "3.693.0" resolved "https://registry.yarnpkg.com/@aws-sdk/util-locate-window/-/util-locate-window-3.693.0.tgz#1160f6d055cf074ca198eb8ecf89b6311537ad6c" @@ -877,6 +1214,16 @@ bowser "^2.11.0" tslib "^2.6.2" +"@aws-sdk/util-user-agent-browser@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.734.0.tgz#bbf3348b14bd7783f60346e1ce86978999450fe7" + integrity sha512-xQTCus6Q9LwUuALW+S76OL0jcWtMOVu14q+GoLnWPUM7QeUw963oQcLhF7oq0CtaLLKyl4GOUfcwc773Zmwwng== + dependencies: + "@aws-sdk/types" "3.734.0" + "@smithy/types" "^4.1.0" + bowser "^2.11.0" + tslib "^2.6.2" + "@aws-sdk/util-user-agent-node@3.721.0": version "3.721.0" resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.721.0.tgz#d5336167c753d1bbb749044155cb54aebdf3cf32" @@ -899,6 +1246,17 @@ "@smithy/types" "^4.0.0" tslib "^2.6.2" +"@aws-sdk/util-user-agent-node@3.734.0": + version "3.734.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.734.0.tgz#d5c6ee192cea9d53a871178a2669b8b4dea39a68" + integrity sha512-c6Iinh+RVQKs6jYUFQ64htOU2HUXFQ3TVx+8Tu3EDF19+9vzWi9UukhIMH9rqyyEXIAkk9XL7avt8y2Uyw2dGA== + dependencies: + "@aws-sdk/middleware-user-agent" "3.734.0" + "@aws-sdk/types" "3.734.0" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@azure/msal-browser@^3.20.0": version "3.28.0" resolved "https://registry.yarnpkg.com/@azure/msal-browser/-/msal-browser-3.28.0.tgz#faf955f1debe24ebf24cf8cbfb67246c658c3f11" @@ -1705,6 +2063,18 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429" integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg== +"@hapi/hoek@^9.0.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== + +"@hapi/topo@^5.0.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + "@humanwhocodes/config-array@^0.13.0": version "0.13.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" @@ -2062,6 +2432,23 @@ resolved "https://registry.yarnpkg.com/@sapphire/snowflake/-/snowflake-3.5.5.tgz#33a60ab4231e3cab29e8a0077f342125f2c8d1bd" integrity sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ== +"@sideway/address@^4.1.0": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" + integrity sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + "@sinonjs/commons@^3.0.0", "@sinonjs/commons@^3.0.1": version "3.0.1" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" @@ -2163,6 +2550,20 @@ "@smithy/util-utf8" "^4.0.0" tslib "^2.6.2" +"@smithy/core@^3.1.1", "@smithy/core@^3.1.2": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.1.2.tgz#f5b4c89bf054b717781d71c66b4fb594e06cbb62" + integrity sha512-htwQXkbdF13uwwDevz9BEzL5ABK+1sJpVQXywwGSH973AVOvisHNfpcB8A8761G6XgHoS2kHPqc9DqHJ2gp+/Q== + dependencies: + "@smithy/middleware-serde" "^4.0.2" + "@smithy/protocol-http" "^5.0.1" + "@smithy/types" "^4.1.0" + "@smithy/util-body-length-browser" "^4.0.0" + "@smithy/util-middleware" "^4.0.1" + "@smithy/util-stream" "^4.0.2" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + "@smithy/credential-provider-imds@^3.2.8": version "3.2.8" resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-3.2.8.tgz#27ed2747074c86a7d627a98e56f324a65cba88de" @@ -2217,7 +2618,7 @@ "@smithy/util-utf8" "^3.0.0" tslib "^2.6.2" -"@smithy/hash-node@^4.0.0": +"@smithy/hash-node@^4.0.0", "@smithy/hash-node@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.0.1.tgz#ce78fc11b848a4f47c2e1e7a07fb6b982d2f130c" integrity sha512-TJ6oZS+3r2Xu4emVse1YPB3Dq3d8RkZDKcPr71Nj/lJsdAP1c7oFzYqEn1IBc915TsgLl2xIJNuxCz+gLbLE0w== @@ -2235,7 +2636,7 @@ "@smithy/types" "^3.7.2" tslib "^2.6.2" -"@smithy/invalid-dependency@^4.0.0": +"@smithy/invalid-dependency@^4.0.0", "@smithy/invalid-dependency@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-4.0.1.tgz#704d1acb6fac105558c17d53f6d55da6b0d6b6fc" integrity sha512-gdudFPf4QRQ5pzj7HEnu6FhKRi61BfH/Gk5Yf6O0KiSbr1LlVhgjThcvjdu658VE6Nve8vaIWB8/fodmS1rBPQ== @@ -2273,7 +2674,7 @@ "@smithy/types" "^3.7.2" tslib "^2.6.2" -"@smithy/middleware-content-length@^4.0.0": +"@smithy/middleware-content-length@^4.0.0", "@smithy/middleware-content-length@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-4.0.1.tgz#378bc94ae623f45e412fb4f164b5bb90b9de2ba3" integrity sha512-OGXo7w5EkB5pPiac7KNzVtfCW2vKBTZNuCctn++TTSOMpe6RZO/n6WEC1AxJINn3+vWLKW49uad3lo/u0WJ9oQ== @@ -2310,6 +2711,20 @@ "@smithy/util-middleware" "^4.0.1" tslib "^2.6.2" +"@smithy/middleware-endpoint@^4.0.2", "@smithy/middleware-endpoint@^4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.0.3.tgz#74b64fb2473ae35649a8d22d41708bc5d8d99df2" + integrity sha512-YdbmWhQF5kIxZjWqPIgboVfi8i5XgiYMM7GGKFMTvBei4XjNQfNv8sukT50ITvgnWKKKpOtp0C0h7qixLgb77Q== + dependencies: + "@smithy/core" "^3.1.2" + "@smithy/middleware-serde" "^4.0.2" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/shared-ini-file-loader" "^4.0.1" + "@smithy/types" "^4.1.0" + "@smithy/url-parser" "^4.0.1" + "@smithy/util-middleware" "^4.0.1" + tslib "^2.6.2" + "@smithy/middleware-retry@^3.0.31": version "3.0.34" resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-3.0.34.tgz#136c89fc22d70819fdefc51b0d24952cf98883f1" @@ -2340,6 +2755,21 @@ tslib "^2.6.2" uuid "^9.0.1" +"@smithy/middleware-retry@^4.0.3": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.0.4.tgz#95e55a1b163ff06264f20b4dbbcbd915c8028f60" + integrity sha512-wmxyUBGHaYUqul0wZiset4M39SMtDBOtUr2KpDuftKNN74Do9Y36Go6Eqzj9tL0mIPpr31ulB5UUtxcsCeGXsQ== + dependencies: + "@smithy/node-config-provider" "^4.0.1" + "@smithy/protocol-http" "^5.0.1" + "@smithy/service-error-classification" "^4.0.1" + "@smithy/smithy-client" "^4.1.3" + "@smithy/types" "^4.1.0" + "@smithy/util-middleware" "^4.0.1" + "@smithy/util-retry" "^4.0.1" + tslib "^2.6.2" + uuid "^9.0.1" + "@smithy/middleware-serde@^3.0.11": version "3.0.11" resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-3.0.11.tgz#c7d54e0add4f83e05c6878a011fc664e21022f12" @@ -2356,6 +2786,14 @@ "@smithy/types" "^4.1.0" tslib "^2.6.2" +"@smithy/middleware-serde@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-4.0.2.tgz#f792d72f6ad8fa6b172e3f19c6fe1932a856a56d" + integrity sha512-Sdr5lOagCn5tt+zKsaW+U2/iwr6bI9p08wOkCp6/eL6iMbgdtc2R5Ety66rf87PeohR0ExI84Txz9GYv5ou3iQ== + dependencies: + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@smithy/middleware-stack@^3.0.11": version "3.0.11" resolved "https://registry.yarnpkg.com/@smithy/middleware-stack/-/middleware-stack-3.0.11.tgz#453af2096924e4064d9da4e053cfdf65d9a36acc" @@ -2414,6 +2852,17 @@ "@smithy/types" "^4.1.0" tslib "^2.6.2" +"@smithy/node-http-handler@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.0.2.tgz#48d47a046cf900ab86bfbe7f5fd078b52c82fab6" + integrity sha512-X66H9aah9hisLLSnGuzRYba6vckuFtGE+a5DcHLliI/YlqKrGoxhisD5XbX44KyoeRzoNlGr94eTsMVHFAzPOw== + dependencies: + "@smithy/abort-controller" "^4.0.1" + "@smithy/protocol-http" "^5.0.1" + "@smithy/querystring-builder" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@smithy/property-provider@^3.1.11": version "3.1.11" resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-3.1.11.tgz#161cf1c2a2ada361e417382c57f5ba6fbca8acad" @@ -2524,7 +2973,7 @@ "@smithy/util-utf8" "^3.0.0" tslib "^2.6.2" -"@smithy/signature-v4@^5.0.0": +"@smithy/signature-v4@^5.0.0", "@smithy/signature-v4@^5.0.1": version "5.0.1" resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.0.1.tgz#f93401b176150286ba246681031b0503ec359270" integrity sha512-nCe6fQ+ppm1bQuw5iKoeJ0MJfz2os7Ic3GBjOkLOPtavbD1ONoyE3ygjBfz2ythFWm4YnRm6OxW+8p/m9uCoIA== @@ -2564,6 +3013,19 @@ "@smithy/util-stream" "^4.0.1" tslib "^2.6.2" +"@smithy/smithy-client@^4.1.2", "@smithy/smithy-client@^4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.1.3.tgz#2c8f9aff3377e7655cebe84239da6be277ba8554" + integrity sha512-A2Hz85pu8BJJaYFdX8yb1yocqigyqBzn+OVaVgm+Kwi/DkN8vhN2kbDVEfADo6jXf5hPKquMLGA3UINA64UZ7A== + dependencies: + "@smithy/core" "^3.1.2" + "@smithy/middleware-endpoint" "^4.0.3" + "@smithy/middleware-stack" "^4.0.1" + "@smithy/protocol-http" "^5.0.1" + "@smithy/types" "^4.1.0" + "@smithy/util-stream" "^4.0.2" + tslib "^2.6.2" + "@smithy/types@^3.7.2": version "3.7.2" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-3.7.2.tgz#05cb14840ada6f966de1bf9a9c7dd86027343e10" @@ -2702,6 +3164,17 @@ bowser "^2.11.0" tslib "^2.6.2" +"@smithy/util-defaults-mode-browser@^4.0.3": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.4.tgz#6fa7ba64a80a77f27b9b5c6972918904578b8d5b" + integrity sha512-Ej1bV5sbrIfH++KnWxjjzFNq9nyP3RIUq2c9Iqq7SmMO/idUR24sqvKH2LUQFTSPy/K7G4sB2m8n7YYlEAfZaw== + dependencies: + "@smithy/property-provider" "^4.0.1" + "@smithy/smithy-client" "^4.1.3" + "@smithy/types" "^4.1.0" + bowser "^2.11.0" + tslib "^2.6.2" + "@smithy/util-defaults-mode-node@^3.0.31": version "3.0.34" resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-3.0.34.tgz#5eb0d97231a34e137980abfb08ea5e3a8f2156f7" @@ -2728,6 +3201,19 @@ "@smithy/types" "^4.1.0" tslib "^2.6.2" +"@smithy/util-defaults-mode-node@^4.0.3": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.4.tgz#5470fdc96672cee5199620b576d7025de3b17333" + integrity sha512-HE1I7gxa6yP7ZgXPCFfZSDmVmMtY7SHqzFF55gM/GPegzZKaQWZZ+nYn9C2Cc3JltCMyWe63VPR3tSFDEvuGjw== + dependencies: + "@smithy/config-resolver" "^4.0.1" + "@smithy/credential-provider-imds" "^4.0.1" + "@smithy/node-config-provider" "^4.0.1" + "@smithy/property-provider" "^4.0.1" + "@smithy/smithy-client" "^4.1.3" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@smithy/util-endpoints@^2.1.7": version "2.1.7" resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-2.1.7.tgz#a088ebfab946a7219dd4763bfced82709894b82d" @@ -2737,7 +3223,7 @@ "@smithy/types" "^3.7.2" tslib "^2.6.2" -"@smithy/util-endpoints@^3.0.0": +"@smithy/util-endpoints@^3.0.0", "@smithy/util-endpoints@^3.0.1": version "3.0.1" resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.0.1.tgz#44ccbf1721447966f69496c9003b87daa8f61975" integrity sha512-zVdUENQpdtn9jbpD9SCFK4+aSiavRb9BxEtw9ZGUR1TYo6bBHbIoi7VkrFQ0/RwZlzx0wRBaRmPclj8iAoJCLA== @@ -2822,6 +3308,20 @@ "@smithy/util-utf8" "^4.0.0" tslib "^2.6.2" +"@smithy/util-stream@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-4.0.2.tgz#63495d3f7fba9d78748d540921136dc4a8d4c067" + integrity sha512-0eZ4G5fRzIoewtHtwaYyl8g2C+osYOT4KClXgfdNEDAgkbe2TYPqcnw4GAWabqkZCax2ihRGPe9LZnsPdIUIHA== + dependencies: + "@smithy/fetch-http-handler" "^5.0.1" + "@smithy/node-http-handler" "^4.0.2" + "@smithy/types" "^4.1.0" + "@smithy/util-base64" "^4.0.0" + "@smithy/util-buffer-from" "^4.0.0" + "@smithy/util-hex-encoding" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + "@smithy/util-uri-escape@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@smithy/util-uri-escape/-/util-uri-escape-3.0.0.tgz#e43358a78bf45d50bb736770077f0f09195b6f54" @@ -2869,6 +3369,15 @@ "@smithy/types" "^3.7.2" tslib "^2.6.2" +"@smithy/util-waiter@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@smithy/util-waiter/-/util-waiter-4.0.2.tgz#0a73a0fcd30ea7bbc3009cf98ad199f51b8eac51" + integrity sha512-piUTHyp2Axx3p/kc2CIJkYSv0BAaheBQmbACZgQSSfWUumWNW+R1lL+H9PDBxKJkvOeEX+hKYEFiwO8xagL8AQ== + dependencies: + "@smithy/abort-controller" "^4.0.1" + "@smithy/types" "^4.1.0" + tslib "^2.6.2" + "@storybook/addon-actions@8.4.7": version "8.4.7" resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-8.4.7.tgz#210c6bb5a7e17c3664c300b4b69b6243ec34b9cd" @@ -3817,6 +4326,14 @@ ansi-styles@^6.1.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + arch@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" @@ -4080,6 +4597,11 @@ balanced-match@^2.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9" integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA== +base64-arraybuffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" + integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== + bcrypt-pbkdf@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" @@ -4094,6 +4616,11 @@ better-opn@^3.0.2: dependencies: open "^8.0.4" +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + bowser@^2.11.0: version "2.11.0" resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" @@ -4128,7 +4655,7 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" -braces@^3.0.3: +braces@^3.0.3, braces@~3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== @@ -4279,6 +4806,21 @@ check-error@^2.1.1: resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== +chokidar@^3.5.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + cli-boxes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-3.0.0.tgz#71a10c716feeba005e4504f36329ef0b17cf3145" @@ -4584,7 +5126,7 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.7: +debug@4, debug@^4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.7: version "4.4.0" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== @@ -4723,6 +5265,11 @@ discord.js@^14.15.3: tslib "^2.6.3" undici "6.19.8" +do-not-zip@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/do-not-zip/-/do-not-zip-1.0.0.tgz#cdced6c6352664ecb368f9fe7a15e1cb40c50c42" + integrity sha512-Pgd81ET43bhAGaN2Hq1zluSX1FmD7kl7KcV9ER/lawiLsRUB9pRA5y8r6us29Xk6BrINZETO8TjhYwtwafWUww== + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -5894,7 +6441,7 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -glob-parent@^5.0.0, glob-parent@^5.1.2: +glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -6151,6 +6698,11 @@ identity-obj-proxy@^3.0.0: dependencies: harmony-reflect "^1.4.6" +ignore-by-default@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== + ignore@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" @@ -6274,6 +6826,13 @@ is-bigint@^1.1.0: dependencies: has-bigints "^1.0.2" +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-boolean-object@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.1.tgz#c20d0c654be05da4fbc23c562635c019e93daf89" @@ -6355,7 +6914,7 @@ is-generator-function@^1.0.10, is-generator-function@^1.0.7: has-tostringtag "^1.0.2" safe-regex-test "^1.1.0" -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== @@ -6515,6 +7074,17 @@ iterator.prototype@^1.1.4: has-symbols "^1.1.0" set-function-name "^2.0.2" +joi@17.4.2: + version "17.4.2" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.4.2.tgz#02f4eb5cf88e515e614830239379dcbbe28ce7f7" + integrity sha512-Lm56PP+n0+Z2A2rfRvsfWVDXGEWjXxatPopkQ8qQ5mxCEhwHG+Ettgg5o98FFaxilOxozoa14cFhrE/hOzh/Nw== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/topo" "^5.0.0" + "@sideway/address" "^4.1.0" + "@sideway/formula" "^3.0.0" + "@sideway/pinpoint" "^2.0.0" + jose@^4.14.6: version "4.15.9" resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.9.tgz#9b68eda29e9a0614c042fa29387196c7dd800100" @@ -7158,6 +7728,11 @@ node-cache@^5.1.2: dependencies: clone "2.x" +node-forge@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== + node-ical@^0.20.1: version "0.20.1" resolved "https://registry.yarnpkg.com/node-ical/-/node-ical-0.20.1.tgz#3a67319af9be956b3cc81cdf6716d1352eaefaca" @@ -7173,7 +7748,23 @@ node-releases@^2.0.19: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== -normalize-path@^3.0.0: +nodemon@^3.1.9: + version "3.1.9" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.9.tgz#df502cdc3b120e1c3c0c6e4152349019efa7387b" + integrity sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg== + dependencies: + chokidar "^3.5.2" + debug "^4" + ignore-by-default "^1.0.1" + minimatch "^3.1.2" + pstree.remy "^1.1.8" + semver "^7.5.3" + simple-update-notifier "^2.0.0" + supports-color "^5.5.0" + touch "^3.1.0" + undefsafe "^2.0.5" + +normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== @@ -7387,6 +7978,16 @@ parse5@^7.1.2: dependencies: entities "^4.5.0" +passkit-generator@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/passkit-generator/-/passkit-generator-3.3.1.tgz#8e711973b40ff09316367823030bd404731b3a7c" + integrity sha512-Kme0BjZTiBNSnCMMM0Vndjq1EzSIePU1Pne9jHY08WctmHlA8CEgbRRjzh1S9oBHO7qkDVOGek5OW4oJWTe8Ug== + dependencies: + do-not-zip "^1.0.0" + joi "17.4.2" + node-forge "^1.3.1" + tslib "^2.7.0" + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -7459,7 +8060,7 @@ picocolors@^1.0.0, picocolors@^1.1.0, picocolors@^1.1.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@^2.3.1: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== @@ -7700,6 +8301,11 @@ psl@^1.1.28, psl@^1.1.33: dependencies: punycode "^2.3.1" +pstree.remy@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" + integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== + punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -7900,6 +8506,13 @@ react-transition-group@4.4.5: dependencies: loose-envify "^1.1.0" +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + real-require@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" @@ -8243,7 +8856,7 @@ semver@^6.1.2, semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.6.3: +semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.6.3: version "7.6.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== @@ -8398,6 +9011,13 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +simple-update-notifier@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" + integrity sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== + dependencies: + semver "^7.5.3" + sinon@^18.0.1: version "18.0.1" resolved "https://registry.yarnpkg.com/sinon/-/sinon-18.0.1.tgz#464334cdfea2cddc5eda9a4ea7e2e3f0c7a91c5e" @@ -8803,7 +9423,7 @@ supertest@^7.0.0: methods "^1.1.2" superagent "^9.0.1" -supports-color@^5.3.0: +supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== @@ -8980,6 +9600,11 @@ totalist@^3.0.0: resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8" integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ== +touch@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" + integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== + tough-cookie@^4.1.4: version "4.1.4" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" @@ -9059,7 +9684,7 @@ tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.6.3: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.6.3, tslib@^2.7.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -9211,6 +9836,11 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" +undefsafe@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" + integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== + undici-types@~6.20.0: version "6.20.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" From 6fbe997b11c9b79b1154393710bb7703c27bacf1 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Wed, 29 Jan 2025 16:06:57 -0600 Subject: [PATCH 12/21] add text to the pkpass issuer email --- src/api/functions/ses.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/functions/ses.ts b/src/api/functions/ses.ts index 0ce5ff22..dd9fc4c8 100644 --- a/src/api/functions/ses.ts +++ b/src/api/functions/ses.ts @@ -89,8 +89,12 @@ export function generateMembershipEmailCommand( If you have any questions, feel free to contact us at infra@acm.illinois.edu.

+

+ We also encourage you to check out our resources page, where you can find the benefits associated with your membership. + Welcome to ACM @ UIUC! +