Skip to content

Commit b2f54d6

Browse files
Merge pull request #1 from Nicolas-Arsenault/admin
added admin panel with stats dashboard
2 parents ac3cd6c + 65d61b9 commit b2f54d6

8 files changed

Lines changed: 1251 additions & 2 deletions

File tree

‎index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
<meta name="twitter:image" content="https://classmo.ca/og-image.png" />
2929

3030
<!-- Security Headers -->
31-
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://www.google-analytics.com https://www.googletagmanager.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" />
31+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://www.google-analytics.com https://www.googletagmanager.com https://api.classmo.ca; object-src 'none'; base-uri 'self'; frame-ancestors 'none';" />
3232
<meta name="referrer" content="strict-origin-when-cross-origin" />
3333

3434
<!-- Favicon & Theme -->

‎src/App.jsx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Suspense } from 'react'
2-
import { Routes, Route, Navigate } from 'react-router-dom'
2+
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
33
import Navbar from './components/Navbar'
44
import Hero from './components/Hero'
55
import Features from './components/Features'
@@ -12,6 +12,10 @@ import Terms from './pages/Terms'
1212
import Blog from './pages/Blog'
1313
import BlogPost from './pages/blog/BlogPost'
1414
import useDocumentMeta from './hooks/useDocumentMeta'
15+
import { AuthProvider } from './admin/context/AuthContext'
16+
import AdminLogin from './admin/pages/AdminLogin'
17+
import Dashboard from './admin/pages/Dashboard'
18+
import ProtectedRoute from './admin/components/ProtectedRoute'
1519

1620
function LandingPage() {
1721
useDocumentMeta({
@@ -32,6 +36,22 @@ function LandingPage() {
3236
}
3337

3438
export default function App() {
39+
const location = useLocation()
40+
41+
if (location.pathname.startsWith('/admin')) {
42+
return (
43+
<AuthProvider>
44+
<Routes>
45+
<Route path="/admin/login" element={<AdminLogin />} />
46+
<Route path="/admin/dashboard" element={
47+
<ProtectedRoute><Dashboard /></ProtectedRoute>
48+
} />
49+
<Route path="/admin/*" element={<Navigate to="/admin/login" replace />} />
50+
</Routes>
51+
</AuthProvider>
52+
)
53+
}
54+
3555
return (
3656
<>
3757
<Navbar />
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Navigate } from 'react-router-dom'
2+
import { useAuth } from '../context/AuthContext'
3+
4+
export default function ProtectedRoute({ children }) {
5+
const { isAuthenticated, isLoading } = useAuth()
6+
7+
if (isLoading) return null
8+
if (!isAuthenticated) return <Navigate to="/admin/login" replace />
9+
10+
return children
11+
}

‎src/admin/context/AuthContext.jsx‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { createContext, useContext, useState, useEffect } from 'react'
2+
3+
const AuthContext = createContext(null)
4+
5+
const STORAGE_KEYS = {
6+
accessToken: 'classmo_access_token',
7+
refreshToken: 'classmo_refresh_token',
8+
user: 'classmo_user',
9+
}
10+
11+
const API_URL = import.meta.env.VITE_API_URL
12+
const ADMIN_ROLE = import.meta.env.VITE_ADMIN_ROLE
13+
14+
export function AuthProvider({ children }) {
15+
const [accessToken, setAccessToken] = useState(null)
16+
const [refreshToken, setRefreshToken] = useState(null)
17+
const [user, setUser] = useState(null)
18+
const [isLoading, setIsLoading] = useState(true)
19+
20+
useEffect(() => {
21+
const storedToken = localStorage.getItem(STORAGE_KEYS.accessToken)
22+
const storedRefresh = localStorage.getItem(STORAGE_KEYS.refreshToken)
23+
const storedUser = localStorage.getItem(STORAGE_KEYS.user)
24+
25+
if (storedToken && storedUser) {
26+
try {
27+
const parsed = JSON.parse(storedUser)
28+
if (parsed.role === ADMIN_ROLE) {
29+
setAccessToken(storedToken)
30+
setRefreshToken(storedRefresh)
31+
setUser(parsed)
32+
} else {
33+
localStorage.removeItem(STORAGE_KEYS.accessToken)
34+
localStorage.removeItem(STORAGE_KEYS.refreshToken)
35+
localStorage.removeItem(STORAGE_KEYS.user)
36+
}
37+
} catch {
38+
localStorage.removeItem(STORAGE_KEYS.user)
39+
}
40+
}
41+
setIsLoading(false)
42+
}, [])
43+
44+
async function login(email, password) {
45+
try {
46+
const res = await fetch(`${API_URL}/api/auth/login`, {
47+
method: 'POST',
48+
headers: { 'Content-Type': 'application/json' },
49+
body: JSON.stringify({ email, password }),
50+
})
51+
if (!res.ok) {
52+
const text = await res.text()
53+
let message = 'Identifiants invalides'
54+
try {
55+
const json = JSON.parse(text)
56+
if (json.message) message = json.message
57+
} catch { /* use default */ }
58+
return { success: false, error: message }
59+
}
60+
61+
const data = await res.json()
62+
63+
if (data.role !== ADMIN_ROLE) {
64+
return { success: false, error: 'Accès réservé aux administrateurs.' }
65+
}
66+
67+
const userData = {
68+
userId: data.userId,
69+
email: data.email,
70+
fullName: data.fullName,
71+
role: data.role,
72+
}
73+
74+
localStorage.setItem(STORAGE_KEYS.accessToken, data.accessToken)
75+
localStorage.setItem(STORAGE_KEYS.refreshToken, data.refreshToken)
76+
localStorage.setItem(STORAGE_KEYS.user, JSON.stringify(userData))
77+
78+
setAccessToken(data.accessToken)
79+
setRefreshToken(data.refreshToken)
80+
setUser(userData)
81+
82+
return { success: true }
83+
} catch {
84+
return { success: false, error: 'Erreur réseau. Vérifie ta connexion.' }
85+
}
86+
}
87+
88+
function logout() {
89+
localStorage.removeItem(STORAGE_KEYS.accessToken)
90+
localStorage.removeItem(STORAGE_KEYS.refreshToken)
91+
localStorage.removeItem(STORAGE_KEYS.user)
92+
setAccessToken(null)
93+
setRefreshToken(null)
94+
setUser(null)
95+
}
96+
97+
return (
98+
<AuthContext.Provider value={{
99+
accessToken,
100+
refreshToken,
101+
user,
102+
isAuthenticated: !!accessToken,
103+
isLoading,
104+
login,
105+
logout,
106+
}}>
107+
{children}
108+
</AuthContext.Provider>
109+
)
110+
}
111+
112+
export function useAuth() {
113+
const ctx = useContext(AuthContext)
114+
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
115+
return ctx
116+
}

‎src/admin/pages/AdminLogin.jsx‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useState } from 'react'
2+
import { Navigate, useNavigate } from 'react-router-dom'
3+
import { Mail, Lock, LogIn, AlertCircle } from 'lucide-react'
4+
import { useAuth } from '../context/AuthContext'
5+
import '../styles/admin-login.css'
6+
7+
export default function AdminLogin() {
8+
const { login, isAuthenticated } = useAuth()
9+
const navigate = useNavigate()
10+
const [email, setEmail] = useState('')
11+
const [password, setPassword] = useState('')
12+
const [error, setError] = useState('')
13+
const [loading, setLoading] = useState(false)
14+
15+
if (isAuthenticated) return <Navigate to="/admin/dashboard" replace />
16+
17+
async function handleSubmit(e) {
18+
e.preventDefault()
19+
setError('')
20+
setLoading(true)
21+
22+
const result = await login(email, password)
23+
24+
if (result.success) {
25+
navigate('/admin/dashboard')
26+
} else {
27+
setError(result.error)
28+
setLoading(false)
29+
}
30+
}
31+
32+
return (
33+
<div className="admin-login">
34+
<div className="admin-login__card">
35+
<div className="admin-login__header">
36+
<img
37+
src="/classmo-icon.png"
38+
alt="Classmo"
39+
className="admin-login__logo"
40+
/>
41+
<h1>Classmo Admin</h1>
42+
<p>Connecte-toi pour accéder au tableau de bord</p>
43+
</div>
44+
45+
{error && (
46+
<div className="admin-login__error">
47+
<AlertCircle size={18} />
48+
<span>{error}</span>
49+
</div>
50+
)}
51+
52+
<form onSubmit={handleSubmit} className="admin-login__form">
53+
<div className="admin-login__field">
54+
<label htmlFor="email">Courriel</label>
55+
<div className="admin-login__input-wrapper">
56+
<Mail size={18} className="admin-login__icon" />
57+
<input
58+
id="email"
59+
type="email"
60+
value={email}
61+
onChange={(e) => setEmail(e.target.value)}
62+
placeholder="courriel@classmo.ca"
63+
required
64+
autoComplete="email"
65+
/>
66+
</div>
67+
</div>
68+
69+
<div className="admin-login__field">
70+
<label htmlFor="password">Mot de passe</label>
71+
<div className="admin-login__input-wrapper">
72+
<Lock size={18} className="admin-login__icon" />
73+
<input
74+
id="password"
75+
type="password"
76+
value={password}
77+
onChange={(e) => setPassword(e.target.value)}
78+
placeholder="••••••••"
79+
required
80+
autoComplete="current-password"
81+
/>
82+
</div>
83+
</div>
84+
85+
<button
86+
type="submit"
87+
className="admin-login__submit"
88+
disabled={loading}
89+
>
90+
{loading ? (
91+
<span>Connexion...</span>
92+
) : (
93+
<>
94+
<LogIn size={18} />
95+
<span>Se connecter</span>
96+
</>
97+
)}
98+
</button>
99+
</form>
100+
</div>
101+
</div>
102+
)
103+
}

0 commit comments

Comments
 (0)