Skip to content

Commit 5799c27

Browse files
dsshimelclaude
andcommitted
add React Router v7 with URL-based routing for login, student, and instructor views
Replaces tab-based state navigation in App.tsx with react-router declarative routes: / (login), /student (student portal), /instructor (admin dashboard). Auth state extracted into AuthProvider context with useAuth() hook. RootLayout handles role-based redirects and guards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a1f754a commit 5799c27

File tree

10 files changed

+253
-147
lines changed

10 files changed

+253
-147
lines changed

attendabot/frontend/CLAUDE.md

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ bun run lint # Run ESLint
1515

1616
```
1717
src/
18-
├── main.tsx # React entry point
19-
├── App.tsx # Root component with tab navigation and auth
18+
├── main.tsx # React entry point (BrowserRouter + AuthProvider + Routes)
2019
├── App.css # Main styles
2120
├── api/
2221
│ └── client.ts # API client (fetch wrapper, cookie auth, all endpoints)
2322
├── lib/
2423
│ └── auth-client.ts # BetterAuth client for Discord OAuth
2524
├── components/
25+
│ ├── RootLayout.tsx # Route layout with auth-based redirects
2626
│ ├── Login.tsx # Discord OAuth login page
2727
│ ├── StudentTable.tsx # Sortable student list with observer dropdown
2828
│ ├── StudentDetail.tsx # Individual student view with summary
@@ -40,17 +40,26 @@ src/
4040
│ ├── TestingPanel.tsx # Test briefings and EOD previews
4141
│ └── Sidebar.tsx # Navigation sidebar
4242
├── hooks/
43+
│ ├── useAuth.tsx # Auth context, provider, and useAuth() hook
4344
│ └── useWebSocket.ts # WebSocket connection hook (cookie auth)
45+
├── pages/
46+
│ ├── LoginPage.tsx # Login route (/)
47+
│ ├── StudentPage.tsx # Student portal route (/student)
48+
│ └── InstructorPage.tsx # Instructor dashboard route (/instructor)
4449
└── utils/
4550
└── linkify.tsx # URL detection and linking
4651
```
4752

48-
## Key Components
53+
## Routing
54+
55+
Uses **React Router v7** (declarative mode) with three routes:
56+
- `/` — Login page (redirects to dashboard if already logged in)
57+
- `/student` — Student portal (role-gated)
58+
- `/instructor` — Instructor admin dashboard (role-gated)
4959

50-
### App.tsx
51-
- Root component managing auth state and tab navigation
52-
- Tabs: Students, Observers, Messages, Testing, Configuration
53-
- Auth state determined by BetterAuth `getSession()` (Discord OAuth)
60+
`RootLayout` handles auth-based redirects. `AuthProvider` (in `useAuth.tsx`) provides auth state via context. The `loading` state stays `true` until both `getSession()` and `getMe()` resolve, preventing redirect flicker.
61+
62+
## Key Components
5463

5564
### StudentTable.tsx
5665
- Displays students in selected cohort with sortable columns
@@ -116,10 +125,11 @@ Authentication uses **BetterAuth** with Discord OAuth. There is no JWT or passwo
116125

117126
## State Management
118127

119-
- **Local state**: useState/useEffect throughout
120-
- **Auth**: BetterAuth session cookies (no localStorage tokens)
128+
- **Auth state**: `AuthProvider` context (`useAuth()` hook) — provides `loggedIn`, `role`, `username`, `sessionInvalid`, `logout()`, etc.
129+
- **Local state**: useState/useEffect throughout for component-specific data
130+
- **Auth cookies**: BetterAuth session cookies (no localStorage tokens)
121131
- **Username display**: Stored in localStorage for display only (not for auth)
122-
- **No global store**: Each component fetches its own data
132+
- **No global store**: Each component fetches its own data (except auth which is in context)
123133

124134
## API Client (api/client.ts)
125135

@@ -179,7 +189,7 @@ const { logs, status, clearLogs } = useWebSocket();
179189
return <div>...</div>;
180190
}
181191
```
182-
3. Import and add to App.tsx or parent component
192+
3. Import and add to the appropriate page component or add a new route
183193

184194
## Adding a New API Endpoint
185195

@@ -198,7 +208,6 @@ const { logs, status, clearLogs } = useWebSocket();
198208

199209
- **Auth redirects**: fetchWithAuth triggers `onAuthFailure` callback on 401/403, which shows session expired warning
200210
- **Vite proxy**: Only works in dev mode; production serves from backend static files
201-
- **No React Router**: Navigation is tab-based within App.tsx, not URL-based
202211
- **Styling**: CSS in App.css, no CSS modules or styled-components
203212
- **Two-click delete pattern**: Used for destructive actions (delete student, delete note). Click shows "Confirm?", click again deletes, blur resets.
204-
- **Role loading race condition**: App.tsx gates on `role` before rendering dashboards. If `role` is `null` (still loading from `getMe()`), a loading screen is shown — NOT the instructor dashboard. Rendering instructor components (e.g., `StudentCohortPanel`) before role is known causes 403s on instructor-only endpoints (`/api/cohorts`, `/api/observers`) which triggers the session expired banner for students.
213+
- **Role loading race condition**: `AuthProvider` waits for both `getSession()` and `getMe()` to resolve before setting `loading=false`. This prevents `RootLayout` from redirecting before the role is known, which would cause instructor components to mount for students and trigger 403 errors.

attendabot/frontend/bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

attendabot/frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"better-auth": "^1.4.18",
1414
"react": "^19.2.0",
1515
"react-dom": "^19.2.0",
16-
"react-markdown": "^10.1.0"
16+
"react-markdown": "^10.1.0",
17+
"react-router": "^7.13.0"
1718
},
1819
"devDependencies": {
1920
"@eslint/js": "^9.39.1",

attendabot/frontend/src/components/Login.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,8 @@
55
import { useState } from "react";
66
import { authClient } from "../lib/auth-client";
77

8-
/** Props for the Login component. */
9-
interface LoginProps {
10-
onLogin: () => void;
11-
}
12-
138
/** Login page with Discord OAuth button. */
14-
export function Login({ onLogin: _onLogin }: LoginProps) {
9+
export function Login() {
1510
const [error, setError] = useState<string | null>(null);
1611
const [loading, setLoading] = useState(false);
1712

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @fileoverview Root layout with auth-based redirects.
3+
*/
4+
5+
import { Outlet, Navigate, useLocation } from "react-router";
6+
import { useAuth } from "../hooks/useAuth";
7+
8+
/** Layout that handles auth redirects and renders child routes via Outlet. */
9+
export function RootLayout() {
10+
const { loading, loggedIn, role, logout } = useAuth();
11+
const location = useLocation();
12+
13+
if (loading) {
14+
return (
15+
<div className="app">
16+
<header>
17+
<h1>Attendabot</h1>
18+
</header>
19+
<main><p>Loading...</p></main>
20+
</div>
21+
);
22+
}
23+
24+
// Not logged in and not on login page → redirect to login
25+
if (!loggedIn && location.pathname !== "/") {
26+
return <Navigate to="/" replace />;
27+
}
28+
29+
// Logged in but role couldn't be determined (getMe() failed)
30+
if (loggedIn && !role && location.pathname !== "/") {
31+
return (
32+
<div className="app">
33+
<header>
34+
<h1>Attendabot</h1>
35+
<div className="header-right">
36+
<button onClick={logout} className="logout-btn">Logout</button>
37+
</div>
38+
</header>
39+
<main><p>Loading...</p></main>
40+
</div>
41+
);
42+
}
43+
44+
// Logged in and on login page → redirect to role-appropriate dashboard
45+
if (loggedIn && location.pathname === "/") {
46+
if (!role) return <Outlet />; // role unknown, stay on login page
47+
const target = role === "student" ? "/student" : "/instructor";
48+
return <Navigate to={target} replace />;
49+
}
50+
51+
// Logged in but on wrong dashboard for their role
52+
if (loggedIn && role === "student" && location.pathname.startsWith("/instructor")) {
53+
return <Navigate to="/student" replace />;
54+
}
55+
if (loggedIn && role === "instructor" && location.pathname.startsWith("/student")) {
56+
return <Navigate to="/instructor" replace />;
57+
}
58+
59+
return <Outlet />;
60+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* @fileoverview Auth context and provider. Extracts all auth state from App.tsx.
3+
*/
4+
5+
import { createContext, useContext, useState, useEffect } from "react";
6+
import type { ReactNode } from "react";
7+
import { setUsername as storeUsername, clearSession, onAuthFailure, getMe } from "../api/client";
8+
import type { MeResponse } from "../api/client";
9+
import { authClient } from "../lib/auth-client";
10+
11+
interface AuthState {
12+
loading: boolean;
13+
loggedIn: boolean;
14+
username: string | null;
15+
role: "instructor" | "student" | null;
16+
sessionInvalid: boolean;
17+
me: MeResponse | null;
18+
studentCohortDates: { startDate?: string; endDate?: string };
19+
logout: () => Promise<void>;
20+
}
21+
22+
const AuthContext = createContext<AuthState | null>(null);
23+
24+
/** Provides auth state to the component tree. */
25+
export function AuthProvider({ children }: { children: ReactNode }) {
26+
const [loading, setLoading] = useState(true);
27+
const [loggedIn, setLoggedIn] = useState(false);
28+
const [username, setUsername] = useState<string | null>(null);
29+
const [role, setRole] = useState<"instructor" | "student" | null>(null);
30+
const [sessionInvalid, setSessionInvalid] = useState(false);
31+
const [me, setMe] = useState<MeResponse | null>(null);
32+
const [studentCohortDates, setStudentCohortDates] = useState<{ startDate?: string; endDate?: string }>({});
33+
34+
useEffect(() => {
35+
// Check for BetterAuth session (Discord OAuth) then fetch role
36+
authClient.getSession().then(async (result) => {
37+
if (result.data?.user) {
38+
const name = result.data.user.name || result.data.user.email || "Discord User";
39+
storeUsername(name);
40+
setLoggedIn(true);
41+
setUsername(name);
42+
43+
// Fetch role before clearing loading state to prevent redirect flicker
44+
const meData = await getMe();
45+
if (meData) {
46+
setRole(meData.role);
47+
setMe(meData);
48+
if (meData.role === "student") {
49+
setStudentCohortDates({
50+
startDate: meData.cohortStartDate,
51+
endDate: meData.cohortEndDate,
52+
});
53+
}
54+
}
55+
}
56+
setLoading(false);
57+
});
58+
59+
const unsubscribe = onAuthFailure(() => {
60+
setSessionInvalid(true);
61+
});
62+
63+
return unsubscribe;
64+
}, []);
65+
66+
const logout = async () => {
67+
try {
68+
await authClient.signOut();
69+
} catch {
70+
// Ignore errors if no BetterAuth session exists
71+
}
72+
clearSession();
73+
setLoggedIn(false);
74+
setSessionInvalid(false);
75+
setUsername(null);
76+
setRole(null);
77+
setMe(null);
78+
};
79+
80+
return (
81+
<AuthContext.Provider value={{ loading, loggedIn, username, role, sessionInvalid, me, studentCohortDates, logout }}>
82+
{children}
83+
</AuthContext.Provider>
84+
);
85+
}
86+
87+
/** Returns the current auth state. Must be used within an AuthProvider. */
88+
export function useAuth(): AuthState {
89+
const ctx = useContext(AuthContext);
90+
if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
91+
return ctx;
92+
}

attendabot/frontend/src/main.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
11
import { StrictMode } from 'react'
22
import { createRoot } from 'react-dom/client'
3+
import { BrowserRouter, Routes, Route } from 'react-router'
34
import './index.css'
4-
import App from './App.tsx'
5+
import './App.css'
6+
import { AuthProvider } from './hooks/useAuth'
7+
import { RootLayout } from './components/RootLayout'
8+
import { LoginPage } from './pages/LoginPage'
9+
import { StudentPage } from './pages/StudentPage'
10+
import { InstructorPage } from './pages/InstructorPage'
511

612
createRoot(document.getElementById('root')!).render(
713
<StrictMode>
8-
<App />
14+
<BrowserRouter>
15+
<AuthProvider>
16+
<Routes>
17+
<Route element={<RootLayout />}>
18+
<Route path="/" element={<LoginPage />} />
19+
<Route path="/student" element={<StudentPage />} />
20+
<Route path="/instructor" element={<InstructorPage />} />
21+
</Route>
22+
</Routes>
23+
</AuthProvider>
24+
</BrowserRouter>
925
</StrictMode>,
1026
)

0 commit comments

Comments
 (0)