From 6b7aab20b62b45980d8774faa181b9bdf1cbc229 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Thu, 24 Apr 2025 18:35:31 +0000 Subject: [PATCH 01/12] EventsHostRestrictionPolicy: no featured events --- src/common/policies/events.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/common/policies/events.ts b/src/common/policies/events.ts index 9894f63a..93733eba 100644 --- a/src/common/policies/events.ts +++ b/src/common/policies/events.ts @@ -6,7 +6,7 @@ import { FastifyRequest } from "fastify"; export const hostRestrictionPolicy = createPolicy( "EventsHostRestrictionPolicy", z.object({ host: z.array(z.enum(OrganizationList)) }), - (request: FastifyRequest, params) => { + (request: FastifyRequest & { username?: string }, params) => { if (!request.url.startsWith("/api/v1/events")) { return { allowed: true, @@ -14,7 +14,7 @@ export const hostRestrictionPolicy = createPolicy( cacheKey: null, }; } - const typedBody = request.body as { host: string }; + const typedBody = request.body as { host: string, featured: boolean }; if (!typedBody || !typedBody["host"]) { return { allowed: true, @@ -22,10 +22,17 @@ export const hostRestrictionPolicy = createPolicy( cacheKey: null, }; } + if (typedBody["featured"]) { + return { + allowed: false, + message: `Denied by policy "EventsHostRestrictionPolicy". Event must not be featured.`, + cacheKey: request.username || null, + }; + } if (!params.host.includes(typedBody["host"])) { return { allowed: false, - message: `Denied by policy "EventsHostRestrictionPolicy".`, + message: `Denied by policy "EventsHostRestrictionPolicy". Host must be one of: ${params.host.toString()}.`, cacheKey: request.username || null, }; } From d8158d0d2bd3b143f2e7f53fc854e1f8cbe412b0 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sat, 26 Apr 2025 17:08:17 -0500 Subject: [PATCH 02/12] Fix Events API responses (#134) * fix events API retrieval * fix typing * fix metadata pull * fix live tests --- src/api/routes/events.ts | 4 +++- src/common/policies/definition.ts | 18 ++++++++---------- tests/live/events.test.ts | 27 ++++++++++++++++++++++++--- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index cc874a43..c2914238 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -56,6 +56,8 @@ const createProjectionParams = (includeMetadata: boolean = false) => { host: "#host", featured: "#featured", id: "#id", + repeats: "#repeats", + repeatEnds: "#repeatEnds", ...(includeMetadata ? { metadata: "#metadata" } : {}), }; @@ -179,7 +181,7 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( async (request, reply) => { const upcomingOnly = request.query?.upcomingOnly || false; const featuredOnly = request.query?.featuredOnly || false; - const includeMetadata = request.query.includeMetadata || true; + const includeMetadata = request.query.includeMetadata || false; const host = request.query?.host; const ts = request.query?.ts; // we only use this to disable cache control const projection = createProjectionParams(includeMetadata); diff --git a/src/common/policies/definition.ts b/src/common/policies/definition.ts index 6f662534..d858e595 100644 --- a/src/common/policies/definition.ts +++ b/src/common/policies/definition.ts @@ -2,6 +2,7 @@ import { FastifyRequest } from "fastify"; import { hostRestrictionPolicy } from "./events.js"; import { z } from "zod"; import { AuthorizationPolicyResult } from "./evaluator.js"; + type Policy> = { name: string; paramsSchema: TParamsSchema; @@ -11,10 +12,8 @@ type Policy> = { ) => AuthorizationPolicyResult; }; -// Type to get parameters type from a policy type PolicyParams = T extends Policy ? z.infer : never; -// Type for a registry of policies type PolicyRegistry = { [key: string]: Policy; }; @@ -27,16 +26,15 @@ type TypedPolicyRegistry = { }; }; +export const AuthorizationPoliciesRegistry: PolicyRegistry = { + EventsHostRestrictionPolicy: hostRestrictionPolicy, +} as const; + export type AvailableAuthorizationPolicies = TypedPolicyRegistry< typeof AuthorizationPoliciesRegistry >; -export const AuthorizationPoliciesRegistry = { - EventsHostRestrictionPolicy: hostRestrictionPolicy, -} as const; export type AvailableAuthorizationPolicy = { - [K in keyof typeof AuthorizationPoliciesRegistry]: { - name: K; - params: PolicyParams<(typeof AuthorizationPoliciesRegistry)[K]>; - }; -}[keyof typeof AuthorizationPoliciesRegistry]; + name: keyof typeof AuthorizationPoliciesRegistry; + params: PolicyParams; +}; diff --git a/tests/live/events.test.ts b/tests/live/events.test.ts index 86ca1cf7..c1d019cb 100644 --- a/tests/live/events.test.ts +++ b/tests/live/events.test.ts @@ -24,7 +24,7 @@ test("getting events for a given host", async () => { }); describe("Event lifecycle tests", async () => { - let createdEventUuid; + let createdEventUuid: string; test("creating an event", { timeout: 30000 }, async () => { const token = await createJwt(); const response = await fetch(`${baseEndpoint}/api/v1/events`, { @@ -34,13 +34,14 @@ describe("Event lifecycle tests", async () => { "Content-Type": "application/json", }, body: JSON.stringify({ - title: "Testing Event", + title: "Live Testing Event", description: "An event of all time", start: "2024-12-31T02:00:00", end: "2024-12-31T03:30:00", location: "ACM Room (Siebel 1104)", host: "ACM", featured: true, + repeats: "weekly", }), }); const responseJson = await response.json(); @@ -49,6 +50,26 @@ describe("Event lifecycle tests", async () => { expect(responseJson).toHaveProperty("resource"); createdEventUuid = responseJson.id; }); + test("getting a created event", { timeout: 30000 }, async () => { + if (!createdEventUuid) { + throw new Error("Event UUID not found"); + } + const response = await fetch( + `${baseEndpoint}/api/v1/events/${createdEventUuid}?ts=${Date.now()}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }, + ); + const responseJson = await response.json(); + expect(response.status).toBe(200); + expect(responseJson).toHaveProperty("id"); + expect(responseJson).toHaveProperty("repeats"); + expect(responseJson["repeatEnds"]).toBeUndefined(); + createdEventUuid = responseJson.id; + }); test("deleting a previously-created event", { timeout: 30000 }, async () => { if (!createdEventUuid) { @@ -72,7 +93,7 @@ describe("Event lifecycle tests", async () => { throw new Error("Event UUID not found"); } const response = await fetch( - `${baseEndpoint}/api/v1/events/${createdEventUuid}`, + `${baseEndpoint}/api/v1/events/${createdEventUuid}?ts=${Date.now()}`, { method: "GET", }, From e4d78daf721a568896213668f05370347190a42b Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sat, 26 Apr 2025 22:19:37 +0000 Subject: [PATCH 03/12] Add metadata state live tests --- tests/live/events.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/live/events.test.ts b/tests/live/events.test.ts index c1d019cb..e8c94271 100644 --- a/tests/live/events.test.ts +++ b/tests/live/events.test.ts @@ -23,6 +23,30 @@ test("getting events for a given host", async () => { }); }); +test("metadata is included when includeMetadata query parameter is set", async () => { + const response = await fetch( + `${baseEndpoint}/api/v1/events?host=Infrastructure Committee&includeMetadata=true&ts=${Date.now()}`, + ); + expect(response.status).toBe(200); + + const responseJson = (await response.json()) as EventsGetResponse; + expect(responseJson.length).toBeGreaterThan(0); + const withMetadata = responseJson.filter((x) => x["metadata"]); + expect(withMetadata.length).toBeGreaterThanOrEqual(1); +}); + +test("metadata is not included when includeMetadata query parameter is unset", async () => { + const response = await fetch( + `${baseEndpoint}/api/v1/events?host=Infrastructure Committee&ts=${Date.now()}`, + ); + expect(response.status).toBe(200); + + const responseJson = (await response.json()) as EventsGetResponse; + expect(responseJson.length).toBeGreaterThan(0); + const withMetadata = responseJson.filter((x) => x["metadata"]); + expect(withMetadata.length).toEqual(0); +}); + describe("Event lifecycle tests", async () => { let createdEventUuid: string; test("creating an event", { timeout: 30000 }, async () => { From 41d1a639de3c48829ab8a298ebe662274c09d115 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 27 Apr 2025 12:51:25 -0500 Subject: [PATCH 04/12] Upgrade packages and fix linting rules (#135) * Upgrade packages * Fix linting issues and reformat code * fix linter and unit test warnings --- .eslintrc | 76 - eslint.config.mjs | 81 + package.json | 60 +- src/api/build.js | 36 +- src/api/components/index.ts | 2 +- src/api/esbuild.config.js | 43 +- src/api/functions/apiKey.ts | 12 +- src/api/functions/auditLog.ts | 8 +- src/api/functions/authorization.ts | 16 +- src/api/functions/cache.ts | 2 +- src/api/functions/discord.ts | 15 +- src/api/functions/entraId.ts | 51 +- src/api/functions/general.ts | 4 +- src/api/functions/linkry.ts | 24 +- src/api/functions/membership.ts | 4 +- src/api/functions/mobileWallet.ts | 4 +- src/api/functions/rateLimit.ts | 2 +- src/api/functions/sts.ts | 3 +- src/api/functions/validation.ts | 2 +- src/api/index.ts | 4 +- src/api/lambda.ts | 2 - src/api/package.json | 55 +- src/api/plugins/auth.ts | 28 +- src/api/routes/apiKey.ts | 3 +- src/api/routes/events.ts | 7 +- src/api/routes/iam.ts | 24 +- src/api/routes/ics.ts | 11 +- src/api/routes/linkry.ts | 28 +- src/api/routes/logs.ts | 15 +- src/api/routes/membership.ts | 31 +- src/api/routes/mobileWallet.ts | 15 +- src/api/routes/roomRequests.ts | 14 +- src/api/routes/tickets.ts | 53 +- src/api/sqs/driver.ts | 2 +- src/api/sqs/emailNotifications.ts | 2 +- src/api/sqs/handlers.ts | 11 +- src/api/sqs/sales.ts | 2 +- src/common/types/roomRequest.ts | 8 +- src/ui/.prettierrc.cjs | 1 - src/ui/App.test.tsx | 44 - src/ui/App.tsx | 28 +- src/ui/ColorSchemeContext.tsx | 2 +- src/ui/Router.tsx | 143 +- src/ui/components/AppShell/index.tsx | 118 +- .../AuthContext/AuthCallbackHandler.page.tsx | 14 +- .../components/AuthContext/LoadingScreen.tsx | 12 +- src/ui/components/AuthContext/index.tsx | 74 +- src/ui/components/AuthGuard/index.tsx | 145 +- src/ui/components/BlurredTextDisplay.tsx | 18 +- src/ui/components/DarkModeSwitch/index.tsx | 14 +- src/ui/components/FullPageError/index.tsx | 22 +- .../LoginComponent/AcmLoginButton.tsx | 9 +- .../LoginComponent/LoginComponent.test.tsx | 50 + src/ui/components/LoginComponent/index.tsx | 24 +- src/ui/components/Navbar/Logo.test.tsx | 30 +- src/ui/components/Navbar/Logo.tsx | 46 +- src/ui/components/Navbar/index.tsx | 53 +- src/ui/components/ProfileDropdown/index.tsx | 22 +- src/ui/config.ts | 101 +- src/ui/main.tsx | 25 +- src/ui/package.json | 78 +- src/ui/pages/Error404.page.tsx | 6 +- src/ui/pages/Error500.page.tsx | 6 +- src/ui/pages/Home.page.tsx | 17 +- src/ui/pages/Login.page.tsx | 42 +- src/ui/pages/Logout.page.tsx | 6 +- src/ui/pages/apiKeys/ManageKeys.page.tsx | 37 +- src/ui/pages/apiKeys/ManageKeysTable.test.tsx | 118 +- src/ui/pages/apiKeys/ManageKeysTable.tsx | 122 +- src/ui/pages/events/ManageEvent.page.tsx | 240 +- src/ui/pages/events/ViewEvents.page.tsx | 94 +- .../pages/iam/GroupMemberManagement.test.tsx | 175 +- src/ui/pages/iam/GroupMemberManagement.tsx | 93 +- src/ui/pages/iam/ManageIam.page.tsx | 74 +- src/ui/pages/iam/UserInvitePanel.tsx | 55 +- src/ui/pages/linkry/LinkShortener.page.tsx | 177 +- src/ui/pages/linkry/ManageLink.page.tsx | 144 +- src/ui/pages/logs/LogRenderer.test.tsx | 219 +- src/ui/pages/logs/LogRenderer.tsx | 160 +- src/ui/pages/logs/ViewLogs.page.tsx | 26 +- src/ui/pages/profile/ManageProfile.page.tsx | 53 +- .../profile/ManageProfileComponent.test.tsx | 160 +- .../pages/profile/ManageProfileComponent.tsx | 86 +- .../roomRequest/ExistingRoomRequests.tsx | 29 +- .../pages/roomRequest/NewRoomRequest.test.tsx | 138 +- src/ui/pages/roomRequest/NewRoomRequest.tsx | 321 +- .../roomRequest/RoomRequestLanding.page.tsx | 38 +- .../roomRequest/ViewRoomRequest.page.tsx | 138 +- src/ui/pages/roomRequest/roomRequestUtils.tsx | 16 +- src/ui/pages/stripe/CreateLink.test.tsx | 129 +- src/ui/pages/stripe/CreateLink.tsx | 70 +- src/ui/pages/stripe/CurrentLinks.test.tsx | 143 +- src/ui/pages/stripe/CurrentLinks.tsx | 72 +- src/ui/pages/stripe/ViewLinks.page.tsx | 47 +- src/ui/pages/tickets/ScanTickets.page.tsx | 149 +- src/ui/pages/tickets/SelectEventId.page.tsx | 188 +- src/ui/pages/tickets/ViewTickets.page.tsx | 114 +- src/ui/pages/tos/TermsOfService.page.tsx | 433 +- src/ui/postcss.config.cjs | 14 +- src/ui/test-utils/index.ts | 2 +- src/ui/test-utils/render.tsx | 10 +- src/ui/theme.ts | 2 +- src/ui/types.d.ts | 22 +- src/ui/util/api.ts | 15 +- src/ui/util/revision.ts | 2 +- src/ui/vite.config.mjs | 51 +- src/ui/vitest.setup.mjs | 22 +- tests/unit/functions/apiKey.test.ts | 2 +- tests/unit/mockEventData.testdata.ts | 2 +- yarn.lock | 4927 +++++++---------- 110 files changed, 5458 insertions(+), 5581 deletions(-) delete mode 100644 .eslintrc create mode 100644 eslint.config.mjs delete mode 100644 src/ui/.prettierrc.cjs delete mode 100644 src/ui/App.test.tsx create mode 100644 src/ui/components/LoginComponent/LoginComponent.test.tsx diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 1cb1b272..00000000 --- a/.eslintrc +++ /dev/null @@ -1,76 +0,0 @@ -{ - "extends": [ - "plugin:prettier/recommended", - "plugin:@typescript-eslint/recommended" - ], - "plugins": [ - "import", - "prettier" - ], - "rules": { - "import/no-unresolved": "error", - "import/extensions": [ - "error", - "ignorePackages", - { - "js": "never", - "jsx": "never", - "ts": "never", - "tsx": "never" - } - ], - "no-unused-vars": "off", - "max-classes-per-file": "off", - "func-names": "off", - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "caughtErrorsIgnorePattern": "^_" - } - ], - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/explicit-module-boundary-types": "off" - }, - "settings": { - "import/parsers": { - "@typescript-eslint/parser": [ - ".ts", - ".tsx", - ".js", - ".jsx" - ] - }, - "import/resolver": { - "typescript": { - "alwaysTryTypes": true, - "project": [ - "src/api/tsconfig.json", // Path to tsconfig.json in src/api - "src/ui/tsconfig.json" // Path to tsconfig.json in src/ui - ] - } - } - }, - "overrides": [ - { - "files": [ - "*.test.ts", - "*.testdata.ts" - ], - "rules": { - "@typescript-eslint/no-explicit-any": "off" - } - }, - { - "files": [ - "src/ui/*", - "src/ui/**/*" - ], - "rules": { - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": "off" - } - } - ] -} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..f3bf65e5 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,81 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import _import from "eslint-plugin-import"; +import prettier from "eslint-plugin-prettier"; +import { fixupPluginRules } from "@eslint/compat"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; +import mantine from "eslint-config-mantine"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default defineConfig([ + globalIgnores(["**/*.d.ts", "**/vite.config.ts"]), + { + extends: [ + ...compat.extends( + "plugin:prettier/recommended", + "plugin:@typescript-eslint/recommended", + ), + ...mantine, + ], + + plugins: { import: fixupPluginRules(_import), prettier }, + + settings: { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx", ".js", ".jsx"], + }, + "import/resolver": { + typescript: { + alwaysTryTypes: true, + project: ["src/api/tsconfig.json", "src/ui/tsconfig.json"], + }, + }, + }, + + rules: { + "import/no-unresolved": "error", + "no-param-reassign": "off", + "import/extensions": [ + "error", + "ignorePackages", + { js: "never", jsx: "never", ts: "never", tsx: "never" }, + ], + "no-unused-vars": "off", + "max-classes-per-file": "off", + "func-names": "off", + "no-case-declarations": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/explicit-module-boundary-types": "off", + }, + }, + { + files: ["**/*.test.ts", "**/*.testdata.ts"], + rules: { "@typescript-eslint/no-explicit-any": "off" }, + }, + { + files: ["src/ui/*", "src/ui/**/*"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + "no-console": ["error", { allow: ["warn", "error"] }], + }, + }, +]); diff --git a/package.json b/package.json index 3f065e98..045cf37d 100644 --- a/package.json +++ b/package.json @@ -27,58 +27,58 @@ }, "dependencies": {}, "devDependencies": { - "@eslint/compat": "^1.1.1", - "@playwright/test": "^1.49.1", - "@tsconfig/node22": "^22.0.0", - "@types/node": "^22.1.0", + "@eslint/compat": "^1.2.8", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.25.1", + "@playwright/test": "^1.52.0", + "@tsconfig/node22": "^22.0.1", + "@types/node": "^22.15.2", "@types/pluralize": "^0.0.33", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@types/supertest": "^6.0.2", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/parser": "^8.0.1", "@vitejs/plugin-react": "^4.3.1", - "@vitest/coverage-istanbul": "2.1.9", - "@vitest/ui": "^2.0.5", + "@vitest/coverage-istanbul": "3.1.2", + "@vitest/ui": "^3.1.2", "aws-sdk-client-mock": "^4.1.0", "concurrently": "^9.1.2", "cross-env": "^7.0.3", - "esbuild": "^0.23.0", + "esbuild": "^0.25.3", "esbuild-plugin-copy": "^2.1.1", - "eslint": "^8.57.0", + "eslint": "^9.25.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-esnext": "^4.1.0", - "eslint-config-mantine": "^3.2.0", - "eslint-config-prettier": "^9.1.0", - "eslint-import-resolver-typescript": "^3.6.1", + "eslint-config-mantine": "^4.0.3", + "eslint-config-prettier": "^10.1.2", + "eslint-import-resolver-typescript": "^4.3.4", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jsx-a11y": "^6.9.0", - "eslint-plugin-prettier": "^5.2.1", - "eslint-plugin-react": "^7.35.0", - "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", "husky": "^9.1.4", "identity-obj-proxy": "^3.0.0", - "jsdom": "^24.1.1", + "jsdom": "^26.1.0", "node-ical": "^0.20.1", - "postcss": "^8.4.41", + "postcss": "^8.5.3", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", - "prettier": "^3.3.3", + "prettier": "^3.5.3", "prop-types": "^15.8.1", "request": "^2.88.2", - "storybook": "^8.2.8", - "storybook-dark-mode": "^4.0.2", - "stylelint": "^16.8.1", - "stylelint-config-standard-scss": "^13.1.0", - "supertest": "^7.0.0", + "stylelint": "^16.19.1", + "stylelint-config-standard-scss": "^14.0.0", + "supertest": "^7.1.0", "synp": "^1.9.14", - "tsx": "^4.16.5", - "typescript": "^5.5.4", - "typescript-eslint": "^8.0.1", + "tsx": "^4.19.3", + "typescript": "^5.8.3", + "typescript-eslint": "^8.31.0", "vite-tsconfig-paths": "^5.0.1", - "vitest": "^2.0.5", - "yarn-upgrade-all": "^0.7.4" + "vitest": "^3.1.2", + "yarn-upgrade-all": "^0.7.5" }, "resolutions": { "pdfjs-dist": "^4.8.69" diff --git a/src/api/build.js b/src/api/build.js index 20fae8e5..9946c61a 100644 --- a/src/api/build.js +++ b/src/api/build.js @@ -1,7 +1,6 @@ import esbuild from "esbuild"; import { resolve } from "path"; -import { copy } from 'esbuild-plugin-copy' - +import { copy } from "esbuild-plugin-copy"; const commonParams = { bundle: true, @@ -16,9 +15,22 @@ const commonParams = { target: "es2022", // Target ES2022 sourcemap: false, platform: "node", - external: ["aws-sdk", "moment-timezone", "passkit-generator", "fastify", "zod", "zod-openapi", "@fastify/swagger", "@fastify/swagger-ui", "argon2"], + external: [ + "aws-sdk", + "moment-timezone", + "passkit-generator", + "fastify", + "zod", + "zod-openapi", + "@fastify/swagger", + "@fastify/swagger-ui", + "argon2", + ], alias: { - 'moment-timezone': resolve(process.cwd(), '../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js') + "moment-timezone": resolve( + process.cwd(), + "../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js", + ), }, banner: { js: ` @@ -33,22 +45,22 @@ const commonParams = { }, // Banner for compatibility with CommonJS plugins: [ copy({ - resolveFrom: 'cwd', + resolveFrom: "cwd", assets: { - from: ['../../node_modules/@fastify/swagger-ui/static/*'], - to: ['../../dist/lambda/static'], + from: ["../../node_modules/@fastify/swagger-ui/static/*"], + to: ["../../dist/lambda/static"], }, }), copy({ - resolveFrom: 'cwd', + resolveFrom: "cwd", assets: { - from: ['./public/*'], - to: ['../../dist/lambda/public'], + from: ["./public/*"], + to: ["../../dist/lambda/public"], }, }), ], inject: [resolve(process.cwd(), "./zod-openapi-patch.js")], -} +}; esbuild .build({ ...commonParams, @@ -62,7 +74,7 @@ esbuild process.exit(1); }); - esbuild +esbuild .build({ ...commonParams, entryPoints: ["api/sqs/index.js", "api/sqs/driver.js"], diff --git a/src/api/components/index.ts b/src/api/components/index.ts index fe15c01c..2afd8e76 100644 --- a/src/api/components/index.ts +++ b/src/api/components/index.ts @@ -47,7 +47,7 @@ export function withRoles( "x-disable-api-key-auth": disableApiKeyAuth, description: roles.length > 0 - ? `${disableApiKeyAuth ? "API key authentication is not permitted for this route.\n\n" : ""}Requires one of the following roles: ${roles.join(", ")}.${schema.description ? "\n\n" + schema.description : ""}` + ? `${disableApiKeyAuth ? "API key authentication is not permitted for this route.\n\n" : ""}Requires one of the following roles: ${roles.join(", ")}.${schema.description ? `\n\n${schema.description}` : ""}` : "Requires valid authentication but no specific role.", ...schema, }; diff --git a/src/api/esbuild.config.js b/src/api/esbuild.config.js index 847d57fa..2f9fead7 100644 --- a/src/api/esbuild.config.js +++ b/src/api/esbuild.config.js @@ -1,27 +1,32 @@ -import { build, context } from 'esbuild'; -import { readFileSync } from 'fs'; -import { resolve } from 'path'; -import copyStaticFiles from 'esbuild-copy-static-files'; +import { build, context } from "esbuild"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import copyStaticFiles from "esbuild-copy-static-files"; -const isWatching = !!process.argv.includes('--watch') -const nodePackage = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')); +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'), + entryPoints: [resolve(process.cwd(), "index.ts")], + outfile: resolve(process.cwd(), "../", "../", "dist_devel", "index.js"), bundle: true, - platform: 'node', - format: 'esm', + 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 + ".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') + "moment-timezone": resolve( + process.cwd(), + "../../node_modules/moment-timezone/builds/moment-timezone-with-data-10-year-range.js", + ), }, banner: { js: ` @@ -33,14 +38,16 @@ const buildOptions = { import "zod-openapi/extend"; `.trim(), }, // Banner for compatibility with CommonJS - plugins: [copyStaticFiles({ - src: './public', - dest: resolve(process.cwd(), '../', '../', 'dist_devel', 'public'), - })], + plugins: [ + copyStaticFiles({ + src: "./public", + dest: resolve(process.cwd(), "../", "../", "dist_devel", "public"), + }), + ], }; if (isWatching) { - context(buildOptions).then(ctx => { + context(buildOptions).then((ctx) => { if (isWatching) { ctx.watch(); } else { @@ -48,5 +55,5 @@ if (isWatching) { } }); } else { - build(buildOptions) + build(buildOptions); } diff --git a/src/api/functions/apiKey.ts b/src/api/functions/apiKey.ts index 6979053f..cbff5536 100644 --- a/src/api/functions/apiKey.ts +++ b/src/api/functions/apiKey.ts @@ -46,10 +46,10 @@ export const getApiKeyParts = (apiKey: string): DecomposedApiKey => { }); } if ( - prefix != "acmuiuc" || - id.length != 12 || - rawKey.length != 64 || - checksum.length != 6 + prefix !== "acmuiuc" || + id.length !== 12 || + rawKey.length !== 64 || + checksum.length !== 6 ) { throw new UnauthenticatedError({ message: "Invalid API key.", @@ -125,9 +125,9 @@ export const getApiKeyData = async ({ return undefined; // bad data, don't cache it } let cacheTime = API_KEY_DATA_CACHE_SECONDS; - if (unmarshalled["expiresAt"]) { + if (unmarshalled.expiresAt) { const currentEpoch = Date.now(); - cacheTime = min(cacheTime, unmarshalled["expiresAt"] - currentEpoch); + cacheTime = min(cacheTime, unmarshalled.expiresAt - currentEpoch); } nodeCache.set(cacheKey, unmarshalled as ApiKeyDynamoEntry, cacheTime); return unmarshalled; diff --git a/src/api/functions/auditLog.ts b/src/api/functions/auditLog.ts index b15f7292..dfd37636 100644 --- a/src/api/functions/auditLog.ts +++ b/src/api/functions/auditLog.ts @@ -31,11 +31,11 @@ export async function createAuditLogEntry({ dynamoClient, entry, }: AuditLogParams) { - if (!dynamoClient) { - dynamoClient = new DynamoDBClient({ + const safeDynamoClient = + dynamoClient || + new DynamoDBClient({ region: genericConfig.AwsRegion, }); - } const item = buildMarshalledAuditLogItem(entry); @@ -44,7 +44,7 @@ export async function createAuditLogEntry({ Item: item, }); - return dynamoClient.send(command); + return safeDynamoClient.send(command); } export function buildAuditLogTransactPut({ diff --git a/src/api/functions/authorization.ts b/src/api/functions/authorization.ts index 83e7b58e..d124651e 100644 --- a/src/api/functions/authorization.ts +++ b/src/api/functions/authorization.ts @@ -17,7 +17,7 @@ export async function getUserRoles( fastifyApp.log.info(`Returning cached auth decision for user ${userId}`); return cachedValue as AppRoles[]; } - const tableName = `${genericConfig["IAMTablePrefix"]}-userroles`; + const tableName = `${genericConfig.IAMTablePrefix}-userroles`; const command = new GetItemCommand({ TableName: tableName, Key: { @@ -37,7 +37,7 @@ export async function getUserRoles( if (!("roles" in items)) { return []; } - if (items["roles"][0] === "all") { + if (items.roles[0] === "all") { fastifyApp.nodeCache.set( `userroles-${userId}`, allAppRoles, @@ -47,10 +47,10 @@ export async function getUserRoles( } fastifyApp.nodeCache.set( `userroles-${userId}`, - items["roles"], + items.roles, AUTH_DECISION_CACHE_SECONDS, ); - return items["roles"] as AppRoles[]; + return items.roles as AppRoles[]; } export async function getGroupRoles( @@ -63,7 +63,7 @@ export async function getGroupRoles( fastifyApp.log.info(`Returning cached auth decision for group ${groupId}`); return cachedValue as AppRoles[]; } - const tableName = `${genericConfig["IAMTablePrefix"]}-grouproles`; + const tableName = `${genericConfig.IAMTablePrefix}-grouproles`; const command = new GetItemCommand({ TableName: tableName, Key: { @@ -93,7 +93,7 @@ export async function getGroupRoles( ); return []; } - if (items["roles"][0] === "all") { + if (items.roles[0] === "all") { fastifyApp.nodeCache.set( `grouproles-${groupId}`, allAppRoles, @@ -103,8 +103,8 @@ export async function getGroupRoles( } fastifyApp.nodeCache.set( `grouproles-${groupId}`, - items["roles"], + items.roles, AUTH_DECISION_CACHE_SECONDS, ); - return items["roles"] as AppRoles[]; + return items.roles as AppRoles[]; } diff --git a/src/api/functions/cache.ts b/src/api/functions/cache.ts index d0c0849a..44890f4a 100644 --- a/src/api/functions/cache.ts +++ b/src/api/functions/cache.ts @@ -29,7 +29,7 @@ export async function getItemFromCache( }), }), ); - if (!Items || Items.length == 0) { + if (!Items || Items.length === 0) { return null; } const item = unmarshall(Items[0]); diff --git a/src/api/functions/discord.ts b/src/api/functions/discord.ts index a60883be..9e06911d 100644 --- a/src/api/functions/discord.ts +++ b/src/api/functions/discord.ts @@ -23,7 +23,8 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; export type IUpdateDiscord = EventPostRequest & { id: string }; -const urlRegex = /https:\/\/[a-z0-9\.-]+\/calendar\?id=([a-f0-9-]+)/; +const urlRegex = /https:\/\/[a-z0-9.-]+\/calendar\?id=([a-f0-9-]+)/; + export const updateDiscord = async ( smClient: SecretsManagerClient, event: IUpdateDiscord, @@ -38,7 +39,7 @@ export const updateDiscord = async ( client.once(Events.ClientReady, async (readyClient: Client) => { logger.info(`Logged in as ${readyClient.user.tag}`); - const guildID = secretApiConfig["discord_guild_id"]; + const guildID = secretApiConfig.discord_guild_id; const guild = await client.guilds.fetch(guildID?.toString() || ""); const discordEvents = await guild.scheduledEvents.fetch(); const snowflakeMeetingLookup = discordEvents.reduce( @@ -100,19 +101,17 @@ export const updateDiscord = async ( } else { await guild.scheduledEvents.edit(existingMetadata.id, payload); } + } else if (payload.scheduledStartTime < new Date()) { + logger.warn(`Refusing to create past event "${title}"`); } else { - if (payload.scheduledStartTime < new Date()) { - logger.warn(`Refusing to create past event "${title}"`); - } else { - await guild.scheduledEvents.create(payload); - } + await guild.scheduledEvents.create(payload); } await client.destroy(); return payload; }); - const token = secretApiConfig["discord_bot_token"]; + const token = secretApiConfig.discord_bot_token; if (!token) { logger.error("No Discord bot token found in secrets!"); diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index ecada3a5..925165d7 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -40,11 +40,9 @@ export async function getEntraIdToken( scopes: string[] = ["https://graph.microsoft.com/.default"], secretName?: string, ) { - if (!secretName) { - secretName = genericConfig.EntraSecretName; - } + const localSecretName = secretName || genericConfig.EntraSecretName; const secretApiConfig = - (await getSecretValue(clients.smClient, secretName)) || {}; + (await getSecretValue(clients.smClient, localSecretName)) || {}; if ( !secretApiConfig.entra_id_private_key || !secretApiConfig.entra_id_thumbprint @@ -59,14 +57,14 @@ export async function getEntraIdToken( ).toString("utf8"); const cachedToken = await getItemFromCache( clients.dynamoClient, - `entra_id_access_token_${secretName}`, + `entra_id_access_token_${localSecretName}`, ); if (cachedToken) { - return cachedToken["token"] as string; + return cachedToken.token as string; } const config = { auth: { - clientId: clientId, + clientId, authority: `https://login.microsoftonline.com/${genericConfig.EntraTenantId}`, clientCertificate: { thumbprint: (secretApiConfig.entra_id_thumbprint as string) || "", @@ -89,7 +87,7 @@ export async function getEntraIdToken( if (result?.accessToken) { await insertItemIntoCache( clients.dynamoClient, - `entra_id_access_token_${secretName}`, + `entra_id_access_token_${localSecretName}`, { token: result?.accessToken }, date, ); @@ -108,21 +106,21 @@ export async function getEntraIdToken( /** * Adds a user to the tenant by sending an invitation to their email * @param token - Entra ID token authorized to take this action. - * @param email - The email address of the user to invite + * @param safeEmail - The email address of the user to invite * @throws {InternalServerError} If the invitation fails * @returns {Promise} True if the invitation was successful */ export async function addToTenant(token: string, email: string) { - email = email.toLowerCase().replace(/\s/g, ""); - if (!email.endsWith("@illinois.edu")) { + const safeEmail = email.toLowerCase().replace(/\s/g, ""); + if (!safeEmail.endsWith("@illinois.edu")) { throw new EntraInvitationError({ - email, + email: safeEmail, message: "User's domain must be illinois.edu to be invited.", }); } try { const body = { - invitedUserEmailAddress: email, + invitedUserEmailAddress: safeEmail, inviteRedirectUrl: "https://acm.illinois.edu", }; const url = "https://graph.microsoft.com/v1.0/invitations"; @@ -139,11 +137,11 @@ export async function addToTenant(token: string, email: string) { const errorData = (await response.json()) as EntraInvitationResponse; throw new EntraInvitationError({ message: errorData.error?.message || response.statusText, - email, + email: safeEmail, }); } - return { success: true, email }; + return { success: true, email: safeEmail }; } catch (error) { if (error instanceof EntraInvitationError) { throw error; @@ -151,7 +149,7 @@ export async function addToTenant(token: string, email: string) { throw new EntraInvitationError({ message: error instanceof Error ? error.message : String(error), - email, + email: safeEmail, }); } } @@ -167,9 +165,9 @@ export async function resolveEmailToOid( token: string, email: string, ): Promise { - email = email.toLowerCase().replace(/\s/g, ""); + const safeEmail = email.toLowerCase().replace(/\s/g, ""); - const url = `https://graph.microsoft.com/v1.0/users?$filter=mail eq '${email}'`; + const url = `https://graph.microsoft.com/v1.0/users?$filter=mail eq '${safeEmail}'`; const response = await fetch(url, { method: "GET", @@ -191,7 +189,7 @@ export async function resolveEmailToOid( }; if (!data.value || data.value.length === 0) { - throw new Error(`No user found with email: ${email}`); + throw new Error(`No user found with email: ${safeEmail}`); } return data.value[0].id; @@ -214,8 +212,8 @@ export async function modifyGroup( action: EntraGroupActions, dynamoClient: DynamoDBClient, ): Promise { - email = email.toLowerCase().replace(/\s/g, ""); - if (!email.endsWith("@illinois.edu")) { + const safeEmail = email.toLowerCase().replace(/\s/g, ""); + if (!safeEmail.endsWith("@illinois.edu")) { throw new EntraGroupError({ group, message: "User's domain must be illinois.edu to be added to the group.", @@ -234,7 +232,7 @@ export async function modifyGroup( paidMemberRequiredGroups.includes(group) && action === EntraGroupActions.ADD ) { - const netId = email.split("@")[0]; + const netId = safeEmail.split("@")[0]; const isPaidMember = checkPaidMembershipFromTable(netId, dynamoClient); // we assume users have been provisioned into the table. if (!isPaidMember) { throw new EntraGroupError({ @@ -244,7 +242,7 @@ export async function modifyGroup( } } try { - const oid = await resolveEmailToOid(token, email); + const oid = await resolveEmailToOid(token, safeEmail); const methodMapper = { [EntraGroupActions.ADD]: "POST", [EntraGroupActions.REMOVE]: "DELETE", @@ -440,7 +438,6 @@ export async function patchUserProfile( email, }); } - return; } catch (error) { if (error instanceof EntraPatchError) { throw error; @@ -466,15 +463,15 @@ export async function isUserInGroup( email: string, group: string, ): Promise { - email = email.toLowerCase().replace(/\s/g, ""); - if (!email.endsWith("@illinois.edu")) { + const safeEmail = email.toLowerCase().replace(/\s/g, ""); + if (!safeEmail.endsWith("@illinois.edu")) { throw new EntraGroupError({ group, message: "User's domain must be illinois.edu to check group membership.", }); } try { - const oid = await resolveEmailToOid(token, email); + const oid = await resolveEmailToOid(token, safeEmail); const url = `https://graph.microsoft.com/v1.0/groups/${group}/members/${oid}`; const response = await fetch(url, { diff --git a/src/api/functions/general.ts b/src/api/functions/general.ts index 1acf57ef..193ff41b 100644 --- a/src/api/functions/general.ts +++ b/src/api/functions/general.ts @@ -11,7 +11,9 @@ export function pollUntilNoError( const result = await fn(); return resolve(result); } catch (err) { - if (Date.now() - start >= timeout) return reject(err); + if (Date.now() - start >= timeout) { + return reject(err); + } setTimeout(attempt, interval); } }; diff --git a/src/api/functions/linkry.ts b/src/api/functions/linkry.ts index 80c4067d..923b94dc 100644 --- a/src/api/functions/linkry.ts +++ b/src/api/functions/linkry.ts @@ -22,19 +22,19 @@ export async function fetchLinkEntry( ScanIndexForward: false, }); const result = await dynamoClient.send(fetchLinkEntry); - if (!result.Items || result.Items.length == 0) { + if (!result.Items || result.Items.length === 0) { return null; } const unmarshalled = result.Items.map((x) => unmarshall(x)); const ownerRecord = unmarshalled.filter((x) => - (x["access"] as string).startsWith("OWNER#"), + (x.access as string).startsWith("OWNER#"), )[0]; return { ...ownerRecord, access: unmarshalled - .filter((x) => (x["access"] as string).startsWith("GROUP#")) - .map((x) => (x["access"] as string).replace("GROUP#", "")), - owner: ownerRecord["access"].replace("OWNER#", ""), + .filter((x) => (x.access as string).startsWith("GROUP#")) + .map((x) => (x.access as string).replace("GROUP#", "")), + owner: ownerRecord.access.replace("OWNER#", ""), } as LinkRecord; } @@ -148,10 +148,10 @@ export async function getAllLinks( const response = await dynamoClient.send(scan); const unmarshalled = (response.Items || []).map((item) => unmarshall(item)); const ownerRecords = unmarshalled.filter((x) => - (x["access"] as string).startsWith("OWNER#"), + (x.access as string).startsWith("OWNER#"), ); const delegations = unmarshalled.filter( - (x) => !(x["access"] as string).startsWith("OWNER#"), + (x) => !(x.access as string).startsWith("OWNER#"), ); const accessGroupMap: Record = {}; // maps slug to access groups for (const deleg of delegations) { @@ -164,7 +164,7 @@ export async function getAllLinks( return ownerRecords.map((x) => ({ ...x, access: accessGroupMap[x.slug], - owner: x["access"].replace("OWNER#", ""), + owner: x.access.replace("OWNER#", ""), })) as LinkRecord[]; } @@ -200,7 +200,9 @@ export async function getDelegatedLinks( ), ]; - if (!delegatedSlugs.length) return []; + if (!delegatedSlugs.length) { + return []; + } // Fetch entry records const results = await Promise.all( @@ -225,7 +227,9 @@ export async function getDelegatedLinks( ? unmarshall(ownerResponse.Items[0]) : null; - if (!ownerRecord) return null; + if (!ownerRecord) { + return null; + } const groupQuery = new QueryCommand({ TableName: tableName, KeyConditionExpression: diff --git a/src/api/functions/membership.ts b/src/api/functions/membership.ts index 907755a1..ad89d31a 100644 --- a/src/api/functions/membership.ts +++ b/src/api/functions/membership.ts @@ -33,7 +33,7 @@ export async function checkExternalMembership( }), }), ); - if (!Items || Items.length == 0) { + if (!Items || Items.length === 0) { return false; } return true; @@ -55,7 +55,7 @@ export async function checkPaidMembershipFromTable( }), }), ); - if (!Items || Items.length == 0) { + if (!Items || Items.length === 0) { return false; } return true; diff --git a/src/api/functions/mobileWallet.ts b/src/api/functions/mobileWallet.ts index 915c5a93..84e8efb5 100644 --- a/src/api/functions/mobileWallet.ts +++ b/src/api/functions/mobileWallet.ts @@ -67,7 +67,7 @@ export async function issueAppleWalletMembershipCard( secretApiConfig.apple_signing_cert_base64, "base64", ).toString("utf-8"); - pass["passTypeIdentifier"] = environmentConfig["PasskitIdentifier"]; + pass.passTypeIdentifier = environmentConfig.PasskitIdentifier; const pkpass = new PKPass( { "icon.png": await fs.readFile(icon), @@ -82,7 +82,7 @@ export async function issueAppleWalletMembershipCard( }, { // logoText: app.runEnvironment === "dev" ? "INVALID Membership Pass" : "Membership Pass", - serialNumber: environmentConfig["PasskitSerialNumber"], + serialNumber: environmentConfig.PasskitSerialNumber, }, ); pkpass.setBarcodes({ diff --git a/src/api/functions/rateLimit.ts b/src/api/functions/rateLimit.ts index 9af78f85..27abfd01 100644 --- a/src/api/functions/rateLimit.ts +++ b/src/api/functions/rateLimit.ts @@ -1,8 +1,8 @@ import { ConditionalCheckFailedException, UpdateItemCommand, + DynamoDBClient, } from "@aws-sdk/client-dynamodb"; -import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { genericConfig } from "common/config.js"; interface RateLimitParams { diff --git a/src/api/functions/sts.ts b/src/api/functions/sts.ts index dd984ffc..d70f6a9e 100644 --- a/src/api/functions/sts.ts +++ b/src/api/functions/sts.ts @@ -1,5 +1,4 @@ -import { AssumeRoleCommand } from "@aws-sdk/client-sts"; -import { STSClient } from "@aws-sdk/client-sts"; +import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; import { genericConfig } from "common/config.js"; import { InternalServerError } from "common/errors/index.js"; diff --git a/src/api/functions/validation.ts b/src/api/functions/validation.ts index 457a4aaa..1c4070d0 100644 --- a/src/api/functions/validation.ts +++ b/src/api/functions/validation.ts @@ -7,6 +7,6 @@ export function validateEmail(email: string): boolean { } export function validateNetId(netId: string): boolean { - const regex = /^[a-zA-Z]{2}[a-zA-Z\-]*(?:[2-9]|[1-9][0-9]{1,2})?$/; + const regex = /^[a-zA-Z]{2}[a-zA-Z-]*(?:[2-9]|[1-9][0-9]{1,2})?$/; return netId.length >= 3 && netId.length <= 8 && regex.test(netId); } diff --git a/src/api/index.ts b/src/api/index.ts index c9eb2573..b1b465fe 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -296,7 +296,9 @@ if (import.meta.url === `file://${process.argv[1]}`) { const app = await init(true); app.listen({ port: 8080 }, async (err) => { /* eslint no-console: ["error", {"allow": ["log", "error"]}] */ - if (err) console.error(err); + if (err) { + console.error(err); + } }); } export default init; diff --git a/src/api/lambda.ts b/src/api/lambda.ts index e1a91500..e3111316 100644 --- a/src/api/lambda.ts +++ b/src/api/lambda.ts @@ -1,5 +1,3 @@ -/* eslint-disable */ - import "zod-openapi/extend"; import awsLambdaFastify from "@fastify/aws-lambda"; import init from "./index.js"; diff --git a/src/api/package.json b/src/api/package.json index 46842ccb..4ca4b0d5 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -15,58 +15,57 @@ "prettier:write": "prettier --write *.ts **/*.ts" }, "dependencies": { - "@aws-sdk/client-cloudfront-keyvaluestore": "^3.787.0", - "@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-sqs": "^3.738.0", - "@aws-sdk/client-sts": "^3.758.0", - "@aws-sdk/signature-v4-crt": "^3.787.0", - "@aws-sdk/util-dynamodb": "^3.624.0", - "@azure/msal-node": "^2.16.1", + "@aws-sdk/client-cloudfront-keyvaluestore": "^3.797.0", + "@aws-sdk/client-dynamodb": "^3.797.0", + "@aws-sdk/client-secrets-manager": "^3.797.0", + "@aws-sdk/client-ses": "^3.797.0", + "@aws-sdk/client-sqs": "^3.797.0", + "@aws-sdk/client-sts": "^3.797.0", + "@aws-sdk/signature-v4-crt": "^3.796.0", + "@aws-sdk/util-dynamodb": "^3.797.0", + "@azure/msal-node": "^3.5.1", "@fastify/auth": "^5.0.1", "@fastify/aws-lambda": "^5.0.0", "@fastify/caching": "^9.0.1", - "@fastify/cors": "^10.0.1", + "@fastify/cors": "^11.0.1", "@fastify/static": "^8.1.1", "@fastify/swagger": "^9.5.0", "@fastify/swagger-ui": "^5.2.2", - "@middy/core": "^6.0.0", - "@middy/event-normalizer": "^6.0.0", - "@middy/sqs-partial-batch-failure": "^6.0.0", + "@middy/core": "^6.1.6", + "@middy/event-normalizer": "^6.1.6", + "@middy/sqs-partial-batch-failure": "^6.1.6", "@touch4it/ical-timezones": "^1.9.0", - "argon2": "^0.41.1", + "argon2": "^0.43.0", "base64-arraybuffer": "^1.0.2", - "discord.js": "^14.15.3", - "dotenv": "^16.4.5", - "esbuild": "^0.24.2", + "discord.js": "^14.19.1", + "dotenv": "^16.5.0", + "esbuild": "^0.25.3", "fastify": "^5.3.2", - "fastify-plugin": "^4.5.1", + "fastify-plugin": "^5.0.1", "fastify-raw-body": "^5.0.0", "fastify-zod-openapi": "^4.1.1", - "ical-generator": "^7.2.0", + "ical-generator": "^8.1.1", "jsonwebtoken": "^9.0.2", - "jwks-rsa": "^3.1.0", + "jwks-rsa": "^3.2.0", "moment": "^2.30.1", - "moment-timezone": "^0.5.45", + "moment-timezone": "^0.5.48", "node-cache": "^5.1.2", "passkit-generator": "^3.3.1", "pino": "^9.6.0", "pluralize": "^8.0.0", "qrcode": "^1.5.4", - "stripe": "^17.6.0", - "uuid": "^11.0.5", - "zod": "^3.23.8", + "stripe": "^18.0.0", + "uuid": "^11.1.0", + "zod": "^3.24.3", "zod-openapi": "^4.2.4", - "zod-to-json-schema": "^3.23.2", "zod-validation-error": "^3.3.1" }, "devDependencies": { - "@tsconfig/node22": "^22.0.0", - "@types/aws-lambda": "^8.10.147", + "@tsconfig/node22": "^22.0.1", + "@types/aws-lambda": "^8.10.149", "@types/qrcode": "^1.5.5", "esbuild-copy-static-files": "^0.1.0", - "nodemon": "^3.1.9", + "nodemon": "^3.1.10", "pino-pretty": "^13.0.0" } } diff --git a/src/api/plugins/auth.ts b/src/api/plugins/auth.ts index f7e75604..e09ce492 100644 --- a/src/api/plugins/auth.ts +++ b/src/api/plugins/auth.ts @@ -120,12 +120,12 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { }; fastify.decorate( "authorize", - async function ( + async ( request: FastifyRequest, reply: FastifyReply, validRoles: AppRoles[], disableApiKeyAuth: boolean, - ): Promise> { + ): Promise> => { const userRoles = new Set([] as AppRoles[]); try { if (!disableApiKeyAuth) { @@ -138,7 +138,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { } const authHeader = request.headers - ? request.headers["authorization"] + ? request.headers.authorization : null; if (!authHeader) { throw new UnauthenticatedError({ @@ -235,18 +235,16 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => { request.log.warn(`Failed to get group roles: ${result.reason}`); } } - } else { - if ( - verifiedTokenData.roles && - fastify.environmentConfig.AzureRoleMapping - ) { - for (const group of verifiedTokenData.roles) { - if (fastify.environmentConfig["AzureRoleMapping"][group]) { - for (const role of fastify.environmentConfig[ - "AzureRoleMapping" - ][group]) { - userRoles.add(role); - } + } else if ( + verifiedTokenData.roles && + fastify.environmentConfig.AzureRoleMapping + ) { + for (const group of verifiedTokenData.roles) { + if (fastify.environmentConfig.AzureRoleMapping[group]) { + for (const role of fastify.environmentConfig.AzureRoleMapping[ + group + ]) { + userRoles.add(role); } } } diff --git a/src/api/routes/apiKey.ts b/src/api/routes/apiKey.ts index 36288745..6f66ffd3 100644 --- a/src/api/routes/apiKey.ts +++ b/src/api/routes/apiKey.ts @@ -4,7 +4,7 @@ import { withRoles, withTags } from "api/components/index.js"; import { AppRoles } from "common/roles.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { apiKeyPostBody } from "common/types/apiKey.js"; -import { createApiKey } from "api/functions/apiKey.js"; +import { createApiKey, ApiKeyDynamoEntry } from "api/functions/apiKey.js"; import { buildAuditLogTransactPut } from "api/functions/auditLog.js"; import { Modules } from "common/modules.js"; import { genericConfig } from "common/config.js"; @@ -22,7 +22,6 @@ import { ValidationError, } from "common/errors/index.js"; import { z } from "zod"; -import { ApiKeyDynamoEntry } from "api/functions/apiKey.js"; const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => { await fastify.register(rateLimiter, { diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index c2914238..e9b2b46e 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -2,7 +2,6 @@ import "zod-openapi/extend"; import { FastifyPluginAsync, FastifyRequest } from "fastify"; import { AppRoles } from "../../common/roles.js"; import { z } from "zod"; -import { zodToJsonSchema } from "zod-to-json-schema"; import { OrganizationList } from "../../common/orgs.js"; import { DeleteItemCommand, @@ -277,7 +276,7 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( return reply.send(parsedItems); } catch (e: unknown) { if (e instanceof Error) { - request.log.error("Failed to get from DynamoDB: " + e.toString()); + request.log.error(`Failed to get from DynamoDB: ${e.toString()}`); } else { request.log.error(`Failed to get from DynamoDB.${e} `); } @@ -419,7 +418,7 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( }); } catch (e: unknown) { if (e instanceof Error) { - request.log.error("Failed to insert to DynamoDB: " + e.toString()); + request.log.error(`Failed to insert to DynamoDB: ${e.toString()}`); } if (e instanceof BaseError) { throw e; @@ -516,7 +515,7 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async ( }); } catch (e: unknown) { if (e instanceof Error) { - request.log.error("Failed to delete from DynamoDB: " + e.toString()); + request.log.error(`Failed to delete from DynamoDB: ${e.toString()}`); } throw new DatabaseInsertError({ message: "Failed to delete event from Dynamo table.", diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts index 0f6d9725..baa41cc5 100644 --- a/src/api/routes/iam.ts +++ b/src/api/routes/iam.ts @@ -1,6 +1,5 @@ import { FastifyPluginAsync } from "fastify"; import { AppRoles } from "../../common/roles.js"; -import { zodToJsonSchema } from "zod-to-json-schema"; import { addToTenant, getEntraIdToken, @@ -38,11 +37,7 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { createAuditLogEntry } from "api/functions/auditLog.js"; import { Modules } from "common/modules.js"; import { groupId, withRoles, withTags } from "api/components/index.js"; -import { - FastifyZodOpenApiTypeProvider, - serializerCompiler, - validatorCompiler, -} from "fastify-zod-openapi"; +import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { z } from "zod"; const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { @@ -66,15 +61,14 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { `Assumed Entra role ${roleArns.Entra} to get the Entra token.`, ); return clients; - } else { - fastify.log.debug( - "Did not assume Entra role as no env variable was present", - ); - return { - smClient: fastify.secretsManagerClient, - dynamoClient: fastify.dynamoClient, - }; } + fastify.log.debug( + "Did not assume Entra role as no env variable was present", + ); + return { + smClient: fastify.secretsManagerClient, + dynamoClient: fastify.dynamoClient, + }; }; fastify.withTypeProvider().patch( "/profile", @@ -94,7 +88,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { message: "Could not find token payload and/or username.", }); } - const userOid = request.tokenPayload["oid"]; + const userOid = request.tokenPayload.oid; const entraIdToken = await getEntraIdToken( await getAuthorizedClients(), fastify.environmentConfig.AadValidClientId, diff --git a/src/api/routes/ics.ts b/src/api/routes/ics.ts index ef3a5eaa..bdc422dd 100644 --- a/src/api/routes/ics.ts +++ b/src/api/routes/ics.ts @@ -32,7 +32,7 @@ const repeatingIcalMap: Record = }; function generateHostName(host: string) { - if (host == "ACM" || !host) { + if (host === "ACM" || !host) { return "ACM@UIUC"; } if (host.includes("ACM")) { @@ -125,7 +125,7 @@ const icalPlugin: FastifyPluginAsync = async (fastify, _options) => { host && host.includes("ACM") ? `${host} Events` : `ACM@UIUC - ${host} Events`; - if (host == "ACM") { + if (host === "ACM") { calendarName = "ACM@UIUC - Major Events"; } if (!host) { @@ -145,9 +145,10 @@ const icalPlugin: FastifyPluginAsync = async (fastify, _options) => { : moment.tz(rawEvent.start, "America/Chicago"), summary: rawEvent.title, description: rawEvent.locationLink - ? `Host: ${rawEvent.host}\nGoogle Maps Link: ${rawEvent.locationLink}\n\n` + - rawEvent.description - : `Host: ${rawEvent.host}\n\n` + rawEvent.description, + ? `Host: ${rawEvent.host}\nGoogle Maps Link: ${rawEvent.locationLink}\n\n${ + rawEvent.description + }` + : `Host: ${rawEvent.host}\n\n${rawEvent.description}`, timezone: "America/Chicago", organizer: generateHostName(host || "ACM"), id: rawEvent.id, diff --git a/src/api/routes/linkry.ts b/src/api/routes/linkry.ts index ad45721f..48da2d47 100644 --- a/src/api/routes/linkry.ts +++ b/src/api/routes/linkry.ts @@ -166,7 +166,7 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => { // Send the response reply.code(200).send({ ownedLinks: ownedLinksWithGroups, - delegatedLinks: delegatedLinks, + delegatedLinks, }); }, ); @@ -212,10 +212,10 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => { if (currentRecord && !request.userRoles!.has(AppRoles.LINKS_ADMIN)) { const setUserGroups = new Set(request.tokenPayload?.groups || []); const mutualGroups = intersection( - new Set(currentRecord["access"]), + new Set(currentRecord.access), setUserGroups, ); - if (mutualGroups.size == 0) { + if (mutualGroups.size === 0) { throw new UnauthorizedError({ message: "You do not own this record and have not been delegated access.", @@ -230,12 +230,12 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => { const mode = currentRecord ? "modify" : "create"; request.log.info(`Operating in ${mode} mode.`); const currentUpdatedAt = - currentRecord && currentRecord["updatedAt"] - ? currentRecord["updatedAt"] + currentRecord && currentRecord.updatedAt + ? currentRecord.updatedAt : null; const currentCreatedAt = - currentRecord && currentRecord["createdAt"] - ? currentRecord["createdAt"] + currentRecord && currentRecord.createdAt + ? currentRecord.createdAt : null; // Generate new timestamp for all records @@ -284,7 +284,7 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => { const ownerRecord: OwnerRecord = { slug: request.body.slug, redirect: request.body.redirect, - access: "OWNER#" + request.username, + access: `OWNER#${request.username}`, updatedAt: newUpdatedAt, createdAt: newCreatedAt, }; @@ -310,12 +310,12 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => { // Add new GROUP records const accessGroups: string[] = request.body.access || []; const newGroupSet = new Set( - accessGroups.map((group) => "GROUP#" + group), + accessGroups.map((group) => `GROUP#${group}`), ); // Add new GROUP records that don't already exist for (const accessGroup of accessGroups) { - const groupKey = "GROUP#" + accessGroup; + const groupKey = `GROUP#${accessGroup}`; // Skip if this group already exists if (existingGroupSet.has(groupKey)) { @@ -481,10 +481,10 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => { if (!request.userRoles!.has(AppRoles.LINKS_ADMIN)) { const setUserGroups = new Set(request.tokenPayload?.groups || []); const mutualGroups = intersection( - new Set(item["access"]), + new Set(item.access), setUserGroups, ); - if (mutualGroups.size == 0) { + if (mutualGroups.size === 0) { throw new UnauthorizedError({ message: "You have not been delegated access.", }); @@ -540,10 +540,10 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => { if (currentRecord && !request.userRoles!.has(AppRoles.LINKS_ADMIN)) { const setUserGroups = new Set(request.tokenPayload?.groups || []); const mutualGroups = intersection( - new Set(currentRecord["access"]), + new Set(currentRecord.access), setUserGroups, ); - if (mutualGroups.size == 0) { + if (mutualGroups.size === 0) { throw new UnauthorizedError({ message: "You do not own this record and have not been delegated access.", diff --git a/src/api/routes/logs.ts b/src/api/routes/logs.ts index 0a665eb2..c1dd63fe 100644 --- a/src/api/routes/logs.ts +++ b/src/api/routes/logs.ts @@ -4,21 +4,12 @@ import { withRoles, withTags } from "api/components/index.js"; import { createAuditLogEntry } from "api/functions/auditLog.js"; import rateLimiter from "api/plugins/rateLimiter.js"; import { genericConfig } from "common/config.js"; -import { - BaseError, - DatabaseFetchError, - ValidationError, -} from "common/errors/index.js"; +import { BaseError, DatabaseFetchError } from "common/errors/index.js"; import { Modules } from "common/modules.js"; import { AppRoles } from "common/roles.js"; import { loggingEntryFromDatabase } from "common/types/logs.js"; -import fastify, { FastifyPluginAsync } from "fastify"; -import { - FastifyZodOpenApiTypeProvider, - serializerCompiler, - validatorCompiler, -} from "fastify-zod-openapi"; -import { request } from "http"; +import { FastifyPluginAsync } from "fastify"; +import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { z } from "zod"; const responseSchema = z.array(loggingEntryFromDatabase); diff --git a/src/api/routes/membership.ts b/src/api/routes/membership.ts index a9546c3f..b9cb3dc0 100644 --- a/src/api/routes/membership.ts +++ b/src/api/routes/membership.ts @@ -23,11 +23,7 @@ import stripe, { Stripe } from "stripe"; import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; import rawbody from "fastify-raw-body"; -import { - FastifyZodOpenApiTypeProvider, - serializerCompiler, - validatorCompiler, -} from "fastify-zod-openapi"; +import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { z } from "zod"; import { withTags } from "api/components/index.js"; @@ -60,15 +56,14 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { `Assumed Entra role ${roleArns.Entra} to get the Entra token.`, ); return clients; - } else { - fastify.log.debug( - "Did not assume Entra role as no env variable was present", - ); - return { - smClient: fastify.secretsManagerClient, - dynamoClient: fastify.dynamoClient, - }; } + fastify.log.debug( + "Did not assume Entra role as no env variable was present", + ); + return { + smClient: fastify.secretsManagerClient, + dynamoClient: fastify.dynamoClient, + }; }; const limitedRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(rateLimiter, { @@ -310,7 +305,7 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { if ( event.data.object.metadata && "initiator" in event.data.object.metadata && - event.data.object.metadata["initiator"] == "purchase-membership" + event.data.object.metadata.initiator === "purchase-membership" ) { const customerEmail = event.data.object.customer_email; if (!customerEmail) { @@ -351,11 +346,11 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { requestId: request.id, queueId: result.MessageId, }); - } else { - return reply - .code(200) - .send({ handled: false, requestId: request.id }); } + return reply + .code(200) + .send({ handled: false, requestId: request.id }); + default: request.log.warn(`Unhandled event type: ${event.type}`); } diff --git a/src/api/routes/mobileWallet.ts b/src/api/routes/mobileWallet.ts index 65047e3c..a4472598 100644 --- a/src/api/routes/mobileWallet.ts +++ b/src/api/routes/mobileWallet.ts @@ -12,20 +12,13 @@ import { } from "../../common/types/sqsMessage.js"; import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; import { genericConfig } from "../../common/config.js"; -import { zodToJsonSchema } from "zod-to-json-schema"; import rateLimiter from "api/plugins/rateLimiter.js"; -import { - FastifyZodOpenApiTypeProvider, - serializerCompiler, - validatorCompiler, -} from "fastify-zod-openapi"; +import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { withTags } from "api/components/index.js"; -const queuedResponseJsonSchema = zodToJsonSchema( - z.object({ - queueId: z.string().uuid(), - }), -); +const queuedResponseJsonSchema = z.object({ + queueId: z.string().uuid(), +}); const mobileWalletRoute: FastifyPluginAsync = async (fastify, _options) => { fastify.register(rateLimiter, { diff --git a/src/api/routes/roomRequests.ts b/src/api/routes/roomRequests.ts index cb821625..3820983e 100644 --- a/src/api/routes/roomRequests.ts +++ b/src/api/routes/roomRequests.ts @@ -81,7 +81,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { }); const createdNotified = await fastify.dynamoClient.send(getReservationData); - if (!createdNotified.Items || createdNotified.Count == 0) { + if (!createdNotified.Items || createdNotified.Count === 0) { throw new InternalServerError({ message: "Could not find original reservation request details", }); @@ -136,7 +136,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { payload: { to: [originalRequestor], subject: "Room Reservation Request Status Change", - content: `Your Room Reservation Request has been been moved to status "${formatStatus(request.body.status)}". Please visit ${fastify.environmentConfig["UserFacingUrl"]}/roomRequests/${semesterId}/${requestId} to view details.`, + content: `Your Room Reservation Request has been been moved to status "${formatStatus(request.body.status)}". Please visit ${fastify.environmentConfig.UserFacingUrl}/roomRequests/${semesterId}/${requestId} to view details.`, }, }; if (!fastify.sqsClient) { @@ -243,7 +243,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { if ( !statusResponse || !statusResponse.Items || - statusResponse.Items.length == 0 + statusResponse.Items.length === 0 ) { return "unknown"; } @@ -347,7 +347,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { payload: { to: [notificationRecipients[fastify.runEnvironment].OfficerBoard], subject: "New Room Reservation Request", - content: `A new room reservation request has been created (${request.body.host} | ${request.body.title}). Please visit ${fastify.environmentConfig["UserFacingUrl"]}/roomRequests/${request.body.semester}/${requestId} to view details.`, + content: `A new room reservation request has been created (${request.body.host} | ${request.body.title}). Please visit ${fastify.environmentConfig.UserFacingUrl}/roomRequests/${request.body.semester}/${requestId} to view details.`, }, }; if (!fastify.sqsClient) { @@ -426,7 +426,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { } try { const resp = await fastify.dynamoClient.send(command); - if (!resp.Items || resp.Count != 1) { + if (!resp.Items || resp.Count !== 1) { throw new DatabaseFetchError({ message: "Recieved no response.", }); @@ -451,10 +451,10 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { const updates = statusesResponse.Items?.map((x) => { const unmarshalled = unmarshall(x); return { - createdBy: unmarshalled["createdBy"], + createdBy: unmarshalled.createdBy, createdAt: unmarshalled["createdAt#status"].split("#")[0], status: unmarshalled["createdAt#status"].split("#")[1], - notes: unmarshalled["notes"], + notes: unmarshalled.notes, }; }); return reply diff --git a/src/api/routes/tickets.ts b/src/api/routes/tickets.ts index 6129eaa3..1aca4445 100644 --- a/src/api/routes/tickets.ts +++ b/src/api/routes/tickets.ts @@ -21,8 +21,7 @@ import { import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; import { validateEmail } from "../functions/validation.js"; import { AppRoles } from "../../common/roles.js"; -import { zodToJsonSchema } from "zod-to-json-schema"; -import { ItemPostData, postMetadataSchema } from "common/types/tickets.js"; +import { postMetadataSchema } from "common/types/tickets.js"; import { createAuditLogEntry } from "api/functions/auditLog.js"; import { Modules } from "common/modules.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; @@ -62,13 +61,11 @@ const ticketInfoEntryZod = ticketEntryZod.extend({ type TicketInfoEntry = z.infer; -const responseJsonSchema = zodToJsonSchema(ticketEntryZod); +const responseJsonSchema = ticketEntryZod; -const getTicketsResponseJsonSchema = zodToJsonSchema( - z.object({ - tickets: z.array(ticketInfoEntryZod), - }), -); +const getTicketsResponse = z.object({ + tickets: z.array(ticketInfoEntryZod), +}); const baseItemMetadata = z.object({ itemId: z.string().min(1), @@ -88,12 +85,10 @@ const ticketingItemMetadata = baseItemMetadata.extend({ type ItemMetadata = z.infer; type TicketItemMetadata = z.infer; -const listMerchItemsResponseJsonSchema = zodToJsonSchema( - z.object({ - merch: z.array(baseItemMetadata), - tickets: z.array(ticketingItemMetadata), - }), -); +const listMerchItemsResponse = z.object({ + merch: z.array(baseItemMetadata), + tickets: z.array(ticketingItemMetadata), +}); const postSchema = z.union([postMerchSchema, postTicketSchema]); @@ -247,14 +242,14 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { issuedTickets.push({ type: "merch", valid: true, - ticketId: unmarshalled["stripe_pi"], - refunded: unmarshalled["refunded"], - fulfilled: unmarshalled["fulfilled"], + ticketId: unmarshalled.stripe_pi, + refunded: unmarshalled.refunded, + fulfilled: unmarshalled.fulfilled, purchaserData: { - email: unmarshalled["email"], + email: unmarshalled.email, productId: eventId, - quantity: unmarshalled["quantity"], - size: unmarshalled["size"], + quantity: unmarshalled.quantity, + size: unmarshalled.size, }, }); } @@ -420,19 +415,19 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { } const attributes = unmarshall(ticketEntry.Attributes); if (request.body.type === "ticket") { - const rawData = attributes["ticketholder_netid"]; - const isEmail = validateEmail(attributes["ticketholder_netid"]); + const rawData = attributes.ticketholder_netid; + const isEmail = validateEmail(attributes.ticketholder_netid); purchaserData = { email: isEmail ? rawData : `${rawData}@illinois.edu`, - productId: attributes["event_id"], + productId: attributes.event_id, quantity: 1, }; } else { purchaserData = { - email: attributes["email"], - productId: attributes["item_id"], - quantity: attributes["quantity"], - size: attributes["size"], + email: attributes.email, + productId: attributes.item_id, + quantity: attributes.quantity, + size: attributes.size, }; } } catch (e: unknown) { @@ -446,12 +441,12 @@ const ticketsPlugin: FastifyPluginAsync = async (fastify, _options) => { if (e instanceof ConditionalCheckFailedException) { if (e.Item) { const unmarshalled = unmarshall(e.Item); - if (unmarshalled["fulfilled"] || unmarshalled["used"]) { + if (unmarshalled.fulfilled || unmarshalled.used) { throw new TicketNotValidError({ message: "Ticket has already been used.", }); } - if (unmarshalled["refunded"]) { + if (unmarshalled.refunded) { throw new TicketNotValidError({ message: "Ticket was already refunded.", }); diff --git a/src/api/sqs/driver.ts b/src/api/sqs/driver.ts index 8f5eb5eb..6c704f8f 100644 --- a/src/api/sqs/driver.ts +++ b/src/api/sqs/driver.ts @@ -5,7 +5,7 @@ import { parseSQSPayload, } from "common/types/sqsMessage.js"; -const queueUrl = environmentConfig["dev"].SqsQueueUrl; +const queueUrl = environmentConfig.dev.SqsQueueUrl; const sqsClient = new SQSClient({ region: genericConfig.AwsRegion, }); diff --git a/src/api/sqs/emailNotifications.ts b/src/api/sqs/emailNotifications.ts index d130cca4..c3b9c55f 100644 --- a/src/api/sqs/emailNotifications.ts +++ b/src/api/sqs/emailNotifications.ts @@ -17,7 +17,7 @@ export const emailNotificationsHandler: SQSHandlerFunction< AvailableSQSFunctions.EmailNotifications > = async (payload, metadata, logger) => { const { to, cc, bcc, content, subject } = payload; - const senderEmail = `ACM @ UIUC `; + const senderEmail = `ACM @ UIUC `; logger.info("Constructing email..."); const command = new SendEmailCommand({ Source: senderEmail, diff --git a/src/api/sqs/handlers.ts b/src/api/sqs/handlers.ts index c0034f17..52d6db83 100644 --- a/src/api/sqs/handlers.ts +++ b/src/api/sqs/handlers.ts @@ -45,13 +45,12 @@ const getAuthorizedClients = async ( }; logger.info(`Assumed Entra role ${roleArns.Entra} to get the Entra token.`); return clients; - } else { - logger.debug("Did not assume Entra role as no env variable was present"); - return { - smClient: new SecretsManagerClient(commonConfig), - dynamoClient: new DynamoDBClient(commonConfig), - }; } + logger.debug("Did not assume Entra role as no env variable was present"); + return { + smClient: new SecretsManagerClient(commonConfig), + dynamoClient: new DynamoDBClient(commonConfig), + }; }; export const emailMembershipPassHandler: SQSHandlerFunction< diff --git a/src/api/sqs/sales.ts b/src/api/sqs/sales.ts index 7cd0a13c..02bf20b4 100644 --- a/src/api/sqs/sales.ts +++ b/src/api/sqs/sales.ts @@ -9,7 +9,7 @@ export const sendSaleEmailhandler: SQSHandlerFunction< AvailableSQSFunctions.SendSaleEmail > = async (payload, _metadata, logger) => { const { qrCodeContent } = payload; - const senderEmail = `sales@${currentEnvironmentConfig["EmailDomain"]}`; + const senderEmail = `sales@${currentEnvironmentConfig.EmailDomain}`; logger.info("Constructing QR Code..."); const qrCode = await QRCode.toBuffer(qrCodeContent, { errorCorrectionLevel: "H", diff --git a/src/common/types/roomRequest.ts b/src/common/types/roomRequest.ts index 0ab530e8..874d0697 100644 --- a/src/common/types/roomRequest.ts +++ b/src/common/types/roomRequest.ts @@ -18,7 +18,7 @@ export function getPreviousSemesters() { const currentYear = currentDate.getFullYear(); const currentMonth = currentDate.getMonth() + 1; - let semesters = []; + let semesters: { value: string, label: string }[] = []; let currentSemester = ""; if (currentMonth >= 1 && currentMonth <= 5) { @@ -63,7 +63,7 @@ export function getSemesters() { const currentYear = currentDate.getFullYear(); const currentMonth = currentDate.getMonth() + 1; - let semesters = []; + let semesters: { value: string, label: string }[] = []; let currentSemester = ""; if (currentMonth >= 1 && currentMonth <= 5) { @@ -300,7 +300,7 @@ export const roomRequestSchema = roomRequestBaseSchema }); } - if (data.estimatedAttendees == null || data.estimatedAttendees <= 0) { + if (data.estimatedAttendees === null || data.estimatedAttendees === undefined || data.estimatedAttendees <= 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Please provide an estimated number of attendees", @@ -308,7 +308,7 @@ export const roomRequestSchema = roomRequestBaseSchema }); } - if (data.seatsNeeded == null || data.seatsNeeded <= 0) { + if (data.seatsNeeded === null || data.seatsNeeded === undefined || data.seatsNeeded <= 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Please specify how many seats you need", diff --git a/src/ui/.prettierrc.cjs b/src/ui/.prettierrc.cjs deleted file mode 100644 index 2945481d..00000000 --- a/src/ui/.prettierrc.cjs +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('eslint-config-mantine/.prettierrc.js'); \ No newline at end of file diff --git a/src/ui/App.test.tsx b/src/ui/App.test.tsx deleted file mode 100644 index 4a2fc2a3..00000000 --- a/src/ui/App.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -describe('App', () => { - it('renders the App component and verifies the logo and text', () => { - render(); - - // Verify there are two instances of the logo - const logos = screen.getAllByAltText(/ACM Logo/i); // Assuming the alt text for the logo is "ACM Logo" - expect(logos).toHaveLength(2); - - // Verify the text "ACM@UIUC Management Portal" is present - const portalText = screen.getByText(/ACM@UIUC Management Portal/i); - expect(portalText).toBeInTheDocument(); - }); - - it('verifies the "Authorized Users Only" section', () => { - render(); - - // Verify the "Authorized Users Only" text is present - const authText = screen.getByText(/Authorized Users Only/i); - expect(authText).toBeInTheDocument(); - - // Verify the explanation text is present - const explanationText = screen.getByText(/Unauthorized or improper use or access/i); - expect(explanationText).toBeInTheDocument(); - }); - - it('verifies the "Sign in with Illinois NetID" button', () => { - render(); - - // Verify the button is present - const signInButton = screen.getByRole('button', { name: /Sign in with Illinois NetID/i }); - expect(signInButton).toBeInTheDocument(); - }); - - it('verifies the theme toggle is present', () => { - render(); - - // Verify the theme toggle is present - const themeToggle = screen.getByRole('switch'); // Assuming it uses a switch role - expect(themeToggle).toBeInTheDocument(); - }); -}); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index bd81b5e8..87e799ae 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,22 +1,28 @@ -import '@mantine/core/styles.css'; -import '@mantine/notifications/styles.css'; -import '@mantine/dates/styles.css'; -import { MantineProvider } from '@mantine/core'; -import { useColorScheme, useLocalStorage } from '@mantine/hooks'; -import { Notifications } from '@mantine/notifications'; +import "@mantine/core/styles.css"; +import "@mantine/notifications/styles.css"; +import "@mantine/dates/styles.css"; +import { MantineProvider } from "@mantine/core"; +import { useColorScheme, useLocalStorage } from "@mantine/hooks"; +import { Notifications } from "@mantine/notifications"; -import ColorSchemeContext from './ColorSchemeContext'; -import { Router } from './Router'; +import ColorSchemeContext from "./ColorSchemeContext"; +import { Router } from "./Router"; export default function App() { const preferredColorScheme = useColorScheme(); const [colorScheme, setColorScheme] = useLocalStorage({ - key: 'acm-manage-color-scheme', + key: "acm-manage-color-scheme", defaultValue: preferredColorScheme, }); return ( - - + + diff --git a/src/ui/ColorSchemeContext.tsx b/src/ui/ColorSchemeContext.tsx index d706e96c..13abde97 100644 --- a/src/ui/ColorSchemeContext.tsx +++ b/src/ui/ColorSchemeContext.tsx @@ -1,4 +1,4 @@ -import { createContext } from 'react'; +import { createContext } from "react"; type ColorSchemeContextType = { colorScheme: string; diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index 3bab2090..0276bb31 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -1,41 +1,44 @@ -import { Anchor } from '@mantine/core'; -import { element } from 'prop-types'; -import React, { useState, useEffect, ReactNode } from 'react'; -import { createBrowserRouter, Navigate, RouterProvider, useLocation } from 'react-router-dom'; -import { AcmAppShell } from './components/AppShell'; -import { useAuth } from './components/AuthContext'; -import AuthCallback from './components/AuthContext/AuthCallbackHandler.page'; -import { Error404Page } from './pages/Error404.page'; -import { Error500Page } from './pages/Error500.page'; -import { HomePage } from './pages/Home.page'; -import { LoginPage } from './pages/Login.page'; -import { LogoutPage } from './pages/Logout.page'; -import { ManageEventPage } from './pages/events/ManageEvent.page'; -import { ViewEventsPage } from './pages/events/ViewEvents.page'; -import { LinkShortener } from './pages/linkry/LinkShortener.page'; -import { ManageLinkPage } from './pages/linkry/ManageLink.page'; -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 { ManageProfilePage } from './pages/profile/ManageProfile.page'; -import { ManageStripeLinksPage } from './pages/stripe/ViewLinks.page'; -import { ManageRoomRequestsPage } from './pages/roomRequest/RoomRequestLanding.page'; -import { ViewRoomRequest } from './pages/roomRequest/ViewRoomRequest.page'; -import { ViewLogsPage } from './pages/logs/ViewLogs.page'; -import { TermsOfService } from './pages/tos/TermsOfService.page'; -import { ManageApiKeysPage } from './pages/apiKeys/ManageKeys.page'; +import React, { useState, useEffect, ReactNode } from "react"; +import { + createBrowserRouter, + Navigate, + RouterProvider, + useLocation, +} from "react-router-dom"; +import { AcmAppShell } from "./components/AppShell"; +import { useAuth } from "./components/AuthContext"; +import AuthCallback from "./components/AuthContext/AuthCallbackHandler.page"; +import { Error404Page } from "./pages/Error404.page"; +import { Error500Page } from "./pages/Error500.page"; +import { HomePage } from "./pages/Home.page"; +import { LoginPage } from "./pages/Login.page"; +import { LogoutPage } from "./pages/Logout.page"; +import { ManageEventPage } from "./pages/events/ManageEvent.page"; +import { ViewEventsPage } from "./pages/events/ViewEvents.page"; +import { LinkShortener } from "./pages/linkry/LinkShortener.page"; +import { ManageLinkPage } from "./pages/linkry/ManageLink.page"; +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 { ManageProfilePage } from "./pages/profile/ManageProfile.page"; +import { ManageStripeLinksPage } from "./pages/stripe/ViewLinks.page"; +import { ManageRoomRequestsPage } from "./pages/roomRequest/RoomRequestLanding.page"; +import { ViewRoomRequest } from "./pages/roomRequest/ViewRoomRequest.page"; +import { ViewLogsPage } from "./pages/logs/ViewLogs.page"; +import { TermsOfService } from "./pages/tos/TermsOfService.page"; +import { ManageApiKeysPage } from "./pages/apiKeys/ManageKeys.page"; 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 + "/login", + "/logout", + "/force_login", + "/a", + "/auth/callback", // Add this to excluded paths ]; if (excludedPaths.includes(location.pathname)) { @@ -54,11 +57,11 @@ const LoginRedirect: React.FC = () => { // 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 + "/login", + "/logout", + "/force_login", + "/a", + "/auth/callback", // Add this to excluded paths ]; if (excludedPaths.includes(location.pathname)) { @@ -73,19 +76,19 @@ const LoginRedirect: React.FC = () => { const commonRoutes = [ { - path: '/force_login', + path: "/force_login", element: , }, { - path: '/logout', + path: "/logout", element: , }, { - path: '/auth/callback', + path: "/auth/callback", element: , }, { - path: '/tos', + path: "/tos", element: , }, ]; @@ -93,11 +96,11 @@ const commonRoutes = [ const profileRouter = createBrowserRouter([ ...commonRoutes, { - path: '/profile', + path: "/profile", element: , }, { - path: '*', + path: "*", element: , }, ]); @@ -105,15 +108,15 @@ const profileRouter = createBrowserRouter([ const unauthenticatedRouter = createBrowserRouter([ ...commonRoutes, { - path: '/', + path: "/", element: , }, { - path: '/login', + path: "/login", element: , }, { - path: '*', + path: "*", element: , }, ]); @@ -121,88 +124,88 @@ const unauthenticatedRouter = createBrowserRouter([ const authenticatedRouter = createBrowserRouter([ ...commonRoutes, { - path: '/', + path: "/", element: {null}, }, { - path: '/login', + path: "/login", element: , }, { - path: '/logout', + path: "/logout", element: , }, { - path: '/profile', + path: "/profile", element: , }, { - path: '/home', + path: "/home", element: , }, { - path: '/events/add', + path: "/events/add", element: , }, { - path: '/events/edit/:eventId', + path: "/events/edit/:eventId", element: , }, { - path: '/events/manage', + path: "/events/manage", element: , }, { - path: '/linkry', + path: "/linkry", element: , }, { - path: '/linkry/add', + path: "/linkry/add", element: , }, { - path: '/linkry/edit/:slug', + path: "/linkry/edit/:slug", element: , }, { - path: '/tickets/scan', + path: "/tickets/scan", element: , }, { - path: '/tickets', + path: "/tickets", element: , }, { - path: '/iam', + path: "/iam", element: , }, { - path: '/tickets/manage/:eventId', + path: "/tickets/manage/:eventId", element: , }, { - path: '/stripe', + path: "/stripe", element: , }, { - path: '/roomRequests', + path: "/roomRequests", element: , }, { - path: '/roomRequests/:semesterId/:requestId', + path: "/roomRequests/:semesterId/:requestId", element: , }, { - path: '/logs', + path: "/logs", element: , }, { - path: '/apiKeys', + path: "/apiKeys", element: , }, // Catch-all route for authenticated users shows 404 page { - path: '*', + path: "*", element: , }, ]); @@ -226,14 +229,14 @@ const ErrorBoundary: React.FC = ({ children }) => { onError(event.error); }; - window.addEventListener('error', errorHandler); + window.addEventListener("error", errorHandler); return () => { - window.removeEventListener('error', errorHandler); + window.removeEventListener("error", errorHandler); }; }, []); if (hasError && error) { - if (error.message === '404') { + if (error.message === "404") { return isLoggedIn ? : ; } return ; diff --git a/src/ui/components/AppShell/index.tsx b/src/ui/components/AppShell/index.tsx index e8c02293..335f0b00 100644 --- a/src/ui/components/AppShell/index.tsx +++ b/src/ui/components/AppShell/index.tsx @@ -8,8 +8,8 @@ import { Skeleton, Text, useMantineColorScheme, -} from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; import { IconCalendar, IconCoin, @@ -21,16 +21,16 @@ import { IconDoor, IconHistory, IconKey, -} from '@tabler/icons-react'; -import { ReactNode } from 'react'; -import { useNavigate } from 'react-router-dom'; +} from "@tabler/icons-react"; +import { ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; -import { useAuth } from '../AuthContext/index.js'; -import { HeaderNavbar } from '../Navbar/index.js'; -import { AuthenticatedProfileDropdown } from '../ProfileDropdown/index.js'; -import { getCurrentRevision } from '@ui/util/revision.js'; -import { AppRoles } from '@common/roles.js'; -import { AuthGuard } from '../AuthGuard/index.js'; +import { useAuth } from "../AuthContext/index.js"; +import { HeaderNavbar } from "../Navbar/index.js"; +import { AuthenticatedProfileDropdown } from "../ProfileDropdown/index.js"; +import { getCurrentRevision } from "@ui/util/revision.js"; +import { AppRoles } from "@common/roles.js"; +import { AuthGuard } from "../AuthGuard/index.js"; export interface AcmAppShellProps { children: ReactNode; @@ -42,57 +42,57 @@ export interface AcmAppShellProps { export const navItems = [ { - link: '/events/manage', - name: 'Events', + link: "/events/manage", + name: "Events", icon: IconCalendar, description: null, validRoles: [AppRoles.EVENTS_MANAGER], }, { - link: '/tickets', - name: 'Ticketing/Merch', + link: "/tickets", + name: "Ticketing/Merch", icon: IconTicket, description: null, validRoles: [AppRoles.TICKETS_MANAGER, AppRoles.TICKETS_SCANNER], }, { - link: '/iam', - name: 'IAM', + link: "/iam", + name: "IAM", icon: IconLock, description: null, validRoles: [AppRoles.IAM_ADMIN, AppRoles.IAM_INVITE_ONLY], }, { - link: '/stripe', - name: 'Stripe Link Creator', + link: "/stripe", + name: "Stripe Link Creator", icon: IconCoin, description: null, validRoles: [AppRoles.STRIPE_LINK_CREATOR], }, { - link: '/roomRequests', - name: 'Room Requests', + link: "/roomRequests", + name: "Room Requests", icon: IconDoor, description: null, validRoles: [AppRoles.ROOM_REQUEST_CREATE, AppRoles.ROOM_REQUEST_UPDATE], }, { - link: '/linkry', - name: 'Link Shortener', + link: "/linkry", + name: "Link Shortener", icon: IconLink, description: null, validRoles: [AppRoles.LINKS_MANAGER, AppRoles.LINKS_ADMIN], }, { - link: '/logs', - name: 'Audit Logs', + link: "/logs", + name: "Audit Logs", icon: IconHistory, description: null, validRoles: [AppRoles.AUDIT_LOG_VIEWER], }, { - link: '/apiKeys', - name: 'API Keys', + link: "/apiKeys", + name: "API Keys", icon: IconKey, description: null, validRoles: [AppRoles.MANAGE_ORG_API_KEYS], @@ -101,25 +101,28 @@ export const navItems = [ export const extLinks = [ { - link: 'https://go.acm.illinois.edu/reimburse', - name: 'Funding and Reimbursement Requests', + link: "https://go.acm.illinois.edu/reimburse", + name: "Funding and Reimbursement Requests", icon: IconFileDollar, description: null, }, { - link: 'https://go.acm.illinois.edu/sigpizza', - name: 'Pizza Request Form', + link: "https://go.acm.illinois.edu/sigpizza", + name: "Pizza Request Form", icon: IconPizza, description: null, }, ]; -function isSameParentPath(path1: string | undefined, path2: string | undefined) { +function isSameParentPath( + path1: string | undefined, + path2: string | undefined, +) { if (!path1 || !path2) { return false; } - const splitPath1 = path1.split('/'); - const splitPath2 = path2.split('/'); + const splitPath1 = path1.split("/"); + const splitPath2 = path2.split("/"); // Ensure both paths are long enough to have a parent path if (splitPath1.length < 2 || splitPath2.length < 2) { @@ -127,15 +130,15 @@ function isSameParentPath(path1: string | undefined, path2: string | undefined) } // Remove the last element (assumed to be the file or final directory) - const parentPath1 = splitPath1.slice(0, -1).join('/'); - const parentPath2 = splitPath2.slice(0, -1).join('/'); - return parentPath1 === parentPath2 && parentPath1 !== '/app'; + const parentPath1 = splitPath1.slice(0, -1).join("/"); + const parentPath2 = splitPath2.slice(0, -1).join("/"); + return parentPath1 === parentPath2 && parentPath1 !== "/app"; } export const renderNavItems = ( items: Record[], active: string | undefined, - navigate: CallableFunction + navigate: CallableFunction, ) => items.map((item) => { const link = ( @@ -144,7 +147,7 @@ export const renderNavItems = ( h={48} mt="sm" onClick={() => { - if (item.link.includes('://')) { + if (item.link.includes("://")) { window.location.href = item.link; } else { navigate(item.link); @@ -163,13 +166,15 @@ export const renderNavItems = ( {item.children ? renderNavItems(item.children, active, navigate) : null} ); - if (item.link.at(0) == '/') { + if (item.link.at(0) === "/") { return ( } + loadingSkeleton={ + + } > {link} @@ -183,7 +188,11 @@ type SidebarNavItemsProps = { visible: boolean; active?: string; }; -const SidebarNavItems: React.FC = ({ items, visible, active }) => { +const SidebarNavItems: React.FC = ({ + items, + visible, + active, +}) => { const navigate = useNavigate(); if (!visible) { return null; @@ -214,7 +223,7 @@ const AcmAppShell: React.FC = ({ header={{ height: 60 }} navbar={{ width: showSidebar ? 200 : 0, - breakpoint: 'sm', + breakpoint: "sm", collapsed: { mobile: !opened }, }} > @@ -224,10 +233,18 @@ const AcmAppShell: React.FC = ({ {showSidebar && ( - +
- + @@ -240,7 +257,12 @@ const AcmAppShell: React.FC = ({ Revision {getCurrentRevision()} - navigate('/tos')}> + navigate("/tos")} + > Terms of Service
@@ -250,7 +272,7 @@ const AcmAppShell: React.FC = ({ {showLoader ? ( ) : ( children diff --git a/src/ui/components/AuthContext/AuthCallbackHandler.page.tsx b/src/ui/components/AuthContext/AuthCallbackHandler.page.tsx index 57ca19e8..ba8d4e57 100644 --- a/src/ui/components/AuthContext/AuthCallbackHandler.page.tsx +++ b/src/ui/components/AuthContext/AuthCallbackHandler.page.tsx @@ -1,7 +1,7 @@ -import { useMsal } from '@azure/msal-react'; -import React, { useEffect } from 'react'; +import { useMsal } from "@azure/msal-react"; +import React, { useEffect } from "react"; -import FullScreenLoader from './LoadingScreen.js'; +import FullScreenLoader from "./LoadingScreen.js"; export const AuthCallback: React.FC = () => { const { instance } = useMsal(); @@ -15,10 +15,10 @@ export const AuthCallback: React.FC = () => { // Check if we have pending redirects const response = await instance.handleRedirectPromise(); if (!response) { - navigate('/'); + navigate("/"); return; } - const returnPath = response.state || '/'; + const returnPath = response.state || "/"; const account = response.account; if (account) { instance.setActiveAccount(account); @@ -26,8 +26,8 @@ export const AuthCallback: React.FC = () => { navigate(returnPath); } catch (error) { - console.error('Failed to handle auth redirect:', error); - navigate('/login?error=callback_failed'); + console.error("Failed to handle auth redirect:", error); + navigate("/login?error=callback_failed"); } }; diff --git a/src/ui/components/AuthContext/LoadingScreen.tsx b/src/ui/components/AuthContext/LoadingScreen.tsx index 96b3dd89..d1222fac 100644 --- a/src/ui/components/AuthContext/LoadingScreen.tsx +++ b/src/ui/components/AuthContext/LoadingScreen.tsx @@ -1,21 +1,21 @@ -import React from 'react'; -import { LoadingOverlay } from '@mantine/core'; -import { useColorScheme, useLocalStorage } from '@mantine/hooks'; +import React from "react"; +import { LoadingOverlay } from "@mantine/core"; +import { useColorScheme, useLocalStorage } from "@mantine/hooks"; const FullScreenLoader = () => { const preferredColorScheme = useColorScheme(); const [colorScheme, setColorScheme] = useLocalStorage({ - key: 'acm-manage-color-scheme', + key: "acm-manage-color-scheme", defaultValue: preferredColorScheme, }); return ( ); diff --git a/src/ui/components/AuthContext/index.tsx b/src/ui/components/AuthContext/index.tsx index 415e7c8f..62cc6929 100644 --- a/src/ui/components/AuthContext/index.tsx +++ b/src/ui/components/AuthContext/index.tsx @@ -2,9 +2,9 @@ import { AuthenticationResult, InteractionRequiredAuthError, InteractionStatus, -} from '@azure/msal-browser'; -import { useMsal } from '@azure/msal-react'; -import { MantineProvider } from '@mantine/core'; +} from "@azure/msal-browser"; +import { useMsal } from "@azure/msal-react"; +import { MantineProvider } from "@mantine/core"; import React, { createContext, ReactNode, @@ -12,15 +12,15 @@ import React, { useState, useEffect, useCallback, -} from 'react'; +} from "react"; -import { CACHE_KEY_PREFIX, setCachedResponse } from '../AuthGuard/index.js'; +import { CACHE_KEY_PREFIX, setCachedResponse } from "../AuthGuard/index.js"; -import FullScreenLoader from './LoadingScreen.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'; +import { getRunEnvironmentConfig, ValidServices } from "@ui/config.js"; +import { transformCommaSeperatedName } from "@common/utils.js"; +import { useApi } from "@ui/util/api.js"; interface AuthContextDataWrapper { isLoggedIn: boolean; @@ -54,9 +54,10 @@ export const AuthProvider: React.FC = ({ children }) => { const { instance, inProgress, accounts } = useMsal(); const [userData, setUserData] = useState(null); const [isLoggedIn, setIsLoggedIn] = useState(false); - const checkRoute = getRunEnvironmentConfig().ServiceConfiguration['core'].authCheckRoute; + const checkRoute = + getRunEnvironmentConfig().ServiceConfiguration.core.authCheckRoute; if (!checkRoute) { - throw new Error('no check route found!'); + throw new Error("no check route found!"); } const navigate = (path: string) => { @@ -71,7 +72,7 @@ export const AuthProvider: React.FC = ({ children }) => { } else if (accounts.length > 0) { setUserData({ email: accounts[0].username, - name: transformCommaSeperatedName(accounts[0].name || ''), + name: transformCommaSeperatedName(accounts[0].name || ""), }); setIsLoggedIn(true); } @@ -89,18 +90,18 @@ export const AuthProvider: React.FC = ({ children }) => { // If accounts array is empty, try silent authentication instance .ssoSilent({ - scopes: ['openid', 'profile', 'email'], + scopes: ["openid", "profile", "email"], loginHint: response.account.username, }) .then(async (silentResponse) => { if (silentResponse?.account?.name) { setUserData({ email: accounts[0].username, - name: transformCommaSeperatedName(accounts[0].name || ''), + name: transformCommaSeperatedName(accounts[0].name || ""), }); - const api = useApi('core'); + const api = useApi("core"); const result = await api.get(checkRoute); - await setCachedResponse('core', checkRoute, result.data); + await setCachedResponse("core", checkRoute, result.data); setIsLoggedIn(true); } }) @@ -109,12 +110,12 @@ export const AuthProvider: React.FC = ({ children }) => { } setUserData({ email: accounts[0].username, - name: transformCommaSeperatedName(accounts[0].name || ''), + name: transformCommaSeperatedName(accounts[0].name || ""), }); setIsLoggedIn(true); } }, - [accounts, instance] + [accounts, instance], ); const getApiToken = useCallback( @@ -122,7 +123,8 @@ export const AuthProvider: React.FC = ({ children }) => { if (!userData) { return null; } - const scope = getRunEnvironmentConfig().ServiceConfiguration[service].loginScope; + const scope = + getRunEnvironmentConfig().ServiceConfiguration[service].loginScope; const { apiId } = getRunEnvironmentConfig().ServiceConfiguration[service]; if (!scope || !apiId) { return null; @@ -137,9 +139,9 @@ export const AuthProvider: React.FC = ({ children }) => { const tokenResponse = await instance.acquireTokenSilent(silentRequest); return tokenResponse.accessToken; } - throw new Error('More than one account found, cannot proceed.'); + throw new Error("More than one account found, cannot proceed."); }, - [userData, instance] + [userData, instance], ); const getToken = useCallback(async () => { @@ -151,25 +153,29 @@ export const AuthProvider: React.FC = ({ children }) => { if (msalAccounts.length > 0) { const silentRequest = { account: msalAccounts[0], - scopes: ['.default'], // Adjust scopes as needed + scopes: [".default"], // Adjust scopes as needed }; const tokenResponse = await instance.acquireTokenSilent(silentRequest); return tokenResponse.accessToken; } - throw new Error('More than one account found, cannot proceed.'); + throw new Error("More than one account found, cannot proceed."); } catch (error) { - console.error('Silent token acquisition failed.', error); + console.error("Silent token acquisition failed.", error); if (error instanceof InteractionRequiredAuthError) { // Fallback to interaction when silent token acquisition fails try { const interactiveRequest = { - scopes: ['.default'], // Adjust scopes as needed - redirectUri: '/auth/callback', // Redirect URI after login + scopes: [".default"], // Adjust scopes as needed + redirectUri: "/auth/callback", // Redirect URI after login }; - const tokenResponse: any = await instance.acquireTokenRedirect(interactiveRequest); + const tokenResponse: any = + await instance.acquireTokenRedirect(interactiveRequest); return tokenResponse.accessToken; } catch (interactiveError) { - console.error('Interactive token acquisition failed.', interactiveError); + console.error( + "Interactive token acquisition failed.", + interactiveError, + ); throw interactiveError; } } else { @@ -181,24 +187,24 @@ export const AuthProvider: React.FC = ({ children }) => { const loginMsal = useCallback( async (returnTo: string) => { if (!checkRoute) { - throw new Error('could not get user roles!'); + throw new Error("could not get user roles!"); } const accountsLocal = instance.getAllAccounts(); if (accountsLocal.length > 0) { instance.setActiveAccount(accountsLocal[0]); - const api = useApi('core'); + const api = useApi("core"); const result = await api.get(checkRoute); - await setCachedResponse('core', checkRoute, result.data); + await setCachedResponse("core", checkRoute, result.data); setIsLoggedIn(true); } else { await instance.loginRedirect({ - scopes: ['openid', 'profile', 'email'], + scopes: ["openid", "profile", "email"], state: returnTo, redirectUri: `${window.location.origin}/auth/callback`, }); } }, - [instance] + [instance], ); const setLoginStatus = useCallback((val: boolean) => { setIsLoggedIn(val); @@ -209,7 +215,7 @@ export const AuthProvider: React.FC = ({ children }) => { clearAuthCache(); await instance.logoutRedirect(); } catch (error) { - console.error('Logout failed:', error); + console.error("Logout failed:", error); } }, [instance, userData]); const logoutCallback = () => { diff --git a/src/ui/components/AuthGuard/index.tsx b/src/ui/components/AuthGuard/index.tsx index cbbaf6a2..cff61d68 100644 --- a/src/ui/components/AuthGuard/index.tsx +++ b/src/ui/components/AuthGuard/index.tsx @@ -1,13 +1,13 @@ -import { Card, Text, Title } from '@mantine/core'; -import React, { ReactNode, useEffect, useState } from 'react'; +import { Card, Text, Title } from "@mantine/core"; +import React, { ReactNode, useEffect, useState } from "react"; -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'; -import { AppRoles } from '@common/roles'; +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"; +import { AppRoles } from "@common/roles"; -export const CACHE_KEY_PREFIX = 'auth_response_cache_'; +export const CACHE_KEY_PREFIX = "auth_response_cache_"; const CACHE_DURATION = 2 * 60 * 60 * 1000; // 2 hours in milliseconds type CacheData = { @@ -25,15 +25,17 @@ const getAuthCacheKey = (service: ValidService, route: string) => export const getCachedResponse = async ( service: ValidService, - route: string + route: string, ): Promise => { const cacheKey = getAuthCacheKey(service, route); const item = (await navigator.locks.request( `lock_${cacheKey}`, - { mode: 'shared' }, + { mode: "shared" }, async (lock) => { const cached = sessionStorage.getItem(getAuthCacheKey(service, route)); - if (!cached) return null; + if (!cached) { + return null; + } try { const data = JSON.parse(cached) as CacheData; @@ -45,24 +47,32 @@ export const getCachedResponse = async ( // Clear expired cache sessionStorage.removeItem(getAuthCacheKey(service, route)); } catch (e) { - console.error('Error parsing auth cache:', e); + console.error("Error parsing auth cache:", e); sessionStorage.removeItem(getAuthCacheKey(service, route)); } return null; - } + }, )) as CacheData | null; return item; }; -export const setCachedResponse = async (service: ValidService, route: string, data: any) => { +export const setCachedResponse = async ( + service: ValidService, + route: string, + data: any, +) => { const cacheData: CacheData = { data, timestamp: Date.now(), }; const cacheKey = getAuthCacheKey(service, route); - await navigator.locks.request(`lock_${cacheKey}`, { mode: 'exclusive' }, async (lock) => { - sessionStorage.setItem(cacheKey, JSON.stringify(cacheData)); - }); + await navigator.locks.request( + `lock_${cacheKey}`, + { mode: "exclusive" }, + async (lock) => { + sessionStorage.setItem(cacheKey, JSON.stringify(cacheData)); + }, + ); }; // Function to clear auth cache for all services @@ -81,7 +91,13 @@ export const AuthGuard: React.FC< isAppShell?: boolean; loadingSkeleton?: ReactNode; } & AcmAppShellProps -> = ({ resourceDef, children, isAppShell = true, loadingSkeleton, ...appShellProps }) => { +> = ({ + resourceDef, + children, + isAppShell = true, + loadingSkeleton, + ...appShellProps +}) => { const { service, validRoles } = resourceDef; const { baseEndpoint, authCheckRoute, friendlyName } = getRunEnvironmentConfig().ServiceConfiguration[service]; @@ -102,17 +118,40 @@ export const AuthGuard: React.FC< return; } const cachedData = await getCachedResponse(service, authCheckRoute); - const lockMode = cachedData ? 'shared' : 'exclusive'; - await navigator.locks.request(`lock_authGuard_loader`, { mode: lockMode }, async (lock) => { - try { - // We have to check the cache twice because if one exclusive process before us - // retrieved it we should now be able to use it. Theoretically this shouldn't - // ever trigger because AuthGuard on the navbar will always call first, but - // to protect against future implementations. - setIsLoading(true); - const cachedData = await getCachedResponse(service, authCheckRoute); - if (cachedData !== null) { - const userRoles = cachedData.data.roles; + const lockMode = cachedData ? "shared" : "exclusive"; + await navigator.locks.request( + `lock_authGuard_loader`, + { mode: lockMode }, + async (lock) => { + try { + // We have to check the cache twice because if one exclusive process before us + // retrieved it we should now be able to use it. Theoretically this shouldn't + // ever trigger because AuthGuard on the navbar will always call first, but + // to protect against future implementations. + setIsLoading(true); + const cachedData = await getCachedResponse(service, authCheckRoute); + if (cachedData !== null) { + const userRoles = cachedData.data.roles; + let authenticated = false; + for (const item of userRoles) { + if (validRoles.indexOf(item) !== -1) { + authenticated = true; + break; + } + } + setUsername(cachedData.data.username); + setRoles(cachedData.data.roles); + setIsAuthenticated(authenticated); + setIsLoading(false); + return; + } + + // If no cache, make the API call + const result = await api.get(authCheckRoute); + // Cache just the response data + await setCachedResponse(service, authCheckRoute, result.data); + + const userRoles = result.data.roles; let authenticated = false; for (const item of userRoles) { if (validRoles.indexOf(item) !== -1) { @@ -120,36 +159,17 @@ export const AuthGuard: React.FC< break; } } - setUsername(cachedData.data.username); - setRoles(cachedData.data.roles); setIsAuthenticated(authenticated); + setRoles(result.data.roles); + setUsername(result.data.username); setIsLoading(false); - return; - } - - // If no cache, make the API call - const result = await api.get(authCheckRoute); - // Cache just the response data - await setCachedResponse(service, authCheckRoute, result.data); - - const userRoles = result.data.roles; - let authenticated = false; - for (const item of userRoles) { - if (validRoles.indexOf(item) !== -1) { - authenticated = true; - break; - } + } catch (e) { + setIsAuthenticated(false); + setIsLoading(false); + console.error(e); } - setIsAuthenticated(authenticated); - setRoles(result.data.roles); - setUsername(result.data.username); - setIsLoading(false); - } catch (e) { - setIsAuthenticated(false); - setIsLoading(false); - console.error(e); - } - }); + }, + ); } getAuth(); }, [baseEndpoint, authCheckRoute, service]); @@ -169,9 +189,11 @@ export const AuthGuard: React.FC< Unauthorized - You have not been granted access to this module. Please fill out the{' '} - access request form to request - access to this module. + You have not been granted access to this module. Please fill out the{" "} + + access request form + {" "} + to request access to this module. @@ -183,9 +205,10 @@ export const AuthGuard: React.FC< Service: {friendlyName} (<code>{service}</code>) </li> <li>User: {username}</li> - <li>Roles: {roles ? roles.join(', ') : <code>none</code>}</li> + <li>Roles: {roles ? roles.join(", ") : <code>none</code>}</li> <li> - Time: {new Date().toDateString()} {new Date().toLocaleTimeString()} + Time: {new Date().toDateString()}{" "} + {new Date().toLocaleTimeString()} </li> </ul> </Card> diff --git a/src/ui/components/BlurredTextDisplay.tsx b/src/ui/components/BlurredTextDisplay.tsx index 157c00c2..b18090a9 100644 --- a/src/ui/components/BlurredTextDisplay.tsx +++ b/src/ui/components/BlurredTextDisplay.tsx @@ -1,11 +1,11 @@ -import React, { useState } from 'react'; -import { Text, Overlay, Box, ActionIcon } from '@mantine/core'; -import { IconEye, IconEyeOff } from '@tabler/icons-react'; +import React, { useState } from "react"; +import { Text, Overlay, Box, ActionIcon } from "@mantine/core"; +import { IconEye, IconEyeOff } from "@tabler/icons-react"; -export const BlurredTextDisplay: React.FC<{ text: string; initialState?: boolean }> = ({ - text, - initialState = false, -}) => { +export const BlurredTextDisplay: React.FC<{ + text: string; + initialState?: boolean; +}> = ({ text, initialState = false }) => { const [visible, setVisible] = useState(initialState); return ( @@ -17,7 +17,7 @@ export const BlurredTextDisplay: React.FC<{ text: string; initialState?: boolean p="md" bg="var(--mantine-color-gray-light)" style={{ - wordBreak: 'break-all', + wordBreak: "break-all", borderRadius: 4, }} > @@ -33,7 +33,7 @@ export const BlurredTextDisplay: React.FC<{ text: string; initialState?: boolean zIndex={5} center style={{ - position: 'absolute', // Made position explicit + position: "absolute", // Made position explicit top: 0, left: 0, right: 0, diff --git a/src/ui/components/DarkModeSwitch/index.tsx b/src/ui/components/DarkModeSwitch/index.tsx index ae350bf6..eda200b4 100644 --- a/src/ui/components/DarkModeSwitch/index.tsx +++ b/src/ui/components/DarkModeSwitch/index.tsx @@ -1,12 +1,12 @@ -import { Switch, useMantineTheme, rem } from '@mantine/core'; -import { useColorScheme, useLocalStorage } from '@mantine/hooks'; -import { IconSun, IconMoonStars } from '@tabler/icons-react'; +import { Switch, useMantineTheme, rem } from "@mantine/core"; +import { useColorScheme, useLocalStorage } from "@mantine/hooks"; +import { IconSun, IconMoonStars } from "@tabler/icons-react"; function DarkModeSwitch() { const theme = useMantineTheme(); const preferredColorScheme = useColorScheme(); const [colorScheme, setColorScheme] = useLocalStorage({ - key: 'acm-manage-color-scheme', + key: "acm-manage-color-scheme", defaultValue: preferredColorScheme, }); const sunIcon = ( @@ -27,9 +27,9 @@ function DarkModeSwitch() { const handleToggle = (event: any) => { if (event.currentTarget.checked) { - setColorScheme('dark'); + setColorScheme("dark"); } else { - setColorScheme('light'); + setColorScheme("light"); } }; @@ -37,7 +37,7 @@ function DarkModeSwitch() { <Switch size="md" color="dark.4" - checked={colorScheme === 'dark'} + checked={colorScheme === "dark"} onChange={(event) => { handleToggle(event); }} diff --git a/src/ui/components/FullPageError/index.tsx b/src/ui/components/FullPageError/index.tsx index 25b43db7..dedeaf6b 100644 --- a/src/ui/components/FullPageError/index.tsx +++ b/src/ui/components/FullPageError/index.tsx @@ -1,5 +1,5 @@ -import { Container, Paper, Title, Text, Button } from '@mantine/core'; -import React, { MouseEventHandler } from 'react'; +import { Container, Paper, Title, Text, Button } from "@mantine/core"; +import React, { MouseEventHandler } from "react"; interface FullPageErrorProps { errorCode?: number; @@ -7,13 +7,23 @@ interface FullPageErrorProps { onRetry?: MouseEventHandler<HTMLButtonElement>; } -const FullPageError: React.FC<FullPageErrorProps> = ({ errorCode, errorMessage, onRetry }) => ( +const FullPageError: React.FC<FullPageErrorProps> = ({ + errorCode, + errorMessage, + onRetry, +}) => ( <Container> <Paper shadow="md" radius="md"> - <Title>{errorCode || 'An error occurred'} - {errorMessage || 'Something went wrong. Please try again later.'} + {errorCode || "An error occurred"} + + {errorMessage || "Something went wrong. Please try again later."} + {onRetry && ( - )} diff --git a/src/ui/components/LoginComponent/AcmLoginButton.tsx b/src/ui/components/LoginComponent/AcmLoginButton.tsx index 02782eb5..2f8a5009 100644 --- a/src/ui/components/LoginComponent/AcmLoginButton.tsx +++ b/src/ui/components/LoginComponent/AcmLoginButton.tsx @@ -1,10 +1,11 @@ -import { useMsal } from '@azure/msal-react'; -import { Button, ButtonProps } from '@mantine/core'; +import { useMsal } from "@azure/msal-react"; +import { Button, ButtonProps } from "@mantine/core"; -import { useAuth } from '../AuthContext/index.js'; +import { useAuth } from "../AuthContext/index.js"; export function AcmLoginButton( - props: ButtonProps & React.ComponentPropsWithoutRef<'button'> & { returnTo: string } + props: ButtonProps & + React.ComponentPropsWithoutRef<"button"> & { returnTo: string }, ) { const { loginMsal } = useAuth(); const { inProgress } = useMsal(); diff --git a/src/ui/components/LoginComponent/LoginComponent.test.tsx b/src/ui/components/LoginComponent/LoginComponent.test.tsx new file mode 100644 index 00000000..9a2145e7 --- /dev/null +++ b/src/ui/components/LoginComponent/LoginComponent.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { MantineProvider } from "@mantine/core"; +import { LoginComponent } from "@ui/components/LoginComponent"; + +const renderWithProviders = () => { + return render( + + + + + , + ); +}; + +describe("LoginComponent tests", () => { + it("renders the login component and verifies the logo and text", () => { + renderWithProviders(); + const logo = screen.getByAltText("ACM Logo"); + expect(logo).toBeInTheDocument(); + + const portalText = screen.getByText( + "Welcome to the ACM@UIUC Management Portal", + ); + expect(portalText).toBeInTheDocument(); + }); + + it('verifies the "Authorized Users Only" section', () => { + renderWithProviders(); + const authText = screen.getByRole("heading", { + name: "Authorized Users Only", + }); + expect(authText).toBeInTheDocument(); + + const explanationText = screen.getByText( + "Unauthorized or improper use or access of this system may result in disciplinary action, as well as civil and criminal penalties.", + ); + expect(explanationText).toBeInTheDocument(); + }); + + it('verifies the "Sign in with Illinois NetID" button', () => { + renderWithProviders(); + const signInButton = screen.getByText("Sign in with Illinois NetID"); + expect(signInButton).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/LoginComponent/index.tsx b/src/ui/components/LoginComponent/index.tsx index 7d083ded..8db0d831 100644 --- a/src/ui/components/LoginComponent/index.tsx +++ b/src/ui/components/LoginComponent/index.tsx @@ -8,22 +8,26 @@ import { Alert, Anchor, Title, -} from '@mantine/core'; -import { IconLock } from '@tabler/icons-react'; -import { useSearchParams } from 'react-router-dom'; +} from "@mantine/core"; +import { IconLock } from "@tabler/icons-react"; +import { useSearchParams } from "react-router-dom"; -import { AcmLoginButton } from './AcmLoginButton.js'; +import { AcmLoginButton } from "./AcmLoginButton.js"; -import brandImgUrl from '@ui/banner-blue.png'; +import brandImgUrl from "@ui/banner-blue.png"; export function LoginComponent(props: PaperProps) { const [searchParams] = useSearchParams(); - const returnTo = searchParams.get('returnTo') || undefined; + const returnTo = searchParams.get("returnTo") || undefined; return (
- ACM Logo + ACM Logo
@@ -38,13 +42,13 @@ export function LoginComponent(props: PaperProps) { color="#0053B3" > - Unauthorized or improper use or access of this system may result in disciplinary action, - as well as civil and criminal penalties. + Unauthorized or improper use or access of this system may result in + disciplinary action, as well as civil and criminal penalties. - + Sign in with Illinois NetID diff --git a/src/ui/components/Navbar/Logo.test.tsx b/src/ui/components/Navbar/Logo.test.tsx index 371e9f73..d5ac5c97 100644 --- a/src/ui/components/Navbar/Logo.test.tsx +++ b/src/ui/components/Navbar/Logo.test.tsx @@ -1,37 +1,37 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { vi } from 'vitest'; -import LogoBadge from './Logo'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { vi } from "vitest"; +import LogoBadge from "./Logo"; -describe('Logo basic tests', () => { +describe("Logo basic tests", () => { beforeEach(async () => { vi.resetModules(); }); - it('renders the logo image', () => { + it("renders the logo image", () => { render( - + , ); - const logo = screen.getByAltText('ACM Logo'); + const logo = screen.getByAltText("ACM Logo"); expect(logo).toBeInTheDocument(); - const logoSrc = logo.getAttribute('src'); - expect(logo).toHaveStyle('height: 3em'); - expect(logoSrc).toEqual('/banner-blue.png'); + const logoSrc = logo.getAttribute("src"); + expect(logo).toHaveStyle("height: 3em"); + expect(logoSrc).toEqual("/banner-blue.png"); }); it('renders the red text "Management Portal DEV ENV" in the dev env', () => { render( - + , ); - const text = screen.getByText('Management Portal DEV ENV'); + const text = screen.getByText("Management Portal DEV ENV"); const style = window.getComputedStyle(text); expect(text).toBeInTheDocument(); - expect(style.color).toBe('rgb(255, 0, 0)'); // Red in RGB format + expect(style.color).toBe("rgb(255, 0, 0)"); // Red in RGB format }); }); diff --git a/src/ui/components/Navbar/Logo.tsx b/src/ui/components/Navbar/Logo.tsx index cf0cbc4b..c769bd81 100644 --- a/src/ui/components/Navbar/Logo.tsx +++ b/src/ui/components/Navbar/Logo.tsx @@ -1,10 +1,10 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; +import React from "react"; +import { Link } from "react-router-dom"; -import brandImgUrl from '@ui/banner-blue.png'; -import brandWhiteImgUrl from '@ui/banner-white.png'; -import { useMantineTheme } from '@mantine/core'; -import { useColorScheme, useLocalStorage } from '@mantine/hooks'; +import brandImgUrl from "@ui/banner-blue.png"; +import brandWhiteImgUrl from "@ui/banner-white.png"; +import { useMantineTheme } from "@mantine/core"; +import { useColorScheme, useLocalStorage } from "@mantine/hooks"; interface LogoBadgeProps { size?: string; @@ -12,41 +12,49 @@ interface LogoBadgeProps { showText?: boolean; } -export const LogoBadge: React.FC = ({ size, linkTo, showText }) => { - const isNonProd = import.meta.env.VITE_RUN_ENVIRONMENT !== 'prod'; +export const LogoBadge: React.FC = ({ + size, + linkTo, + showText, +}) => { + const isNonProd = import.meta.env.VITE_RUN_ENVIRONMENT !== "prod"; if (!showText) { showText = true; } if (!size) { - size = '1em'; + size = "1em"; } const preferredColorScheme = useColorScheme(); const [colorScheme, setColorScheme] = useLocalStorage({ - key: 'acm-manage-color-scheme', + key: "acm-manage-color-scheme", defaultValue: preferredColorScheme, }); const runEnv = import.meta.env.VITE_RUN_ENVIRONMENT; return ( ACM Logo {showText ? isNonProd && runEnv - ? `Management Portal ${runEnv.toUpperCase().replace('LOCAL-DEV', 'DEV')} ENV` - : 'Management Portal' + ? `Management Portal ${runEnv.toUpperCase().replace("LOCAL-DEV", "DEV")} ENV` + : "Management Portal" : null} diff --git a/src/ui/components/Navbar/index.tsx b/src/ui/components/Navbar/index.tsx index da4139c6..2f4ecc4e 100644 --- a/src/ui/components/Navbar/index.tsx +++ b/src/ui/components/Navbar/index.tsx @@ -1,4 +1,4 @@ -'use client'; +"use client"; import { Group, @@ -10,21 +10,22 @@ import { rem, AppShell, Text, -} from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; -import { useNavigate } from 'react-router-dom'; +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { useNavigate } from "react-router-dom"; -import { extLinks, navItems, renderNavItems } from '../AppShell/index.js'; -import { useAuth } from '../AuthContext/index.js'; -import { DarkModeSwitch } from '../DarkModeSwitch/index.js'; -import { AuthenticatedProfileDropdown } from '../ProfileDropdown/index.js'; +import { extLinks, navItems, renderNavItems } from "../AppShell/index.js"; +import { useAuth } from "../AuthContext/index.js"; +import { DarkModeSwitch } from "../DarkModeSwitch/index.js"; +import { AuthenticatedProfileDropdown } from "../ProfileDropdown/index.js"; -import LogoBadge from './Logo.js'; -import classes from './index.module.css'; -import { getCurrentRevision } from '@ui/util/revision.js'; +import LogoBadge from "./Logo.js"; +import classes from "./index.module.css"; +import { getCurrentRevision } from "@ui/util/revision.js"; const HeaderNavbar: React.FC = () => { - const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] = useDisclosure(false); + const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] = + useDisclosure(false); const { userData } = useAuth(); const navigate = useNavigate(); return ( @@ -34,11 +35,23 @@ const HeaderNavbar: React.FC = () => { - + - {userData ? : null} + {userData ? ( + + ) : null} - + @@ -52,12 +65,14 @@ const HeaderNavbar: React.FC = () => { zIndex={1000000} > - {renderNavItems(navItems, '', navigate)} + {renderNavItems(navItems, "", navigate)} - {renderNavItems(extLinks, '', navigate)} + {renderNavItems(extLinks, "", navigate)} - {userData ? : null} - + {userData ? ( + + ) : null} + © {new Date().getFullYear()} ACM @ UIUC diff --git a/src/ui/components/ProfileDropdown/index.tsx b/src/ui/components/ProfileDropdown/index.tsx index 71760e8b..01c9fa9a 100644 --- a/src/ui/components/ProfileDropdown/index.tsx +++ b/src/ui/components/ProfileDropdown/index.tsx @@ -12,19 +12,21 @@ import { rem, useMantineTheme, Avatar, -} from '@mantine/core'; -import { IconChevronDown, IconUser, IconMail } from '@tabler/icons-react'; -import { useState } from 'react'; +} from "@mantine/core"; +import { IconChevronDown, IconUser, IconMail } from "@tabler/icons-react"; +import { useState } from "react"; -import { AuthContextData, useAuth } from '../AuthContext/index.js'; -import classes from '../Navbar/index.module.css'; -import { useNavigate } from 'react-router-dom'; +import { AuthContextData, useAuth } from "../AuthContext/index.js"; +import classes from "../Navbar/index.module.css"; +import { useNavigate } from "react-router-dom"; interface ProfileDropdownProps { userData?: AuthContextData; } -const AuthenticatedProfileDropdown: React.FC = ({ userData }) => { +const AuthenticatedProfileDropdown: React.FC = ({ + userData, +}) => { const [opened, setOpened] = useState(false); const theme = useMantineTheme(); const navigate = useNavigate(); @@ -55,7 +57,7 @@ const AuthenticatedProfileDropdown: React.FC = ({ userData
- + @@ -72,7 +74,7 @@ const AuthenticatedProfileDropdown: React.FC = ({ userData @@ -118,7 +120,7 @@ const AuthenticatedProfileDropdown: React.FC = ({ userData mb="sm" fullWidth onClick={() => { - navigate('/profile'); + navigate("/profile"); }} > Edit Profile diff --git a/src/ui/config.ts b/src/ui/config.ts index 74276625..0010f1da 100644 --- a/src/ui/config.ts +++ b/src/ui/config.ts @@ -4,12 +4,12 @@ import { execCouncilGroupId, execCouncilTestingGroupId, miscTestingGroupId, -} from '@common/config'; +} from "@common/config"; -export const runEnvironments = ['dev', 'prod', 'local-dev'] as const; +export const runEnvironments = ["dev", "prod", "local-dev"] as const; // local dev should be used when you want to test against a local instance of the API -export const services = ['core', 'tickets', 'merch', 'msGraphApi'] as const; +export const services = ["core", "tickets", "merch", "msGraphApi"] as const; export type RunEnvironment = (typeof runEnvironments)[number]; export type ValidServices = (typeof services)[number]; export type ValidService = ValidServices; @@ -41,29 +41,30 @@ type EnvironmentConfigType = { }; const environmentConfig: EnvironmentConfigType = { - 'local-dev': { - AadValidClientId: 'd1978c23-6455-426a-be4d-528b2d2e4026', + "local-dev": { + AadValidClientId: "d1978c23-6455-426a-be4d-528b2d2e4026", ServiceConfiguration: { core: { - friendlyName: 'Core Management Service (NonProd)', - baseEndpoint: 'http://localhost:8080', - authCheckRoute: '/api/v1/protected', - loginScope: 'api://39c28870-94e4-47ee-b4fb-affe0bf96c9f/ACM.Events.Login', - apiId: 'api://39c28870-94e4-47ee-b4fb-affe0bf96c9f', + friendlyName: "Core Management Service (NonProd)", + baseEndpoint: "http://localhost:8080", + authCheckRoute: "/api/v1/protected", + loginScope: + "api://39c28870-94e4-47ee-b4fb-affe0bf96c9f/ACM.Events.Login", + apiId: "api://39c28870-94e4-47ee-b4fb-affe0bf96c9f", }, tickets: { - friendlyName: 'Ticketing Service (NonProd)', - baseEndpoint: 'https://ticketing.aws.qa.acmuiuc.org', + friendlyName: "Ticketing Service (NonProd)", + baseEndpoint: "https://ticketing.aws.qa.acmuiuc.org", }, merch: { - friendlyName: 'Merch Sales Service (Prod)', - baseEndpoint: 'https://merchapi.acm.illinois.edu', + friendlyName: "Merch Sales Service (Prod)", + baseEndpoint: "https://merchapi.acm.illinois.edu", }, msGraphApi: { - friendlyName: 'Microsoft Graph API', - baseEndpoint: 'https://graph.microsoft.com', - loginScope: 'https://graph.microsoft.com/.default', - apiId: 'https://graph.microsoft.com', + friendlyName: "Microsoft Graph API", + baseEndpoint: "https://graph.microsoft.com", + loginScope: "https://graph.microsoft.com/.default", + apiId: "https://graph.microsoft.com", }, }, KnownGroupMappings: { @@ -73,28 +74,29 @@ const environmentConfig: EnvironmentConfigType = { }, }, dev: { - AadValidClientId: 'd1978c23-6455-426a-be4d-528b2d2e4026', + AadValidClientId: "d1978c23-6455-426a-be4d-528b2d2e4026", ServiceConfiguration: { core: { - friendlyName: 'Core Management Service (NonProd)', - baseEndpoint: 'https://core.aws.qa.acmuiuc.org', - authCheckRoute: '/api/v1/protected', - loginScope: 'api://39c28870-94e4-47ee-b4fb-affe0bf96c9f/ACM.Events.Login', - apiId: 'api://39c28870-94e4-47ee-b4fb-affe0bf96c9f', + friendlyName: "Core Management Service (NonProd)", + baseEndpoint: "https://core.aws.qa.acmuiuc.org", + authCheckRoute: "/api/v1/protected", + loginScope: + "api://39c28870-94e4-47ee-b4fb-affe0bf96c9f/ACM.Events.Login", + apiId: "api://39c28870-94e4-47ee-b4fb-affe0bf96c9f", }, tickets: { - friendlyName: 'Ticketing Service (NonProd)', - baseEndpoint: 'https://ticketing.aws.qa.acmuiuc.org', + friendlyName: "Ticketing Service (NonProd)", + baseEndpoint: "https://ticketing.aws.qa.acmuiuc.org", }, merch: { - friendlyName: 'Merch Sales Service (Prod)', - baseEndpoint: 'https://merchapi.acm.illinois.edu', + friendlyName: "Merch Sales Service (Prod)", + baseEndpoint: "https://merchapi.acm.illinois.edu", }, msGraphApi: { - friendlyName: 'Microsoft Graph API', - baseEndpoint: 'https://graph.microsoft.com', - loginScope: 'https://graph.microsoft.com/.default', - apiId: 'https://graph.microsoft.com', + friendlyName: "Microsoft Graph API", + baseEndpoint: "https://graph.microsoft.com", + loginScope: "https://graph.microsoft.com/.default", + apiId: "https://graph.microsoft.com", }, }, KnownGroupMappings: { @@ -104,39 +106,42 @@ const environmentConfig: EnvironmentConfigType = { }, }, prod: { - AadValidClientId: '43fee67e-e383-4071-9233-ef33110e9386', + AadValidClientId: "43fee67e-e383-4071-9233-ef33110e9386", ServiceConfiguration: { core: { - friendlyName: 'Core Management Service', - baseEndpoint: 'https://core.acm.illinois.edu', - authCheckRoute: '/api/v1/protected', - loginScope: 'api://5e08cf0f-53bb-4e09-9df2-e9bdc3467296/ACM.Events.Login', - apiId: 'api://5e08cf0f-53bb-4e09-9df2-e9bdc3467296', + friendlyName: "Core Management Service", + baseEndpoint: "https://core.acm.illinois.edu", + authCheckRoute: "/api/v1/protected", + loginScope: + "api://5e08cf0f-53bb-4e09-9df2-e9bdc3467296/ACM.Events.Login", + apiId: "api://5e08cf0f-53bb-4e09-9df2-e9bdc3467296", }, tickets: { - friendlyName: 'Ticketing Service', - baseEndpoint: 'https://ticketing.aws.acmuiuc.org', + friendlyName: "Ticketing Service", + baseEndpoint: "https://ticketing.aws.acmuiuc.org", }, merch: { - friendlyName: 'Merch Sales Service', - baseEndpoint: 'https://merchapi.acm.illinois.edu', + friendlyName: "Merch Sales Service", + baseEndpoint: "https://merchapi.acm.illinois.edu", }, msGraphApi: { - friendlyName: 'Microsoft Graph API', - baseEndpoint: 'https://graph.microsoft.com', - loginScope: 'https://graph.microsoft.com/.default', - apiId: 'https://graph.microsoft.com', + friendlyName: "Microsoft Graph API", + baseEndpoint: "https://graph.microsoft.com", + loginScope: "https://graph.microsoft.com/.default", + apiId: "https://graph.microsoft.com", }, }, KnownGroupMappings: { Exec: execCouncilGroupId, CommChairs: commChairsGroupId, - StripeLinkCreators: '675203eb-fbb9-4789-af2f-e87a3243f8e6', + StripeLinkCreators: "675203eb-fbb9-4789-af2f-e87a3243f8e6", }, }, } as const; const getRunEnvironmentConfig = () => - environmentConfig[(import.meta.env.VITE_RUN_ENVIRONMENT || 'dev') as RunEnvironment]; + environmentConfig[ + (import.meta.env.VITE_RUN_ENVIRONMENT || "dev") as RunEnvironment + ]; export { getRunEnvironmentConfig }; diff --git a/src/ui/main.tsx b/src/ui/main.tsx index c4308787..5e02c27e 100644 --- a/src/ui/main.tsx +++ b/src/ui/main.tsx @@ -1,34 +1,35 @@ -import 'zod-openapi/extend'; -import { Configuration, PublicClientApplication } from '@azure/msal-browser'; -import { MsalProvider } from '@azure/msal-react'; -import ReactDOM from 'react-dom/client'; +import "zod-openapi/extend"; +import { Configuration, PublicClientApplication } from "@azure/msal-browser"; +import { MsalProvider } from "@azure/msal-react"; +import ReactDOM from "react-dom/client"; -import App from './App'; -import { AuthProvider } from './components/AuthContext'; -import '@ungap/with-resolvers'; -import { getRunEnvironmentConfig } from './config'; +import App from "./App"; +import { AuthProvider } from "./components/AuthContext"; +import "@ungap/with-resolvers"; +import { getRunEnvironmentConfig } from "./config"; const envConfig = getRunEnvironmentConfig(); const msalConfiguration: Configuration = { auth: { clientId: envConfig.AadValidClientId, - authority: 'https://login.microsoftonline.com/c8d9148f-9a59-4db3-827d-42ea0c2b6e2e', + authority: + "https://login.microsoftonline.com/c8d9148f-9a59-4db3-827d-42ea0c2b6e2e", redirectUri: `${window.location.origin}/auth/callback`, postLogoutRedirectUri: `${window.location.origin}/logout`, }, cache: { - cacheLocation: 'sessionStorage', + cacheLocation: "sessionStorage", storeAuthStateInCookie: true, }, }; const pca = new PublicClientApplication(msalConfiguration); -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - + , ); diff --git a/src/ui/package.json b/src/ui/package.json index 444023d6..cf1081fd 100644 --- a/src/ui/package.json +++ b/src/ui/package.json @@ -19,76 +19,76 @@ "storybook:build": "storybook build" }, "dependencies": { - "@azure/msal-browser": "^3.20.0", - "@azure/msal-react": "^2.0.22", - "@mantine/core": "^7.12.0", - "@mantine/dates": "^7.12.0", - "@mantine/form": "^7.12.0", - "@mantine/hooks": "^7.12.0", - "@mantine/notifications": "^7.12.0", - "@tabler/icons-react": "^3.29.0", + "@azure/msal-browser": "^4.11.0", + "@azure/msal-react": "^3.0.10", + "@mantine/core": "^7.17.5", + "@mantine/dates": "^7.17.5", + "@mantine/form": "^7.17.5", + "@mantine/hooks": "^7.17.5", + "@mantine/notifications": "^7.17.5", + "@tabler/icons-react": "^3.31.0", "@ungap/with-resolvers": "^0.1.0", - "axios": "^1.8.4", + "axios": "^1.9.0", "dayjs": "^1.11.12", - "dotenv": "^16.4.5", + "dotenv": "^16.5.0", "dotenv-cli": "^8.0.0", "html5-qrcode": "^2.3.8", "jsqr": "^1.4.0", - "pdfjs-dist": "^4.5.136", + "pdfjs-dist": "^5.2.133", "pluralize": "^8.0.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.1.0", + "react-dom": "^19.1.0", "react-pdf": "^9.1.0", "react-pdftotext": "^1.3.0", "react-qr-reader": "^3.0.0-beta-1", - "react-router-dom": "^6.26.0", - "zod": "^3.23.8", + "react-router-dom": "^7.5.2", + "zod": "^3.24.3", "zod-openapi": "^4.2.4" }, "devDependencies": { - "@eslint/compat": "^1.1.1", - "@storybook/addon-essentials": "^8.2.8", - "@storybook/addon-interactions": "^8.2.8", - "@storybook/addon-links": "^8.2.8", - "@storybook/blocks": "^8.2.8", - "@storybook/react": "^8.2.8", - "@storybook/react-vite": "^8.2.8", + "@eslint/compat": "^1.2.8", + "@storybook/addon-essentials": "^8.6.12", + "@storybook/addon-interactions": "^8.6.12", + "@storybook/addon-links": "^8.6.12", + "@storybook/blocks": "^8.6.12", + "@storybook/react": "^8.6.12", + "@storybook/react-vite": "^8.6.12", "@storybook/testing-library": "^0.2.2", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.4.8", - "@testing-library/react": "^16.0.0", + "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.5.2", "@types/pluralize": "^0.0.33", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", "@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/parser": "^8.0.1", "@vitejs/plugin-react": "^4.3.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^18.0.0", - "eslint-config-mantine": "^3.2.0", + "eslint-config-mantine": "^4.0.3", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jsx-a11y": "^6.9.0", - "eslint-plugin-react": "^7.35.0", - "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", "identity-obj-proxy": "^3.0.0", - "jsdom": "^24.1.1", - "postcss": "^8.4.41", + "jsdom": "^26.1.0", + "postcss": "^8.5.3", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", - "prettier": "^3.3.3", + "prettier": "^3.5.3", "prop-types": "^15.8.1", "serve": "^14.2.4", - "storybook": "^8.2.8", + "storybook": "^8.6.12", "storybook-dark-mode": "^4.0.2", - "stylelint": "^16.8.1", - "stylelint-config-standard-scss": "^13.1.0", - "typescript": "^5.5.4", - "typescript-eslint": "^8.0.1", - "vite": "^6.0.11", + "stylelint": "^16.19.1", + "stylelint-config-standard-scss": "^14.0.0", + "typescript": "^5.8.3", + "typescript-eslint": "^8.31.0", + "vite": "^6.3.3", "vite-tsconfig-paths": "^5.0.1", - "vitest": "^2.0.5", - "yarn-upgrade-all": "^0.7.4" + "vitest": "^3.1.2", + "yarn-upgrade-all": "^0.7.5" }, "resolutions": { "pdfjs-dist": "4.5.136" diff --git a/src/ui/pages/Error404.page.tsx b/src/ui/pages/Error404.page.tsx index 0fac9b73..0d67e41c 100644 --- a/src/ui/pages/Error404.page.tsx +++ b/src/ui/pages/Error404.page.tsx @@ -1,6 +1,6 @@ -import { Container, Title, Text, Anchor } from '@mantine/core'; -import React from 'react'; -import { AcmAppShell } from '@ui/components/AppShell'; +import { Container, Title, Text, Anchor } from "@mantine/core"; +import React from "react"; +import { AcmAppShell } from "@ui/components/AppShell"; export const Error404Page: React.FC = () => { return ( diff --git a/src/ui/pages/Error500.page.tsx b/src/ui/pages/Error500.page.tsx index abf82138..79c5d8b2 100644 --- a/src/ui/pages/Error500.page.tsx +++ b/src/ui/pages/Error500.page.tsx @@ -1,7 +1,7 @@ -import { Container, Title, Text, Anchor } from '@mantine/core'; -import React from 'react'; +import { Container, Title, Text, Anchor } from "@mantine/core"; +import React from "react"; -import { HeaderNavbar } from '@ui/components/Navbar'; +import { HeaderNavbar } from "@ui/components/Navbar"; export const Error500Page: React.FC = () => ( <> diff --git a/src/ui/pages/Home.page.tsx b/src/ui/pages/Home.page.tsx index 432a48e7..13c67518 100644 --- a/src/ui/pages/Home.page.tsx +++ b/src/ui/pages/Home.page.tsx @@ -1,16 +1,19 @@ -import React from 'react'; +import React from "react"; -import { AcmAppShell } from '@ui/components/AppShell'; -import { Title, Text } from '@mantine/core'; -import { useAuth } from '@ui/components/AuthContext'; +import { AcmAppShell } from "@ui/components/AppShell"; +import { Title, Text } from "@mantine/core"; +import { useAuth } from "@ui/components/AuthContext"; export const HomePage: React.FC = () => { const { userData } = useAuth(); return ( <> - - Welcome, {userData?.name?.split(' ')[0]}! - Navigate the ACM @ UIUC Management Portal using the links in the menu bar. + + Welcome, {userData?.name?.split(" ")[0]}! + + Navigate the ACM @ UIUC Management Portal using the links in the menu + bar. + ); diff --git a/src/ui/pages/Login.page.tsx b/src/ui/pages/Login.page.tsx index b6a28add..6fc59bf5 100644 --- a/src/ui/pages/Login.page.tsx +++ b/src/ui/pages/Login.page.tsx @@ -1,33 +1,37 @@ -import { useAuth } from '@ui/components/AuthContext'; -import { LoginComponent } from '@ui/components/LoginComponent'; -import { HeaderNavbar } from '@ui/components/Navbar'; -import { Center, Alert } from '@mantine/core'; -import { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react'; -import { useEffect } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useApi } from '@ui/util/api'; +import { useAuth } from "@ui/components/AuthContext"; +import { LoginComponent } from "@ui/components/LoginComponent"; +import { HeaderNavbar } from "@ui/components/Navbar"; +import { Center, Alert } from "@mantine/core"; +import { IconAlertCircle, IconAlertTriangle } from "@tabler/icons-react"; +import { useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useApi } from "@ui/util/api"; export function LoginPage() { const navigate = useNavigate(); - const graphApi = useApi('msGraphApi'); + const graphApi = useApi("msGraphApi"); const { isLoggedIn, setLoginStatus } = useAuth(); const [searchParams] = useSearchParams(); - const showLogoutMessage = searchParams.get('lc') === 'true'; - const showLoginMessage = !showLogoutMessage && searchParams.get('li') === 'true'; + const showLogoutMessage = searchParams.get("lc") === "true"; + const showLoginMessage = + !showLogoutMessage && searchParams.get("li") === "true"; useEffect(() => { const evalState = async () => { if (isLoggedIn) { - const returnTo = searchParams.get('returnTo'); - const me = (await graphApi.get('/v1.0/me?$select=givenName,surname')).data as { + const returnTo = searchParams.get("returnTo"); + const me = (await graphApi.get("/v1.0/me?$select=givenName,surname")) + .data as { givenName?: string; surname?: string; }; if (!me.givenName || !me.surname) { setLoginStatus(null); - navigate(`/profile?firstTime=true${returnTo ? `&returnTo=${returnTo}` : ''}`); + navigate( + `/profile?firstTime=true${returnTo ? `&returnTo=${returnTo}` : ""}`, + ); } else { - navigate(returnTo || '/home'); + navigate(returnTo || "/home"); } } }; @@ -35,7 +39,7 @@ export function LoginPage() { }, [navigate, isLoggedIn, searchParams]); return ( -
+
{showLogoutMessage && ( } title="Logged Out" color="blue"> @@ -43,7 +47,11 @@ export function LoginPage() { )} {showLoginMessage && ( - } title="Authentication Required" color="orange"> + } + title="Authentication Required" + color="orange" + > You must log in to view this page. )} diff --git a/src/ui/pages/Logout.page.tsx b/src/ui/pages/Logout.page.tsx index 41010592..82351fd7 100644 --- a/src/ui/pages/Logout.page.tsx +++ b/src/ui/pages/Logout.page.tsx @@ -1,9 +1,9 @@ -import { Navigate } from 'react-router-dom'; +import { Navigate } from "react-router-dom"; -import { useAuth } from '@ui/components/AuthContext'; +import { useAuth } from "@ui/components/AuthContext"; export function LogoutPage() { const { logoutCallback } = useAuth(); logoutCallback(); - return ; + return ; } diff --git a/src/ui/pages/apiKeys/ManageKeys.page.tsx b/src/ui/pages/apiKeys/ManageKeys.page.tsx index d3848a6d..80b62a51 100644 --- a/src/ui/pages/apiKeys/ManageKeys.page.tsx +++ b/src/ui/pages/apiKeys/ManageKeys.page.tsx @@ -1,32 +1,41 @@ -import React, { useState } from 'react'; -import { Card, Container, Divider, Title, Text } from '@mantine/core'; -import { AuthGuard } from '@ui/components/AuthGuard'; -import { AppRoles } from '@common/roles'; -import { useApi } from '@ui/util/api'; -import { OrgApiKeyTable } from './ManageKeysTable'; +import React, { useState } from "react"; +import { Card, Container, Divider, Title, Text } from "@mantine/core"; +import { AuthGuard } from "@ui/components/AuthGuard"; +import { AppRoles } from "@common/roles"; +import { useApi } from "@ui/util/api"; +import { OrgApiKeyTable } from "./ManageKeysTable"; export const ManageApiKeysPage: React.FC = () => { - const api = useApi('core'); + const api = useApi("core"); return ( API Keys Manage organization API keys. - These keys' permissions are not tied to any one user, and can be managed by organization - admins. + These keys' permissions are not tied to any one user, and can be + managed by organization admins. api.get('/api/v1/apiKey/org').then((res) => res.data)} + getApiKeys={() => + api.get("/api/v1/apiKey/org").then((res) => res.data) + } deleteApiKeys={(ids) => - Promise.all(ids.map((id) => api.delete(`/api/v1/apiKey/org/${id}`))).then(() => {}) + Promise.all( + ids.map((id) => api.delete(`/api/v1/apiKey/org/${id}`)), + ).then(() => {}) + } + createApiKey={(data) => + api.post("/api/v1/apiKey/org", data).then((res) => res.data) } - createApiKey={(data) => api.post('/api/v1/apiKey/org', data).then((res) => res.data)} /> diff --git a/src/ui/pages/apiKeys/ManageKeysTable.test.tsx b/src/ui/pages/apiKeys/ManageKeysTable.test.tsx index d25c6c13..0a489017 100644 --- a/src/ui/pages/apiKeys/ManageKeysTable.test.tsx +++ b/src/ui/pages/apiKeys/ManageKeysTable.test.tsx @@ -1,38 +1,40 @@ -import React from 'react'; -import { render, screen, act, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { vi } from 'vitest'; -import { MantineProvider } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; -import { OrgApiKeyTable } from './ManageKeysTable'; -import { MemoryRouter } from 'react-router-dom'; -import { ApiKeyMaskedEntry, ApiKeyPostBody } from '@common/types/apiKey'; -import { AppRoles } from '@common/roles'; +import React from "react"; +import { render, screen, act, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; +import { MantineProvider } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { OrgApiKeyTable } from "./ManageKeysTable"; +import { MemoryRouter } from "react-router-dom"; +import { ApiKeyMaskedEntry, ApiKeyPostBody } from "@common/types/apiKey"; +import { AppRoles } from "@common/roles"; // Mock the notifications module -vi.mock('@mantine/notifications', () => ({ +vi.mock("@mantine/notifications", () => ({ notifications: { show: vi.fn(), }, })); // Mock the AuthContext -vi.mock('@ui/components/AuthContext', async () => { +vi.mock("@ui/components/AuthContext", async () => { return { useAuth: vi.fn().mockReturnValue({ - userData: { email: 'test@example.com' }, + userData: { email: "test@example.com" }, }), }; }); // Mock BlurredTextDisplay component -vi.mock('../../components/BlurredTextDisplay', () => ({ - BlurredTextDisplay: ({ text }: { text: string }) =>
{text}
, +vi.mock("../../components/BlurredTextDisplay", () => ({ + BlurredTextDisplay: ({ text }: { text: string }) => ( +
{text}
+ ), })); // Mock Modal component to avoid portal issues in tests -vi.mock('@mantine/core', async () => { - const actual = await vi.importActual('@mantine/core'); +vi.mock("@mantine/core", async () => { + const actual = await vi.importActual("@mantine/core"); return { ...actual, Modal: ({ children, opened, onClose, title }: any) => @@ -40,30 +42,32 @@ vi.mock('@mantine/core', async () => {

{title}

{children}
- +
) : null, }; }); -describe('OrgApiKeyTable Tests', () => { +describe("OrgApiKeyTable Tests", () => { const getApiKeys = vi.fn(); const deleteApiKeys = vi.fn(); const createApiKey = vi.fn(); const mockApiKeys: ApiKeyMaskedEntry[] = [ { - keyId: 'key123', - description: 'Test API Key 1', - owner: 'test@example.com', + keyId: "key123", + description: "Test API Key 1", + owner: "test@example.com", createdAt: Math.floor(Date.now() / 1000) - 86400, // yesterday expiresAt: Math.floor(Date.now() / 1000) + 86400 * 30, // 30 days from now roles: [AppRoles.EVENTS_MANAGER, AppRoles.LINKS_MANAGER], }, { - keyId: 'key456', - description: 'Test API Key 2', - owner: 'other@example.com', + keyId: "key456", + description: "Test API Key 2", + owner: "other@example.com", createdAt: Math.floor(Date.now() / 1000) - 86400 * 7, // 7 days ago expiresAt: undefined, // never expires roles: [AppRoles.EVENTS_MANAGER], @@ -74,14 +78,18 @@ describe('OrgApiKeyTable Tests', () => { await act(async () => { render( - + - + , ); }); }; @@ -90,19 +98,19 @@ describe('OrgApiKeyTable Tests', () => { vi.clearAllMocks(); }); - it('renders the table headers correctly', async () => { + it("renders the table headers correctly", async () => { getApiKeys.mockResolvedValue([]); await renderComponent(); - expect(screen.getByText('Key ID')).toBeInTheDocument(); - expect(screen.getByText('Description')).toBeInTheDocument(); - expect(screen.getByText('Owner')).toBeInTheDocument(); - expect(screen.getByText('Created')).toBeInTheDocument(); - expect(screen.getByText('Expires')).toBeInTheDocument(); - expect(screen.getByText('Permissions')).toBeInTheDocument(); + expect(screen.getByText("Key ID")).toBeInTheDocument(); + expect(screen.getByText("Description")).toBeInTheDocument(); + expect(screen.getByText("Owner")).toBeInTheDocument(); + expect(screen.getByText("Created")).toBeInTheDocument(); + expect(screen.getByText("Expires")).toBeInTheDocument(); + expect(screen.getByText("Permissions")).toBeInTheDocument(); }); - it('shows loading state initially', async () => { + it("shows loading state initially", async () => { getApiKeys.mockResolvedValue([]); await renderComponent(); @@ -111,57 +119,57 @@ describe('OrgApiKeyTable Tests', () => { expect(getApiKeys).toHaveBeenCalledTimes(1); }); - it('displays API keys when loaded', async () => { + it("displays API keys when loaded", async () => { getApiKeys.mockResolvedValue(mockApiKeys); await renderComponent(); // Wait for loading to complete await waitFor(() => { - expect(screen.getByText('acmuiuc_key123')).toBeInTheDocument(); + expect(screen.getByText("acmuiuc_key123")).toBeInTheDocument(); }); - expect(screen.getByText('Test API Key 1')).toBeInTheDocument(); - expect(screen.getByText('You')).toBeInTheDocument(); // Current user's key - expect(screen.getByText('other@example.com')).toBeInTheDocument(); - expect(screen.getByText('Never')).toBeInTheDocument(); // For key that never expires + expect(screen.getByText("Test API Key 1")).toBeInTheDocument(); + expect(screen.getByText("You")).toBeInTheDocument(); // Current user's key + expect(screen.getByText("other@example.com")).toBeInTheDocument(); + expect(screen.getByText("Never")).toBeInTheDocument(); // For key that never expires }); - it('handles empty API key list', async () => { + it("handles empty API key list", async () => { getApiKeys.mockResolvedValue([]); await renderComponent(); await waitFor(() => { - expect(screen.getByText('No API keys found.')).toBeInTheDocument(); + expect(screen.getByText("No API keys found.")).toBeInTheDocument(); }); }); - it('shows notification on API key fetch error', async () => { - const notificationsMock = vi.spyOn(notifications, 'show'); - getApiKeys.mockRejectedValue(new Error('Failed to load')); + it("shows notification on API key fetch error", async () => { + const notificationsMock = vi.spyOn(notifications, "show"); + getApiKeys.mockRejectedValue(new Error("Failed to load")); await renderComponent(); await waitFor(() => { expect(notificationsMock).toHaveBeenCalledWith( expect.objectContaining({ - title: 'Error loading API keys', - color: 'red', - }) + title: "Error loading API keys", + color: "red", + }), ); }); }); - it('allows selecting and deselecting rows', async () => { + it("allows selecting and deselecting rows", async () => { getApiKeys.mockResolvedValue(mockApiKeys); await renderComponent(); const user = userEvent.setup(); // Wait for data to load await waitFor(() => { - expect(screen.getByText('acmuiuc_key123')).toBeInTheDocument(); + expect(screen.getByText("acmuiuc_key123")).toBeInTheDocument(); }); // Find checkboxes and select first row - const checkboxes = screen.getAllByRole('checkbox'); + const checkboxes = screen.getAllByRole("checkbox"); expect(checkboxes.length).toBeGreaterThan(1); // Header + rows // Select first row @@ -177,18 +185,18 @@ describe('OrgApiKeyTable Tests', () => { expect(screen.queryByText(/Delete 1 API Key/)).not.toBeInTheDocument(); }); - it('allows selecting all rows with header checkbox', async () => { + it("allows selecting all rows with header checkbox", async () => { getApiKeys.mockResolvedValue(mockApiKeys); await renderComponent(); const user = userEvent.setup(); // Wait for data to load await waitFor(() => { - expect(screen.getByText('acmuiuc_key123')).toBeInTheDocument(); + expect(screen.getByText("acmuiuc_key123")).toBeInTheDocument(); }); // Check that header checkbox exists - const headerCheckbox = screen.getAllByRole('checkbox')[0]; // Header checkbox + const headerCheckbox = screen.getAllByRole("checkbox")[0]; // Header checkbox expect(headerCheckbox).toBeInTheDocument(); // Click header checkbox diff --git a/src/ui/pages/apiKeys/ManageKeysTable.tsx b/src/ui/pages/apiKeys/ManageKeysTable.tsx index c22ba817..b5be8d6b 100644 --- a/src/ui/pages/apiKeys/ManageKeysTable.tsx +++ b/src/ui/pages/apiKeys/ManageKeysTable.tsx @@ -13,20 +13,31 @@ import { Table, Text, TextInput, -} from '@mantine/core'; -import { DateTimePicker } from '@mantine/dates'; -import { IconAlertCircle, IconEye, IconPlus, IconTrash } from '@tabler/icons-react'; -import React, { useEffect, useState } from 'react'; -import { apiKeyAllowedRoles, ApiKeyMaskedEntry, ApiKeyPostBody } from '@common/types/apiKey'; -import { useAuth } from '@ui/components/AuthContext'; -import { notifications } from '@mantine/notifications'; -import pluralize from 'pluralize'; -import dayjs from 'dayjs'; -import { AppRoles } from '@common/roles'; -import { BlurredTextDisplay } from '../../components/BlurredTextDisplay'; +} from "@mantine/core"; +import { DateTimePicker } from "@mantine/dates"; +import { + IconAlertCircle, + IconEye, + IconPlus, + IconTrash, +} from "@tabler/icons-react"; +import React, { useEffect, useState } from "react"; +import { + apiKeyAllowedRoles, + ApiKeyMaskedEntry, + ApiKeyPostBody, +} from "@common/types/apiKey"; +import { useAuth } from "@ui/components/AuthContext"; +import { notifications } from "@mantine/notifications"; +import pluralize from "pluralize"; +import dayjs from "dayjs"; +import { AppRoles } from "@common/roles"; +import { BlurredTextDisplay } from "../../components/BlurredTextDisplay"; const HumanFriendlyDate = ({ date }: { date: number }) => { - return {dayjs(date * 1000).format('MMMM D, YYYY h:mm A')}; + return ( + {dayjs(date * 1000).format("MMMM D, YYYY h:mm A")} + ); }; interface OrgApiKeyTableProps { @@ -49,7 +60,8 @@ export const OrgApiKeyTable: React.FC = ({ const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [idsToDelete, setIdsToDelete] = useState([]); // New state for view permissions modal - const [viewPermissionsModalOpen, setViewPermissionsModalOpen] = useState(false); + const [viewPermissionsModalOpen, setViewPermissionsModalOpen] = + useState(false); const [selectedKeyForPermissions, setSelectedKeyForPermissions] = useState(null); @@ -62,9 +74,9 @@ export const OrgApiKeyTable: React.FC = ({ setApiKeys(data); } catch (e) { notifications.show({ - title: 'Error loading API keys', - message: 'Unable to fetch API keys. Try again later.', - color: 'red', + title: "Error loading API keys", + message: "Unable to fetch API keys. Try again later.", + color: "red", icon: , }); } finally { @@ -76,17 +88,17 @@ export const OrgApiKeyTable: React.FC = ({ try { await deleteApiKeys(ids); notifications.show({ - title: 'Deleted', - message: `${pluralize('API key', ids.length, true)} deleted successfully.`, - color: 'green', + title: "Deleted", + message: `${pluralize("API key", ids.length, true)} deleted successfully.`, + color: "green", }); setSelected([]); fetchKeys(); } catch (e) { notifications.show({ - title: 'Delete failed', - message: 'Something went wrong while deleting the API keys.', - color: 'red', + title: "Delete failed", + message: "Something went wrong while deleting the API keys.", + color: "red", icon: , }); } finally { @@ -119,9 +131,9 @@ export const OrgApiKeyTable: React.FC = ({ await fetchKeys(); } catch (e) { notifications.show({ - title: 'Create failed', - message: 'Unable to create API key.', - color: 'red', + title: "Create failed", + message: "Unable to create API key.", + color: "red", }); } }; @@ -135,7 +147,7 @@ export const OrgApiKeyTable: React.FC = ({ setSelected( event.currentTarget.checked ? [...selected, entry.keyId] - : selected.filter((id) => id !== entry.keyId) + : selected.filter((id) => id !== entry.keyId), ) } /> @@ -144,7 +156,9 @@ export const OrgApiKeyTable: React.FC = ({ acmuiuc_{entry.keyId} {entry.description} - {entry.owner === userData?.email ? 'You' : entry.owner} + + {entry.owner === userData?.email ? "You" : entry.owner} + @@ -172,7 +186,7 @@ export const OrgApiKeyTable: React.FC = ({ // --- Create Form State --- const [roles, setRoles] = useState([]); - const [description, setDescription] = useState(''); + const [description, setDescription] = useState(""); const [expiresAt, setExpiresAt] = useState(null); return ( @@ -185,7 +199,7 @@ export const OrgApiKeyTable: React.FC = ({ leftSection={} onClick={() => { setRoles([]); - setDescription(''); + setDescription(""); setExpiresAt(null); setCreateModalOpen(true); setCreatedKey(null); @@ -199,7 +213,7 @@ export const OrgApiKeyTable: React.FC = ({ leftSection={} onClick={() => confirmDelete(selected)} > - Delete {pluralize('API Key', selected.length, true)} + Delete {pluralize("API Key", selected.length, true)} )} @@ -214,7 +228,9 @@ export const OrgApiKeyTable: React.FC = ({ checked={apiKeys ? selected.length === apiKeys.length : false} onChange={(event) => setSelected( - event.currentTarget.checked && apiKeys ? apiKeys.map((k) => k.keyId) : [] + event.currentTarget.checked && apiKeys + ? apiKeys.map((k) => k.keyId) + : [], ) } /> @@ -257,7 +273,8 @@ export const OrgApiKeyTable: React.FC = ({ - All times shown in local timezone ({Intl.DateTimeFormat().resolvedOptions().timeZone}). + All times shown in local timezone ( + {Intl.DateTimeFormat().resolvedOptions().timeZone}). {/* Create Modal */} @@ -287,7 +304,7 @@ export const OrgApiKeyTable: React.FC = ({ label="Expires At (optional)" value={expiresAt} minDate={new Date(Date.now() + 60 * 24 * 60 * 1000)} - valueFormat={'MM-DD-YYYY h:mm A'} + valueFormat="MM-DD-YYYY h:mm A" onChange={setExpiresAt} clearable mt="md" @@ -298,10 +315,12 @@ export const OrgApiKeyTable: React.FC = ({ handleCreate({ roles, description, - expiresAt: expiresAt ? Math.floor(expiresAt.getTime() / 1000) : undefined, + expiresAt: expiresAt + ? Math.floor(expiresAt.getTime() / 1000) + : undefined, }) } - disabled={roles.length === 0 || description.trim() === ''} + disabled={roles.length === 0 || description.trim() === ""} > Create @@ -321,13 +340,13 @@ export const OrgApiKeyTable: React.FC = ({ {createdKey ? ( ) : ( - 'An error occurred and your key cannot be displayed' + "An error occurred and your key cannot be displayed" )} - + {({ copied, copy }) => ( - )} @@ -342,12 +361,15 @@ export const OrgApiKeyTable: React.FC = ({ centered > - Are you sure you want to delete the following API {pluralize('key', idsToDelete.length)}? + Are you sure you want to delete the following API{" "} + {pluralize("key", idsToDelete.length)}? - {pluralize('This', idsToDelete.length)} {pluralize('key', idsToDelete.length)} will - immediately be deactivated, and API requests using {pluralize('this', idsToDelete.length)}{' '} - {pluralize('key', idsToDelete.length)} will fail. + {pluralize("This", idsToDelete.length)}{" "} + {pluralize("key", idsToDelete.length)} will immediately be + deactivated, and API requests using{" "} + {pluralize("this", idsToDelete.length)}{" "} + {pluralize("key", idsToDelete.length)} will fail. {idsToDelete.map((id) => ( @@ -414,7 +436,7 @@ export const OrgApiKeyTable: React.FC = ({ {selectedKeyForPermissions.expiresAt ? ( ) : ( - 'Never' + "Never" )} @@ -423,7 +445,7 @@ export const OrgApiKeyTable: React.FC = ({ {selectedKeyForPermissions.owner === userData?.email - ? 'You' + ? "You" : selectedKeyForPermissions.owner} @@ -433,13 +455,19 @@ export const OrgApiKeyTable: React.FC = ({ Policy Restrictions - {JSON.stringify(selectedKeyForPermissions.restrictions, null, 2)} + {JSON.stringify( + selectedKeyForPermissions.restrictions, + null, + 2, + )} )} - + )} diff --git a/src/ui/pages/events/ManageEvent.page.tsx b/src/ui/pages/events/ManageEvent.page.tsx index ae6b5b49..5b012857 100644 --- a/src/ui/pages/events/ManageEvent.page.tsx +++ b/src/ui/pages/events/ManageEvent.page.tsx @@ -10,43 +10,46 @@ import { Group, ActionIcon, Text, -} from '@mantine/core'; -import { DateTimePicker } from '@mantine/dates'; -import { useForm, zodResolver } from '@mantine/form'; -import { notifications } from '@mantine/notifications'; -import dayjs from 'dayjs'; -import React, { useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { z } from 'zod'; -import { AuthGuard } from '@ui/components/AuthGuard'; -import { useApi } from '@ui/util/api'; -import { OrganizationList as orgList } from '@common/orgs'; -import { AppRoles } from '@common/roles'; -import { EVENT_CACHED_DURATION } from '@common/config'; -import { IconPlus, IconTrash } from '@tabler/icons-react'; +} from "@mantine/core"; +import { DateTimePicker } from "@mantine/dates"; +import { useForm, zodResolver } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import dayjs from "dayjs"; +import React, { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { z } from "zod"; +import { AuthGuard } from "@ui/components/AuthGuard"; +import { useApi } from "@ui/util/api"; +import { OrganizationList as orgList } from "@common/orgs"; +import { AppRoles } from "@common/roles"; +import { EVENT_CACHED_DURATION } from "@common/config"; +import { IconPlus, IconTrash } from "@tabler/icons-react"; import { MAX_METADATA_KEYS, MAX_KEY_LENGTH, MAX_VALUE_LENGTH, metadataSchema, -} from '@common/types/events'; +} from "@common/types/events"; export function capitalizeFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); } -const repeatOptions = ['weekly', 'biweekly'] as const; +const repeatOptions = ["weekly", "biweekly"] as const; const baseBodySchema = z.object({ - title: z.string().min(1, 'Title is required'), - description: z.string().min(1, 'Description is required'), + title: z.string().min(1, "Title is required"), + description: z.string().min(1, "Description is required"), start: z.date(), end: z.optional(z.date()), - location: z.string().min(1, 'Location is required'), - locationLink: z.optional(z.string().url('Invalid URL')), - host: z.string().min(1, 'Host is required'), + location: z.string().min(1, "Location is required"), + locationLink: z.optional(z.string().url("Invalid URL")), + host: z.string().min(1, "Host is required"), featured: z.boolean().default(false), - paidEventId: z.string().min(1, 'Paid Event ID must be at least 1 character').optional(), + paidEventId: z + .string() + .min(1, "Paid Event ID must be at least 1 character") + .optional(), // Add metadata field metadata: metadataSchema, }); @@ -57,15 +60,15 @@ const requestBodySchema = baseBodySchema repeatEnds: z.date().optional(), }) .refine((data) => (data.repeatEnds ? data.repeats !== undefined : true), { - message: 'Repeat frequency is required when Repeat End is specified.', + message: "Repeat frequency is required when Repeat End is specified.", }) .refine((data) => !data.end || data.end >= data.start, { - message: 'Event end date cannot be earlier than the start date.', - path: ['end'], + message: "Event end date cannot be earlier than the start date.", + path: ["end"], }) .refine((data) => !data.repeatEnds || data.repeatEnds >= data.start, { - message: 'Repeat end date cannot be earlier than the start date.', - path: ['repeatEnds'], + message: "Repeat end date cannot be earlier than the start date.", + path: ["repeatEnds"], }); type EventPostRequest = z.infer; @@ -73,7 +76,7 @@ type EventPostRequest = z.infer; export const ManageEventPage: React.FC = () => { const [isSubmitting, setIsSubmitting] = useState(false); const navigate = useNavigate(); - const api = useApi('core'); + const api = useApi("core"); const { eventId } = useParams(); @@ -86,7 +89,9 @@ export const ManageEventPage: React.FC = () => { // Fetch event data and populate form const getEvent = async () => { try { - const response = await api.get(`/api/v1/events/${eventId}?ts=${Date.now()}`); + const response = await api.get( + `/api/v1/events/${eventId}?ts=${Date.now()}`, + ); const eventData = response.data; const formValues = { @@ -99,15 +104,17 @@ export const ManageEventPage: React.FC = () => { host: eventData.host, featured: eventData.featured, repeats: eventData.repeats, - repeatEnds: eventData.repeatEnds ? new Date(eventData.repeatEnds) : undefined, + repeatEnds: eventData.repeatEnds + ? new Date(eventData.repeatEnds) + : undefined, paidEventId: eventData.paidEventId, metadata: eventData.metadata || {}, }; form.setValues(formValues); } catch (error) { - console.error('Error fetching event data:', error); + console.error("Error fetching event data:", error); notifications.show({ - message: 'Failed to fetch event data, please try again.', + message: "Failed to fetch event data, please try again.", }); } }; @@ -118,13 +125,13 @@ export const ManageEventPage: React.FC = () => { const form = useForm({ validate: zodResolver(requestBodySchema), initialValues: { - title: '', - description: '', + title: "", + description: "", start: new Date(startDate), end: new Date(startDate + 3.6e6), // 1 hr later - location: 'ACM Room (Siebel CS 1104)', - locationLink: 'https://maps.app.goo.gl/dwbBBBkfjkgj8gvA8', - host: 'ACM', + location: "ACM Room (Siebel CS 1104)", + locationLink: "https://maps.app.goo.gl/dwbBBBkfjkgj8gvA8", + host: "ACM", featured: false, repeats: undefined, repeatEnds: undefined, @@ -135,13 +142,13 @@ export const ManageEventPage: React.FC = () => { useEffect(() => { if (form.values.end && form.values.end <= form.values.start) { - form.setFieldValue('end', new Date(form.values.start.getTime() + 3.6e6)); // 1 hour after the start date + form.setFieldValue("end", new Date(form.values.start.getTime() + 3.6e6)); // 1 hour after the start date } }, [form.values.start]); useEffect(() => { - if (form.values.locationLink === '') { - form.setFieldValue('locationLink', undefined); + if (form.values.locationLink === "") { + form.setFieldValue("locationLink", undefined); } }, [form.values.locationLink]); @@ -151,28 +158,35 @@ export const ManageEventPage: React.FC = () => { const realValues = { ...values, - start: dayjs(values.start).format('YYYY-MM-DD[T]HH:mm:00'), - end: values.end ? dayjs(values.end).format('YYYY-MM-DD[T]HH:mm:00') : undefined, + start: dayjs(values.start).format("YYYY-MM-DD[T]HH:mm:00"), + end: values.end + ? dayjs(values.end).format("YYYY-MM-DD[T]HH:mm:00") + : undefined, repeatEnds: values.repeatEnds && values.repeats - ? dayjs(values.repeatEnds).format('YYYY-MM-DD[T]HH:mm:00') + ? dayjs(values.repeatEnds).format("YYYY-MM-DD[T]HH:mm:00") : undefined, repeats: values.repeats ? values.repeats : undefined, - metadata: Object.keys(values.metadata || {}).length > 0 ? values.metadata : undefined, + metadata: + Object.keys(values.metadata || {}).length > 0 + ? values.metadata + : undefined, }; - const eventURL = isEditing ? `/api/v1/events/${eventId}` : '/api/v1/events'; + const eventURL = isEditing + ? `/api/v1/events/${eventId}` + : "/api/v1/events"; await api.post(eventURL, realValues); notifications.show({ - title: isEditing ? 'Event updated!' : 'Event created!', + title: isEditing ? "Event updated!" : "Event created!", message: `Changes may take up to ${Math.ceil(EVENT_CACHED_DURATION / 60)} minutes to reflect to users.`, }); - navigate('/events/manage'); + navigate("/events/manage"); } catch (error) { setIsSubmitting(false); - console.error('Error creating/editing event:', error); + console.error("Error creating/editing event:", error); notifications.show({ - message: 'Failed to create/edit event, please try again.', + message: "Failed to create/edit event, please try again.", }); } }; @@ -191,7 +205,7 @@ export const ManageEventPage: React.FC = () => { let tempKey = `key${Object.keys(currentMetadata).length + 1}`; // Make sure it's unique while (currentMetadata[tempKey] !== undefined) { - tempKey = `key${parseInt(tempKey.replace('key', '')) + 1}`; + tempKey = `key${parseInt(tempKey.replace("key", ""), 10) + 1}`; } // Update the form @@ -199,7 +213,7 @@ export const ManageEventPage: React.FC = () => { ...form.values, metadata: { ...currentMetadata, - [tempKey]: '', + [tempKey]: "", }, }); }; @@ -217,7 +231,9 @@ export const ManageEventPage: React.FC = () => { const updateMetadataKey = (oldKey: string, newKey: string) => { const metadata = { ...form.values.metadata }; - if (oldKey === newKey) return; + if (oldKey === newKey) { + return; + } const value = metadata[oldKey]; delete metadata[oldKey]; @@ -249,7 +265,8 @@ export const ManageEventPage: React.FC = () => { // For existing metadata, create stable IDs Object.keys(form.values.metadata || {}).forEach((key) => { if (!metadataKeys[key]) { - newMetadataKeys[key] = `meta-${Math.random().toString(36).substring(2, 9)}`; + newMetadataKeys[key] = + `meta-${Math.random().toString(36).substring(2, 9)}`; } else { newMetadataKeys[key] = metadataKeys[key]; } @@ -259,7 +276,9 @@ export const ManageEventPage: React.FC = () => { }, [Object.keys(form.values.metadata || {}).length]); return ( - + {isEditing ? `Edit` : `Create`} Event @@ -269,50 +288,50 @@ export const ManageEventPage: React.FC = () => { label="Event Title" withAsterisk placeholder="Event title" - {...form.getInputProps('title')} + {...form.getInputProps("title")} /> <Textarea label="Event Description" withAsterisk placeholder="Event description" - {...form.getInputProps('description')} + {...form.getInputProps("description")} /> <DateTimePicker label="Start Date" withAsterisk valueFormat="MM-DD-YYYY h:mm A [Urbana Time]" placeholder="Pick start date" - {...form.getInputProps('start')} + {...form.getInputProps("start")} /> <DateTimePicker label="End Date" withAsterisk valueFormat="MM-DD-YYYY h:mm A [Urbana Time]" placeholder="Pick end date (optional)" - {...form.getInputProps('end')} + {...form.getInputProps("end")} /> <TextInput label="Event Location" withAsterisk placeholder="ACM Room" - {...form.getInputProps('location')} + {...form.getInputProps("location")} /> <TextInput label="Location Link" placeholder="Google Maps link for location" - {...form.getInputProps('locationLink')} + {...form.getInputProps("locationLink")} /> <Select label="Host" placeholder="Select host organization" withAsterisk data={orgList.map((org) => ({ value: org, label: org }))} - {...form.getInputProps('host')} + {...form.getInputProps("host")} /> <Switch - label={`Show on home page carousel${!form.values.repeats ? ' and Discord' : ''}?`} - style={{ paddingTop: '0.5em' }} - {...form.getInputProps('featured', { type: 'checkbox' })} + label={`Show on home page carousel${!form.values.repeats ? " and Discord" : ""}?`} + style={{ paddingTop: "0.5em" }} + {...form.getInputProps("featured", { type: "checkbox" })} /> <Select label="Repeats" @@ -322,20 +341,20 @@ export const ManageEventPage: React.FC = () => { label: capitalizeFirstLetter(option), }))} clearable - {...form.getInputProps('repeats')} + {...form.getInputProps("repeats")} /> {form.values.repeats && ( <DateTimePicker valueFormat="MM-DD-YYYY h:mm A [Urbana Time]" label="Repeat Ends" placeholder="Pick repeat end date" - {...form.getInputProps('repeatEnds')} + {...form.getInputProps("repeatEnds")} /> )} <TextInput label="Paid Event ID" placeholder="Enter Ticketing ID or Merch ID prefixed with merch:" - {...form.getInputProps('paidEventId')} + {...form.getInputProps("paidEventId")} /> {/* Metadata Section */} @@ -347,56 +366,67 @@ export const ManageEventPage: React.FC = () => { variant="outline" leftSection={<IconPlus size={16} />} onClick={addMetadataField} - disabled={Object.keys(form.values.metadata || {}).length >= MAX_METADATA_KEYS} + disabled={ + Object.keys(form.values.metadata || {}).length >= + MAX_METADATA_KEYS + } > Add Field </Button> </Group> <Text size="xs" c="dimmed"> - These values can be acceessed via the API. Max {MAX_KEY_LENGTH} characters for keys - and {MAX_VALUE_LENGTH} characters for values. + These values can be acceessed via the API. Max {MAX_KEY_LENGTH}{" "} + characters for keys and {MAX_VALUE_LENGTH} characters for values. </Text> - {Object.entries(form.values.metadata || {}).map(([key, value], index) => { - const keyError = key.trim() === '' ? 'Key is required' : undefined; - const valueError = value.trim() === '' ? 'Value is required' : undefined; - - return ( - <Group key={index} align="start" gap={'sm'}> - <TextInput - label="Key" - value={key} - onChange={(e) => updateMetadataKey(key, e.currentTarget.value)} - error={keyError} - style={{ flex: 1 }} - /> - <Box style={{ flex: 1 }}> + {Object.entries(form.values.metadata || {}).map( + ([key, value], index) => { + const keyError = + key.trim() === "" ? "Key is required" : undefined; + const valueError = + value.trim() === "" ? "Value is required" : undefined; + + return ( + <Group key={index} align="start" gap="sm"> <TextInput - label="Value" - value={value} - onChange={(e) => updateMetadataValue(key, e.currentTarget.value)} - error={valueError} + label="Key" + value={key} + onChange={(e) => + updateMetadataKey(key, e.currentTarget.value) + } + error={keyError} + style={{ flex: 1 }} /> - {/* Empty space to maintain consistent height */} - {valueError && <div style={{ height: '0.75rem' }} />} - </Box> - <ActionIcon - color="red" - variant="light" - onClick={() => removeMetadataField(key)} - mt={30} // align with inputs when label is present - > - <IconTrash size={16} /> - </ActionIcon> - </Group> - ); - })} + <Box style={{ flex: 1 }}> + <TextInput + label="Value" + value={value} + onChange={(e) => + updateMetadataValue(key, e.currentTarget.value) + } + error={valueError} + /> + {/* Empty space to maintain consistent height */} + {valueError && <div style={{ height: "0.75rem" }} />} + </Box> + <ActionIcon + color="red" + variant="light" + onClick={() => removeMetadataField(key)} + mt={30} // align with inputs when label is present + > + <IconTrash size={16} /> + </ActionIcon> + </Group> + ); + }, + )} {Object.keys(form.values.metadata || {}).length > 0 && ( <Box mt="xs" size="xs" ta="right"> <small> - {Object.keys(form.values.metadata || {}).length} of {MAX_METADATA_KEYS} fields - used + {Object.keys(form.values.metadata || {}).length} of{" "} + {MAX_METADATA_KEYS} fields used </small> </Box> )} @@ -409,7 +439,7 @@ export const ManageEventPage: React.FC = () => { Submitting... </> ) : ( - `${isEditing ? 'Save' : 'Create'} Event` + `${isEditing ? "Save" : "Create"} Event` )} </Button> </form> diff --git a/src/ui/pages/events/ViewEvents.page.tsx b/src/ui/pages/events/ViewEvents.page.tsx index 586aee71..b1689ff6 100644 --- a/src/ui/pages/events/ViewEvents.page.tsx +++ b/src/ui/pages/events/ViewEvents.page.tsx @@ -9,22 +9,22 @@ import { Title, Badge, Anchor, -} 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'; +} 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'; +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 repeatOptions = ["weekly", "biweekly"] as const; const baseSchema = z.object({ title: z.string().min(1), @@ -54,10 +54,11 @@ export type EventsGetResponse = z.infer<typeof getEventsSchema>; export const ViewEventsPage: React.FC = () => { const [eventList, setEventList] = useState<EventsGetResponse>([]); - const api = useApi('core'); + const api = useApi("core"); const [opened, { open, close }] = useDisclosure(false); const [showPrevious, { toggle: togglePrevious }] = useDisclosure(false); // Changed default to false - const [deleteCandidate, setDeleteCandidate] = useState<EventGetResponse | null>(null); + const [deleteCandidate, setDeleteCandidate] = + useState<EventGetResponse | null>(null); const navigate = useNavigate(); const renderTableRow = (event: EventGetResponse) => { @@ -73,14 +74,21 @@ export const ViewEventsPage: React.FC = () => { > {(styles) => ( <tr - style={{ ...styles, display: shouldShow ? 'table-row' : 'none' }} + style={{ ...styles, display: shouldShow ? "table-row" : "none" }} key={`${event.id}-tr`} > <Table.Td> - {event.title} {event.featured ? <Badge color="green">Featured</Badge> : null} + {event.title}{" "} + {event.featured ? <Badge color="green">Featured</Badge> : null} + </Table.Td> + <Table.Td> + {dayjs(event.start).format("MMM D YYYY hh:mm A")} + </Table.Td> + <Table.Td> + {event.end + ? dayjs(event.end).format("MMM D YYYY hh:mm A") + : "N/A"} </Table.Td> - <Table.Td>{dayjs(event.start).format('MMM D YYYY hh:mm A')}</Table.Td> - <Table.Td>{event.end ? dayjs(event.end).format('MMM D YYYY hh:mm A') : 'N/A'}</Table.Td> <Table.Td> {event.locationLink ? ( <Anchor target="_blank" size="sm" href={event.locationLink}> @@ -91,7 +99,9 @@ export const ViewEventsPage: React.FC = () => { )} </Table.Td> <Table.Td>{event.host}</Table.Td> - <Table.Td>{capitalizeFirstLetter(event.repeats || 'Never')}</Table.Td> + <Table.Td> + {capitalizeFirstLetter(event.repeats || "Never")} + </Table.Td> <Table.Td> <ButtonGroup> <Button component="a" href={`/events/edit/${event.id}`}> @@ -118,8 +128,12 @@ export const ViewEventsPage: React.FC = () => { const getEvents = async () => { // setting ts lets us tell cloudfront I want fresh data const response = await api.get(`/api/v1/events?ts=${Date.now()}`); - const upcomingEvents = await api.get(`/api/v1/events?upcomingOnly=true&ts=${Date.now()}`); - const upcomingEventsSet = new Set(upcomingEvents.data.map((x: EventGetResponse) => x.id)); + const upcomingEvents = await api.get( + `/api/v1/events?upcomingOnly=true&ts=${Date.now()}`, + ); + const upcomingEventsSet = new Set( + upcomingEvents.data.map((x: EventGetResponse) => x.id), + ); const events = response.data; events.sort((a: EventGetResponse, b: EventGetResponse) => { return a.start.localeCompare(b.start); @@ -138,18 +152,20 @@ export const ViewEventsPage: React.FC = () => { const deleteEvent = async (eventId: string) => { try { await api.delete(`/api/v1/events/${eventId}`); - setEventList((prevEvents) => prevEvents.filter((event) => event.id !== eventId)); + setEventList((prevEvents) => + prevEvents.filter((event) => event.id !== eventId), + ); notifications.show({ - title: 'Event deleted', - message: 'The event was successfully deleted.', + title: "Event deleted", + message: "The event was successfully deleted.", }); close(); } catch (error) { console.error(error); notifications.show({ - title: 'Error deleting event', + title: "Error deleting event", message: `${error}`, - color: 'red', + color: "red", }); } }; @@ -159,8 +175,10 @@ export const ViewEventsPage: React.FC = () => { } return ( - <AuthGuard resourceDef={{ service: 'core', validRoles: [AppRoles.EVENTS_MANAGER] }}> - <Title order={1} mb={'md'}> + <AuthGuard + resourceDef={{ service: "core", validRoles: [AppRoles.EVENTS_MANAGER] }} + > + <Title order={1} mb="md"> Event Management {deleteCandidate && ( @@ -173,7 +191,8 @@ export const ViewEventsPage: React.FC = () => { title="Confirm action" > - Are you sure you want to delete the event {deleteCandidate?.title}? + Are you sure you want to delete the event{" "} + {deleteCandidate?.title}?
@@ -188,20 +207,25 @@ export const ViewEventsPage: React.FC = () => { )} -
+
- +
Title diff --git a/src/ui/pages/iam/GroupMemberManagement.test.tsx b/src/ui/pages/iam/GroupMemberManagement.test.tsx index 633c5741..d4232cb8 100644 --- a/src/ui/pages/iam/GroupMemberManagement.test.tsx +++ b/src/ui/pages/iam/GroupMemberManagement.test.tsx @@ -1,24 +1,31 @@ -import React from 'react'; -import { render, screen, act } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { vi } from 'vitest'; -import GroupMemberManagement from './GroupMemberManagement'; -import { MantineProvider } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; -import userEvent from '@testing-library/user-event'; - -describe('Exec Group Management Panel tests', () => { +import React from "react"; +import { render, screen, act } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { vi } from "vitest"; +import GroupMemberManagement from "./GroupMemberManagement"; +import { MantineProvider } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import userEvent from "@testing-library/user-event"; + +describe("Exec Group Management Panel tests", () => { const renderComponent = async ( fetchMembers: () => Promise, - updateMembers: () => Promise + updateMembers: () => Promise, ) => { await act(async () => { render( - - + + - + , ); }); }; @@ -27,146 +34,164 @@ describe('Exec Group Management Panel tests', () => { vi.resetModules(); }); - it('renders with no members', async () => { + it("renders with no members", async () => { const fetchMembers = async () => []; const updateMembers = async () => ({ success: [] }); await renderComponent(fetchMembers, updateMembers); expect(screen.queryByText(/.*@.*/)).not.toBeInTheDocument(); }); - it('renders with a single member', async () => { - const fetchMembers = async () => [{ name: 'Doe, John', email: 'jdoe@illinois.edu' }]; - const updateMembers = async () => ({ success: [{ email: 'jdoe@illinois.edu' }] }); + it("renders with a single member", async () => { + const fetchMembers = async () => [ + { name: "Doe, John", email: "jdoe@illinois.edu" }, + ]; + const updateMembers = async () => ({ + success: [{ email: "jdoe@illinois.edu" }], + }); await renderComponent(fetchMembers, updateMembers); expect( - screen.getByText((content, element) => element?.textContent === 'Doe, Johnjdoe@illinois.edu') + screen.getByText( + (content, element) => + element?.textContent === "Doe, Johnjdoe@illinois.edu", + ), ).toBeInTheDocument(); }); - it('renders with multiple members', async () => { + it("renders with multiple members", async () => { const fetchMembers = async () => [ - { name: 'Doe, John', email: 'jdoe@illinois.edu' }, - { name: 'Smith, Jane', email: 'jsmith@illinois.edu' }, - { name: 'Brown, Bob', email: 'bbrown@illinois.edu' }, + { name: "Doe, John", email: "jdoe@illinois.edu" }, + { name: "Smith, Jane", email: "jsmith@illinois.edu" }, + { name: "Brown, Bob", email: "bbrown@illinois.edu" }, ]; const updateMembers = async () => ({ success: [ - { email: 'jdoe@illinois.edu' }, - { email: 'jsmith@illinois.edu' }, - { email: 'bbrown@illinois.edu' }, + { email: "jdoe@illinois.edu" }, + { email: "jsmith@illinois.edu" }, + { email: "bbrown@illinois.edu" }, ], }); await renderComponent(fetchMembers, updateMembers); expect( - screen.getByText((content, element) => element?.textContent === 'Doe, Johnjdoe@illinois.edu') + screen.getByText( + (content, element) => + element?.textContent === "Doe, Johnjdoe@illinois.edu", + ), ).toBeInTheDocument(); expect( screen.getByText( - (content, element) => element?.textContent === 'Smith, Janejsmith@illinois.edu' - ) + (content, element) => + element?.textContent === "Smith, Janejsmith@illinois.edu", + ), ).toBeInTheDocument(); expect( screen.getByText( - (content, element) => element?.textContent === 'Brown, Bobbbrown@illinois.edu' - ) + (content, element) => + element?.textContent === "Brown, Bobbbrown@illinois.edu", + ), ).toBeInTheDocument(); }); - it('adds a new member and saves changes', async () => { - const notificationsMock = vi.spyOn(notifications, 'show'); + it("adds a new member and saves changes", async () => { + const notificationsMock = vi.spyOn(notifications, "show"); const user = userEvent.setup(); const fetchMembers = async () => []; const updateMembers = vi.fn().mockResolvedValue({ - success: [{ email: 'member@illinois.edu' }], + success: [{ email: "member@illinois.edu" }], failure: [], }); await renderComponent(fetchMembers, updateMembers); // Input the email - const emailInput = screen.getByPlaceholderText('Enter email'); - await user.type(emailInput, 'member@illinois.edu'); + const emailInput = screen.getByPlaceholderText("Enter email"); + await user.type(emailInput, "member@illinois.edu"); // Click Add Member button - const addButton = screen.getByRole('button', { name: 'Add Member' }); + const addButton = screen.getByRole("button", { name: "Add Member" }); await user.click(addButton); // Match the queued member - expect(screen.getByText('member')).toBeInTheDocument(); - expect(screen.getByText('member@illinois.edu')).toBeInTheDocument(); + expect(screen.getByText("member")).toBeInTheDocument(); + expect(screen.getByText("member@illinois.edu")).toBeInTheDocument(); // Save Changes - const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + const saveButton = screen.getByRole("button", { name: "Save Changes" }); expect(saveButton).toBeEnabled(); await user.click(saveButton); - await screen.findByText('Confirm Changes'); - const confirmButton = screen.getByRole('button', { name: 'Confirm and Save' }); + await screen.findByText("Confirm Changes"); + const confirmButton = screen.getByRole("button", { + name: "Confirm and Save", + }); await user.click(confirmButton); - expect(updateMembers).toHaveBeenCalledWith(['member@illinois.edu'], []); + expect(updateMembers).toHaveBeenCalledWith(["member@illinois.edu"], []); notificationsMock.mockRestore(); }); - it('removes an existing member and saves changes', async () => { - const notificationsMock = vi.spyOn(notifications, 'show'); + it("removes an existing member and saves changes", async () => { + const notificationsMock = vi.spyOn(notifications, "show"); const user = userEvent.setup(); - const fetchMembers = async () => [{ name: 'Existing Member', email: 'existing@illinois.edu' }]; + const fetchMembers = async () => [ + { name: "Existing Member", email: "existing@illinois.edu" }, + ]; const updateMembers = vi.fn().mockResolvedValue({ - success: [{ email: 'existing@illinois.edu' }], + success: [{ email: "existing@illinois.edu" }], failure: [], }); await renderComponent(fetchMembers, updateMembers); // Click remove button for the existing member - const removeButton = screen.getByRole('button', { name: /Remove/ }); + const removeButton = screen.getByRole("button", { name: /Remove/ }); await user.click(removeButton); // Verify member shows removal badge - expect(screen.getByText('Queued for removal')).toBeInTheDocument(); + expect(screen.getByText("Queued for removal")).toBeInTheDocument(); // Save changes - const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + const saveButton = screen.getByRole("button", { name: "Save Changes" }); expect(saveButton).toBeEnabled(); await user.click(saveButton); - await screen.findByText('Confirm Changes'); - const confirmButton = screen.getByRole('button', { name: 'Confirm and Save' }); + await screen.findByText("Confirm Changes"); + const confirmButton = screen.getByRole("button", { + name: "Confirm and Save", + }); await user.click(confirmButton); // Verify updateMembers was called with correct parameters - expect(updateMembers).toHaveBeenCalledWith([], ['existing@illinois.edu']); + expect(updateMembers).toHaveBeenCalledWith([], ["existing@illinois.edu"]); // Verify member is removed from the list expect( - screen.queryByText(/Existing Member \(existing@illinois\.edu\)/) + screen.queryByText(/Existing Member \(existing@illinois\.edu\)/), ).not.toBeInTheDocument(); // Verify success notification expect(notificationsMock).toHaveBeenCalledWith( expect.objectContaining({ - message: 'All changes processed successfully!', - color: 'green', - }) + message: "All changes processed successfully!", + color: "green", + }), ); notificationsMock.mockRestore(); }); - it('handles failed member updates correctly', async () => { - const notificationsMock = vi.spyOn(notifications, 'show'); + it("handles failed member updates correctly", async () => { + const notificationsMock = vi.spyOn(notifications, "show"); const user = userEvent.setup(); const fetchMembers = async () => []; const updateMembers = vi.fn().mockResolvedValue({ success: [], failure: [ { - email: 'member@illinois.edu', - message: 'User does not exist in directory', + email: "member@illinois.edu", + message: "User does not exist in directory", }, ], }); @@ -174,32 +199,32 @@ describe('Exec Group Management Panel tests', () => { await renderComponent(fetchMembers, updateMembers); // Add a member that will fail - const emailInput = screen.getByPlaceholderText('Enter email'); - await user.type(emailInput, 'member@illinois.edu'); - await user.click(screen.getByRole('button', { name: 'Add Member' })); + const emailInput = screen.getByPlaceholderText("Enter email"); + await user.type(emailInput, "member@illinois.edu"); + await user.click(screen.getByRole("button", { name: "Add Member" })); // Verify member shows in queue - expect(screen.getByText('member@illinois.edu')).toBeInTheDocument(); - expect(screen.getByText('Queued for addition')).toBeInTheDocument(); + expect(screen.getByText("member@illinois.edu")).toBeInTheDocument(); + expect(screen.getByText("Queued for addition")).toBeInTheDocument(); // Try to save changes - await user.click(screen.getByRole('button', { name: 'Save Changes' })); - await screen.findByText('Confirm Changes'); - await user.click(screen.getByRole('button', { name: 'Confirm and Save' })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + await screen.findByText("Confirm Changes"); + await user.click(screen.getByRole("button", { name: "Confirm and Save" })); expect(notificationsMock).toHaveBeenCalledWith( expect.objectContaining({ - title: 'Error with member@illinois.edu', - message: 'User does not exist in directory', - color: 'red', - }) + title: "Error with member@illinois.edu", + message: "User does not exist in directory", + color: "red", + }), ); // Verify member is no longer shown as queued - expect(screen.queryByText('Queued for addition')).not.toBeInTheDocument(); + expect(screen.queryByText("Queued for addition")).not.toBeInTheDocument(); // Verify Save Changes button is disabled - expect(screen.getByRole('button', { name: 'Save Changes' })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled(); notificationsMock.mockRestore(); }); diff --git a/src/ui/pages/iam/GroupMemberManagement.tsx b/src/ui/pages/iam/GroupMemberManagement.tsx index 2369d728..3fe44aff 100644 --- a/src/ui/pages/iam/GroupMemberManagement.tsx +++ b/src/ui/pages/iam/GroupMemberManagement.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect } from "react"; import { Avatar, Badge, @@ -10,14 +10,17 @@ import { Modal, Loader, Skeleton, -} from '@mantine/core'; -import { IconUserPlus, IconTrash } from '@tabler/icons-react'; -import { notifications } from '@mantine/notifications'; -import { GroupMemberGetResponse, EntraActionResponse } from '@common/types/iam'; +} from "@mantine/core"; +import { IconUserPlus, IconTrash } from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; +import { GroupMemberGetResponse, EntraActionResponse } from "@common/types/iam"; interface GroupMemberManagementProps { fetchMembers: () => Promise; - updateMembers: (toAdd: string[], toRemove: string[]) => Promise; + updateMembers: ( + toAdd: string[], + toRemove: string[], + ) => Promise; } const GroupMemberManagement: React.FC = ({ @@ -27,7 +30,7 @@ const GroupMemberManagement: React.FC = ({ const [members, setMembers] = useState([]); const [toAdd, setToAdd] = useState([]); const [toRemove, setToRemove] = useState([]); - const [email, setEmail] = useState(''); + const [email, setEmail] = useState(""); const [isLoading, setIsLoading] = useState(true); const [confirmationModal, setConfirmationModal] = useState(false); const loadMembers = async () => { @@ -38,9 +41,9 @@ const GroupMemberManagement: React.FC = ({ setMembers(memberList); } catch (error) { notifications.show({ - title: 'Error', - message: 'Failed to retrieve members.', - color: 'red', + title: "Error", + message: "Failed to retrieve members.", + color: "red", }); } finally { setIsLoading(false); @@ -52,14 +55,18 @@ const GroupMemberManagement: React.FC = ({ }, [fetchMembers]); const handleAddMember = () => { - if (email && !members.some((member) => member.email === email) && !toAdd.includes(email)) { + if ( + email && + !members.some((member) => member.email === email) && + !toAdd.includes(email) + ) { setToAdd((prev) => [...prev, email]); - setEmail(''); + setEmail(""); } else { notifications.show({ - title: 'Invalid Input', - message: 'Email is missing or the user already exists.', - color: 'orange', + title: "Invalid Input", + message: "Email is missing or the user already exists.", + color: "orange", }); } }; @@ -76,13 +83,27 @@ const GroupMemberManagement: React.FC = ({ const response = await updateMembers(toAdd, toRemove); const { success = [], failure = [] } = response; - const successfulAdds = success.filter((entry) => toAdd.includes(entry.email)); - const successfulRemoves = success.filter((entry) => toRemove.includes(entry.email)); + const successfulAdds = success.filter((entry) => + toAdd.includes(entry.email), + ); + const successfulRemoves = success.filter((entry) => + toRemove.includes(entry.email), + ); setMembers((prev) => prev - .filter((member) => !successfulRemoves.some((remove) => remove.email === member.email)) - .concat(successfulAdds.map(({ email }) => ({ name: email.split('@')[0], email }))) + .filter( + (member) => + !successfulRemoves.some( + (remove) => remove.email === member.email, + ), + ) + .concat( + successfulAdds.map(({ email }) => ({ + name: email.split("@")[0], + email, + })), + ), ); loadMembers(); setToAdd([]); @@ -90,24 +111,24 @@ const GroupMemberManagement: React.FC = ({ if (failure.length === 0) { notifications.show({ - title: 'Success', - message: 'All changes processed successfully!', - color: 'green', + title: "Success", + message: "All changes processed successfully!", + color: "green", }); } else { failure.forEach(({ email, message }) => { notifications.show({ title: `Error with ${email}`, message, - color: 'red', + color: "red", }); }); } } catch (error) { notifications.show({ - title: 'Error', - message: 'Failed to save changes.', - color: 'red', + title: "Error", + message: "Failed to save changes.", + color: "red", }); } finally { setIsLoading(false); @@ -119,7 +140,7 @@ const GroupMemberManagement: React.FC = ({ - +
{member.name} @@ -158,10 +179,10 @@ const GroupMemberManagement: React.FC = ({ - +
- {email.split('@')[0]} + {email.split("@")[0]} {email} @@ -179,7 +200,9 @@ const GroupMemberManagement: React.FC = ({ color="red" variant="light" size="xs" - onClick={() => setToAdd((prev) => prev.filter((item) => item !== email))} + onClick={() => + setToAdd((prev) => prev.filter((item) => item !== email)) + } leftSection={} > Cancel @@ -203,9 +226,9 @@ const GroupMemberManagement: React.FC = ({ {isLoading ? ( - + - +
Johnathan Doe @@ -218,12 +241,12 @@ const GroupMemberManagement: React.FC = ({ - - + + - +