Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 28 additions & 25 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,35 +23,38 @@ import { JEPostView } from './pages/admin/JEPostView';
import { AdminPostView } from './pages/admin/AdminPostView';
import { GuestRoute } from './components/GuestRoute';
import { NotFound } from './pages/NotFound';
import { AuthProvider } from './context/AuthContext';

function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/faculty/signup" element={<FacultySignup />} />
<Route path="/warden/signup" element={<WardenSignup />} />
<Route path="/centre-head/signup" element={<CentreHeadSignup />} />
<Route path="/faculty/login" element={<GuestRoute><FacultyLogin /></GuestRoute>} />
<Route path="/warden/login" element={<GuestRoute><WardenLogin /></GuestRoute>} />
<Route path="/centre-head/login" element={<GuestRoute><CentreHeadLogin /></GuestRoute>} />
<Route path="/faculty/forgot-password" element={<FacultyForgotPassword />} />
<Route path="/warden/forgot-password" element={<WardenForgotPassword />} />
<Route path="/centre-head/forgot-password" element={<CentreHeadForgotPassword />} />
<Route path="/account/reset-password" element={<AccountResetPass />} />
<Route path="/staff/login" element={<GuestRoute><StaffLogin /></GuestRoute>} />
<Route path="/faculty/posts" element={<FacultyPost />} />
<Route path="/warden/posts" element={<WardenPost />} />
<Route path="/centre-head/posts" element={<CentreHeadPost />} />
<Route path="/posts/:role/:post_id" element={<PostView />} />
<Route path="/account/verify" element={<VerifyAccount />} />
<Route path="/profile" element={<Profile />} />
<Route path="/admin/xen" element={<XENPostView />} />
<Route path="/admin/ae" element={<AEPostView />} />
<Route path="/admin/je" element={<JEPostView />} />
<Route path="/admin/posts/:role/:post_id" element={<AdminPostView />} />
<Route path="*" element={<NotFound />} />
</Routes>
<AuthProvider>
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/faculty/signup" element={<FacultySignup />} />
<Route path="/warden/signup" element={<WardenSignup />} />
<Route path="/centre-head/signup" element={<CentreHeadSignup />} />
<Route path="/faculty/login" element={<GuestRoute><FacultyLogin /></GuestRoute>} />
<Route path="/warden/login" element={<GuestRoute><WardenLogin /></GuestRoute>} />
<Route path="/centre-head/login" element={<GuestRoute><CentreHeadLogin /></GuestRoute>} />
<Route path="/faculty/forgot-password" element={<FacultyForgotPassword />} />
<Route path="/warden/forgot-password" element={<WardenForgotPassword />} />
<Route path="/centre-head/forgot-password" element={<CentreHeadForgotPassword />} />
<Route path="/account/reset-password" element={<AccountResetPass />} />
<Route path="/staff/login" element={<GuestRoute><StaffLogin /></GuestRoute>} />
<Route path="/faculty/posts" element={<FacultyPost />} />
<Route path="/warden/posts" element={<WardenPost />} />
<Route path="/centre-head/posts" element={<CentreHeadPost />} />
<Route path="/posts/:role/:post_id" element={<PostView />} />
<Route path="/account/verify" element={<VerifyAccount />} />
<Route path="/profile" element={<Profile />} />
<Route path="/admin/xen" element={<XENPostView />} />
<Route path="/admin/ae" element={<AEPostView />} />
<Route path="/admin/je" element={<JEPostView />} />
<Route path="/admin/posts/:role/:post_id" element={<AdminPostView />} />
<Route path="*" element={<NotFound />} />
</Routes>
</AuthProvider>
</Router>
);
}
Expand Down
20 changes: 4 additions & 16 deletions app/src/components/GuestRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,24 @@
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { Loader } from './Loader';
import { useAuth } from '../context/auth-context';

interface GuestRouteProps {
children: ReactNode;
}

export function GuestRoute({ children }: GuestRouteProps) {
const [isAuth, setIsAuth] = useState<boolean | null>(null);
const { status } = useAuth();

useEffect(() => {
fetch('/api/profile', { credentials: 'include' })
.then(res => {
if (!res.ok) {
setIsAuth(false);
return;
}
setIsAuth(true);
})
.catch(() => setIsAuth(false));
}, []);

if (isAuth === null) {
if (status === 'loading') {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<Loader size="lg" color="dark" />
</div>
);
}

if (isAuth) {
if (status === 'authenticated') {
return <Navigate to="/profile" replace />;
}

Expand Down
17 changes: 4 additions & 13 deletions app/src/components/layout/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Mail, MapPin, ChevronDown } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/auth-context';

type Profile = { department?: string; hostel?: string; building?: string } | null;

Expand All @@ -11,21 +12,11 @@ function getPostRoute(profile: NonNullable<Profile>): string {
}

export function Footer() {
const [profile, setProfile] = useState<Profile>(null);
const [isAuth, setIsAuth] = useState<boolean | null>(null);
const { profile, status } = useAuth();
const isAuth = status === 'authenticated';
const [lodgeOpen, setLodgeOpen] = useState(false);
const navigate = useNavigate();

useEffect(() => {
fetch('/api/profile', { credentials: 'include' })
.then(res => {
if (!res.ok) { setIsAuth(false); return null; }
return res.json();
})
.then(data => { if (data) { setProfile(data); setIsAuth(true); } })
.catch(() => setIsAuth(false));
}, []);

function handleLodgeComplaintClick() {
if (isAuth && profile) {
navigate(getPostRoute(profile));
Expand Down
15 changes: 4 additions & 11 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { ChevronDown, Menu, X, User, LogOut } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../context/auth-context';

export function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false);
const [lodgeOpen, setLodgeOpen] = useState(false);
const [adminOpen, setAdminOpen] = useState(false);
const [loginDropdownOpen, setLoginDropdownOpen] = useState(false);
const [isAuth, setIsAuth] = useState<boolean | null>(null);

useEffect(() => {
fetch('/api/profile', { credentials: 'include' })
.then(res => {
if (!res.ok) { setIsAuth(false); return; }
setIsAuth(true);
})
.catch(() => setIsAuth(false));
}, []);
const { status } = useAuth();
const isAuth = status === 'loading' ? null : status === 'authenticated';

const closeMobile = () => {
setMobileOpen(false);
Expand Down
56 changes: 56 additions & 0 deletions app/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { AuthContext } from './auth-context';
import type { AuthStatus, ProfileData } from './auth-context';

// AuthProvider fetches /api/profile exactly once for the whole app and shares
// the result via context. Every component that previously fetched /api/profile
// on its own (Navbar, Footer, Landing, GuestRoute, Profile) reads from here
// instead, so a single page load makes one request, not five.
export function AuthProvider({ children }: { children: ReactNode }) {
const [profile, setProfile] = useState<ProfileData | null>(null);
const [status, setStatus] = useState<AuthStatus>('loading');
const [errorMessage, setErrorMessage] = useState<string | null>(null);

const refetch = useCallback(() => {
setStatus('loading');
setErrorMessage(null);
fetch('/api/profile', { credentials: 'include' })
.then(async (res) => {
if (res.status === 429) {
const b = await res.json().catch(() => ({}));
setErrorMessage(b.error ?? "You've hit the rate limit. Please try again in a moment.");
setStatus('rate-limited');
return null;
}
if (!res.ok) {
setProfile(null);
setStatus('unauthenticated');
return null;
}
return res.json();
})
.then((data) => {
if (data) {
setProfile(data);
setStatus('authenticated');
}
})
.catch(() => {
setErrorMessage('Failed to reach the server.');
setStatus('error');
});
}, []);

const patchProfile = useCallback((patch: Partial<ProfileData>) => {
setProfile((prev) => prev ? { ...prev, ...patch } : prev);
}, []);

useEffect(() => { refetch(); }, [refetch]);

return (
<AuthContext.Provider value={{ profile, status, errorMessage, refetch, patchProfile }}>
{children}
</AuthContext.Provider>
);
}
34 changes: 34 additions & 0 deletions app/src/context/auth-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createContext, useContext } from 'react';

export interface ProfileData {
name?: string;
email?: string;
is_verified?: boolean;
phone_number?: string;
department?: string;
house_number?: string;
block?: string;
type?: string;
hostel?: string;
building?: string;
}

export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated' | 'rate-limited' | 'error';

export interface AuthState {
profile: ProfileData | null;
status: AuthStatus;
errorMessage: string | null;
/** Re-fetches /api/profile from scratch. */
refetch: () => void;
/** Merges a patch into the cached profile without hitting the network again. */
patchProfile: (patch: Partial<ProfileData>) => void;
}

export const AuthContext = createContext<AuthState | undefined>(undefined);

export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
return ctx;
}
15 changes: 3 additions & 12 deletions app/src/pages/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { ArrowRight } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { MainLayout } from '../components/layout/MainLayout';
import { useAuth } from '../context/auth-context';

type Profile = { department?: string; hostel?: string; building?: string } | null;

Expand All @@ -12,24 +13,14 @@ function getPostRoute(profile: NonNullable<Profile>): string {
}

export function Landing() {
const [profile, setProfile] = useState<Profile>(null);
const [isAuth, setIsAuth] = useState<boolean | null>(null);
const { profile, status } = useAuth();
const isAuth = status === 'loading' ? null : status === 'authenticated';
const [showLoginMenu, setShowLoginMenu] = useState(false);
const [showSignupMenu, setShowSignupMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const signupMenuRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();

useEffect(() => {
fetch('/api/profile', { credentials: 'include' })
.then(res => {
if (!res.ok) { setIsAuth(false); return null; }
return res.json();
})
.then(data => { if (data) { setProfile(data); setIsAuth(true); } })
.catch(() => setIsAuth(false));
}, []);

useEffect(() => {
function handleClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
Expand Down
3 changes: 2 additions & 1 deletion app/src/pages/post/PostView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ export function PostView() {
try {
const res = await fetch(`/api/posts/${role}/${post_id}`, { credentials: 'include' });
if (!res.ok) {
throw new Error(`Failed to fetch post details (${res.status})`);
const b = await res.json().catch(() => ({}));
throw new Error(b.error ?? `Failed to fetch post details (${res.status})`);
}
const data = await res.json();
setPost(data.post);
Expand Down
Loading
Loading