From 9487ac8062bdc0ed9f837d7c06225e33dd76ebdb Mon Sep 17 00:00:00 2001 From: Jordan Wong <42422209+JorWo@users.noreply.github.com> Date: Fri, 9 Feb 2024 13:13:48 -1000 Subject: [PATCH] Setup project with layout.tsx, CAS auth, and .github (#1) --- .github/pull_request_template.md | 15 ++ .github/workflows/badges.yml | 133 +++++++++++++ .github/workflows/ci.yml | 56 ++++++ ui/.env | 14 ++ ui/.env.production | 7 + ui/.env.test | 3 + ui/.eslintrc.json | 44 +++++ ui/.gitignore | 37 ++++ ui/__mocks__/iron-session.ts | 3 + ui/__mocks__/next/headers.ts | 3 + ui/__mocks__/next/navigation.ts | 3 + ui/components.json | 17 ++ ui/jest.config.ts | 31 ++++ ui/next.config.mjs | 17 ++ ui/package.json | 59 ++++++ ui/postcss.config.js | 6 + ui/public/uh-groupings-logo-large.svg | 3 + ui/public/uh-groupings-logo.svg | 3 + ui/public/uh-logo-system.svg | 140 ++++++++++++++ ui/src/access/AuthenticationService.ts | 82 +++++++++ ui/src/access/AuthorizationService.ts | 77 ++++++++ ui/src/access/Role.ts | 8 + ui/src/access/Saml11Validator.ts | 46 +++++ ui/src/access/Session.ts | 14 ++ ui/src/access/User.ts | 21 +++ ui/src/app/(index)/page.tsx | 7 + ui/src/app/api/cas/login/route.ts | 17 ++ ui/src/app/api/cas/logout/route.ts | 13 ++ ui/src/app/favicon.ico | Bin 0 -> 15086 bytes ui/src/app/globals.css | 13 ++ ui/src/app/layout.tsx | 32 ++++ ui/src/components/layout/footer/Footer.tsx | 37 ++++ .../components/layout/navbar/LoginButton.tsx | 32 ++++ .../components/layout/navbar/MobileNavbar.tsx | 59 ++++++ ui/src/components/layout/navbar/NavLinks.ts | 29 +++ ui/src/components/layout/navbar/Navbar.tsx | 52 ++++++ ui/src/components/ui/button.tsx | 58 ++++++ ui/src/components/ui/sheet.tsx | 174 ++++++++++++++++++ ui/src/components/ui/utils.ts | 6 + ui/src/middleware.ts | 31 ++++ ui/tailwind.config.ts | 60 ++++++ ui/tests/access/AuthenticationService.test.ts | 98 ++++++++++ ui/tests/access/AuthorizationService.test.ts | 93 ++++++++++ ui/tests/access/Role.test.ts | 11 ++ ui/tests/access/Saml11Validator.test.ts | 31 ++++ ui/tests/access/User.test.ts | 17 ++ ui/tests/app/api/cas/login/route.test.ts | 24 +++ ui/tests/app/api/cas/logout/route.test.ts | 21 +++ .../components/layout/footer/Footer.test.tsx | 17 ++ .../layout/navbar/LoginButton.test.tsx | 53 ++++++ .../layout/navbar/MobileNavbar.test.tsx | 86 +++++++++ .../components/layout/navbar/Navbar.test.tsx | 92 +++++++++ ui/tests/middleware.test.tsx | 107 +++++++++++ ui/tests/setupJest.tsx | 26 +++ ui/tsconfig.json | 26 +++ ...pings-ui-3-0-overrides.skeleton.properties | 7 + 56 files changed, 2171 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/badges.yml create mode 100644 .github/workflows/ci.yml create mode 100644 ui/.env create mode 100644 ui/.env.production create mode 100644 ui/.env.test create mode 100644 ui/.eslintrc.json create mode 100644 ui/.gitignore create mode 100644 ui/__mocks__/iron-session.ts create mode 100644 ui/__mocks__/next/headers.ts create mode 100644 ui/__mocks__/next/navigation.ts create mode 100644 ui/components.json create mode 100644 ui/jest.config.ts create mode 100644 ui/next.config.mjs create mode 100644 ui/package.json create mode 100644 ui/postcss.config.js create mode 100644 ui/public/uh-groupings-logo-large.svg create mode 100644 ui/public/uh-groupings-logo.svg create mode 100644 ui/public/uh-logo-system.svg create mode 100644 ui/src/access/AuthenticationService.ts create mode 100644 ui/src/access/AuthorizationService.ts create mode 100644 ui/src/access/Role.ts create mode 100644 ui/src/access/Saml11Validator.ts create mode 100644 ui/src/access/Session.ts create mode 100644 ui/src/access/User.ts create mode 100644 ui/src/app/(index)/page.tsx create mode 100644 ui/src/app/api/cas/login/route.ts create mode 100644 ui/src/app/api/cas/logout/route.ts create mode 100644 ui/src/app/favicon.ico create mode 100644 ui/src/app/globals.css create mode 100644 ui/src/app/layout.tsx create mode 100644 ui/src/components/layout/footer/Footer.tsx create mode 100644 ui/src/components/layout/navbar/LoginButton.tsx create mode 100644 ui/src/components/layout/navbar/MobileNavbar.tsx create mode 100644 ui/src/components/layout/navbar/NavLinks.ts create mode 100644 ui/src/components/layout/navbar/Navbar.tsx create mode 100644 ui/src/components/ui/button.tsx create mode 100644 ui/src/components/ui/sheet.tsx create mode 100644 ui/src/components/ui/utils.ts create mode 100644 ui/src/middleware.ts create mode 100644 ui/tailwind.config.ts create mode 100644 ui/tests/access/AuthenticationService.test.ts create mode 100644 ui/tests/access/AuthorizationService.test.ts create mode 100644 ui/tests/access/Role.test.ts create mode 100644 ui/tests/access/Saml11Validator.test.ts create mode 100644 ui/tests/access/User.test.ts create mode 100644 ui/tests/app/api/cas/login/route.test.ts create mode 100644 ui/tests/app/api/cas/logout/route.test.ts create mode 100644 ui/tests/components/layout/footer/Footer.test.tsx create mode 100644 ui/tests/components/layout/navbar/LoginButton.test.tsx create mode 100644 ui/tests/components/layout/navbar/MobileNavbar.test.tsx create mode 100644 ui/tests/components/layout/navbar/Navbar.test.tsx create mode 100644 ui/tests/middleware.test.tsx create mode 100644 ui/tests/setupJest.tsx create mode 100644 ui/tsconfig.json create mode 100644 ui/uh-groupings-ui-3-0-overrides.skeleton.properties diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5a0e996b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +# Ticket Link + +[Ticket](Insert Link Here) + +# List of squashed commits + +- Commit 1 +- Commit 2 +- Commit 3 + +# Test Checklist + +- [ ] Unit Tests Passed +- [ ] Integration Tests Passed +- [ ] General Visual Inspection diff --git a/.github/workflows/badges.yml b/.github/workflows/badges.yml new file mode 100644 index 00000000..9733087e --- /dev/null +++ b/.github/workflows/badges.yml @@ -0,0 +1,133 @@ +name: Generate badges + +on: + push: + branches: [ main ] + +jobs: + create-branch: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Git + run: | + git config --global user.name 'Groupings Project' + git config --global user.email 'actions@noreply.its.hawaii.edu' + + - name: Create the badges branch if it doesn't exist + run: | + branch_name="badges" + if ! git ls-remote --exit-code origin refs/heads/$branch_name; then + echo "Branch $branch_name does not exist. Creating it..." + git checkout --orphan $branch_name + git rm -rf . + git commit --allow-empty -m "Create $branch_name branch" + git push -u origin $branch_name + else + echo "Branch $branch_name already exists." + fi + + generate-jest-badges: + needs: create-branch + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: 16.x + + - name: Installing dependencies + run: | + cd ui + npm install + + - name: Tests + run: | + cd ui + npm run test:coverage + + - name: Switch to badges branch + run: | + git fetch + git switch badges + + - name: Generating coverage badges + uses: jpb06/jest-badges-action@latest + with: + branches: '*' + coverage-summary-path: ui/coverage/coverage-summary.json + no-commit: true + target-branch: badges + + - name: Commit and push badges + run: | + cd badges + if [[ `git status --porcelain *.svg` ]]; then + git config --global user.name 'Groupings Project' + git config --global user.email 'actions@noreply.its.hawaii.edu' + git add *.svg + git commit -m "Autogenerated Jest badges" *.svg + git push origin badges -f + fi + + # Re-enable once UH Groupings API is added to this repo + # generate-jacoco-badges: + # needs: create-branch + # runs-on: ubuntu-latest + # steps: + # - name: Checkout repository + # uses: actions/checkout@v3 + # with: + # fetch-depth: 0 + + # - name: Set up JDK 17 + # uses: actions/setup-java@v1 + # with: + # java-version: 17 + + # - name: Build with Maven and Generate JaCoCo Report + # run: | + # mvn clean test jacoco:report -f api/pom.xml -D'logging.level.edu.hawaii.its.holiday=OFF' -D'logging.level.org.springframework=ERROR' -D'spring.main.banner-mode=off' + # mv api/target/ target/ + + # - name: Switch to badges branch + # run: | + # git fetch + # git switch badges + + # - name: Generate Jacoco Badge + # id: jacoco + # uses: cicirello/jacoco-badge-generator@v2 + # with: + # coverage-label: junit coverage + # badges-directory: badges + # generate-branches-badge: true + + # - name: Log coverage percentage + # run: | + # echo "coverage = ${{ steps.jacoco.outputs.coverage }}" + + # - name: Commit and push + # if: ${{ github.event_name != 'pull_request' }} + # run: | + # cd badges + # if [[ `git status --porcelain *.svg` ]]; then + # git config --global user.name 'Groupings Project' + # git config --global user.email 'actions@noreply.its.hawaii.edu' + # git add *.svg + # git commit -m "Autogenerated JaCoCo coverage badge" *.svg + # git push origin badges -f + # fi + + # - name: Upload Jacoco coverage report + # uses: actions/upload-artifact@v2 + # with: + # name: jacoco-report + # path: target/site/jacoco/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..22db033a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + # Re-enable once UH Groupings API is added to this repo + # junit: + # runs-on: ubuntu-latest + + # steps: + # - name: Checkout code + # uses: actions/checkout@v3 + + # - name: Set up JDK 17 + # uses: actions/setup-java@v1 + # with: + # java-version: 17 + + # - name: Build with Maven + # run: | + # cd api + # mvn clean test + + jest: + strategy: + matrix: + node-version: [ 18.x, 20.x ] + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: | + cd ui + npm install + + - name: Run ESLint + run: | + cd ui + npm run lint + + - name: Run Jest Tests + run: | + cd ui + npm run test diff --git a/ui/.env b/ui/.env new file mode 100644 index 00000000..64641bc9 --- /dev/null +++ b/ui/.env @@ -0,0 +1,14 @@ +# ========================================================================= +NEXT_PUBLIC_BASE_URL=http://localhost:8080/uhgroupings +NEXT_PUBLIC_API_2_1_BASE_URL=http://localhost:8081/uhgroupingsapi/api/groupings/v2.1 + +# ========================================================================= +# CAS. +NEXT_PUBLIC_CAS_URL=https://cas-test.its.hawaii.edu/cas +NEXT_PUBLIC_SAML_REQUEST_TEMPLATE=%s + +# ========================================================================= +# Test Properties. +TEST_USER_A={ "name": "Testf-iwt-a TestIAM-staff", "firstName": "Testf-iwt-a", "lastName": "TestIAM-staff", "uid": "testiwta", "uhUuid": "99997010", "roles": [] } +XML_SOAP_RESPONSE=https://myserver.example.edu/myappiam_0108urn:oasis:names:tc:SAML:1.0:cm:artifacttestiwtaurn:oasis:names:tc:SAML:1.0:cm:artifacttestiwta@hawaii.edustaff99997010Testf-iwt-auhsystemTestf-iwt-a TestIAM-stafftestiwtatestiwta@hawaii.edutestiwta@hawaii.eduTestIAM-staffeduPersonOrgDN=uhsystem,eduPersonAffiliation=staff +IRON_SESSION_SECRET=IronSessionSecretForTestingAuthentication diff --git a/ui/.env.production b/ui/.env.production new file mode 100644 index 00000000..be786180 --- /dev/null +++ b/ui/.env.production @@ -0,0 +1,7 @@ +# ========================================================================= +BASE_URL=https://www.hawaii.edu/its/uhgroupings +API_2_1_BASE_URL=http://iws04.pvt.hawaii.edu:8080/uhgroupingsapi/api/groupings/v2.1 + +# ========================================================================= +# CAS. +CAS_URL=https://authn.hawaii.edu/cas diff --git a/ui/.env.test b/ui/.env.test new file mode 100644 index 00000000..e30571ad --- /dev/null +++ b/ui/.env.test @@ -0,0 +1,3 @@ +# ========================================================================= +BASE_URL=https://www.test.hawaii.edu/uhgroupings +API_2_1_BASE_URL=http://iws74.pvt.hawaii.edu:8080/uhgroupingsapi/api/groupings/v2.1 diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json new file mode 100644 index 00000000..c0ce4add --- /dev/null +++ b/ui/.eslintrc.json @@ -0,0 +1,44 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended", + "next/core-web-vitals" + ], + "overrides": [ + { + "files": ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)"], + "extends": ["plugin:testing-library/react"] + } + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": [ + "react", + "@typescript-eslint", + "@stylistic", + "eslint-plugin-tsdoc" + ], + "rules": { + "@stylistic/max-len": ["error", { "code": 120 }], + "@stylistic/indent": ["error", 4], + "@stylistic/eol-last": ["error", "always"], + "@stylistic/quotes": ["error", "single", { "allowTemplateLiterals": true }], + "@stylistic/arrow-parens": "off", + "@stylistic/linebreak-style": "off", + "react/function-component-definition": [2, { "namedComponents": "arrow-function" }], + "tsdoc/syntax": "warn", + "func-names": "off", + "no-plusplus": "off", + "no-restricted-syntax": "off", + "no-return-assign": "off", + "prefer-arrow-callback": "off" + } +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 00000000..6d333829 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz +package-lock.json + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/ui/__mocks__/iron-session.ts b/ui/__mocks__/iron-session.ts new file mode 100644 index 00000000..5c4f3b86 --- /dev/null +++ b/ui/__mocks__/iron-session.ts @@ -0,0 +1,3 @@ +const ironSession = jest.genMockFromModule('iron-session'); + +module.exports = ironSession; diff --git a/ui/__mocks__/next/headers.ts b/ui/__mocks__/next/headers.ts new file mode 100644 index 00000000..b70d1a94 --- /dev/null +++ b/ui/__mocks__/next/headers.ts @@ -0,0 +1,3 @@ +const nextHeaders = jest.genMockFromModule('next/headers'); + +module.exports = nextHeaders; diff --git a/ui/__mocks__/next/navigation.ts b/ui/__mocks__/next/navigation.ts new file mode 100644 index 00000000..00652f92 --- /dev/null +++ b/ui/__mocks__/next/navigation.ts @@ -0,0 +1,3 @@ +const nextNavigation = jest.genMockFromModule('next/navigation'); + +module.exports = nextNavigation; diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 00000000..ea229572 --- /dev/null +++ b/ui/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": false, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/components/ui/utils" + } +} diff --git a/ui/jest.config.ts b/ui/jest.config.ts new file mode 100644 index 00000000..b21da05b --- /dev/null +++ b/ui/jest.config.ts @@ -0,0 +1,31 @@ +import type { Config } from 'jest' +import nextJest from 'next/jest.js' + +const createJestConfig = nextJest({ + dir: './', +}); + +const config: Config = { + clearMocks: true, + collectCoverageFrom: [ + './src/**/*.ts*', + ], + coveragePathIgnorePatterns: [ + './src/components/ui', // Ignore shadcn/ui components + ], + coverageReporters: ['json-summary', 'text', 'html'], + testEnvironment: 'jsdom', + testEnvironmentOptions: { + customExportConditions: [] + }, + setupFilesAfterEnv: [ + '/tests/setupJest.tsx' + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + moduleDirectories: ['node_modules', ''], + moduleNameMapper: { + "^@/(.*)$": "/src/$1" + } +}; + +export default createJestConfig(config); diff --git a/ui/next.config.mjs b/ui/next.config.mjs new file mode 100644 index 00000000..79f8b2b6 --- /dev/null +++ b/ui/next.config.mjs @@ -0,0 +1,17 @@ +/** @type {import('next').NextConfig} */ + +import dotenv from 'dotenv'; +import os from 'os'; + +// Define path to overrides file +// TODO: Rename uh-groupings-ui-3-0-overrides to uh-groupings-ui-overrides once 3.0 is complete +dotenv.config({path: `${os.homedir()}/.${os.userInfo().username}-conf/uh-groupings-ui-3-0-overrides.properties`}) + +const nextConfig = { + basePath: '/uhgroupings', + experimental: { + serverComponentsExternalPackages: ['camaro'] + } +}; + +export default nextConfig; diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 00000000..823844cb --- /dev/null +++ b/ui/package.json @@ -0,0 +1,59 @@ +{ + "name": "uh-groupings", + "version": "3.0", + "private": true, + "scripts": { + "dev": "next dev -p 8080", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "jest", + "test:coverage": "jest --coverage", + "test:watch": "jest --watch" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-slot": "^1.0.2", + "axios": "^1.6.7", + "camaro": "^6.2.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "dotenv": "^16.4.1", + "iron-session": "^8.0.1", + "lucide-react": "^0.321.0", + "next": "14.1.0", + "react": "^18", + "react-dom": "^18", + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7", + "uniqid": "^5.4.0" + }, + "devDependencies": { + "@stylistic/eslint-plugin": "^1.6.1", + "@swc/core": "^1.3.106", + "@testing-library/jest-dom": "^6.3.0", + "@testing-library/react": "^14.1.2", + "@types/jest": "^29.5.11", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/uniqid": "^5.3.4", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "@typescript-eslint/parser": "^6.19.1", + "autoprefixer": "^10.0.1", + "axios-mock-adapter": "^1.22.0", + "eslint": "^8", + "eslint-config-next": "14.1.0", + "eslint-plugin-testing-library": "^6.2.0", + "eslint-plugin-tsdoc": "^0.2.17", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^3.0.3", + "postcss": "^8.4.33", + "postcss-preset-mantine": "^1.12.3", + "postcss-simple-vars": "^7.0.1", + "tailwindcss": "^3.3.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 00000000..21312be2 --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + 'tailwindcss': {}, + 'autoprefixer': {}, + }, +}; diff --git a/ui/public/uh-groupings-logo-large.svg b/ui/public/uh-groupings-logo-large.svg new file mode 100644 index 00000000..34f84fcb --- /dev/null +++ b/ui/public/uh-groupings-logo-large.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/public/uh-groupings-logo.svg b/ui/public/uh-groupings-logo.svg new file mode 100644 index 00000000..dc04b50e --- /dev/null +++ b/ui/public/uh-groupings-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/public/uh-logo-system.svg b/ui/public/uh-logo-system.svg new file mode 100644 index 00000000..8d78b359 --- /dev/null +++ b/ui/public/uh-logo-system.svg @@ -0,0 +1,140 @@ + +image/svg+xml diff --git a/ui/src/access/AuthenticationService.ts b/ui/src/access/AuthenticationService.ts new file mode 100644 index 00000000..0ab1d854 --- /dev/null +++ b/ui/src/access/AuthenticationService.ts @@ -0,0 +1,82 @@ +'use server'; + +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { IronSession, getIronSession } from 'iron-session'; +import { SessionData, SessionOptions } from './Session'; +import User, { AnonymousUser } from './User'; +import { validateTicket } from './Saml11Validator'; +import { setRoles } from './AuthorizationService'; +import { isDeepStrictEqual } from 'util'; + +const casUrl = process.env.NEXT_PUBLIC_CAS_URL as string; +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; + +/** + * Gets the current logged-in user. + * + * @returns The current user + */ +export const getCurrentUser = async (): Promise => { + const session = await getSession(); + if (!session.user) { + return AnonymousUser; + } + return session.user; +} + +/** + * Redirects the user to the CAS login. + */ +export const login = (): void => { + redirect(`${casUrl}/login?service=${encodeURIComponent(`${baseUrl}/api/cas/login`)}`); +} + +/** + * Redirects the user to the CAS logout. + */ +export const logout = (): void => { + redirect(`${casUrl}/logout?service=${encodeURIComponent(`${baseUrl}/api/cas/logout`)}`); +} + +/** + * Validates ticket after successful CAS login, sets their roles, then saves the user to the session. + * + * @remarks + * This function is primarily used in the /api/cas/login API endpoint to catch the redirect + * after successfully logging in through CAS. + * + * @param ticket - The ticket returned from successful CAS login + */ +export const handleLogin = async (ticket: string): Promise => { + const user = await validateTicket(ticket); + if (isDeepStrictEqual(user, AnonymousUser)) { + return; + } + await setRoles(user); + + const session = await getSession(); + session.user = user; + await session.save(); +}; + +/** + * Removes the user from the session, therby logging them out. + * + * @remarks + * This function is primarly used in the /api/cas/logout API endpoint to catch the redirect + * after successfully logging out through CAS. + */ +export const handleLogout = async (): Promise => { + const session = await getSession(); + session.destroy(); +} + +/** + * Get the session data containing the current user stored in Iron Session. + * + * @returns The session containing the current user + */ +const getSession = async (): Promise> => { + return await getIronSession(cookies(), SessionOptions); +} diff --git a/ui/src/access/AuthorizationService.ts b/ui/src/access/AuthorizationService.ts new file mode 100644 index 00000000..6e868f1a --- /dev/null +++ b/ui/src/access/AuthorizationService.ts @@ -0,0 +1,77 @@ +'use server'; + +import User from './User'; +import Role from './Role'; +import axios from 'axios'; + +const apiBaseUrl = process.env.NEXT_PUBLIC_API_2_1_BASE_URL as string; + +/** + * Sets the appropriate roles for a user. + * + * @param user - The user + */ +export const setRoles = async (user: User): Promise => { + // All users should have ANONYMOUS role to describe universal access (e.g. /about page in NavLinks.ts) + user.roles.push(Role.ANONYMOUS); + + if (isValidUhUuid(user.uhUuid)) { + user.roles.push(Role.UH); + } + if (await isOwner(user.uhUuid)) { + user.roles.push(Role.OWNER); + } + if (await isAdmin(user.uhUuid)) { + user.roles.push(Role.ADMIN); + } +} + +/** + * Calls UH Groupings API to check if the uhIdentifier is an owner. + * + * @param uhIdentifier - The uid or uhUuid + * + * @returns True if the uhIdentifier is an owner of a grouping + */ +const isOwner = async (uhIdentifier: string): Promise => { + try { + const { data } = await axios.get(`${apiBaseUrl}/owners`, { + headers: { 'current_user': uhIdentifier } + }); + return data; + } catch (error) { + console.error(error); + } + return false; +} + +/** + * Calls UH Groupings API to check if the uhIdentifier is an admin. + * + * @param uhIdentifier - The uid or uhUuid + * + * @returns True if the uhIdentifier is an admin + */ +const isAdmin = async (uhIdentifier: string): Promise => { + try { + const { data } = await axios.get(`${apiBaseUrl}/admins`, { + headers: { 'current_user': uhIdentifier } + }); + return data; + } catch (error) { + console.error(error); + } + return false; +} + +/** + * Checks if uhUuid is valid using Regex. + * + * @param uhUuid - 8 digit unique user indentifier + * + * @returns True if uhUuid is valid + */ +const isValidUhUuid = (uhUuid: string): boolean => { + const uhUuidPattern = new RegExp(/^[0-9]{8}$/); + return uhUuidPattern.test(uhUuid); +} diff --git a/ui/src/access/Role.ts b/ui/src/access/Role.ts new file mode 100644 index 00000000..58c32d8f --- /dev/null +++ b/ui/src/access/Role.ts @@ -0,0 +1,8 @@ +enum Role { + ADMIN = 'ADMIN', + ANONYMOUS = 'ANONYMOUS', + OWNER = 'OWNER', + UH = 'UH' +} + +export default Role; diff --git a/ui/src/access/Saml11Validator.ts b/ui/src/access/Saml11Validator.ts new file mode 100644 index 00000000..1887ead0 --- /dev/null +++ b/ui/src/access/Saml11Validator.ts @@ -0,0 +1,46 @@ +import User, { AnonymousUser } from './User'; +import uniqid from 'uniqid'; +import { format } from 'util'; +import { transform } from 'camaro'; +import axios from 'axios'; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; +const casUrl = process.env.NEXT_PUBLIC_CAS_URL as string; +const samlRequestTemplate = process.env.NEXT_PUBLIC_SAML_REQUEST_TEMPLATE as string; + +/** + * Validates ticket by calling the /samlValidate endpoint of CAS server with the ticket formatted as a SAML. + * Then the CAS server responds with a SAML containing the user, which is then transformed into the User type. + * + * @param ticket - The ticket returned from successful CAS login + * + * @returns The user from the ticket or an AnonymousUser if error occurs + */ +export const validateTicket = async (ticket: string): Promise => { + const samlValidateUrl = `${casUrl}/samlValidate?TARGET=${encodeURIComponent(`${baseUrl}/api/cas/login`)}`; + const samlResponseTemplate = { + name: '//*[local-name() = "Attribute"][@AttributeName="cn"]', + firstName: '//*[local-name() = "Attribute"][@AttributeName="givenName"]', + lastName: '//*[local-name() = "Attribute"][@AttributeName="sn"]', + uid: '//*[local-name() = "Attribute"][@AttributeName="uid"]', + uhUuid: '//*[local-name() = "Attribute"][@AttributeName="uhUuid"]', + }; + + const currentDate = new Date().toISOString(); + const samlRequestBody = format(samlRequestTemplate, `${uniqid()}.${currentDate}`, currentDate, ticket); + + try { + const response = await axios.post(samlValidateUrl, samlRequestBody, { + headers: { 'Content-Type': 'text/xml' } + }); + const casUser = await transform(response.data, samlResponseTemplate); + + return { + ...casUser, + roles: [] + } as User; + + } catch (error) { + return AnonymousUser; + } +} diff --git a/ui/src/access/Session.ts b/ui/src/access/Session.ts new file mode 100644 index 00000000..9f37f9c3 --- /dev/null +++ b/ui/src/access/Session.ts @@ -0,0 +1,14 @@ +import User from './User'; + +export interface SessionData { + user: User; +} + +export const SessionOptions = { + cookieName: 'SESSIONID', + password: process.env.IRON_SESSION_SECRET as string, + cookieOptions: { + maxAge: undefined, + secure: process.env.NODE_ENV === 'production', + }, +}; diff --git a/ui/src/access/User.ts b/ui/src/access/User.ts new file mode 100644 index 00000000..71d1fea7 --- /dev/null +++ b/ui/src/access/User.ts @@ -0,0 +1,21 @@ +import Role from './Role'; + +type User = { + name: string, + firstName: string, + lastName: string, + uid: string, + uhUuid: string, + roles: Role[] +} + +export const AnonymousUser: User = { + name: '', + firstName: '', + lastName: '', + uid: '', + uhUuid: '', + roles: [Role.ANONYMOUS] as const +} + +export default User; diff --git a/ui/src/app/(index)/page.tsx b/ui/src/app/(index)/page.tsx new file mode 100644 index 00000000..b0223723 --- /dev/null +++ b/ui/src/app/(index)/page.tsx @@ -0,0 +1,7 @@ +const Home = () => { + return ( + null + ); +} + +export default Home; diff --git a/ui/src/app/api/cas/login/route.ts b/ui/src/app/api/cas/login/route.ts new file mode 100644 index 00000000..e1649eaf --- /dev/null +++ b/ui/src/app/api/cas/login/route.ts @@ -0,0 +1,17 @@ +import { redirect } from 'next/navigation'; +import type { NextRequest } from 'next/server'; +import { handleLogin } from '@/access/AuthenticationService'; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; + +/** + * Next.js route handler to catch the redirect after successfully logging in through CAS. + * Handles the login then redirects the user back to the home page. + * + * @param req - The request object + */ +export const GET = async (req: NextRequest) => { + const ticket = req.nextUrl.searchParams.get('ticket') as string; + await handleLogin(ticket); + redirect(baseUrl); +} diff --git a/ui/src/app/api/cas/logout/route.ts b/ui/src/app/api/cas/logout/route.ts new file mode 100644 index 00000000..2d7b4681 --- /dev/null +++ b/ui/src/app/api/cas/logout/route.ts @@ -0,0 +1,13 @@ +import { redirect } from 'next/navigation'; +import { handleLogout } from '@/access/AuthenticationService'; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; + +/** + * Next.js route handler to catch the redirect after successfully logging out through CAS. + * Handles the logout then redirects the user back to the home page. + */ +export const GET = async () => { + await handleLogout(); + redirect(baseUrl); +} diff --git a/ui/src/app/favicon.ico b/ui/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..56fe42c4d6268f83ee15d75489c9ad257bcea99e GIT binary patch literal 15086 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 => ( + + + + {children} +