Browse Source
- Add login and register pages with form validation and file upload support - Implement AuthContext for global authentication state management - Create ProtectedRoute component for route-based authentication - Update Navbar to show user profile and authentication status - Add media controller debugging and fix file serving paths - Fix nginx configuration for proper API proxying and client-side routing - Update Home page to display personalized content for authenticated users - Integrate profile picture display in navigation bar Technical improvements: - Fix nginx rule priority to handle API routes before static assets - Add proper ES modules support in media controller - Implement JWT token persistence with localStorage - Add loading states and error handling throughout auth flowfix/clean
25 changed files with 816 additions and 71 deletions
@ -0,0 +1,23 @@ |
|||
up: |
|||
docker compose up --build -d |
|||
|
|||
down: |
|||
docker compose down |
|||
|
|||
logs: |
|||
docker compose logs -f |
|||
|
|||
dev: |
|||
docker compose -f developpement.yaml up --build -d |
|||
|
|||
dev-down: |
|||
docker compose -f developpement.yaml down |
|||
|
|||
dev-logs: |
|||
docker compose -f developpement.yaml logs -f |
|||
|
|||
dev-volumes: |
|||
docker compose -f developpement.yaml down -v |
|||
|
|||
dev-build: |
|||
docker compose -f developpement.yaml build |
|||
@ -0,0 +1,57 @@ |
|||
import path from 'path'; |
|||
import { fileURLToPath } from 'url'; |
|||
|
|||
const __filename = fileURLToPath(import.meta.url); |
|||
const __dirname = path.dirname(__filename); |
|||
|
|||
export async function getProfilePicture(req, res) { |
|||
const file = req.params.file; |
|||
|
|||
// Try different path approaches
|
|||
const possiblePaths = [ |
|||
path.join(__dirname, '../../uploads/profiles', file), |
|||
path.join(process.cwd(), 'app/uploads/profiles', file), |
|||
path.join('/app/app/uploads/profiles', file), |
|||
path.join('/app/uploads/profiles', file) |
|||
]; |
|||
|
|||
console.log('Possible paths:', possiblePaths); |
|||
console.log('Current working directory:', process.cwd()); |
|||
console.log('__dirname:', __dirname); |
|||
|
|||
// Try the most likely path first (based on your volume mapping)
|
|||
const filePath = path.join('/app/app/uploads/profiles', file); |
|||
|
|||
try { |
|||
res.sendFile(filePath, (err) => { |
|||
if (err) { |
|||
console.error("Error sending profile picture:", err); |
|||
res.status(404).json({ error: "Profile picture not found." }); |
|||
} else { |
|||
console.log("Profile picture sent successfully."); |
|||
} |
|||
}); |
|||
} catch (error) { |
|||
console.error("Error fetching profile picture:", error); |
|||
res.status(500).json({ error: "Internal server error while fetching profile picture." }); |
|||
} |
|||
} |
|||
|
|||
export async function getThumbnail(req, res) { |
|||
const file = req.params.file; |
|||
const filePath = path.join('/app/app/uploads/thumbnails', file); |
|||
|
|||
try { |
|||
res.sendFile(filePath, (err) => { |
|||
if (err) { |
|||
console.error("Error sending thumbnail:", err); |
|||
res.status(404).json({ error: "Thumbnail not found." }); |
|||
} else { |
|||
console.log("Thumbnail sent successfully."); |
|||
} |
|||
}); |
|||
} catch (error) { |
|||
console.error("Error fetching thumbnail:", error); |
|||
res.status(500).json({ error: "Internal server error while fetching thumbnail." }); |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
import {Router} from 'express'; |
|||
import {getProfilePicture, getThumbnail} from "../controllers/media.controller.js"; |
|||
|
|||
const router = Router(); |
|||
|
|||
router.get("/profile/:file", getProfilePicture); |
|||
|
|||
//router.get("/video/:file", getVideo);
|
|||
|
|||
router.get("/thumbnail/:file", getThumbnail); |
|||
|
|||
export default router; |
|||
@ -1,8 +1,10 @@ |
|||
import { Router } from 'express'; |
|||
import {getRecommendations} from "../controllers/recommendation.controller.js"; |
|||
import {getRecommendations, getTrendingVideos} from "../controllers/recommendation.controller.js"; |
|||
|
|||
const router = Router(); |
|||
|
|||
router.get('/', [], getRecommendations); |
|||
|
|||
router.get('/trending', [], getTrendingVideos); |
|||
|
|||
export default router; |
|||
@ -0,0 +1,11 @@ |
|||
### GET PROFILE PICTURE (through nginx) |
|||
GET http://localhost/api/media/profile/sacha.jpg |
|||
|
|||
### GET PROFILE PICTURE (direct backend - for testing) |
|||
GET http://localhost:8000/api/media/profile/sacha.jpg |
|||
|
|||
### GET THUMBNAIL (through nginx) |
|||
GET http://localhost/api/media/thumbnail/E90B982DE9C5112C.jpg |
|||
|
|||
### GET THUMBNAIL (direct backend - for testing) |
|||
GET http://localhost:8000/api/media/thumbnail/E90B982DE9C5112C.jpg |
|||
@ -1,2 +1,5 @@ |
|||
### GET NON-AUTHENTICATED RECOMMENDATIONS |
|||
GET http://localhost:8000/api/recommendations/ |
|||
GET http://localhost:8000/api/recommendations/ |
|||
|
|||
### GET TRENDING VIDS |
|||
GET http://localhost:8000/api/recommendations/trending/ |
|||
@ -1,13 +1,28 @@ |
|||
import React from 'react'; |
|||
import { useRoutes } from 'react-router-dom'; |
|||
import { AuthProvider, useAuth } from './contexts/AuthContext'; |
|||
import routes from './routes/routes.jsx'; |
|||
|
|||
const App = () => { |
|||
const AppContent = () => { |
|||
const { loading } = useAuth(); |
|||
const routing = useRoutes(routes); |
|||
|
|||
if (loading) { |
|||
return ( |
|||
<div className="min-h-screen bg-linear-to-br from-left-gradient to-right-gradient flex items-center justify-center"> |
|||
<div className="text-white text-2xl font-montserrat">Chargement...</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
return <div>{routing}</div>; |
|||
}; |
|||
|
|||
const App = () => { |
|||
return ( |
|||
<div> |
|||
{routing} |
|||
</div> |
|||
<AuthProvider> |
|||
<AppContent /> |
|||
</AuthProvider> |
|||
); |
|||
}; |
|||
|
|||
|
|||
@ -0,0 +1,27 @@ |
|||
import React from 'react'; |
|||
import { Navigate } from 'react-router-dom'; |
|||
import { useAuth } from '../contexts/AuthContext'; |
|||
|
|||
const ProtectedRoute = ({ children, requireAuth = true }) => { |
|||
const { isAuthenticated, loading } = useAuth(); |
|||
|
|||
if (loading) { |
|||
return ( |
|||
<div className="min-h-screen flex items-center justify-center"> |
|||
<div className="text-white text-xl">Chargement...</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
if (requireAuth && !isAuthenticated) { |
|||
return <Navigate to="/login" replace />; |
|||
} |
|||
|
|||
if (!requireAuth && isAuthenticated) { |
|||
return <Navigate to="/" replace />; |
|||
} |
|||
|
|||
return children; |
|||
}; |
|||
|
|||
export default ProtectedRoute; |
|||
@ -0,0 +1,112 @@ |
|||
import React, { createContext, useContext, useState, useEffect } from 'react'; |
|||
|
|||
const AuthContext = createContext(); |
|||
|
|||
export const useAuth = () => { |
|||
const context = useContext(AuthContext); |
|||
if (!context) { |
|||
throw new Error('useAuth must be used within an AuthProvider'); |
|||
} |
|||
return context; |
|||
}; |
|||
|
|||
export const AuthProvider = ({ children }) => { |
|||
const [user, setUser] = useState(null); |
|||
const [loading, setLoading] = useState(true); |
|||
|
|||
// Check if user is logged in on app start |
|||
useEffect(() => { |
|||
const token = localStorage.getItem('token'); |
|||
const userData = localStorage.getItem('user'); |
|||
|
|||
if (token && userData) { |
|||
setUser(JSON.parse(userData)); |
|||
} |
|||
setLoading(false); |
|||
}, []); |
|||
|
|||
const login = async (username, password) => { |
|||
try { |
|||
const response = await fetch('/api/users/login', { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
body: JSON.stringify({ username, password }), |
|||
}); |
|||
|
|||
const data = await response.json(); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error(data.message || 'Erreur de connexion'); |
|||
} |
|||
|
|||
// Store token and user data |
|||
localStorage.setItem('token', data.token); |
|||
localStorage.setItem('user', JSON.stringify(data.user)); |
|||
setUser(data.user); |
|||
|
|||
return data; |
|||
} catch (error) { |
|||
throw error; |
|||
} |
|||
}; |
|||
|
|||
const register = async (email, username, password, profileImage) => { |
|||
try { |
|||
const formData = new FormData(); |
|||
formData.append('email', email); |
|||
formData.append('username', username); |
|||
formData.append('password', password); |
|||
|
|||
if (profileImage) { |
|||
formData.append('profile', profileImage); |
|||
} |
|||
|
|||
const response = await fetch('/api/users/', { |
|||
method: 'POST', |
|||
body: formData, |
|||
}); |
|||
|
|||
const data = await response.json(); |
|||
|
|||
if (!response.ok) { |
|||
throw new Error(data.message || 'Erreur lors de la création du compte'); |
|||
} |
|||
|
|||
// After successful registration, log the user in |
|||
await login(username, password); |
|||
|
|||
return data; |
|||
} catch (error) { |
|||
throw error; |
|||
} |
|||
}; |
|||
|
|||
const logout = () => { |
|||
localStorage.removeItem('token'); |
|||
localStorage.removeItem('user'); |
|||
setUser(null); |
|||
}; |
|||
|
|||
const getAuthHeaders = () => { |
|||
const token = localStorage.getItem('token'); |
|||
return token ? { Authorization: `Bearer ${token}` } : {}; |
|||
}; |
|||
|
|||
const value = { |
|||
user, |
|||
login, |
|||
register, |
|||
logout, |
|||
getAuthHeaders, |
|||
isAuthenticated: !!user, |
|||
loading |
|||
}; |
|||
|
|||
return ( |
|||
<AuthContext.Provider value={value}> |
|||
{children} |
|||
</AuthContext.Provider> |
|||
); |
|||
}; |
|||
@ -0,0 +1,107 @@ |
|||
import React, { useState } from 'react'; |
|||
import { useNavigate } from 'react-router-dom'; |
|||
import { useAuth } from '../contexts/AuthContext'; |
|||
import Navbar from '../components/Navbar'; |
|||
|
|||
export default function Login() { |
|||
const [formData, setFormData] = useState({ |
|||
username: '', |
|||
password: '' |
|||
}); |
|||
const [error, setError] = useState(''); |
|||
const [loading, setLoading] = useState(false); |
|||
|
|||
const navigate = useNavigate(); |
|||
const { login } = useAuth(); |
|||
|
|||
const handleChange = (e) => { |
|||
setFormData({ |
|||
...formData, |
|||
[e.target.name]: e.target.value |
|||
}); |
|||
}; |
|||
|
|||
const handleSubmit = async (e) => { |
|||
e.preventDefault(); |
|||
setError(''); |
|||
setLoading(true); |
|||
|
|||
try { |
|||
await login(formData.username, formData.password); |
|||
navigate('/'); |
|||
} catch (err) { |
|||
setError(err.message || 'Erreur de connexion'); |
|||
} finally { |
|||
setLoading(false); |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient"> |
|||
<Navbar isSearchPage={false} /> |
|||
|
|||
<div className="flex justify-center items-center min-h-screen pt-20"> |
|||
<div className="bg-white p-8 rounded-lg shadow-lg w-full max-w-md"> |
|||
<h2 className="text-3xl font-bold text-center mb-6 font-montserrat">Connexion</h2> |
|||
|
|||
{error && ( |
|||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> |
|||
{error} |
|||
</div> |
|||
)} |
|||
|
|||
<form onSubmit={handleSubmit} className="space-y-4"> |
|||
<div> |
|||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1"> |
|||
Nom d'utilisateur |
|||
</label> |
|||
<input |
|||
type="text" |
|||
id="username" |
|||
name="username" |
|||
value={formData.username} |
|||
onChange={handleChange} |
|||
required |
|||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" |
|||
placeholder="Entrez votre nom d'utilisateur" |
|||
/> |
|||
</div> |
|||
|
|||
<div> |
|||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1"> |
|||
Mot de passe |
|||
</label> |
|||
<input |
|||
type="password" |
|||
id="password" |
|||
name="password" |
|||
value={formData.password} |
|||
onChange={handleChange} |
|||
required |
|||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" |
|||
placeholder="Entrez votre mot de passe" |
|||
/> |
|||
</div> |
|||
|
|||
<button |
|||
type="submit" |
|||
disabled={loading} |
|||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 font-montserrat" |
|||
> |
|||
{loading ? 'Connexion...' : 'Se connecter'} |
|||
</button> |
|||
</form> |
|||
|
|||
<div className="mt-6 text-center"> |
|||
<p className="text-gray-600"> |
|||
Pas encore de compte ?{' '} |
|||
<a href="/register" className="text-blue-600 hover:underline"> |
|||
Créer un compte |
|||
</a> |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,195 @@ |
|||
import React, { useState } from 'react'; |
|||
import { useNavigate } from 'react-router-dom'; |
|||
import { useAuth } from '../contexts/AuthContext'; |
|||
import Navbar from '../components/Navbar'; |
|||
|
|||
export default function Register() { |
|||
const [formData, setFormData] = useState({ |
|||
email: '', |
|||
username: '', |
|||
password: '', |
|||
confirmPassword: '', |
|||
profile: null |
|||
}); |
|||
const [error, setError] = useState(''); |
|||
const [loading, setLoading] = useState(false); |
|||
const [previewImage, setPreviewImage] = useState(null); |
|||
|
|||
const navigate = useNavigate(); |
|||
const { register } = useAuth(); |
|||
|
|||
const handleChange = (e) => { |
|||
if (e.target.name === 'profile') { |
|||
const file = e.target.files[0]; |
|||
setFormData({ |
|||
...formData, |
|||
profile: file |
|||
}); |
|||
|
|||
// Create preview |
|||
if (file) { |
|||
const reader = new FileReader(); |
|||
reader.onload = (e) => setPreviewImage(e.target.result); |
|||
reader.readAsDataURL(file); |
|||
} else { |
|||
setPreviewImage(null); |
|||
} |
|||
} else { |
|||
setFormData({ |
|||
...formData, |
|||
[e.target.name]: e.target.value |
|||
}); |
|||
} |
|||
}; |
|||
|
|||
const handleSubmit = async (e) => { |
|||
e.preventDefault(); |
|||
setError(''); |
|||
|
|||
// Validation |
|||
if (formData.password !== formData.confirmPassword) { |
|||
setError('Les mots de passe ne correspondent pas'); |
|||
return; |
|||
} |
|||
|
|||
if (formData.password.length < 6) { |
|||
setError('Le mot de passe doit contenir au moins 6 caractères'); |
|||
return; |
|||
} |
|||
|
|||
setLoading(true); |
|||
|
|||
try { |
|||
await register(formData.email, formData.username, formData.password, formData.profile); |
|||
navigate('/'); |
|||
} catch (err) { |
|||
setError(err.message || 'Erreur lors de la création du compte'); |
|||
} finally { |
|||
setLoading(false); |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient"> |
|||
<Navbar isSearchPage={false} /> |
|||
|
|||
<div className="flex justify-center items-center min-h-screen pt-20 pb-10"> |
|||
<div className="bg-white p-8 rounded-lg shadow-lg w-full max-w-md"> |
|||
<h2 className="text-3xl font-bold text-center mb-6 font-montserrat">Créer un compte</h2> |
|||
|
|||
{error && ( |
|||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> |
|||
{error} |
|||
</div> |
|||
)} |
|||
|
|||
<form onSubmit={handleSubmit} className="space-y-4"> |
|||
<div> |
|||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1"> |
|||
Email |
|||
</label> |
|||
<input |
|||
type="email" |
|||
id="email" |
|||
name="email" |
|||
value={formData.email} |
|||
onChange={handleChange} |
|||
required |
|||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" |
|||
placeholder="Entrez votre email" |
|||
/> |
|||
</div> |
|||
|
|||
<div> |
|||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1"> |
|||
Nom d'utilisateur |
|||
</label> |
|||
<input |
|||
type="text" |
|||
id="username" |
|||
name="username" |
|||
value={formData.username} |
|||
onChange={handleChange} |
|||
required |
|||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" |
|||
placeholder="Entrez votre nom d'utilisateur" |
|||
/> |
|||
</div> |
|||
|
|||
<div> |
|||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1"> |
|||
Mot de passe |
|||
</label> |
|||
<input |
|||
type="password" |
|||
id="password" |
|||
name="password" |
|||
value={formData.password} |
|||
onChange={handleChange} |
|||
required |
|||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" |
|||
placeholder="Entrez votre mot de passe" |
|||
/> |
|||
</div> |
|||
|
|||
<div> |
|||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1"> |
|||
Confirmer le mot de passe |
|||
</label> |
|||
<input |
|||
type="password" |
|||
id="confirmPassword" |
|||
name="confirmPassword" |
|||
value={formData.confirmPassword} |
|||
onChange={handleChange} |
|||
required |
|||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" |
|||
placeholder="Confirmez votre mot de passe" |
|||
/> |
|||
</div> |
|||
|
|||
<div> |
|||
<label htmlFor="profile" className="block text-sm font-medium text-gray-700 mb-1"> |
|||
Photo de profil (optionnel) |
|||
</label> |
|||
<input |
|||
type="file" |
|||
id="profile" |
|||
name="profile" |
|||
accept="image/*" |
|||
onChange={handleChange} |
|||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" |
|||
/> |
|||
{previewImage && ( |
|||
<div className="mt-2"> |
|||
<img |
|||
src={previewImage} |
|||
alt="Aperçu" |
|||
className="w-20 h-20 object-cover rounded-full mx-auto" |
|||
/> |
|||
</div> |
|||
)} |
|||
</div> |
|||
|
|||
<button |
|||
type="submit" |
|||
disabled={loading} |
|||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 font-montserrat" |
|||
> |
|||
{loading ? 'Création du compte...' : 'Créer un compte'} |
|||
</button> |
|||
</form> |
|||
|
|||
<div className="mt-6 text-center"> |
|||
<p className="text-gray-600"> |
|||
Déjà un compte ?{' '} |
|||
<a href="/login" className="text-blue-600 hover:underline"> |
|||
Se connecter |
|||
</a> |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -1,7 +1,26 @@ |
|||
import Home from '../pages/Home.jsx' |
|||
import Login from '../pages/Login.jsx' |
|||
import Register from '../pages/Register.jsx' |
|||
import ProtectedRoute from '../components/ProtectedRoute.jsx' |
|||
|
|||
const routes = [ |
|||
{ path: "/", element: <Home/> }, |
|||
{ |
|||
path: "/login", |
|||
element: ( |
|||
<ProtectedRoute requireAuth={false}> |
|||
<Login /> |
|||
</ProtectedRoute> |
|||
) |
|||
}, |
|||
{ |
|||
path: "/register", |
|||
element: ( |
|||
<ProtectedRoute requireAuth={false}> |
|||
<Register /> |
|||
</ProtectedRoute> |
|||
) |
|||
}, |
|||
] |
|||
|
|||
export default routes; |
|||
Loading…
Reference in new issue