rqzEBMn#;x67>BLt@QvUlzi~X>CusUF@xkuOl7!MgsVV?$QqCa zmAXtgYJ{M2C?Mo2Od}!ilg;9$$Il#B)q60y{{0H4H(lamOIDiJ1fYfzL6`tOm<*J! zQ`J`>*^M@RK+9i8?+huknP%S%8fxj&DQ9im6O@s^6*Em2vfiT&%p^Ov4q=Wc{|GR5 zrMCD!tqDMPbQ+@y-G=f4lFc`mfpU~B8Y2H=pM`H<ETCb% z&UN~_semR+Ayjo9Q^8MQj+6O1rnNtc8Pi 9c1(H0Sj2=)$@YET>GBxZ%{G2knhZJ@_6Ce3+_P^P~%3ecJWtN>Z+ zMH^sxbfk@`kh7Rx!d8p6B=4JmjU;AOAW}32nIr1;b^SIXw<6r1gq^`ya3+&9=GIi4 zX0$Xz^BOIhreFJLfC!EIF~X1vL@$W|AQB@{y#9r6I0VGK&wh0l|J } + disabled={disabled} sx={{ color: 'white', borderColor: 'white', diff --git a/frontend/src/components/LoadingButton.tsx b/frontend/src/components/LoadingButton.tsx new file mode 100644 index 0000000..7e7e423 --- /dev/null +++ b/frontend/src/components/LoadingButton.tsx @@ -0,0 +1,43 @@ +import { Theme } from '@emotion/react'; +import { SxProps } from '@mui/material'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; + +interface LoadingButtonProps { + loading: boolean; + onClick: () => void; + text: string; + disabled?: boolean; + sx?: SxProps ; + type?: 'button' | 'submit' | 'reset'; +} + +export default function LoadingButton({ loading, onClick, text, disabled=false, sx, type = 'button' }: LoadingButtonProps) { + + return ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/NextButton.tsx b/frontend/src/components/NextButton.tsx index 2ab09c0..3e3d244 100644 --- a/frontend/src/components/NextButton.tsx +++ b/frontend/src/components/NextButton.tsx @@ -5,11 +5,12 @@ import { Button } from '@mui/material'; * Component for rendering a button with an arrow icon indicating next action. * @returns JSX element representing the NextButton component */ -export default function NextButton() { +export default function NextButton({ disabled=false } : {disabled?: boolean}) { return ( ); + } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4e01bfe..fa2e5a3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App.tsx' import './styles/global.css' +import AuthContextProvider from './security/AuthContext.tsx' createRoot(document.getElementById('root')!).render( - ) diff --git a/frontend/src/pages/Enrolment.tsx b/frontend/src/pages/Enrolment.tsx index 995336e..1d1cf76 100644 --- a/frontend/src/pages/Enrolment.tsx +++ b/frontend/src/pages/Enrolment.tsx @@ -4,6 +4,10 @@ import UploadPopUp from "../components/UploadPopUp.tsx"; import Header from "../components/Header.tsx"; import Footer from "../components/Footer.tsx"; import Photo from "../assets/frontpage.jpg"; +import { useEffect } from "react"; +import { REMOTE_API_URL, TimetableSolution } from "../scripts/api.ts"; +import { useAuthContext } from "../security/AuthContext.tsx"; +import SkipButton from "../components/SkipButton.tsx"; /** * Renders the Starter Page component with specific time and tabler styles. @@ -20,6 +24,26 @@ export default function StarterPage() { const tablerStyle = { color: "black", }; + const { authHeader } = useAuthContext(); + useEffect(() => { + fetch(REMOTE_API_URL + "/timetabling/view", { + headers: { Authorization: authHeader }, + }) + .then((response) => { + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }) + .then((data) => { + const timetableSolutions: TimetableSolution[] = + data as TimetableSolution[]; + sessionStorage.setItem( + "campusSolutions", + JSON.stringify(timetableSolutions) + ); + }); + }, []); return (+ + + @@ -31,6 +55,7 @@ export default function StarterPage() { A timetabling website for the Victorian Institute of Technology
-Team JetEdge
+ diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..195e094 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { AuthHeader, useAuthContext } from '../security/AuthContext'; +import '../styles/login.css'; +import VIT_Logo from '../assets/logo.png'; +import { REMOTE_API_URL } from '../scripts/api'; +import LoadingButton from '../components/LoadingButton'; + +export default function LoginPage() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const { setAuthHeader } = useAuthContext(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + + const encodedHeader: AuthHeader = `Basic ${btoa(`${username}:${password}`)}`; + + try { + // Send a request to the backend to validate credentials + const response = await fetch(REMOTE_API_URL + "/login", { + method: 'GET', + headers: { + 'Authorization': encodedHeader, + }, + }); + + if (response.status != 401) { + setAuthHeader(encodedHeader); + navigate("/enrolment"); + } + else { + alert("Unauthorised"); + } + } + catch (error) { + alert(error); + } + finally { + setLoading(false); + } + + } + + return ( + + ++ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/SemesterInfo.tsx b/frontend/src/pages/SemesterInfo.tsx index 4a00ccc..816f87c 100644 --- a/frontend/src/pages/SemesterInfo.tsx +++ b/frontend/src/pages/SemesterInfo.tsx @@ -25,7 +25,7 @@ export default function SemesterInfo() { diff --git a/frontend/src/pages/SendData.tsx b/frontend/src/pages/SendData.tsx index ec90966..e96a357 100644 --- a/frontend/src/pages/SendData.tsx +++ b/frontend/src/pages/SendData.tsx @@ -7,6 +7,8 @@ import { DB_ROOMS, DB_UNITS, getSpreadsheetData } from "../scripts/persistence"; import { getTimetableProblems } from "../scripts/handleInput"; import { useState } from "react"; import { fetchTimetableSolution } from "../scripts/api"; +import { useAuthContext } from '../security/AuthContext'; +import LoadingButton from "../components/LoadingButton"; /** * Page for containing UI elements that allow user to send input data to backend. @@ -18,10 +20,14 @@ import { fetchTimetableSolution } from "../scripts/api"; */ export default function SendData() { - const [isGenerated, setIsGenerated] = useState(""); + const [loading, setLoading] = useState(false); + const { authHeader } = useAuthContext(); function generateTimetable() { - setIsGenerated(""); + setLoading(true); + setTimeout(() => { + setLoading(false); + }, 120000); Promise.all([getSpreadsheetData(DB_ROOMS), getSpreadsheetData(DB_UNITS)]) .then((responses) => { const [roomData, unitData] = [...responses]; @@ -34,28 +40,37 @@ export default function SendData() { return getTimetableProblems(roomData, unitData); }) .then((problems) => { - return Promise.all(problems.map(p => fetchTimetableSolution(p))); + return Promise.all(problems.map(p => fetchTimetableSolution(p, authHeader))); }) .then((solutions) => { console.log(solutions); - setIsGenerated(JSON.stringify(solutions, null, 2)); + // setLoading(false); }) .catch((error) => { alert(error); + // setLoading(false); }) } return ( <>- -diff --git a/frontend/src/pages/spreadsheets/Building.tsx b/frontend/src/pages/spreadsheets/Building.tsx deleted file mode 100644 index be3ccc9..0000000 --- a/frontend/src/pages/spreadsheets/Building.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Spreadsheet from '../../components/Spreadsheet.tsx' -import { DB_BUILDINGS } from '../../scripts/persistence.ts'; - -/** - * - * @returns Spreadsheet input page for buildings information. - */ -export default function Building() { - - return ( - <> -{isGenerated.toString()}++> diff --git a/frontend/src/pages/TimetableMod.tsx b/frontend/src/pages/TimetableMod.tsx index bcb7fa6..0c321bf 100644 --- a/frontend/src/pages/TimetableMod.tsx +++ b/frontend/src/pages/TimetableMod.tsx @@ -5,7 +5,8 @@ import Footer from "../components/Footer"; import BackButton from "../components/BackButton"; import { Outlet } from "react-router-dom"; import ModSidebar from "../components/ModSiderbar"; -import { TimetableSolution } from "../scripts/api"; +import { REMOTE_API_URL, TimetableSolution } from "../scripts/api"; +import { useAuthContext } from "../security/AuthContext"; /** * Renders the TimetableMod component to display and modify the generated @@ -16,9 +17,10 @@ import { TimetableSolution } from "../scripts/api"; */ export default function TimetableMod() { const [loading, setLoading] = useState(true); + const { authHeader } = useAuthContext(); useEffect(() => { - fetch("http://localhost:8080/timetabling/view") + fetch(REMOTE_API_URL + "/timetabling/view", { headers: { 'Authorization': authHeader } }) .then((response) => { if (!response.ok) { throw new Error("Network response was not ok"); @@ -54,10 +56,8 @@ export default function TimetableMod() {Building
-- > - ); -}; \ No newline at end of file diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 48d05c2..fcdc0c5 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,12 +1,14 @@ import SemesterInfo from './pages/SemesterInfo.tsx' import TimetableMod from './pages/TimetableMod.tsx' -import Building from './pages/spreadsheets/Building.tsx' import Room from './pages/spreadsheets/Room.tsx' import Unit from './pages/spreadsheets/Unit.tsx' import Download from './pages/Download.tsx' import Enrolment from './pages/Enrolment.tsx' import SendData from './pages/SendData.tsx' import GanttChart from './components/GanttChart.tsx' +import LoginPage from './pages/LoginPage.tsx' +import { Navigate } from 'react-router-dom' +import PrivateRoute from './security/PrivateRoute.tsx' /** * Defines the routes configuration for the application. * Each route specifies a path and the corresponding component to render. @@ -16,31 +18,38 @@ import GanttChart from './components/GanttChart.tsx' const routes = [ { path: "/", - element: , + element: , + }, + { + path: "/login", + element: , + }, + { + path: "enrolment", + element: } />, }, { path: "seminfo", - element: , + element: } />, children: [ - { path: "building", element: }, { path: "room", element: }, { path: "unit", element: }, ], }, { path: "senddata", - element: , + element: } />, }, { path: "timetablemod/*", - element: , + element: } />, children: [ {path: ":location", element: } ], }, { path: "download", - element: , + element: } />, }, ]; diff --git a/frontend/src/scripts/api.ts b/frontend/src/scripts/api.ts index c49eff6..ceb1357 100644 --- a/frontend/src/scripts/api.ts +++ b/frontend/src/scripts/api.ts @@ -1,5 +1,8 @@ +import { AuthHeader } from "../security/AuthContext"; + /* Timetable solver backend endpoint URL */ -const API_URL = "http://localhost:8080/timetabling"; +export const REMOTE_API_URL = "https://jetedge-backend-e1eeff4b0c04.herokuapp.com"; +export const LOCAL_API_URL = "http://localhost:8080"; /* =========================================== Defining types =========================================== */ @@ -59,19 +62,24 @@ export type Time = string; * @param problem A TimetableProblem is a list of units with no allocated time and room. * @returns A TimetableSolution with all units allocated a time and a room. */ -export async function fetchTimetableSolution(problem: TimetableProblem): Promise { +export async function fetchTimetableSolution(problem: TimetableProblem, authHeader: AuthHeader, url?: string): Promise { try { - const response = await fetch(API_URL, { + let api_url = REMOTE_API_URL; + if (url !== undefined) { + api_url = url; + } + const response = await fetch(api_url+"/timetabling", { method: 'POST', headers: { 'Content-Type': 'application/json', + 'Authorization': authHeader, }, body: JSON.stringify(problem) }); if (!response.ok) { if (response.status === 500) { - alert(response.statusText + " " + response.status + ": server was not able to solve the problem. Please check for missing input (i.e. make sure there are at least 1 available room and no rooms with duplicate ID)."); + alert(response.statusText + " " + response.status + ": server was not able to solve the problem."); } throw new Error(`HTTP error! Status: ${response.status} ${response.statusText}`); } diff --git a/frontend/src/scripts/handleInput.ts b/frontend/src/scripts/handleInput.ts index 51bf38a..81ada5f 100644 --- a/frontend/src/scripts/handleInput.ts +++ b/frontend/src/scripts/handleInput.ts @@ -12,7 +12,6 @@ import { DB_UNITS, storeSpreadsheetData } from './persistence'; function isExcelFile(file: File) { const fileExtension = file.name.split('.').pop(); if (fileExtension === undefined || !['xlsx', 'xls'].includes(fileExtension)) { - alert("Wrong file type, file type must be .xlsx or .xls"); return false; } return true; @@ -33,7 +32,6 @@ function validateEnrolmentHeader(inputHeader: Row) { return true; } else { - alert("Enrolment data header row is invalid"); return false; } } @@ -134,7 +132,9 @@ export async function prefillUnitSpreadsheet(enrolmentExcel: File) { const units = unitsMaps.map(m => { const unitsData = Array.from(m.values()); - const transformed = unitsData.map(ud => { return { ...ud, enrolment: JSON.stringify(ud.enrolment) } }); + const transformed = unitsData + .filter((ud) => ud.enrolment.length > 0) + .map(ud => { return { ...ud, enrolment: JSON.stringify(ud.enrolment) } }); return transformed; }).flat(); diff --git a/frontend/src/security/AuthContext.tsx b/frontend/src/security/AuthContext.tsx new file mode 100644 index 0000000..89286ca --- /dev/null +++ b/frontend/src/security/AuthContext.tsx @@ -0,0 +1,28 @@ +import { createContext, useState, ReactNode, useContext } from 'react'; + +export type AuthHeader = `Basic ${string}` | ''; + +type AuthContext = { + authHeader: AuthHeader; + setAuthHeader: React.Dispatch >; +}; + +const AuthContext = createContext (undefined); + +export default function AuthContextProvider({ children }: { children: ReactNode }) { + const [authHeader, setAuthHeader] = useState (''); + + return ( + + {children} + + ); +}; + +export const useAuthContext = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuthContext must be used within an AuthContextProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/security/PrivateRoute.tsx b/frontend/src/security/PrivateRoute.tsx new file mode 100644 index 0000000..14464ef --- /dev/null +++ b/frontend/src/security/PrivateRoute.tsx @@ -0,0 +1,13 @@ +import { useAuthContext } from './AuthContext'; +import { Navigate } from 'react-router-dom'; + +export default function PrivateRoute({ element }: { element: JSX.Element }) { + const { authHeader } = useAuthContext(); + + // If no credentials are set, redirect to the login page + if (authHeader === '') { + return; + } + + return element; +}; \ No newline at end of file diff --git a/frontend/src/styles/login.css b/frontend/src/styles/login.css new file mode 100644 index 0000000..721ad43 --- /dev/null +++ b/frontend/src/styles/login.css @@ -0,0 +1,79 @@ +/* Container */ +.login-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: #f9f9f9; +} + +/* Form */ +.login-form { + background-color: #fff; + padding: 2rem; + border-radius: 10px; + box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + align-items: center; +} + +/* Logo */ +.login-logo { + width: 30%; /* Adjust the size of the logo */ + height: auto; + margin-bottom: 0.5rem; /* Space between logo and title */ +} + +/* Title */ +.login-title { + color: #333; + margin-bottom: 1.5rem; + font-size: 24px; + text-align: center; +} + +/* Input Wrapper */ +.input-group { + width: 100%; + margin-bottom: 1rem; +} + +/* Input */ +.login-input { + box-sizing: border-box; + width: 100%; + padding: 12px; + border-radius: 5px; + border: 1px solid #ccc; + font-size: 16px; + transition: border-color 0.3s ease; +} + +.login-input:focus { + outline: none; + border-color: #f05a22; +} + +/* Button */ +.login-button { + background-color: #f05a22; + color: white; + border: none; + padding: 12px; + border-radius: 5px; + font-size: 16px; + cursor: pointer; + width: 100%; + transition: background-color 0.3s ease; +} + +.login-button:hover { + background-color: #e0481f; +} + +.login-button:active { + background-color: #cc3d1b; +} diff --git a/frontend/src/tests/api.test.ts b/frontend/src/tests/api.test.ts index fe9d6fe..0f26572 100644 --- a/frontend/src/tests/api.test.ts +++ b/frontend/src/tests/api.test.ts @@ -1,26 +1,29 @@ import { describe, it, expect } from 'vitest'; -import { fetchTimetableSolution, TimetableProblem } from '../scripts/api'; +import { fetchTimetableSolution, LOCAL_API_URL, TimetableProblem } from '../scripts/api'; import moment from 'moment'; +import { AuthHeader } from '../security/AuthContext'; /** * Test fetchTimetableSolution API method. * Check if connection to backend is working. * Check that output matches expected output. */ -describe('fetchTimetableSolution', { timeout: 60000 }, () => { +describe('fetchTimetableSolution', { timeout: 200000 }, () => { /** * Validate end-to-end scheduling and data consistency of 1 API method call. */ it('return TimetableSolution', async () => { const problem: TimetableProblem = { - campusName: "A", - units: [{ campus: "A", course: "B", unitId: 0, name: "Unit0", duration: 1200, students: [], wantsLab: true }], + campusName: "Geelong", + units: [{ campus: "Geelong", course: "B", unitId: 0, name: "Unit0", duration: 1200, students: [], wantsLab: true }], daysOfWeek: ["MONDAY"], startTimes: ["11:00:00"], - rooms: [{ campus: "A", buildingId: "01", roomCode: "Room A", capacity: 10, lab: true }] + rooms: [{ campus: "Geelong", buildingId: "01", roomCode: "Room A", capacity: 10, lab: true }] }; - - const solution = await fetchTimetableSolution(problem); + + const authHeader: AuthHeader = `Basic ${btoa(`${import.meta.env.VITE_FRONTEND_USERNAME}:${import.meta.env.VITE_FRONTEND_PASSWORD}`)}`; + + const solution = await fetchTimetableSolution(problem, authHeader, LOCAL_API_URL); expect(solution).not.toBeNull(); expect(solution?.units[0].dayOfWeek).toEqual(problem.daysOfWeek[0]); expect(solution?.units[0].startTime).toEqual(problem.startTimes[0]); @@ -35,15 +38,24 @@ describe('fetchTimetableSolution', { timeout: 60000 }, () => { * Validate that backend server can handle multiple solve requests concurrently. */ it ('can be called multiple times', async () => { - const problem: TimetableProblem = { - campusName: "B", - units: [{ campus: "B", course: "C", unitId: 0, name: "Unit0", duration: 1200, students: [], wantsLab: true }], + const problem0: TimetableProblem = { + campusName: "Adelaide", + units: [{ campus: "Adelaide", course: "C", unitId: 0, name: "Unit0", duration: 1200, students: [], wantsLab: true }], + daysOfWeek: ["MONDAY"], + startTimes: ["11:00:00"], + rooms: [{ campus: "Adelaide", buildingId: "02", roomCode: "Room A", capacity: 10, lab: true }] + }; + + const problem1: TimetableProblem = { + campusName: "Melbourne", + units: [{ campus: "Melbourne", course: "C", unitId: 0, name: "Unit0", duration: 1200, students: [], wantsLab: true }], daysOfWeek: ["MONDAY"], startTimes: ["11:00:00"], - rooms: [{ campus: "B", buildingId: "02", roomCode: "Room A", capacity: 10, lab: true }] + rooms: [{ campus: "Melbourne", buildingId: "02", roomCode: "Room A", capacity: 10, lab: true }] }; - const solutions = await Promise.all([fetchTimetableSolution(problem), fetchTimetableSolution(problem), fetchTimetableSolution(problem)]); + const authHeader: AuthHeader = `Basic ${btoa(`${import.meta.env.VITE_FRONTEND_USERNAME}:${import.meta.env.VITE_FRONTEND_PASSWORD}`)}`; + const solutions = await Promise.all([fetchTimetableSolution(problem0, authHeader, LOCAL_API_URL), fetchTimetableSolution(problem1, authHeader, LOCAL_API_URL)]); for (let i = 0; i < solutions.length; i++) { expect(solutions[i]).not.toBeNull();