diff --git a/client/src/assets/notanglesWithBg.png b/client/src/assets/notanglesWithBg.png new file mode 100644 index 000000000..85bf6dbb2 Binary files /dev/null and b/client/src/assets/notanglesWithBg.png differ diff --git a/client/src/components/landingPage/HeroSection/HeroSection.tsx b/client/src/components/landingPage/HeroSection/HeroSection.tsx index 33b53afed..8c5196a49 100644 --- a/client/src/components/landingPage/HeroSection/HeroSection.tsx +++ b/client/src/components/landingPage/HeroSection/HeroSection.tsx @@ -1,14 +1,19 @@ import { NavigateNext } from '@mui/icons-material'; - +import { useNavigate } from 'react-router-dom'; import notangles from '../../../assets/notangles_1.png'; import { FlipWords } from '../flip-words'; +import { useAuth } from '../../../hooks/useAuth'; -const handleStartClick = () => { - localStorage.setItem('visited', 'true'); - window.location.href = '/'; -}; +const HeroSection = ({ handleStartClick }: { handleStartClick: () => void }) => { + const { loggedIn } = useAuth(); + const navigate = useNavigate(); + + // const handleStartClick = () => { + // // TODO: Phase out the visited item + // localStorage.setItem('visited', 'true'); + // navigate('/home', { replace: true }); + // }; -const HeroSection = () => { const words = ['plan', 'create', 'organise', 'optimise', 'design']; return ( @@ -35,7 +40,9 @@ const HeroSection = () => { className="flex justify-center items-center shadow-[0_4px_14px_0_rgb(0,118,255,39%)] hover:shadow-[0_6px_20px_rgba(0,118,255,23%)] hover:bg-[rgba(0,118,255,0.9)] hover:scale-105 px-6 sm:px-8 py-2 sm:py-3 bg-[#0070f3] rounded-3xl text-white font-light transition duration-200 ease-linear mt-5" onClick={handleStartClick} > -

Start

+

+ {loggedIn ? 'Goto Timetable' : 'Get Started'} +

diff --git a/client/src/components/landingPage/LandingPage.tsx b/client/src/components/landingPage/LandingPage.tsx index f1f3abaef..34cc189da 100644 --- a/client/src/components/landingPage/LandingPage.tsx +++ b/client/src/components/landingPage/LandingPage.tsx @@ -1,36 +1,52 @@ +import { useState } from 'react'; import notangles from '../../assets/notangles_1.png'; +import { useAuth } from '../../hooks/useAuth'; +import AuthModal, { AuthModalProps } from '../login/AuthModal'; import FeedbackSection from './FeedbackSection'; import Footer from './Footer'; import HeroSection from './HeroSection/HeroSection'; import FeaturesSection from './KeyFeaturesSection/FeaturesSection'; import ScrollingFeaturesSection from './ScrollingFeaturesSection'; import SponsorsSection from './SponsorsSection'; +import { useNavigate } from 'react-router-dom'; +import { API_URL } from '../../api/config'; const LandingPage = () => { + const [authModalOpen, setAuthModalOpen] = useState(false); + const { loading } = useAuth(); + const onSignIn: AuthModalProps['onSignIn'] = (provider) => { + localStorage.setItem('visited', 'true'); + setAuthModalOpen(false); + window.location.href = `${API_URL.server}/auth/login/${provider}`; + }; + return ( -
-
-
- -

Notangles

+ <> +
+
+
+ +

Notangles

+
+
+
+ setAuthModalOpen(true)} />
-
-
- -
-
-
- - +
+
+ + +
+
+ +
+ + {/* Sticky Footer */} +
- -
- - {/* Sticky Footer */} -
-
-
+ setAuthModalOpen(false)} loading={loading} onSignIn={onSignIn} /> + ); }; diff --git a/client/src/components/login/AuthGuard.tsx b/client/src/components/login/AuthGuard.tsx new file mode 100644 index 000000000..02f3c3bef --- /dev/null +++ b/client/src/components/login/AuthGuard.tsx @@ -0,0 +1,11 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../../hooks/useAuth'; +import PageLoading from '../pageLoading/PageLoading'; + +export function AuthGuard({ children }: { children: JSX.Element }) { + const { loading, loggedIn } = useAuth(); + + if (loading) return ; + if (!loggedIn) return ; + return children; +} diff --git a/client/src/components/login/AuthModal.tsx b/client/src/components/login/AuthModal.tsx new file mode 100644 index 000000000..74e564d9f --- /dev/null +++ b/client/src/components/login/AuthModal.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { Dialog, DialogContent, Grid, Box, Typography, Button, IconButton, Skeleton, useTheme } from '@mui/material'; +import GitHubIcon from '@mui/icons-material/GitHub'; +import GoogleIcon from '@mui/icons-material/Google'; +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import CloseIcon from '@mui/icons-material/Close'; + +export interface AuthModalProps { + open: boolean; + onClose: () => void; + onSignIn: (provider: 'devsoc' | 'google' | 'github' | 'guest') => void; + loading?: boolean; +} + +export default function LoginDialog({ open, onClose, onSignIn, loading = false }: AuthModalProps) { + return ( + + + + + + + + {/* ◀ Illustration Panel */} + + + + notangles + + timetable planner + + + + {/* ▶ Form Panel */} + + {loading ? ( + <> + {/* Header skeletons */} + + + + {/* Button skeletons */} + + + + + {/* Guest text skeleton */} + + + ) : ( + <> + + Welcome back + + + Sign in to continue + + + + + + + + + + + )} + + + + + ); +} diff --git a/client/src/components/pageLoading/PageLoading.tsx b/client/src/components/pageLoading/PageLoading.tsx new file mode 100644 index 000000000..fea7ef1ad --- /dev/null +++ b/client/src/components/pageLoading/PageLoading.tsx @@ -0,0 +1,33 @@ +import { keyframes, styled } from '@mui/system'; +import logo from '../../assets/notanglesWithBg.png'; + +const PageWrapper = styled('div')` + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +`; + +const pulse = keyframes` + 0% { + box-shadow: 0 0 0 0px rgba(84, 72, 91, 0.2); + } + 100% { + box-shadow: 0 0 0 40px rgba(17, 3, 52, 0); + } +`; + +const LoadingLogo = styled('img')` + width: 200px; + border-radius: 50%; + animation: ${pulse} 1.5s infinite; +`; + +const PageLoading = () => ( + + + +); + +export default PageLoading; diff --git a/client/src/hooks/useAuth.tsx b/client/src/hooks/useAuth.tsx new file mode 100644 index 000000000..cd6ab5af0 --- /dev/null +++ b/client/src/hooks/useAuth.tsx @@ -0,0 +1,39 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import { UserInfo } from '../interfaces/User'; +import { API_URL } from '../api/config'; + +type AuthContextType = { + loading: boolean; + loggedIn: boolean; + user: UserInfo | null; +}; + +const AuthContext = createContext({ + loading: true, + loggedIn: false, + user: null, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState({ + loading: true, + loggedIn: false, + user: null, + }); + + useEffect(() => { + fetch(`${API_URL.server}/user/profile`, { credentials: 'include' }) + .then((res) => { + if (!res.ok) throw new Error('not logged in'); + return res.json(); + }) + .then((data) => setState({ loading: false, loggedIn: true, user: data })) + .catch(() => setState({ loading: false, loggedIn: false, user: null })); + }, []); + + return {children}; +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/client/src/index.tsx b/client/src/index.tsx index 650d38859..f4af9b1a0 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -15,6 +15,8 @@ import LandingPage from './components/landingPage/LandingPage'; import AppContextProvider from './context/AppContext'; import CourseContextProvider from './context/CourseContext'; import * as swRegistration from './serviceWorkerRegistration'; +import { AuthGuard } from './components/login/AuthGuard'; +import { AuthProvider } from './hooks/useAuth'; Sentry.init({ dsn: import.meta.env.VITE_APP_SENTRY_INGEST_CLIENT, @@ -23,26 +25,30 @@ Sentry.init({ }); const Root: React.FC = () => { - const hasVisited = localStorage.getItem('visited'); - return ( - - - - - - {hasVisited ? ( - } path="/"> - } /> - - ) : ( + + + + + + } path="/" /> - )} - - - - - + + + + } + path="/home" + > + } /> + + + + + + + ); }; diff --git a/client/src/interfaces/User.ts b/client/src/interfaces/User.ts new file mode 100644 index 000000000..831b0befc --- /dev/null +++ b/client/src/interfaces/User.ts @@ -0,0 +1,6 @@ +export type UserInfo = { + id: string; + firstName: string; + lastName: string; + profilePictureUrl?: string; +}; diff --git a/server/package.json b/server/package.json index 654d4b2bd..551b832a8 100644 --- a/server/package.json +++ b/server/package.json @@ -91,5 +91,5 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" }, - "packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184" + "packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad" } diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index 3209d65d6..e3ddc59ac 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -4,13 +4,16 @@ import { Response } from 'express'; @Controller('auth') export class AuthController { - @Get('login') + @Get('login/devsoc') @UseGuards(AuthGuard('oidc')) login() {} @Get('callback/devsoc') @UseGuards(AuthGuard('oidc')) callback(@Res() res: Response) { - res.redirect('http://localhost:3001/api/user/profile'); + res.redirect( + (process.env.NODE_ENV === 'dev' ? `http://` : `https://`) + + `${process.env.CLIENT_HOST_NAME}:${process.env.CLIENT_HOST_PORT}`, + ); } }