From f8a107d276786cb76b22e43dbb1860f85d2a2289 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 5 Jan 2025 14:05:02 -0600 Subject: [PATCH 01/69] Move management UI into this repo (#25) * base setup for group relation creation * add some stuff * reorganize codebase to support adding UI * move all the UI into one repo * fix git * test * fix command * fix * fix * fix deps * update concurrency groups * update names * branch: main for cloudflare --- .env.sample | 3 +- .eslintignore | 3 +- .eslintrc | 7 + .github/workflows/deploy-dev.yml | 35 +- .github/workflows/deploy-prod.yml | 68 +- .husky/pre-commit | 2 +- .lintstagedrc.json | 7 +- .yarnrc.yml | 1 + Makefile | 7 +- README.md | 46 +- cloudformation/iam.yml | 4 + cloudformation/main.yml | 36 +- package.json | 209 +- src/api/README.md | 41 + src/{ => api}/functions/cache.ts | 2 +- src/{ => api}/functions/discord.ts | 4 +- src/{ => api}/functions/entraId.ts | 7 +- src/{ => api}/functions/validation.ts | 0 src/{ => api}/index.ts | 13 +- src/{ => api}/lambda.ts | 0 src/api/package.json | 17 + src/{ => api}/plugins/auth.ts | 6 +- src/{ => api}/plugins/errorHandler.ts | 2 +- src/{ => api}/plugins/validate.ts | 5 +- src/{ => api}/routes/events.ts | 8 +- src/api/routes/iam.ts | 206 + src/{ => api}/routes/ics.ts | 6 +- src/{ => api}/routes/organizations.ts | 2 +- src/{ => api}/routes/protected.ts | 0 src/{ => api}/routes/tickets.ts | 6 +- src/{ => api}/routes/vending.ts | 0 tsconfig.json => src/api/tsconfig.json | 3 +- src/{ => api}/types.d.ts | 6 +- src/{ => common}/config.ts | 2 + src/{ => common}/errors/index.ts | 11 + src/{ => common}/orgs.ts | 0 src/{ => common}/roles.ts | 1 + src/routes/sso.ts | 75 - src/ui/.prettierrc.cjs | 1 + src/ui/.stylelintrc.json | 28 + src/ui/App.tsx | 26 + src/ui/ColorSchemeContext.tsx | 8 + src/ui/README.md | 10 + src/ui/Router.tsx | 168 + src/ui/banner-blue.png | Bin 0 -> 30876 bytes src/ui/banner-white.png | Bin 0 -> 28139 bytes src/ui/components/AppShell/index.tsx | 191 + .../AuthContext/AuthCallbackHandler.page.tsx | 47 + .../components/AuthContext/LoadingScreen.tsx | 24 + src/ui/components/AuthContext/index.tsx | 223 + src/ui/components/AuthGuard/index.tsx | 174 + src/ui/components/DarkModeSwitch/index.tsx | 50 + src/ui/components/FullPageError/index.tsx | 24 + .../LoginComponent/AcmLoginButton.tsx | 22 + src/ui/components/LoginComponent/index.tsx | 53 + src/ui/components/Navbar/Logo.tsx | 55 + src/ui/components/Navbar/index.module.css | 46 + src/ui/components/Navbar/index.tsx | 55 + src/ui/components/ProfileDropdown/index.tsx | 129 + src/ui/config.ts | 94 + src/ui/index.html | 46 + src/ui/main.tsx | 33 + src/ui/package.json | 20 + src/ui/pages/Error404.page.tsx | 24 + src/ui/pages/Error500.page.tsx | 16 + src/ui/pages/Home.page.tsx | 9 + src/ui/pages/Login.page.tsx | 41 + src/ui/pages/Logout.page.tsx | 9 + src/ui/pages/events/ManageEvent.page.tsx | 246 + src/ui/pages/events/ViewEvents.page.tsx | 189 + src/ui/pages/tickets/ScanTickets.page.tsx | 434 ++ src/ui/pages/tickets/SelectEventId.page.tsx | 343 + src/ui/pages/tickets/ViewTickets.page.tsx | 201 + src/ui/postcss.config.cjs | 14 + src/ui/public/banner-blue.png | Bin 0 -> 30876 bytes src/ui/public/square-blue.png | Bin 0 -> 9262 bytes src/ui/test-utils/index.ts | 1 + src/ui/test-utils/render.tsx | 24 + src/ui/theme.ts | 5 + src/ui/tsconfig.json | 26 + src/ui/util/api.ts | 36 + src/ui/vite-env.d.ts | 1 + src/ui/vite.config.mjs | 41 + src/ui/vitest.setup.mjs | 28 + tests/live/events.test.ts | 4 +- tests/live/healthz.test.ts | 2 +- tests/live/ical.test.ts | 4 +- tests/live/organizations.test.ts | 2 +- tests/unit/auth.test.ts | 4 +- tests/unit/discordEvent.test.ts | 8 +- tests/unit/entraInviteUser.test.ts | 21 +- tests/unit/eventPost.test.ts | 2 +- tests/unit/events.test.ts | 4 +- tests/unit/health.test.ts | 4 +- tests/unit/ical.test.ts | 2 +- tests/unit/organizations.test.ts | 2 +- tests/unit/secret.testdata.ts | 2 +- tests/unit/tickets.test.ts | 3 +- tests/unit/vending.test.ts | 2 +- yarn.lock | 6186 ++++++++++++----- 100 files changed, 8356 insertions(+), 1962 deletions(-) create mode 100644 .yarnrc.yml create mode 100644 src/api/README.md rename src/{ => api}/functions/cache.ts (95%) rename src/{ => api}/functions/discord.ts (97%) rename src/{ => api}/functions/entraId.ts (95%) rename src/{ => api}/functions/validation.ts (100%) rename src/{ => api}/index.ts (89%) rename src/{ => api}/lambda.ts (100%) create mode 100644 src/api/package.json rename src/{ => api}/plugins/auth.ts (97%) rename src/{ => api}/plugins/errorHandler.ts (97%) rename src/{ => api}/plugins/validate.ts (92%) rename src/{ => api}/routes/events.ts (98%) create mode 100644 src/api/routes/iam.ts rename src/{ => api}/routes/ics.ts (95%) rename src/{ => api}/routes/organizations.ts (88%) rename src/{ => api}/routes/protected.ts (100%) rename src/{ => api}/routes/tickets.ts (98%) rename src/{ => api}/routes/vending.ts (100%) rename tsconfig.json => src/api/tsconfig.json (73%) rename src/{ => api}/types.d.ts (81%) rename src/{ => common}/config.ts (98%) rename src/{ => common}/errors/index.ts (93%) rename src/{ => common}/orgs.ts (100%) rename src/{ => common}/roles.ts (94%) delete mode 100644 src/routes/sso.ts create mode 100644 src/ui/.prettierrc.cjs create mode 100644 src/ui/.stylelintrc.json create mode 100644 src/ui/App.tsx create mode 100644 src/ui/ColorSchemeContext.tsx create mode 100644 src/ui/README.md create mode 100644 src/ui/Router.tsx create mode 100644 src/ui/banner-blue.png create mode 100644 src/ui/banner-white.png create mode 100644 src/ui/components/AppShell/index.tsx create mode 100644 src/ui/components/AuthContext/AuthCallbackHandler.page.tsx create mode 100644 src/ui/components/AuthContext/LoadingScreen.tsx create mode 100644 src/ui/components/AuthContext/index.tsx create mode 100644 src/ui/components/AuthGuard/index.tsx create mode 100644 src/ui/components/DarkModeSwitch/index.tsx create mode 100644 src/ui/components/FullPageError/index.tsx create mode 100644 src/ui/components/LoginComponent/AcmLoginButton.tsx create mode 100644 src/ui/components/LoginComponent/index.tsx create mode 100644 src/ui/components/Navbar/Logo.tsx create mode 100644 src/ui/components/Navbar/index.module.css create mode 100644 src/ui/components/Navbar/index.tsx create mode 100644 src/ui/components/ProfileDropdown/index.tsx create mode 100644 src/ui/config.ts create mode 100644 src/ui/index.html create mode 100644 src/ui/main.tsx create mode 100644 src/ui/package.json create mode 100644 src/ui/pages/Error404.page.tsx create mode 100644 src/ui/pages/Error500.page.tsx create mode 100644 src/ui/pages/Home.page.tsx create mode 100644 src/ui/pages/Login.page.tsx create mode 100644 src/ui/pages/Logout.page.tsx create mode 100644 src/ui/pages/events/ManageEvent.page.tsx create mode 100644 src/ui/pages/events/ViewEvents.page.tsx create mode 100644 src/ui/pages/tickets/ScanTickets.page.tsx create mode 100644 src/ui/pages/tickets/SelectEventId.page.tsx create mode 100644 src/ui/pages/tickets/ViewTickets.page.tsx create mode 100644 src/ui/postcss.config.cjs create mode 100644 src/ui/public/banner-blue.png create mode 100644 src/ui/public/square-blue.png create mode 100644 src/ui/test-utils/index.ts create mode 100644 src/ui/test-utils/render.tsx create mode 100644 src/ui/theme.ts create mode 100644 src/ui/tsconfig.json create mode 100644 src/ui/util/api.ts create mode 100644 src/ui/vite-env.d.ts create mode 100644 src/ui/vite.config.mjs create mode 100644 src/ui/vitest.setup.mjs 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 0000000000000000000000000000000000000000..2acdad7502574cc451e32cb1d5be9a82d7c502dd GIT binary patch literal 30876 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 U>gBvj!4I$os$5g2NlZndv~9D{H17_XSWQ rh=*vn6R)71(csbl?eBa)OV|}={U=;Rk29cz&HI9h<=LV$t`GhP!laKt literal 0 HcmV?d00001 diff --git a/src/ui/banner-white.png b/src/ui/banner-white.png new file mode 100644 index 0000000000000000000000000000000000000000..ae292cf507a6406c6485af4c0fe17933dceef14a GIT binary patch literal 28139 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 z[], + 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 + +
+ +