diff --git a/.env.sample b/.env.sample index 3eb9dda7..51626e22 100644 --- a/.env.sample +++ b/.env.sample @@ -1,4 +1,5 @@ AadValidClientId="39c28870-94e4-47ee-b4fb-affe0bf96c9f" AadClientSecret="" RunEnvironment="dev" -JwtSigningKey="YOUR_RANDOM_STRING HERE" \ No newline at end of file +JwtSigningKey="YOUR_RANDOM_STRING HERE" +VITE_RUN_ENVIRONMENT="local-dev" \ No newline at end of file diff --git a/.eslintignore b/.eslintignore index 5e8f0509..dbfc1986 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ -/**/*.d.ts \ No newline at end of file +/**/*.d.ts +vite.config.ts \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index ff2dd6af..cc72a57b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -42,6 +42,13 @@ "rules": { "@typescript-eslint/no-explicit-any": "off" } + }, + { + "files": ["src/ui/*", "src/ui/**/*"], // Or *.test.js + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off" + } } ] } diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 54e8cd44..10a6b5c0 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -27,7 +27,7 @@ jobs: deploy-dev: runs-on: ubuntu-latest concurrency: - group: ${{ github.event.repository.name }}-dev + group: ${{ github.event.repository.name }}-dev-aws cancel-in-progress: false environment: "AWS DEV" name: Deploy to AWS DEV @@ -56,11 +56,44 @@ jobs: - run: make deploy_dev env: HUSKY: "0" + deploy-cf-pages-dev: + runs-on: ubuntu-latest + concurrency: + group: ${{ github.event.repository.name }}-dev-cf + cancel-in-progress: false + permissions: + contents: read + deployments: write + needs: + - test-unit + name: Deploy to Cloudflare Pages DEV + environment: "Cloudflare Pages" + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Node LTS + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Build + run: corepack enable && yarn && yarn run build + env: + VITE_RUN_ENVIRONMENT: dev + - name: Publish + uses: cloudflare/pages-action@v1 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: management-ui-dev + directory: dist/ui/ + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + branch: main test: runs-on: ubuntu-latest name: Run Live Integration Tests needs: - deploy-dev + - deploy-cf-pages-dev concurrency: group: ${{ github.event.repository.name }}-dev cancel-in-progress: false diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index cb99f8c4..862efd45 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -27,7 +27,7 @@ jobs: deploy-dev: runs-on: ubuntu-latest concurrency: - group: ${{ github.event.repository.name }}-dev + group: ${{ github.event.repository.name }}-dev-aws cancel-in-progress: false environment: "AWS DEV" name: Deploy to AWS DEV @@ -56,11 +56,43 @@ jobs: - run: make deploy_dev env: HUSKY: "0" + deploy-cf-pages-dev: + runs-on: ubuntu-latest + concurrency: + group: ${{ github.event.repository.name }}-dev-cf + cancel-in-progress: false + permissions: + contents: read + deployments: write + needs: + - test-unit + name: Deploy to Cloudflare Pages DEV + environment: "Cloudflare Pages" + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Node LTS + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Build + run: corepack enable && yarn && yarn run build + env: + VITE_RUN_ENVIRONMENT: dev + - name: Publish + uses: cloudflare/pages-action@v1 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: management-ui-dev + directory: dist/ui/ + gitHubToken: ${{ secrets.GITHUB_TOKEN }} test: runs-on: ubuntu-latest name: Run Live Integration Tests needs: - deploy-dev + - deploy-cf-pages-dev concurrency: group: ${{ github.event.repository.name }}-dev cancel-in-progress: false @@ -82,7 +114,7 @@ jobs: runs-on: ubuntu-latest name: Deploy to AWS PROD concurrency: - group: ${{ github.event.repository.name }}-prod + group: ${{ github.event.repository.name }}-prod-aws cancel-in-progress: false needs: - test @@ -110,6 +142,38 @@ jobs: - run: make deploy_prod env: HUSKY: "0" + deploy-cf-pages-prod: + runs-on: ubuntu-latest + needs: + - test + permissions: + contents: read + deployments: write + concurrency: + group: ${{ github.event.repository.name }}-prod-cf + cancel-in-progress: false + name: Deploy to Cloudflare Pages Prod + environment: "Cloudflare Pages" + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Node LTS + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Build + run: corepack enable && yarn && yarn run build + env: + VITE_RUN_ENVIRONMENT: prod + - name: Publish + uses: cloudflare/pages-action@v1 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: management-ui-prod + directory: dist/ui/ + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + branch: main health-check-prod: runs-on: ubuntu-latest name: Confirm services healthy diff --git a/.husky/pre-commit b/.husky/pre-commit index 965b1094..8f78b629 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -yarn run lint-staged \ No newline at end of file +yarn lint --fix \ No newline at end of file diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 33cf71e8..3fdff7d7 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,6 +1 @@ -{ - "*.ts": [ - "yarn run prettier:write", - "yarn run lint --fix" - ] - } \ No newline at end of file +{ "src/**/*.{ts,js,tsx,jsx}": ["yarn run lint --fix", "yarn run prettier:write"] } \ No newline at end of file diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 00000000..3186f3f0 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/Makefile b/Makefile index acb21e21..86ac74fa 100644 --- a/Makefile +++ b/Makefile @@ -42,12 +42,13 @@ check_account_dev: clean: rm -rf .aws-sam rm -rf node_modules/ - rm -rf src/dist/ - rm -rf src/build/ + rm -rf src/api/node_modules/ + rm -rf src/ui/node_modules/ + rm -rf dist/ build: src/ cloudformation/ docs/ yarn -D - yarn build:lambda + yarn build sam build --template-file cloudformation/main.yml local: diff --git a/README.md b/README.md index 6d2d2890..2d79d466 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,5 @@ -# ACM @ UIUC Core API - -## Run Locally -1. Copy `.env.sample` as `.env` and set the `JwtSigningKey` to a random string. -2. Enable Tailscale VPN so you can reach the development database in AWS -3. Log into AWS with `aws configure sso` so you can retrieve the AWS secret and configuration. -4. `yarn -D` -5. `make check_account_dev` - If this fails make sure that AWS is configured. -6. `make local` - -## Build for AWS Lambda -1. `make clean` -2. `make build` - -## Deploy to AWS env - -1. Get AWS credentials with `aws configure sso` -2. Ensure AWS profile is set to the right account (DEV or PROD). -3. Run `make deploy_dev` or `make deploy_prod`. - -## Generating JWT token - -Create a `.env` file containing your `AadClientSecret`. - -```bash -node --env-file=.env get_msft_jwt.js -``` - -## Configuring AWS - -SSO URL: `https://acmillinois.awsapps.com/start/#` - -``` -aws configure sso -``` - -Log in with SSO. Then, export the `AWS_PROFILE` that the above command outputted. - -```bash -export AWS_PROFILE=ABC-DEV -``` +# ACM @ UIUC Core +This repository is split into two: +* `src/api/` for the API source code +* `src/ui/` for the UI source code +* `src/common/` for common modules between the two (such as types) \ No newline at end of file diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index fd00d2b5..756c9706 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -73,6 +73,10 @@ Resources: - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-events-ticketing-metadata - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata/* - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-merchstore-metadata + - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles + - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-userroles/* + - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles + - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles/* PolicyName: lambda-dynamo Outputs: diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 46a55c9a..823e594b 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -81,12 +81,12 @@ Resources: DependsOn: - AppLogGroups Properties: - CodeUri: ../dist/src/ + CodeUri: ../dist AutoPublishAlias: live Runtime: nodejs20.x Description: !Sub "${ApplicationFriendlyName} API Lambda" FunctionName: !Sub ${ApplicationPrefix}-lambda - Handler: lambda.handler + Handler: api/lambda.handler MemorySize: 512 Role: !GetAtt AppSecurityRoles.Outputs.MainFunctionRoleArn Timeout: 60 @@ -105,6 +105,38 @@ Resources: Path: /{proxy+} Method: ANY + IamGroupRolesTable: + Type: 'AWS::DynamoDB::Table' + DeletionPolicy: "Retain" + Properties: + BillingMode: 'PAY_PER_REQUEST' + TableName: infra-core-api-iam-grouproles + DeletionProtectionEnabled: true + PointInTimeRecoverySpecification: + PointInTimeRecoveryEnabled: false + AttributeDefinitions: + - AttributeName: groupUuid + AttributeType: S + KeySchema: + - AttributeName: groupUuid + KeyType: HASH + + IamUserRolesTable: + Type: 'AWS::DynamoDB::Table' + DeletionPolicy: "Retain" + Properties: + BillingMode: 'PAY_PER_REQUEST' + TableName: infra-core-api-iam-userroles + DeletionProtectionEnabled: true + PointInTimeRecoverySpecification: + PointInTimeRecoveryEnabled: false + AttributeDefinitions: + - AttributeName: userEmail + AttributeType: S + KeySchema: + - AttributeName: userEmail + KeyType: HASH + EventRecordsTable: Type: 'AWS::DynamoDB::Table' DeletionPolicy: "Retain" diff --git a/package.json b/package.json index 2760240a..1022f0ce 100644 --- a/package.json +++ b/package.json @@ -1,77 +1,136 @@ { - "name": "infra-sample-api-node", - "version": "1.0.0", - "description": "ACM@UIUC Infra - Sample AWS Lambda in Node", - "main": "index.js", - "author": "ACM@UIUC", - "license": "BSD-3-Clause", - "type": "module", - "scripts": { - "build": "rm -rf dist/ && tsc", - "dev": "touch .env && tsx watch src/index.ts", - "build:lambda": "yarn build && cp package.json dist/src/ && yarn lockfile-manage", - "lockfile-manage": "synp --source-file yarn.lock && cp package-lock.json dist/src/ && rm package-lock.json", - "typecheck": "tsc --noEmit", - "lint": "eslint . --ext .ts --cache", - "prettier": "prettier --check src/*.ts src/**/*.ts tests/**/*.ts", - "prettier:write": "prettier --write src/*.ts src/**/*.ts tests/**/*.ts", - "prepare": "node .husky/install.mjs || true", - "lint-staged": "lint-staged", - "test:unit": "cross-env APPLICATION_KEY=infra-core-api vitest tests/unit", - "test:unit-ui": "yarn test:unit --ui", - "test:unit-watch": "cross-env APPLICATION_KEY=infra-core-api vitest tests/unit", - "test:live": "cross-env APPLICATION_KEY=infra-core-api vitest tests/live", - "test:live-ui": "yarn test:live --ui" - }, - "devDependencies": { - "@tsconfig/node20": "^20.1.4", - "@types/node": "^22.1.0", - "@types/supertest": "^6.0.2", - "@typescript-eslint/eslint-plugin": "^8.0.1", - "@typescript-eslint/parser": "^8.0.1", - "@vitest/ui": "^2.0.5", - "aws-sdk-client-mock": "^4.0.1", - "cross-env": "^7.0.3", - "esbuild": "^0.23.0", - "eslint": "^8.57.0", - "eslint-config-esnext": "^4.1.0", - "eslint-config-prettier": "^9.1.0", - "eslint-import-resolver-typescript": "^3.6.1", - "eslint-plugin-import": "^2.29.1", - "eslint-plugin-prettier": "^5.2.1", - "husky": "^9.1.4", - "lint-staged": "^15.2.8", - "node-ical": "^0.18.0", - "prettier": "^3.3.3", - "request": "^2.88.2", - "supertest": "^7.0.0", - "synp": "^1.9.13", - "tsx": "^4.16.5", - "typescript": "^5.5.4", - "vitest": "^2.0.5" - }, - "dependencies": { - "@aws-sdk/client-dynamodb": "^3.624.0", - "@aws-sdk/client-secrets-manager": "^3.624.0", - "@aws-sdk/util-dynamodb": "^3.624.0", - "@azure/msal-node": "^2.16.1", - "@fastify/auth": "^5.0.1", - "@fastify/aws-lambda": "^5.0.0", - "@fastify/caching": "^9.0.1", - "@fastify/cors": "^10.0.1", - "@touch4it/ical-timezones": "^1.9.0", - "discord.js": "^14.15.3", - "dotenv": "^16.4.5", - "fastify": "^5.1.0", - "fastify-plugin": "^4.5.1", - "ical-generator": "^7.2.0", - "jsonwebtoken": "^9.0.2", - "jwks-rsa": "^3.1.0", - "moment": "^2.30.1", - "moment-timezone": "^0.5.45", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.23.2", - "zod-validation-error": "^3.3.1" - }, - "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" + "name": "infra-core", + "version": "1.0.0", + "private": true, + "type": "module", + "workspaces": [ + "src/api", + "src/ui" + ], + "packageManager": "yarn@1.22.22", + "scripts": { + "build": "yarn workspaces run build && yarn lockfile-manage", + "dev": "concurrently --names 'api,ui' 'yarn workspace infra-core-api run dev' 'yarn workspace infra-core-ui run dev'", + "lockfile-manage": "synp --with-workspace --source-file yarn.lock && cp package-lock.json dist/ && cp package.json dist/ && rm package-lock.json", + "prettier": "yarn workspaces run prettier && prettier --check tests/**/*.ts", + "prettier:write": "yarn workspaces run prettier:write && prettier --write tests/**/*.ts", + "lint": "yarn workspaces run lint", + "prepare": "node .husky/install.mjs || true", + "typecheck": "yarn workspaces run typecheck", + "test:unit": "cross-env APPLICATION_KEY=infra-core-api vitest run tests/unit", + "test:unit-ui": "yarn test:unit --ui", + "test:unit-watch": "cross-env APPLICATION_KEY=infra-core-api vitest tests/unit", + "test:live": "cross-env APPLICATION_KEY=infra-core-api vitest tests/live", + "test:live-ui": "yarn test:live --ui" + }, + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.624.0", + "@aws-sdk/client-secrets-manager": "^3.624.0", + "@aws-sdk/util-dynamodb": "^3.624.0", + "@azure/msal-browser": "^3.20.0", + "@azure/msal-node": "^2.16.1", + "@azure/msal-react": "^2.0.22", + "@fastify/auth": "^5.0.1", + "@fastify/aws-lambda": "^5.0.0", + "@fastify/caching": "^9.0.1", + "@fastify/cors": "^10.0.1", + "@mantine/core": "^7.15.2", + "@mantine/dates": "^7.15.2", + "@mantine/form": "^7.15.2", + "@mantine/hooks": "^7.15.2", + "@mantine/notifications": "^7.15.2", + "@tabler/icons-react": "^3.12.0", + "@touch4it/ical-timezones": "^1.9.0", + "@ungap/with-resolvers": "^0.1.0", + "axios": "^1.7.3", + "dayjs": "^1.11.12", + "discord.js": "^14.15.3", + "dotenv": "^16.4.5", + "fastify": "^5.1.0", + "fastify-plugin": "^4.5.1", + "html5-qrcode": "^2.3.8", + "ical-generator": "^7.2.0", + "jsonwebtoken": "^9.0.2", + "jsqr": "^1.4.0", + "jwks-rsa": "^3.1.0", + "moment": "^2.30.1", + "moment-timezone": "^0.5.45", + "pdfjs-dist": "^4.5.136", + "pluralize": "^8.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "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", + "zod-to-json-schema": "^3.23.2", + "zod-validation-error": "^3.3.1" + }, + "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", + "@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/user-event": "^14.5.2", + "@tsconfig/node20": "^20.1.4", + "@types/node": "^22.1.0", + "@types/pluralize": "^0.0.33", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/supertest": "^6.0.2", + "@typescript-eslint/eslint-plugin": "^8.0.1", + "@typescript-eslint/parser": "^8.0.1", + "@vitejs/plugin-react": "^4.3.1", + "@vitest/ui": "^2.0.5", + "aws-sdk-client-mock": "^4.0.1", + "concurrently": "^9.1.2", + "cross-env": "^7.0.3", + "esbuild": "^0.23.0", + "eslint": "^8.57.0", + "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-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", + "husky": "^9.1.4", + "identity-obj-proxy": "^3.0.0", + "jsdom": "^24.1.1", + "node-ical": "^0.18.0", + "postcss": "^8.4.41", + "postcss-preset-mantine": "^1.17.0", + "postcss-simple-vars": "^7.0.1", + "prettier": "^3.3.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", + "synp": "^1.9.14", + "tsx": "^4.16.5", + "typescript": "^5.5.4", + "typescript-eslint": "^8.0.1", + "vite": "^5.4.0", + "vite-tsconfig-paths": "^5.0.1", + "vitest": "^2.0.5", + "yarn-upgrade-all": "^0.7.4" + }, + "resolutions": { + "pdfjs-dist": "4.5.136" + } } diff --git a/src/api/README.md b/src/api/README.md new file mode 100644 index 00000000..6d2d2890 --- /dev/null +++ b/src/api/README.md @@ -0,0 +1,41 @@ +# ACM @ UIUC Core API + +## Run Locally +1. Copy `.env.sample` as `.env` and set the `JwtSigningKey` to a random string. +2. Enable Tailscale VPN so you can reach the development database in AWS +3. Log into AWS with `aws configure sso` so you can retrieve the AWS secret and configuration. +4. `yarn -D` +5. `make check_account_dev` - If this fails make sure that AWS is configured. +6. `make local` + +## Build for AWS Lambda +1. `make clean` +2. `make build` + +## Deploy to AWS env + +1. Get AWS credentials with `aws configure sso` +2. Ensure AWS profile is set to the right account (DEV or PROD). +3. Run `make deploy_dev` or `make deploy_prod`. + +## Generating JWT token + +Create a `.env` file containing your `AadClientSecret`. + +```bash +node --env-file=.env get_msft_jwt.js +``` + +## Configuring AWS + +SSO URL: `https://acmillinois.awsapps.com/start/#` + +``` +aws configure sso +``` + +Log in with SSO. Then, export the `AWS_PROFILE` that the above command outputted. + +```bash +export AWS_PROFILE=ABC-DEV +``` diff --git a/src/functions/cache.ts b/src/api/functions/cache.ts similarity index 95% rename from src/functions/cache.ts rename to src/api/functions/cache.ts index b3a053eb..5d007e70 100644 --- a/src/functions/cache.ts +++ b/src/api/functions/cache.ts @@ -3,7 +3,7 @@ import { PutItemCommand, QueryCommand, } from "@aws-sdk/client-dynamodb"; -import { genericConfig } from "../config.js"; +import { genericConfig } from "../../common/config.js"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; const dynamoClient = new DynamoDBClient({ diff --git a/src/functions/discord.ts b/src/api/functions/discord.ts similarity index 97% rename from src/functions/discord.ts rename to src/api/functions/discord.ts index 53d53ef9..51ed6485 100644 --- a/src/functions/discord.ts +++ b/src/api/functions/discord.ts @@ -12,9 +12,9 @@ import { type EventPostRequest } from "../routes/events.js"; import moment from "moment-timezone"; import { FastifyBaseLogger } from "fastify"; -import { DiscordEventError } from "../errors/index.js"; +import { DiscordEventError } from "../../common/errors/index.js"; import { getSecretValue } from "../plugins/auth.js"; -import { genericConfig } from "../config.js"; +import { genericConfig } from "../../common/config.js"; // https://stackoverflow.com/a/3809435/5684541 // https://calendar-buff.acmuiuc.pages.dev/calendar?id=dd7af73a-3df6-4e12-b228-0d2dac34fda7&date=2024-08-30 diff --git a/src/functions/entraId.ts b/src/api/functions/entraId.ts similarity index 95% rename from src/functions/entraId.ts rename to src/api/functions/entraId.ts index 709f3798..7a764ac5 100644 --- a/src/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -1,5 +1,8 @@ -import { genericConfig } from "../config.js"; -import { EntraInvitationError, InternalServerError } from "../errors/index.js"; +import { genericConfig } from "../../common/config.js"; +import { + EntraInvitationError, + InternalServerError, +} from "../../common/errors/index.js"; import { getSecretValue } from "../plugins/auth.js"; import { ConfidentialClientApplication } from "@azure/msal-node"; import { getItemFromCache, insertItemIntoCache } from "./cache.js"; diff --git a/src/functions/validation.ts b/src/api/functions/validation.ts similarity index 100% rename from src/functions/validation.ts rename to src/api/functions/validation.ts diff --git a/src/index.ts b/src/api/index.ts similarity index 89% rename from src/index.ts rename to src/api/index.ts index 683b5929..f82b9d85 100644 --- a/src/index.ts +++ b/src/api/index.ts @@ -5,17 +5,17 @@ import FastifyAuthProvider from "@fastify/auth"; import fastifyAuthPlugin from "./plugins/auth.js"; import protectedRoute from "./routes/protected.js"; import errorHandlerPlugin from "./plugins/errorHandler.js"; -import { RunEnvironment, runEnvironments } from "./roles.js"; -import { InternalServerError } from "./errors/index.js"; +import { RunEnvironment, runEnvironments } from "../common/roles.js"; +import { InternalServerError } from "../common/errors/index.js"; import eventsPlugin from "./routes/events.js"; import cors from "@fastify/cors"; import fastifyZodValidationPlugin from "./plugins/validate.js"; -import { environmentConfig } from "./config.js"; +import { environmentConfig } from "../common/config.js"; import organizationsPlugin from "./routes/organizations.js"; import icalPlugin from "./routes/ics.js"; import vendingPlugin from "./routes/vending.js"; import * as dotenv from "dotenv"; -import ssoManagementRoute from "./routes/sso.js"; +import iamRoutes from "./routes/iam.js"; import ticketsPlugin from "./routes/tickets.js"; dotenv.config(); @@ -48,7 +48,8 @@ async function init() { }); } app.runEnvironment = process.env.RunEnvironment as RunEnvironment; - app.environmentConfig = environmentConfig[app.runEnvironment]; + app.environmentConfig = + environmentConfig[app.runEnvironment as RunEnvironment]; app.addHook("onRequest", (req, _, done) => { req.startTime = now(); req.log.info({ url: req.raw.url }, "received request"); @@ -73,7 +74,7 @@ async function init() { api.register(eventsPlugin, { prefix: "/events" }); api.register(organizationsPlugin, { prefix: "/organizations" }); api.register(icalPlugin, { prefix: "/ical" }); - api.register(ssoManagementRoute, { prefix: "/sso" }); + api.register(iamRoutes, { prefix: "/iam" }); api.register(ticketsPlugin, { prefix: "/tickets" }); if (app.runEnvironment === "dev") { api.register(vendingPlugin, { prefix: "/vending" }); diff --git a/src/lambda.ts b/src/api/lambda.ts similarity index 100% rename from src/lambda.ts rename to src/api/lambda.ts diff --git a/src/api/package.json b/src/api/package.json new file mode 100644 index 00000000..7e016dd3 --- /dev/null +++ b/src/api/package.json @@ -0,0 +1,17 @@ +{ + "name": "infra-core-api", + "version": "1.0.0", + "description": "ACM@UIUC Infra - Sample AWS Lambda in Node", + "main": "index.js", + "author": "ACM@UIUC", + "license": "BSD-3-Clause", + "type": "module", + "scripts": { + "build": "tsc", + "dev": "touch .env && tsx watch index.ts", + "typecheck": "tsc --noEmit", + "lint": "eslint . --ext .ts --cache", + "prettier": "prettier --check *.ts **/*.ts", + "prettier:write": "prettier --write *.ts **/*.ts" + } +} diff --git a/src/plugins/auth.ts b/src/api/plugins/auth.ts similarity index 97% rename from src/plugins/auth.ts rename to src/api/plugins/auth.ts index afe8e14b..7d17736c 100644 --- a/src/plugins/auth.ts +++ b/src/api/plugins/auth.ts @@ -6,14 +6,14 @@ import { SecretsManagerClient, GetSecretValueCommand, } from "@aws-sdk/client-secrets-manager"; -import { AppRoles } from "../roles.js"; +import { AppRoles } from "../../common/roles.js"; import { BaseError, InternalServerError, UnauthenticatedError, UnauthorizedError, -} from "../errors/index.js"; -import { genericConfig, SecretConfig } from "../config.js"; +} from "../../common/errors/index.js"; +import { genericConfig, SecretConfig } from "../../common/config.js"; function intersection(setA: Set, setB: Set): Set { const _intersection = new Set(); diff --git a/src/plugins/errorHandler.ts b/src/api/plugins/errorHandler.ts similarity index 97% rename from src/plugins/errorHandler.ts rename to src/api/plugins/errorHandler.ts index b1d998dc..53b7c6e5 100644 --- a/src/plugins/errorHandler.ts +++ b/src/api/plugins/errorHandler.ts @@ -5,7 +5,7 @@ import { InternalServerError, NotFoundError, ValidationError, -} from "../errors/index.js"; +} from "../../common/errors/index.js"; const errorHandlerPlugin = fp(async (fastify) => { fastify.setErrorHandler( diff --git a/src/plugins/validate.ts b/src/api/plugins/validate.ts similarity index 92% rename from src/plugins/validate.ts rename to src/api/plugins/validate.ts index 12724b4b..ca3c0bab 100644 --- a/src/plugins/validate.ts +++ b/src/api/plugins/validate.ts @@ -1,6 +1,9 @@ import { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; import fp from "fastify-plugin"; -import { InternalServerError, ValidationError } from "../errors/index.js"; +import { + InternalServerError, + ValidationError, +} from "../../common/errors/index.js"; import { z, ZodError } from "zod"; import { fromError } from "zod-validation-error"; diff --git a/src/routes/events.ts b/src/api/routes/events.ts similarity index 98% rename from src/routes/events.ts rename to src/api/routes/events.ts index 2fa43dd0..88fdc3a7 100644 --- a/src/routes/events.ts +++ b/src/api/routes/events.ts @@ -1,8 +1,8 @@ import { FastifyPluginAsync, FastifyRequest } from "fastify"; -import { AppRoles } from "../roles.js"; +import { AppRoles } from "../../common/roles.js"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; -import { OrganizationList } from "../orgs.js"; +import { OrganizationList } from "../../common/orgs.js"; import { DeleteItemCommand, DynamoDBClient, @@ -11,7 +11,7 @@ import { QueryCommand, ScanCommand, } from "@aws-sdk/client-dynamodb"; -import { genericConfig } from "../config.js"; +import { genericConfig } from "../../common/config.js"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; import { BaseError, @@ -19,7 +19,7 @@ import { DatabaseInsertError, DiscordEventError, ValidationError, -} from "../errors/index.js"; +} from "../../common/errors/index.js"; import { randomUUID } from "crypto"; import moment from "moment-timezone"; import { IUpdateDiscord, updateDiscord } from "../functions/discord.js"; diff --git a/src/api/routes/iam.ts b/src/api/routes/iam.ts new file mode 100644 index 00000000..76a4767f --- /dev/null +++ b/src/api/routes/iam.ts @@ -0,0 +1,206 @@ +import { FastifyPluginAsync } from "fastify"; +import { AppRoles } from "../../common/roles.js"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { addToTenant, getEntraIdToken } from "../functions/entraId.js"; +import { + BaseError, + DatabaseFetchError, + DatabaseInsertError, + EntraInvitationError, + InternalServerError, + NotFoundError, +} from "../../common/errors/index.js"; +import { + DynamoDBClient, + GetItemCommand, + PutItemCommand, +} from "@aws-sdk/client-dynamodb"; +import { genericConfig } from "../../common/config.js"; +import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; + +const invitePostRequestSchema = z.object({ + emails: z.array(z.string()), +}); +export type InviteUserPostRequest = z.infer; + +const groupMappingCreatePostSchema = z.object({ + roles: z + .array(z.nativeEnum(AppRoles)) + .min(1) + .refine((items) => new Set(items).size === items.length, { + message: "All roles must be unique, no duplicate values allowed", + }), +}); + +export type GroupMappingCreatePostRequest = z.infer< + typeof groupMappingCreatePostSchema +>; + +const invitePostResponseSchema = zodToJsonSchema( + z.object({ + success: z.array(z.object({ email: z.string() })).optional(), + failure: z + .array(z.object({ email: z.string(), message: z.string() })) + .optional(), + }), +); + +const dynamoClient = new DynamoDBClient({ + region: genericConfig.AwsRegion, +}); + +const iamRoutes: FastifyPluginAsync = async (fastify, _options) => { + fastify.get<{ + Body: undefined; + Querystring: { groupId: string }; + }>( + "/groupRoles/:groupId", + { + schema: { + querystring: { + type: "object", + properties: { + groupId: { + type: "string", + }, + }, + }, + }, + onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + }, + }, + async (request, reply) => { + const groupId = (request.params as Record).groupId; + try { + const command = new GetItemCommand({ + TableName: `${genericConfig.IAMTablePrefix}-grouproles`, + Key: { groupUuid: { S: groupId } }, + }); + const response = await dynamoClient.send(command); + if (!response.Item) { + throw new NotFoundError({ + endpointName: `/api/v1/iam/groupRoles/${groupId}`, + }); + } + reply.send(unmarshall(response.Item)); + } catch (e: unknown) { + if (e instanceof BaseError) { + throw e; + } + + request.log.error(e); + throw new DatabaseFetchError({ + message: "An error occurred finding the group role mapping.", + }); + } + }, + ); + fastify.post<{ + Body: GroupMappingCreatePostRequest; + Querystring: { groupId: string }; + }>( + "/groupRoles/:groupId", + { + schema: { + querystring: { + type: "object", + properties: { + groupId: { + type: "string", + }, + }, + }, + }, + preValidation: async (request, reply) => { + await fastify.zodValidateBody( + request, + reply, + groupMappingCreatePostSchema, + ); + }, + onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + }, + }, + async (request, reply) => { + const groupId = (request.params as Record).groupId; + try { + const timestamp = new Date().toISOString(); + const command = new PutItemCommand({ + TableName: `${genericConfig.IAMTablePrefix}-grouproles`, + Item: marshall({ + groupUuid: groupId, + roles: request.body.roles, + createdAt: timestamp, + }), + }); + + await dynamoClient.send(command); + } catch (e: unknown) { + if (e instanceof BaseError) { + throw e; + } + + request.log.error(e); + throw new DatabaseInsertError({ + message: "Could not create group role mapping.", + }); + } + reply.send({ message: "OK" }); + }, + ); + fastify.post<{ Body: InviteUserPostRequest }>( + "/inviteUsers", + { + schema: { + response: { 200: invitePostResponseSchema }, + }, + preValidation: async (request, reply) => { + await fastify.zodValidateBody(request, reply, invitePostRequestSchema); + }, + onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.SSO_INVITE_USER]); + }, + }, + async (request, reply) => { + const emails = request.body.emails; + const entraIdToken = await getEntraIdToken( + fastify.environmentConfig.AadValidClientId, + ); + if (!entraIdToken) { + throw new InternalServerError({ + message: "Could not get Entra ID token to perform task.", + }); + } + const response: Record[]> = { + success: [], + failure: [], + }; + const results = await Promise.allSettled( + emails.map((email) => addToTenant(entraIdToken, email)), + ); + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status === "fulfilled") { + response.success.push({ email: emails[i] }); + } else { + if (result.reason instanceof EntraInvitationError) { + response.failure.push({ + email: emails[i], + message: result.reason.message, + }); + } + } + } + let statusCode = 201; + if (response.success.length === 0) { + statusCode = 500; + } + reply.status(statusCode).send(response); + }, + ); +}; + +export default iamRoutes; diff --git a/src/routes/ics.ts b/src/api/routes/ics.ts similarity index 95% rename from src/routes/ics.ts rename to src/api/routes/ics.ts index aa1f6cc7..706dc10b 100644 --- a/src/routes/ics.ts +++ b/src/api/routes/ics.ts @@ -5,9 +5,9 @@ import { QueryCommandInput, ScanCommand, } from "@aws-sdk/client-dynamodb"; -import { genericConfig } from "../config.js"; +import { genericConfig } from "../../common/config.js"; import { unmarshall } from "@aws-sdk/util-dynamodb"; -import { NotFoundError, ValidationError } from "../errors/index.js"; +import { NotFoundError, ValidationError } from "../../common/errors/index.js"; import ical, { ICalCalendarMethod, ICalEventJSONRepeatingData, @@ -15,7 +15,7 @@ import ical, { } from "ical-generator"; import moment from "moment"; import { getVtimezoneComponent } from "@touch4it/ical-timezones"; -import { OrganizationList } from "../orgs.js"; +import { OrganizationList } from "../../common/orgs.js"; import { EventRepeatOptions } from "./events.js"; const dynamoClient = new DynamoDBClient({ diff --git a/src/routes/organizations.ts b/src/api/routes/organizations.ts similarity index 88% rename from src/routes/organizations.ts rename to src/api/routes/organizations.ts index ec4871d3..da05b327 100644 --- a/src/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -1,5 +1,5 @@ import { FastifyPluginAsync } from "fastify"; -import { OrganizationList } from "../orgs.js"; +import { OrganizationList } from "../../common/orgs.js"; import fastifyCaching from "@fastify/caching"; const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { diff --git a/src/routes/protected.ts b/src/api/routes/protected.ts similarity index 100% rename from src/routes/protected.ts rename to src/api/routes/protected.ts diff --git a/src/routes/tickets.ts b/src/api/routes/tickets.ts similarity index 98% rename from src/routes/tickets.ts rename to src/api/routes/tickets.ts index 58f0a3af..58d850b0 100644 --- a/src/routes/tickets.ts +++ b/src/api/routes/tickets.ts @@ -6,7 +6,7 @@ import { ScanCommand, UpdateItemCommand, } from "@aws-sdk/client-dynamodb"; -import { genericConfig } from "../config.js"; +import { genericConfig } from "../../common/config.js"; import { BaseError, DatabaseFetchError, @@ -16,10 +16,10 @@ import { TicketNotValidError, UnauthenticatedError, ValidationError, -} from "../errors/index.js"; +} from "../../common/errors/index.js"; import { unmarshall } from "@aws-sdk/util-dynamodb"; import { validateEmail } from "../functions/validation.js"; -import { AppRoles } from "../roles.js"; +import { AppRoles } from "../../common/roles.js"; import { zodToJsonSchema } from "zod-to-json-schema"; const postMerchSchema = z.object({ diff --git a/src/routes/vending.ts b/src/api/routes/vending.ts similarity index 100% rename from src/routes/vending.ts rename to src/api/routes/vending.ts diff --git a/tsconfig.json b/src/api/tsconfig.json similarity index 73% rename from tsconfig.json rename to src/api/tsconfig.json index cb903f21..145a7828 100644 --- a/tsconfig.json +++ b/src/api/tsconfig.json @@ -2,9 +2,10 @@ "extends": "@tsconfig/node20/tsconfig.json", "compilerOptions": { "module": "Node16", - "outDir": "dist", + "outDir": "../../dist", }, "ts-node": { "esm": true }, + "include": ["./*"], } diff --git a/src/types.d.ts b/src/api/types.d.ts similarity index 81% rename from src/types.d.ts rename to src/api/types.d.ts index e265a070..79b874c8 100644 --- a/src/types.d.ts +++ b/src/api/types.d.ts @@ -1,7 +1,7 @@ import { FastifyRequest, FastifyInstance, FastifyReply } from "fastify"; -import { AppRoles, RunEnvironment } from "./roles.ts"; -import { AadToken } from "./plugins/auth.ts"; -import { ConfigType } from "./config.ts"; +import { AppRoles, RunEnvironment } from "../common/roles.js"; +import { AadToken } from "./plugins/auth.js"; +import { ConfigType } from "../common/config.js"; declare module "fastify" { interface FastifyInstance { authenticate: ( diff --git a/src/config.ts b/src/common/config.ts similarity index 98% rename from src/config.ts rename to src/common/config.ts index bcb189d6..08a74176 100644 --- a/src/config.ts +++ b/src/common/config.ts @@ -29,6 +29,7 @@ type GenericConfigType = { TicketPurchasesTableName: string; TicketMetadataTableName: string; MerchStoreMetadataTableName: string; + IAMTablePrefix: string; }; type EnvironmentConfigType = { @@ -46,6 +47,7 @@ const genericConfig: GenericConfigType = { MerchStoreMetadataTableName: "infra-merchstore-metadata", TicketPurchasesTableName: "infra-events-tickets", TicketMetadataTableName: "infra-events-ticketing-metadata", + IAMTablePrefix: "infra-core-api-iam", } as const; const environmentConfig: EnvironmentConfigType = { diff --git a/src/errors/index.ts b/src/common/errors/index.ts similarity index 93% rename from src/errors/index.ts rename to src/common/errors/index.ts index 85902049..fcd209e7 100644 --- a/src/errors/index.ts +++ b/src/common/errors/index.ts @@ -38,6 +38,17 @@ export abstract class BaseError extends Error { } } +export class NotImplementedError extends BaseError<"NotImplementedError"> { + constructor({ message }: { message?: string }) { + super({ + name: "NotImplementedError", + id: 100, + message: message || "This feature has not been implemented yet.", + httpStatusCode: 500, + }); + } +} + export class UnauthorizedError extends BaseError<"UnauthorizedError"> { constructor({ message }: { message: string }) { super({ name: "UnauthorizedError", id: 101, message, httpStatusCode: 401 }); diff --git a/src/orgs.ts b/src/common/orgs.ts similarity index 100% rename from src/orgs.ts rename to src/common/orgs.ts diff --git a/src/roles.ts b/src/common/roles.ts similarity index 94% rename from src/roles.ts rename to src/common/roles.ts index c4d2ce0c..abfff501 100644 --- a/src/roles.ts +++ b/src/common/roles.ts @@ -6,6 +6,7 @@ export enum AppRoles { SSO_INVITE_USER = "invite:sso", TICKETS_SCANNER = "scan:tickets", TICKETS_MANAGER = "manage:tickets", + IAM_ADMIN = "admin:iam", } export const allAppRoles = Object.values(AppRoles).filter( (value) => typeof value === "string", diff --git a/src/routes/sso.ts b/src/routes/sso.ts deleted file mode 100644 index 4c622da9..00000000 --- a/src/routes/sso.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { FastifyPluginAsync } from "fastify"; -import { AppRoles } from "../roles.js"; -import { z } from "zod"; -import { zodToJsonSchema } from "zod-to-json-schema"; -import { addToTenant, getEntraIdToken } from "../functions/entraId.js"; -import { EntraInvitationError, InternalServerError } from "../errors/index.js"; - -const invitePostRequestSchema = z.object({ - emails: z.array(z.string()), -}); -export type InviteUserPostRequest = z.infer; - -const invitePostResponseSchema = zodToJsonSchema( - z.object({ - success: z.array(z.object({ email: z.string() })).optional(), - failure: z - .array(z.object({ email: z.string(), message: z.string() })) - .optional(), - }), -); - -const ssoManagementRoute: FastifyPluginAsync = async (fastify, _options) => { - fastify.post<{ Body: InviteUserPostRequest }>( - "/inviteUsers", - { - schema: { - response: { 200: invitePostResponseSchema }, - }, - preValidation: async (request, reply) => { - await fastify.zodValidateBody(request, reply, invitePostRequestSchema); - }, - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, [AppRoles.SSO_INVITE_USER]); - }, - }, - async (request, reply) => { - const emails = request.body.emails; - const entraIdToken = await getEntraIdToken( - fastify.environmentConfig.AadValidClientId, - ); - if (!entraIdToken) { - throw new InternalServerError({ - message: "Could not get Entra ID token to perform task.", - }); - } - const response: Record[]> = { - success: [], - failure: [], - }; - const results = await Promise.allSettled( - emails.map((email) => addToTenant(entraIdToken, email)), - ); - for (let i = 0; i < results.length; i++) { - const result = results[i]; - if (result.status === "fulfilled") { - response.success.push({ email: emails[i] }); - } else { - if (result.reason instanceof EntraInvitationError) { - response.failure.push({ - email: emails[i], - message: result.reason.message, - }); - } - } - } - let statusCode = 201; - if (response.success.length === 0) { - statusCode = 500; - } - reply.status(statusCode).send(response); - }, - ); -}; - -export default ssoManagementRoute; diff --git a/src/ui/.prettierrc.cjs b/src/ui/.prettierrc.cjs new file mode 100644 index 00000000..2945481d --- /dev/null +++ b/src/ui/.prettierrc.cjs @@ -0,0 +1 @@ +module.exports = require('eslint-config-mantine/.prettierrc.js'); \ No newline at end of file diff --git a/src/ui/.stylelintrc.json b/src/ui/.stylelintrc.json new file mode 100644 index 00000000..a8a4b897 --- /dev/null +++ b/src/ui/.stylelintrc.json @@ -0,0 +1,28 @@ +{ + "extends": ["stylelint-config-standard-scss"], + "rules": { + "custom-property-pattern": null, + "selector-class-pattern": null, + "scss/no-duplicate-mixins": null, + "declaration-empty-line-before": null, + "declaration-block-no-redundant-longhand-properties": null, + "alpha-value-notation": null, + "custom-property-empty-line-before": null, + "property-no-vendor-prefix": null, + "color-function-notation": null, + "length-zero-no-unit": null, + "selector-not-notation": null, + "no-descending-specificity": null, + "comment-empty-line-before": null, + "scss/at-mixin-pattern": null, + "scss/at-rule-no-unknown": null, + "value-keyword-case": null, + "media-feature-range-notation": null, + "selector-pseudo-class-no-unknown": [ + true, + { + "ignorePseudoClasses": ["global"] + } + ] + } + } \ No newline at end of file diff --git a/src/ui/App.tsx b/src/ui/App.tsx new file mode 100644 index 00000000..b8d99486 --- /dev/null +++ b/src/ui/App.tsx @@ -0,0 +1,26 @@ +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'; + +export default function App() { + const preferredColorScheme = useColorScheme(); + const [colorScheme, setColorScheme] = useLocalStorage({ + key: 'acm-manage-color-scheme', + defaultValue: preferredColorScheme, + }); + + return ( + + + + + + + ); +} diff --git a/src/ui/ColorSchemeContext.tsx b/src/ui/ColorSchemeContext.tsx new file mode 100644 index 00000000..d706e96c --- /dev/null +++ b/src/ui/ColorSchemeContext.tsx @@ -0,0 +1,8 @@ +import { createContext } from 'react'; + +type ColorSchemeContextType = { + colorScheme: string; + onChange: CallableFunction; +} | null; + +export default createContext(null); diff --git a/src/ui/README.md b/src/ui/README.md new file mode 100644 index 00000000..df2028ac --- /dev/null +++ b/src/ui/README.md @@ -0,0 +1,10 @@ +# Management API +## Running Locally +Create `.env.local` in this directory with the following content: +``` +VITE_RUN_ENVIRONMENT="dev" +``` + +If running against a local instance of the api, set to `local-dev` instead. + +Then install dependencies and run `yarn dev` to start development server. \ No newline at end of file diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx new file mode 100644 index 00000000..c0467958 --- /dev/null +++ b/src/ui/Router.tsx @@ -0,0 +1,168 @@ +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 { ScanTicketsPage } from './pages/tickets/ScanTickets.page'; +import { SelectTicketsPage } from './pages/tickets/SelectEventId.page'; +import { ViewTicketsPage } from './pages/tickets/ViewTickets.page'; + +// Component to handle redirects to login with return path +const LoginRedirect: React.FC = () => { + const location = useLocation(); + + // Don't store login-related paths and ALLOW the callback path + const excludedPaths = [ + '/login', + '/logout', + '/force_login', + '/a', + '/auth/callback', // Add this to excluded paths + ]; + + if (excludedPaths.includes(location.pathname)) { + return ; + } + + // Include search params and hash in the return URL if they exist + const returnPath = location.pathname + location.search + location.hash; + const loginUrl = `/login?returnTo=${encodeURIComponent(returnPath)}&li=true`; + return ; +}; + +const commonRoutes = [ + { + path: '/force_login', + element: , + }, + { + path: '/logout', + element: , + }, + { + path: '/auth/callback', + element: , + }, +]; + +const unauthenticatedRouter = createBrowserRouter([ + ...commonRoutes, + { + path: '/', + element: , + }, + { + path: '/login', + element: , + }, + // Catch-all route that preserves the attempted path + { + path: '*', + element: , + }, +]); + +const authenticatedRouter = createBrowserRouter([ + ...commonRoutes, + { + path: '/', + element: {null}, + }, + { + path: '/login', + element: , + }, + { + path: '/logout', + element: , + }, + { + path: '/home', + element: , + }, + { + path: '/events/add', + element: , + }, + { + path: '/events/edit/:eventId', + element: , + }, + { + path: '/events/manage', + element: , + }, + { + path: '/tickets/scan', + element: , + }, + { + path: '/tickets', + element: , + }, + { + path: '/tickets/manage/:eventId', + element: , + }, + // Catch-all route for authenticated users shows 404 page + { + path: '*', + element: , + }, +]); + +interface ErrorBoundaryProps { + children: ReactNode; +} + +const ErrorBoundary: React.FC = ({ children }) => { + const [hasError, setHasError] = useState(false); + const [error, setError] = useState(null); + const { isLoggedIn } = useAuth(); + + const onError = (errorObj: Error) => { + setHasError(true); + setError(errorObj); + }; + + useEffect(() => { + const errorHandler = (event: ErrorEvent) => { + onError(event.error); + }; + + window.addEventListener('error', errorHandler); + return () => { + window.removeEventListener('error', errorHandler); + }; + }, []); + + if (hasError && error) { + if (error.message === '404') { + return isLoggedIn ? : ; + } + return ; + } + + return <>{children}; +}; + +export const Router: React.FC = () => { + const { isLoggedIn } = useAuth(); + const router = isLoggedIn ? authenticatedRouter : unauthenticatedRouter; + + return ( + + + + ); +}; diff --git a/src/ui/banner-blue.png b/src/ui/banner-blue.png new file mode 100644 index 00000000..2acdad75 Binary files /dev/null and b/src/ui/banner-blue.png differ diff --git a/src/ui/banner-white.png b/src/ui/banner-white.png new file mode 100644 index 00000000..ae292cf5 Binary files /dev/null and b/src/ui/banner-white.png differ diff --git a/src/ui/components/AppShell/index.tsx b/src/ui/components/AppShell/index.tsx new file mode 100644 index 00000000..ae266f1a --- /dev/null +++ b/src/ui/components/AppShell/index.tsx @@ -0,0 +1,191 @@ +import { + AppShell, + Divider, + Group, + LoadingOverlay, + NavLink, + Text, + useMantineColorScheme, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { + IconCalendar, + IconCoin, + IconLink, + IconFileDollar, + IconPizza, + IconTicket, +} 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'; + +interface AcmAppShellProps { + children: ReactNode; + active?: string; + showLoader?: boolean; + authenticated?: boolean; + showSidebar?: boolean; +} + +export const navItems = [ + { + link: '/events/manage', + name: 'Events', + icon: IconCalendar, + description: null, + }, + { + link: '/tickets', + name: 'Ticketing/Merch', + icon: IconTicket, + description: null, + }, +]; + +export const extLinks = [ + { + link: 'https://go.acm.illinois.edu/create', + name: 'Link Shortener', + icon: IconLink, + description: null, + }, + { + link: 'https://stripelinks.acm.illinois.edu/create', + name: 'Stripe Link Creator', + icon: IconCoin, + description: null, + }, + { + 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', + icon: IconPizza, + description: null, + }, +]; + +function isSameParentPath(path1: string | undefined, path2: string | undefined) { + if (!path1 || !path2) { + return false; + } + 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) { + return false; + } + + // 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'; +} + +export const renderNavItems = ( + items: Record[], + active: string | undefined, + navigate: CallableFunction +) => + items.map((item) => ( + { + if (item.link.includes('://')) { + window.location.href = item.link; + } else { + navigate(item.link); + } + }} + key={item.link} + label={ + + {item.name} + + } + active={active === item.link || isSameParentPath(active, item.link)} + description={item.description || null} + leftSection={} + > + {item.children ? renderNavItems(item.children, active, navigate) : null} + + )); + +type SidebarNavItemsProps = { + items: Record[]; + visible: boolean; + active?: string; +}; +const SidebarNavItems: React.FC = ({ items, visible, active }) => { + const navigate = useNavigate(); + if (!visible) { + return null; + } + return renderNavItems(items, active, navigate); +}; + +const AcmAppShell: React.FC = ({ + children, + active, + showLoader, + authenticated, + showSidebar, +}) => { + const { colorScheme } = useMantineColorScheme(); + if (authenticated === undefined) { + authenticated = true; + } + if (showSidebar === undefined) { + showSidebar = true; + } + const [opened, { toggle }] = useDisclosure(); + const { userData } = useAuth(); + return ( + + + + + + +
+ + + + + + +
+ + {showLoader ? ( + + ) : ( + children + )} + +
+ ); +}; + +export { AcmAppShell, SidebarNavItems }; diff --git a/src/ui/components/AuthContext/AuthCallbackHandler.page.tsx b/src/ui/components/AuthContext/AuthCallbackHandler.page.tsx new file mode 100644 index 00000000..5eb6c49e --- /dev/null +++ b/src/ui/components/AuthContext/AuthCallbackHandler.page.tsx @@ -0,0 +1,47 @@ +import { useMsal } from '@azure/msal-react'; +import React, { useEffect } from 'react'; + +import FullScreenLoader from './LoadingScreen.js'; + +export const AuthCallback: React.FC = () => { + const { instance } = useMsal(); + const navigate = (path: string) => { + window.location.href = path; + }; + + useEffect(() => { + const handleCallback = async () => { + try { + // Check if we have pending redirects + const response = await instance.handleRedirectPromise(); + if (!response) { + navigate('/'); + return; + } + const returnPath = response.state || '/'; + const account = response.account; + if (account) { + instance.setActiveAccount(account); + } + + navigate(returnPath); + } catch (error) { + console.error('Failed to handle auth redirect:', error); + navigate('/login?error=callback_failed'); + } + }; + + setTimeout(() => { + handleCallback(); + }, 100); + + // Cleanup function + return () => { + console.log('Callback component unmounting'); // Debug log 8 + }; + }, [instance, navigate]); + + return ; +}; + +export default AuthCallback; diff --git a/src/ui/components/AuthContext/LoadingScreen.tsx b/src/ui/components/AuthContext/LoadingScreen.tsx new file mode 100644 index 00000000..96b3dd89 --- /dev/null +++ b/src/ui/components/AuthContext/LoadingScreen.tsx @@ -0,0 +1,24 @@ +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', + defaultValue: preferredColorScheme, + }); + return ( + + ); +}; + +export default FullScreenLoader; diff --git a/src/ui/components/AuthContext/index.tsx b/src/ui/components/AuthContext/index.tsx new file mode 100644 index 00000000..c805b401 --- /dev/null +++ b/src/ui/components/AuthContext/index.tsx @@ -0,0 +1,223 @@ +import { + AuthenticationResult, + InteractionRequiredAuthError, + InteractionStatus, +} from '@azure/msal-browser'; +import { useMsal } from '@azure/msal-react'; +import { MantineProvider } from '@mantine/core'; +import React, { + createContext, + ReactNode, + useContext, + useState, + useEffect, + useCallback, +} from 'react'; + +import { CACHE_KEY_PREFIX } from '../AuthGuard/index.js'; + +import FullScreenLoader from './LoadingScreen.js'; + +import { getRunEnvironmentConfig, ValidServices } from '@ui/config.js'; + +interface AuthContextDataWrapper { + isLoggedIn: boolean; + userData: AuthContextData | null; + loginMsal: CallableFunction; + logout: CallableFunction; + getToken: CallableFunction; + logoutCallback: CallableFunction; + getApiToken: CallableFunction; +} + +export type AuthContextData = { + email?: string; + name?: string; +}; + +export const AuthContext = createContext({} as AuthContextDataWrapper); + +export const useAuth = () => useContext(AuthContext); + +interface AuthProviderProps { + children: ReactNode; +} + +export const clearAuthCache = () => { + for (const key of Object.keys(sessionStorage)) { + if (key.startsWith(CACHE_KEY_PREFIX)) { + sessionStorage.removeItem(key); + } + } +}; + +export const AuthProvider: React.FC = ({ children }) => { + const { instance, inProgress, accounts } = useMsal(); + + const [userData, setUserData] = useState(null); + const [isLoggedIn, setIsLoggedIn] = useState(false); + + const navigate = (path: string) => { + window.location.href = path; + }; + + useEffect(() => { + const handleRedirect = async () => { + const response = await instance.handleRedirectPromise(); + if (response) { + handleMsalResponse(response); + } else if (accounts.length > 0) { + // User is already logged in, set the state + const [lastName, firstName] = accounts[0].name?.split(',') || []; + setUserData({ + email: accounts[0].username, + name: `${firstName} ${lastName}`, + }); + setIsLoggedIn(true); + } + }; + + if (inProgress === InteractionStatus.None) { + handleRedirect(); + } + }, [inProgress, accounts, instance]); + + const handleMsalResponse = useCallback( + (response: AuthenticationResult) => { + if (response?.account) { + if (!accounts.length) { + // If accounts array is empty, try silent authentication + instance + .ssoSilent({ + scopes: ['openid', 'profile', 'email'], + loginHint: response.account.username, + }) + .then((silentResponse) => { + if (silentResponse?.account?.name) { + const [lastName, firstName] = silentResponse.account.name.split(','); + setUserData({ + email: silentResponse.account.username, + name: `${firstName} ${lastName}`, + }); + setIsLoggedIn(true); + } + }) + .catch(console.error); + return; + } + + // Use response.account instead of accounts[0] + const [lastName, firstName] = response.account.name?.split(',') || []; + setUserData({ + email: response.account.username, + name: `${firstName} ${lastName}`, + }); + setIsLoggedIn(true); + } + }, + [accounts, instance] + ); + const getApiToken = useCallback( + async (service: ValidServices) => { + if (!userData) { + return null; + } + const scope = getRunEnvironmentConfig().ServiceConfiguration[service].loginScope; + const { apiId } = getRunEnvironmentConfig().ServiceConfiguration[service]; + if (!scope || !apiId) { + return null; + } + const msalAccounts = instance.getAllAccounts(); + if (msalAccounts.length > 0) { + const silentRequest = { + account: msalAccounts[0], + scopes: [scope], // Adjust scopes as needed, + resource: apiId, + }; + const tokenResponse = await instance.acquireTokenSilent(silentRequest); + return tokenResponse.accessToken; + } + throw new Error('More than one account found, cannot proceed.'); + }, + [userData, instance] + ); + + const getToken = useCallback(async () => { + if (!userData) { + return null; + } + try { + const msalAccounts = instance.getAllAccounts(); + if (msalAccounts.length > 0) { + const silentRequest = { + account: msalAccounts[0], + 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.'); + } catch (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 + }; + const tokenResponse: any = await instance.acquireTokenRedirect(interactiveRequest); + return tokenResponse.accessToken; + } catch (interactiveError) { + console.error('Interactive token acquisition failed.', interactiveError); + throw interactiveError; + } + } else { + throw error; + } + } + }, [userData, instance]); + + const loginMsal = useCallback( + async (returnTo: string) => { + const accountsLocal = instance.getAllAccounts(); + if (accountsLocal.length > 0) { + instance.setActiveAccount(accountsLocal[0]); + setIsLoggedIn(true); + } else { + await instance.loginRedirect({ + scopes: ['openid', 'profile', 'email'], + state: returnTo, + redirectUri: `${window.location.origin}/auth/callback`, + }); + } + }, + [instance] + ); + + const logout = useCallback(async () => { + try { + clearAuthCache(); + await instance.logoutRedirect(); + } catch (error) { + console.error('Logout failed:', error); + } + }, [instance, userData]); + const logoutCallback = () => { + setIsLoggedIn(false); + setUserData(null); + }; + return ( + + {inProgress !== InteractionStatus.None ? ( + + + + ) : ( + children + )} + + ); +}; diff --git a/src/ui/components/AuthGuard/index.tsx b/src/ui/components/AuthGuard/index.tsx new file mode 100644 index 00000000..0ba91aee --- /dev/null +++ b/src/ui/components/AuthGuard/index.tsx @@ -0,0 +1,174 @@ +import { Card, Text, Title } from '@mantine/core'; +import React, { ReactNode, useEffect, useState } from 'react'; + +import { AcmAppShell } from '@ui/components/AppShell'; +import FullScreenLoader from '@ui/components/AuthContext/LoadingScreen'; +import { getRunEnvironmentConfig, ValidService } from '@ui/config'; +import { useApi } from '@ui/util/api'; + +export const CACHE_KEY_PREFIX = 'auth_response_cache_'; +const CACHE_DURATION = 2 * 60 * 60 * 1000; // 2 hours in milliseconds + +type CacheData = { + data: any; // Just the JSON response data + timestamp: number; +}; + +export type ResourceDefinition = { + service: ValidService; + validRoles: string[]; +}; + +const getAuthCacheKey = (service: ValidService, route: string) => + `${CACHE_KEY_PREFIX}${service}_${route}`; + +const getCachedResponse = (service: ValidService, route: string): CacheData | null => { + const cached = sessionStorage.getItem(getAuthCacheKey(service, route)); + if (!cached) return null; + + try { + const data = JSON.parse(cached) as CacheData; + const now = Date.now(); + + if (now - data.timestamp <= CACHE_DURATION) { + return data; + } + // Clear expired cache + sessionStorage.removeItem(getAuthCacheKey(service, route)); + } catch (e) { + console.error('Error parsing auth cache:', e); + sessionStorage.removeItem(getAuthCacheKey(service, route)); + } + return null; +}; + +const setCachedResponse = (service: ValidService, route: string, data: any) => { + const cacheData: CacheData = { + data, + timestamp: Date.now(), + }; + sessionStorage.setItem(getAuthCacheKey(service, route), JSON.stringify(cacheData)); +}; + +// Function to clear auth cache for all services +export const clearAuthCache = () => { + for (const key of Object.keys(sessionStorage)) { + if (key.startsWith(CACHE_KEY_PREFIX)) { + sessionStorage.removeItem(key); + } + } +}; + +export const AuthGuard: React.FC<{ + resourceDef: ResourceDefinition; + children: ReactNode; + isAppShell?: boolean; +}> = ({ resourceDef, children, isAppShell = true }) => { + const { service, validRoles } = resourceDef; + const { baseEndpoint, authCheckRoute, friendlyName } = + getRunEnvironmentConfig().ServiceConfiguration[service]; + const [isAuthenticated, setIsAuthenticated] = useState(null); + const [username, setUsername] = useState(null); + const [roles, setRoles] = useState(null); + const api = useApi(service); + + useEffect(() => { + async function getAuth() { + try { + if (!authCheckRoute) { + setIsAuthenticated(true); + return; + } + + // Check for cached response first + const cachedData = 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); + return; + } + + // If no cache, make the API call + const result = await api.get(authCheckRoute); + // Cache just the response data + 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; + } + } + setIsAuthenticated(authenticated); + setRoles(result.data.roles); + setUsername(result.data.username); + } catch (e) { + setIsAuthenticated(false); + console.error(e); + } + } + + getAuth(); + }, [baseEndpoint, authCheckRoute, service]); + + if (isAuthenticated === null) { + if (isAppShell) { + return ; + } + return null; + } + + if (!isAuthenticated) { + if (isAppShell) { + return ( + + Unauthorized + + You have not been granted access to this module. Please fill out the{' '} + access request form to request + access to this module. + + + + Diagnostic Details + +
    +
  • Endpoint: {baseEndpoint}
  • +
  • + Service: {friendlyName} ({service}) +
  • +
  • User: {username}
  • +
  • Roles: {roles ? roles.join(', ') : none}
  • +
  • + Time: {new Date().toDateString()} {new Date().toLocaleTimeString()} +
  • +
+
+
+ ); + } + return null; + } + + if (isAppShell) { + return ( + + {friendlyName} + {children} + + ); + } + + return <>{children}; +}; diff --git a/src/ui/components/DarkModeSwitch/index.tsx b/src/ui/components/DarkModeSwitch/index.tsx new file mode 100644 index 00000000..ae350bf6 --- /dev/null +++ b/src/ui/components/DarkModeSwitch/index.tsx @@ -0,0 +1,50 @@ +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', + defaultValue: preferredColorScheme, + }); + const sunIcon = ( + + ); + + const moonIcon = ( + + ); + + const handleToggle = (event: any) => { + if (event.currentTarget.checked) { + setColorScheme('dark'); + } else { + setColorScheme('light'); + } + }; + + return ( + { + handleToggle(event); + }} + onLabel={moonIcon} + offLabel={sunIcon} + /> + ); +} + +export { DarkModeSwitch }; diff --git a/src/ui/components/FullPageError/index.tsx b/src/ui/components/FullPageError/index.tsx new file mode 100644 index 00000000..25b43db7 --- /dev/null +++ b/src/ui/components/FullPageError/index.tsx @@ -0,0 +1,24 @@ +import { Container, Paper, Title, Text, Button } from '@mantine/core'; +import React, { MouseEventHandler } from 'react'; + +interface FullPageErrorProps { + errorCode?: number; + errorMessage?: string; + onRetry?: MouseEventHandler; +} + +const FullPageError: React.FC = ({ errorCode, errorMessage, onRetry }) => ( + + + {errorCode || 'An error occurred'} + {errorMessage || 'Something went wrong. Please try again later.'} + {onRetry && ( + + )} + + +); + +export default FullPageError; diff --git a/src/ui/components/LoginComponent/AcmLoginButton.tsx b/src/ui/components/LoginComponent/AcmLoginButton.tsx new file mode 100644 index 00000000..02782eb5 --- /dev/null +++ b/src/ui/components/LoginComponent/AcmLoginButton.tsx @@ -0,0 +1,22 @@ +import { useMsal } from '@azure/msal-react'; +import { Button, ButtonProps } from '@mantine/core'; + +import { useAuth } from '../AuthContext/index.js'; + +export function AcmLoginButton( + props: ButtonProps & React.ComponentPropsWithoutRef<'button'> & { returnTo: string } +) { + const { loginMsal } = useAuth(); + const { inProgress } = useMsal(); + return ( + + + + + ); +}; + +export { AuthenticatedProfileDropdown }; diff --git a/src/ui/config.ts b/src/ui/config.ts new file mode 100644 index 00000000..5fd09bf7 --- /dev/null +++ b/src/ui/config.ts @@ -0,0 +1,94 @@ +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'] as const; +export type RunEnvironment = (typeof runEnvironments)[number]; +export type ValidServices = (typeof services)[number]; +export type ValidService = ValidServices; + +export type ConfigType = { + AadValidClientId: string; + ServiceConfiguration: Record; +}; + +export type ServiceConfiguration = { + friendlyName: string; + baseEndpoint: string; + authCheckRoute?: string; + loginScope?: string; + apiId?: string; +}; + +// type GenericConfigType = {}; + +type EnvironmentConfigType = { + [env in RunEnvironment]: ConfigType; +}; + +const environmentConfig: EnvironmentConfigType = { + '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', + }, + tickets: { + friendlyName: 'Ticketing Service (NonProd)', + baseEndpoint: 'https://ticketing.aws.qa.acmuiuc.org', + }, + merch: { + friendlyName: 'Merch Sales Service (Prod)', + baseEndpoint: 'https://merchapi.acm.illinois.edu', + }, + }, + }, + dev: { + AadValidClientId: 'd1978c23-6455-426a-be4d-528b2d2e4026', + ServiceConfiguration: { + core: { + friendlyName: 'Core Management Service (NonProd)', + baseEndpoint: 'https://infra-core-api.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', + }, + merch: { + friendlyName: 'Merch Sales Service (Prod)', + baseEndpoint: 'https://merchapi.acm.illinois.edu', + }, + }, + }, + prod: { + AadValidClientId: '43fee67e-e383-4071-9233-ef33110e9386', + ServiceConfiguration: { + core: { + friendlyName: 'Core Management Service', + baseEndpoint: 'https://infra-core-api.aws.acmuiuc.org', + 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', + }, + merch: { + friendlyName: 'Merch Sales Service', + baseEndpoint: 'https://merchapi.acm.illinois.edu', + }, + }, + }, +} as const; + +const getRunEnvironmentConfig = () => + environmentConfig[(import.meta.env.VITE_RUN_ENVIRONMENT || 'dev') as RunEnvironment]; + +export { getRunEnvironmentConfig }; diff --git a/src/ui/index.html b/src/ui/index.html new file mode 100644 index 00000000..63c98ca8 --- /dev/null +++ b/src/ui/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Management Portal | ACM@UIUC + + +
+ + + diff --git a/src/ui/main.tsx b/src/ui/main.tsx new file mode 100644 index 00000000..b3c1a514 --- /dev/null +++ b/src/ui/main.tsx @@ -0,0 +1,33 @@ +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'; + +const envConfig = getRunEnvironmentConfig(); + +const msalConfiguration: Configuration = { + auth: { + clientId: envConfig.AadValidClientId, + authority: 'https://login.microsoftonline.com/c8d9148f-9a59-4db3-827d-42ea0c2b6e2e', + redirectUri: `${window.location.origin}/auth/callback`, + postLogoutRedirectUri: `${window.location.origin}/logout`, + }, + cache: { + cacheLocation: 'sessionStorage', + storeAuthStateInCookie: true, + }, +}; + +const pca = new PublicClientApplication(msalConfiguration); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/src/ui/package.json b/src/ui/package.json new file mode 100644 index 00000000..0f0d484a --- /dev/null +++ b/src/ui/package.json @@ -0,0 +1,20 @@ +{ + "name": "infra-core-ui", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "cross-env VITE_RUN_ENVIRONMENT=local-dev vite", + "dev:aws": "cross-env VITE_RUN_ENVIRONMENT=dev vite", + "build": "tsc && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": " eslint . --ext .ts,.tsx --cache", + "prettier": "prettier --check \"**/*.{ts,tsx}\"", + "prettier:write": "prettier --write \"**/*.{ts,tsx}\"", + "vitest": "vitest run", + "vitest:watch": "vitest", + "test": "npm run typecheck && npm run prettier && npm run lint && npm run vitest && npm run build", + "storybook": "storybook dev -p 6006", + "storybook:build": "storybook build" + } +} diff --git a/src/ui/pages/Error404.page.tsx b/src/ui/pages/Error404.page.tsx new file mode 100644 index 00000000..3f970f00 --- /dev/null +++ b/src/ui/pages/Error404.page.tsx @@ -0,0 +1,24 @@ +import { Container, Title, Text, Anchor } from '@mantine/core'; +import React from 'react'; + +import { HeaderNavbar } from '@ui/components/Navbar'; + +export const Error404Page: React.FC<{ showNavbar?: boolean }> = ({ showNavbar }) => { + const realStuff = ( + <> + Page Not Found + + Perhaps you would like to go home? + + + ); + if (!showNavbar) { + return realStuff; + } + return ( + <> + + {realStuff} + + ); +}; diff --git a/src/ui/pages/Error500.page.tsx b/src/ui/pages/Error500.page.tsx new file mode 100644 index 00000000..abf82138 --- /dev/null +++ b/src/ui/pages/Error500.page.tsx @@ -0,0 +1,16 @@ +import { Container, Title, Text, Anchor } from '@mantine/core'; +import React from 'react'; + +import { HeaderNavbar } from '@ui/components/Navbar'; + +export const Error500Page: React.FC = () => ( + <> + + + An Error Occurred + + Perhaps you would like to go home? + + + +); diff --git a/src/ui/pages/Home.page.tsx b/src/ui/pages/Home.page.tsx new file mode 100644 index 00000000..c24da2f4 --- /dev/null +++ b/src/ui/pages/Home.page.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import { AcmAppShell } from '@ui/components/AppShell'; + +export const HomePage: React.FC = () => ( + <> + {null} + +); diff --git a/src/ui/pages/Login.page.tsx b/src/ui/pages/Login.page.tsx new file mode 100644 index 00000000..b75dc5be --- /dev/null +++ b/src/ui/pages/Login.page.tsx @@ -0,0 +1,41 @@ +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'; + +export function LoginPage() { + const navigate = useNavigate(); + const { isLoggedIn } = useAuth(); + const [searchParams] = useSearchParams(); + const showLogoutMessage = searchParams.get('lc') === 'true'; + const showLoginMessage = !showLogoutMessage && searchParams.get('li') === 'true'; + + useEffect(() => { + if (isLoggedIn) { + const returnTo = searchParams.get('returnTo'); + navigate(returnTo || '/home'); + } + }, [navigate, isLoggedIn, searchParams]); + + return ( +
+ + {showLogoutMessage && ( + } title="Logged Out" color="blue"> + You have successfully logged out. + + )} + {showLoginMessage && ( + } 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 new file mode 100644 index 00000000..41010592 --- /dev/null +++ b/src/ui/pages/Logout.page.tsx @@ -0,0 +1,9 @@ +import { Navigate } from 'react-router-dom'; + +import { useAuth } from '@ui/components/AuthContext'; + +export function LogoutPage() { + const { logoutCallback } = useAuth(); + logoutCallback(); + return ; +} diff --git a/src/ui/pages/events/ManageEvent.page.tsx b/src/ui/pages/events/ManageEvent.page.tsx new file mode 100644 index 00000000..fbbd3854 --- /dev/null +++ b/src/ui/pages/events/ManageEvent.page.tsx @@ -0,0 +1,246 @@ +import { Title, Box, TextInput, Textarea, Switch, Select, Button, Loader } from '@mantine/core'; +import { DateTimePicker } from '@mantine/dates'; +import { useForm, zodResolver } from '@mantine/form'; +import { notifications } from '@mantine/notifications'; +import dayjs from 'dayjs'; +import React, { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { z } from 'zod'; +import { AuthGuard } from '@ui/components/AuthGuard'; +import { getRunEnvironmentConfig } from '@ui/config'; +import { useApi } from '@ui/util/api'; +import { OrganizationList as orgList } from '@common/orgs'; + +export function capitalizeFirstLetter(string: string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +const repeatOptions = ['weekly', 'biweekly'] as const; + +const baseBodySchema = z.object({ + title: z.string().min(1, 'Title is required'), + description: z.string().min(1, 'Description is required'), + start: z.date(), + end: z.optional(z.date()), + location: z.string().min(1, 'Location is required'), + locationLink: z.optional(z.string().url('Invalid URL')), + host: z.string().min(1, 'Host is required'), + featured: z.boolean().default(false), + paidEventId: z.string().min(1, 'Paid Event ID must be at least 1 character').optional(), +}); + +const requestBodySchema = baseBodySchema + .extend({ + repeats: z.optional(z.enum(repeatOptions)).nullable(), + repeatEnds: z.date().optional(), + }) + .refine((data) => (data.repeatEnds ? data.repeats !== undefined : true), { + message: 'Repeat frequency is required when Repeat End is specified.', + }) + .refine((data) => !data.end || data.end >= data.start, { + message: 'Event end date cannot be earlier than the start date.', + path: ['end'], + }) + .refine((data) => !data.repeatEnds || data.repeatEnds >= data.start, { + message: 'Repeat end date cannot be earlier than the start date.', + path: ['repeatEnds'], + }); + +type EventPostRequest = z.infer; + +export const ManageEventPage: React.FC = () => { + const [isSubmitting, setIsSubmitting] = useState(false); + const navigate = useNavigate(); + const api = useApi('core'); + + const { eventId } = useParams(); + + const isEditing = eventId !== undefined; + + useEffect(() => { + if (!isEditing) { + return; + } + // Fetch event data and populate form + const getEvent = async () => { + try { + const response = await api.get(`/api/v1/events/${eventId}`); + const eventData = response.data; + const formValues = { + title: eventData.title, + description: eventData.description, + start: new Date(eventData.start), + end: eventData.end ? new Date(eventData.end) : undefined, + location: eventData.location, + locationLink: eventData.locationLink, + host: eventData.host, + featured: eventData.featured, + repeats: eventData.repeats, + repeatEnds: eventData.repeatEnds ? new Date(eventData.repeatEnds) : undefined, + paidEventId: eventData.paidEventId, + }; + form.setValues(formValues); + } catch (error) { + console.error('Error fetching event data:', error); + notifications.show({ + message: 'Failed to fetch event data, please try again.', + }); + } + }; + getEvent(); + }, [eventId, isEditing]); + + const form = useForm({ + validate: zodResolver(requestBodySchema), + initialValues: { + title: '', + description: '', + start: new Date(), + end: new Date(new Date().valueOf() + 3.6e6), // 1 hr later + location: 'ACM Room (Siebel CS 1104)', + locationLink: 'https://maps.app.goo.gl/dwbBBBkfjkgj8gvA8', + host: 'ACM', + featured: false, + repeats: undefined, + repeatEnds: undefined, + paidEventId: undefined, + }, + }); + + const checkPaidEventId = async (paidEventId: string) => { + try { + const merchEndpoint = getRunEnvironmentConfig().ServiceConfiguration.merch.baseEndpoint; + const ticketEndpoint = getRunEnvironmentConfig().ServiceConfiguration.tickets.baseEndpoint; + const paidEventHref = paidEventId.startsWith('merch:') + ? `${merchEndpoint}/api/v1/merch/details?itemid=${paidEventId.slice(6)}` + : `${ticketEndpoint}/api/v1/event/details?eventid=${paidEventId}`; + const response = await api.get(paidEventHref); + return Boolean(response.status < 299 && response.status >= 200); + } catch (error) { + console.error('Error validating paid event ID:', error); + return false; + } + }; + + const handleSubmit = async (values: EventPostRequest) => { + try { + setIsSubmitting(true); + const realValues = { + ...values, + start: dayjs(values.start).format('YYYY-MM-DD[T]HH:mm:00'), + end: values.end ? dayjs(values.end).format('YYYY-MM-DD[T]HH:mm:00') : undefined, + repeatEnds: + values.repeatEnds && values.repeats + ? dayjs(values.repeatEnds).format('YYYY-MM-DD[T]HH:mm:00') + : undefined, + repeats: values.repeats ? values.repeats : undefined, + }; + + const eventURL = isEditing ? `/api/v1/events/${eventId}` : '/api/v1/events'; + const response = await api.post(eventURL, realValues); + notifications.show({ + title: isEditing ? 'Event updated!' : 'Event created!', + message: isEditing ? undefined : `The event ID is "${response.data.id}".`, + }); + navigate('/events/manage'); + } catch (error) { + setIsSubmitting(false); + console.error('Error creating/editing event:', error); + notifications.show({ + message: 'Failed to create/edit event, please try again.', + }); + } + }; + + return ( + + {isEditing ? `Edit` : `Add`} Event + +
+ +