diff --git a/ui/.env b/ui/.env index 1161785b..5fa2208a 100644 --- a/ui/.env +++ b/ui/.env @@ -15,6 +15,3 @@ NEXT_PUBLIC_PAGE_SIZE=20 # ========================================================================= # 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 -XML_SOAP_RESPONSE_REQUEST_DENIED=Ticket 'test' not recognized -IRON_SESSION_SECRET=IronSessionSecretForTestingAuthentication diff --git a/ui/__mocks__/iron-session.ts b/ui/__mocks__/iron-session.ts deleted file mode 100644 index 5c4f3b86..00000000 --- a/ui/__mocks__/iron-session.ts +++ /dev/null @@ -1,3 +0,0 @@ -const ironSession = jest.genMockFromModule('iron-session'); - -module.exports = ironSession; diff --git a/ui/__mocks__/next-cas-client.ts b/ui/__mocks__/next-cas-client.ts new file mode 100644 index 00000000..1a0cdecc --- /dev/null +++ b/ui/__mocks__/next-cas-client.ts @@ -0,0 +1 @@ +module.exports = jest.genMockFromModule('next-cas-client'); diff --git a/ui/__mocks__/next/headers.ts b/ui/__mocks__/next/headers.ts deleted file mode 100644 index b70d1a94..00000000 --- a/ui/__mocks__/next/headers.ts +++ /dev/null @@ -1,3 +0,0 @@ -const nextHeaders = jest.genMockFromModule('next/headers'); - -module.exports = nextHeaders; diff --git a/ui/__mocks__/next/navigation.ts b/ui/__mocks__/next/navigation.ts deleted file mode 100644 index 00652f92..00000000 --- a/ui/__mocks__/next/navigation.ts +++ /dev/null @@ -1,3 +0,0 @@ -const nextNavigation = jest.genMockFromModule('next/navigation'); - -module.exports = nextNavigation; diff --git a/ui/jest.config.ts b/ui/jest.config.ts index e36aab35..a9a1576b 100644 --- a/ui/jest.config.ts +++ b/ui/jest.config.ts @@ -1,26 +1,22 @@ -import type { Config } from 'jest' -import nextJest from 'next/jest.js' +import type { Config } from 'jest'; +import nextJest from 'next/jest.js'; const createJestConfig = nextJest({ - dir: './', + dir: './' }); const config: Config = { clearMocks: true, - collectCoverageFrom: [ - './src/**/*.ts*', - ], + collectCoverageFrom: ['./src/**/*.ts*'], coveragePathIgnorePatterns: [ - './src/components/ui', // Ignore shadcn/ui components + './src/components/ui' // Ignore shadcn/ui components ], coverageReporters: ['json-summary', 'text', 'html'], testEnvironment: 'jsdom', testEnvironmentOptions: { customExportConditions: [] }, - setupFilesAfterEnv: [ - '/tests/setup-jest.ts' - ], + setupFilesAfterEnv: ['/tests/setup-jest.ts'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleDirectories: ['node_modules', ''], moduleNameMapper: { diff --git a/ui/package.json b/ui/package.json index 2ab018dc..9a06510b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,20 +28,18 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.1.2", "@tanstack/react-table": "^8.20.1", - "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.453.0", "next": "14.2.15", + "next-cas-client": "^1.2.2", "react": "^18", "react-dom": "^18", "react-hook-form": "^7.50.1", "react-idle-timer": "^5.7.2", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", - "uniqid": "^5.4.0", "usehooks-ts": "^3.1.0", "zod": "^3.22.4" }, diff --git a/ui/src/access/authentication.ts b/ui/src/access/authentication.ts deleted file mode 100644 index a4798066..00000000 --- a/ui/src/access/authentication.ts +++ /dev/null @@ -1,82 +0,0 @@ -'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 './saml-11-validator'; -import { setRoles } from './authorization'; -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/saml-11-validator.ts b/ui/src/access/saml-11-validator.ts deleted file mode 100644 index c526ac7b..00000000 --- a/ui/src/access/saml-11-validator.ts +++ /dev/null @@ -1,57 +0,0 @@ -import User, { AnonymousUser } from './user'; -import uniqid from 'uniqid'; -import { format } from 'util'; -import { transform } from 'camaro'; - -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 fetch(samlValidateUrl, { - method: 'POST', - headers: { 'Content-Type': 'text/xml' }, - body: samlRequestBody - }); - const data = await response.text(); - - const { statusCode }: { statusCode: string } = await transform(data, { - statusCode: '//*[local-name() = "Status"]/*[local-name() ="StatusCode"]/@Value' - }); - - if (statusCode.endsWith('RequestDenied')) { - throw new Error('Invalid ticket'); - } - - const casUser = await transform(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 deleted file mode 100644 index 5db36db9..00000000 --- a/ui/src/access/session.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 730d5912..00000000 --- a/ui/src/access/user.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { MemberResult } from '@/lib/types'; -import Role from './role'; - -type User = { - roles: Role[]; -} & MemberResult; - -export const AnonymousUser: User = { - name: '', - firstName: '', - lastName: '', - uid: '', - uhUuid: '', - roles: [Role.ANONYMOUS] as const -}; - -export default User; diff --git a/ui/src/app/(home)/_components/after-login.tsx b/ui/src/app/(home)/_components/after-login.tsx index 17d2bf1d..f1e5361c 100644 --- a/ui/src/app/(home)/_components/after-login.tsx +++ b/ui/src/app/(home)/_components/after-login.tsx @@ -1,14 +1,14 @@ -import Role from '@/access/role'; +import Role from '@/lib/access/role'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import {faKey, faIdCard, faWrench, faUser} from '@fortawesome/free-solid-svg-icons'; +import { faKey, faIdCard, faWrench, faUser } from '@fortawesome/free-solid-svg-icons'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { getNumberOfGroupings, getNumberOfMemberships } from '@/lib/fetchers'; -import { getCurrentUser } from '@/access/authentication'; +import { getUser } from '@/lib/access/user'; const AfterLogin = async () => { const [currentUser, numberOfGroupings, numberOfMemberships] = await Promise.all([ - getCurrentUser(), + getUser(), getNumberOfGroupings(), getNumberOfMemberships() ]); @@ -72,7 +72,11 @@ const AfterLogin = async () => { className="bg-blue-background rounded-full flex justify-center items-center h-[30px] w-[30px] absolute left-3 bottom-0 ml-16" > - + @@ -98,7 +102,12 @@ const AfterLogin = async () => {
- + {pageInfoItem.number !== null && ( {pageInfoItem.number} )} diff --git a/ui/src/app/(home)/_components/login-button.tsx b/ui/src/app/(home)/_components/login-button.tsx index 997e5284..dc536f12 100644 --- a/ui/src/app/(home)/_components/login-button.tsx +++ b/ui/src/app/(home)/_components/login-button.tsx @@ -1,28 +1,18 @@ 'use client'; import { Button } from '@/components/ui/button'; -import Role from '@/access/role'; -import User from '@/access/user'; -import { login, logout } from '@/access/authentication'; +import Role from '@/lib/access/role'; +import { login, logout } from 'next-cas-client'; +import { User } from '@/lib/access/user'; -const LoginButton = ({ - currentUser -}: { - currentUser: User; -}) => ( +const LoginButton = ({ currentUser }: { currentUser: User }) => ( <> - {!currentUser.roles.includes(Role.UH) ? ( - ) : ( - )} diff --git a/ui/src/app/(home)/page.tsx b/ui/src/app/(home)/page.tsx index 6e8bf4e0..656f7fe9 100644 --- a/ui/src/app/(home)/page.tsx +++ b/ui/src/app/(home)/page.tsx @@ -1,13 +1,13 @@ import Image from 'next/image'; import BeforeLogin from '@/app/(home)/_components/before-login'; import AfterLogin from '@/app/(home)/_components/after-login'; -import { getCurrentUser } from '@/access/authentication'; -import Role from '@/access/role'; +import Role from '@/lib/access/role'; import LoginButton from '@/app/(home)/_components/login-button'; import Announcements from '@/app/(home)/_components/announcements'; +import { getUser } from '@/lib/access/user'; const Home = async () => { - const currentUser = await getCurrentUser(); + const currentUser = await getUser(); return (
diff --git a/ui/src/app/api/cas/[client]/route.ts b/ui/src/app/api/cas/[client]/route.ts new file mode 100644 index 00000000..ef38845f --- /dev/null +++ b/ui/src/app/api/cas/[client]/route.ts @@ -0,0 +1,5 @@ +import { loadUser } from '@/lib/access/user'; +import { ValidatorProtocol } from 'next-cas-client'; +import { handleAuth } from 'next-cas-client/app'; + +export const GET = handleAuth({ loadUser, validator: ValidatorProtocol.SAML11 }); diff --git a/ui/src/app/api/cas/login/route.ts b/ui/src/app/api/cas/login/route.ts deleted file mode 100644 index cf7d8e4f..00000000 --- a/ui/src/app/api/cas/login/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { redirect } from 'next/navigation'; -import type { NextRequest } from 'next/server'; -import { handleLogin } from '@/access/authentication'; - -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 deleted file mode 100644 index 6fb133e5..00000000 --- a/ui/src/app/api/cas/logout/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { redirect } from 'next/navigation'; -import { handleLogout } from '@/access/authentication'; - -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/feedback/_components/feedback-form.tsx b/ui/src/app/feedback/_components/feedback-form.tsx index 33f34dc6..6b78f4f4 100644 --- a/ui/src/app/feedback/_components/feedback-form.tsx +++ b/ui/src/app/feedback/_components/feedback-form.tsx @@ -7,7 +7,7 @@ import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { sendFeedback } from '@/lib/actions'; -import User from '@/access/user'; +import User from '@/lib/access/user'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useState } from 'react'; diff --git a/ui/src/app/feedback/page.tsx b/ui/src/app/feedback/page.tsx index 41dcaf8b..c84544f8 100644 --- a/ui/src/app/feedback/page.tsx +++ b/ui/src/app/feedback/page.tsx @@ -1,8 +1,8 @@ +import { getUser } from '@/lib/access/user'; import FeedbackForm from '@/app/feedback/_components/feedback-form'; -import { getCurrentUser } from '@/access/authentication'; const Feedback = async () => { - const currentUser = await getCurrentUser(); + const currentUser = await getUser(); return (
diff --git a/ui/src/components/layout/navbar/login-button.tsx b/ui/src/components/layout/navbar/login-button.tsx index 508ce91f..526caea1 100644 --- a/ui/src/components/layout/navbar/login-button.tsx +++ b/ui/src/components/layout/navbar/login-button.tsx @@ -1,34 +1,26 @@ 'use client'; import { Button } from '@/components/ui/button'; -import Role from '@/access/role'; -import User from '@/access/user'; -import { login, logout } from '@/access/authentication'; +import Role from '@/lib/access/role'; +import User from '@/lib/access/user'; +import { login, logout } from 'next-cas-client'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSignInAlt, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'; - -const LoginButton = ({ - currentUser -}: { - currentUser: User; -}) => ( +const LoginButton = ({ currentUser }: { currentUser: User }) => ( <> {!currentUser.roles.includes(Role.UH) ? ( - ) : ( - )} - + ); export default LoginButton; diff --git a/ui/src/components/layout/navbar/navbar-links.ts b/ui/src/components/layout/navbar/navbar-links.ts index de2c535f..4fb2ca23 100644 --- a/ui/src/components/layout/navbar/navbar-links.ts +++ b/ui/src/components/layout/navbar/navbar-links.ts @@ -1,4 +1,4 @@ -import Role from '@/access/role'; +import Role from '@/lib/access/role'; export const NavbarLinks = [ { diff --git a/ui/src/components/layout/navbar/navbar-menu.tsx b/ui/src/components/layout/navbar/navbar-menu.tsx index b0f94f5e..483e605b 100644 --- a/ui/src/components/layout/navbar/navbar-menu.tsx +++ b/ui/src/components/layout/navbar/navbar-menu.tsx @@ -1,13 +1,13 @@ 'use client'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; -import User from '@/access/user'; +import { User } from 'next-cas-client'; import Link from 'next/link'; import { NavbarLinks } from './navbar-links'; import { useState } from 'react'; -import Role from '@/access/role'; +import Role from '@/lib/access/role'; import NavbarMenuIcon from './navbar-menu-icon'; -import {VisuallyHidden} from '@radix-ui/react-visually-hidden'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; const NavbarMenu = ({ currentUser }: { currentUser: User }) => { const [open, setOpen] = useState(false); @@ -21,25 +21,22 @@ const NavbarMenu = ({ currentUser }: { currentUser: User }) => {