nice
This commit is contained in:
parent
a2fda03471
commit
663268bbe9
94
README.md
94
README.md
@ -1,22 +1,46 @@
|
|||||||
# Template Django + React + MySQL
|
# Template Django + React + MySQL
|
||||||
|
|
||||||
Un squelette réutilisable inspiré de l'organisation de GoodDrive, **sans ses fonctionnalités** :
|
Un squelette réutilisable inspiré de l'organisation de GoodDrive, **sans ses fonctionnalités** :
|
||||||
Django REST Framework, React/Vite, Tailwind 4, composants shadcn/ui, MySQL 8 et Docker Compose.
|
Django REST Framework, React/Vite, Tailwind 4, composants shadcn/ui, Oxlint, MySQL 8 et Docker Compose.
|
||||||
|
|
||||||
## Démarrage rapide
|
## Démarrage rapide
|
||||||
|
|
||||||
1. Compléter les secrets du `.env` (ou les générer avec `python scripts/init_env.py`).
|
1. Compléter les secrets du `.env` (ou les générer avec `python scripts/init_env.py`).
|
||||||
2. Modifier si nécessaire `FRONTEND_PORT`, `BACKEND_PORT`, `ALLOWED_HOSTS` et les origines autorisées.
|
2. Modifier si nécessaire `FRONTEND_PORT`, `BACKEND_PORT`, `ALLOWED_HOSTS` et les origines autorisées.
|
||||||
3. Démarrer :
|
3. Démarrer avec le script principal :
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python scripts/init_env.py
|
chmod +x start.sh
|
||||||
docker compose up -d --build
|
./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`start.sh` initialise le `.env`, puis détecte Docker Compose. Si Docker est disponible,
|
||||||
|
il construit et démarre les trois services. Sinon, il lance **Django et React dans le même
|
||||||
|
environnement** (mode Pterodactyl, avec une base MySQL externe déjà accessible).
|
||||||
|
|
||||||
|
Tu peux choisir explicitement :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./start.sh --compose # Docker Compose
|
||||||
|
./start.sh --standalone # Un seul environnement, sans Docker
|
||||||
|
```
|
||||||
|
|
||||||
|
En mode standalone, Python 3, Node.js/npm et les bibliothèques nécessaires à `mysqlclient`
|
||||||
|
doivent être disponibles. Le script installe les dépendances Python manquantes, installe
|
||||||
|
les dépendances React et compile le frontend avant de lancer les deux serveurs.
|
||||||
|
La variable `MYSQL_HOST=db` du modèle fonctionne **uniquement en mode Compose** :
|
||||||
|
en mode standalone, indique l'adresse réelle de ton serveur MySQL dans `.env`.
|
||||||
|
|
||||||
|
Le mode standalone utilise `vite preview` pour servir le build et le proxy API ; pour
|
||||||
|
un site public à forte charge, préfère le mode Docker Compose avec Nginx.
|
||||||
|
|
||||||
|
Le script ne télécharge ni n'écrase automatiquement le dépôt Git.
|
||||||
|
|
||||||
|
|
||||||
- **Frontend** : http://localhost:1028 (ou `FRONTEND_PORT`)
|
- **Frontend** : http://localhost:1028 (ou `FRONTEND_PORT`)
|
||||||
- **Backend** : http://localhost:1029/api/health/ (ou `BACKEND_PORT`)
|
- **Backend** : http://localhost:1029/api/health/ (ou `BACKEND_PORT`)
|
||||||
- **Admin Django** : http://localhost:1028/admin/
|
- **Administration React (shadcn/ui)** : http://localhost:1028/admin/
|
||||||
|
- **Admin Django technique** : http://localhost:1028/django-admin/
|
||||||
|
|
||||||
Au premier lancement, `ensure_admin` crée le compte à partir de `ADMIN_*`. Si le compte existe déjà, il **ne change pas son mot de passe** à chaque redémarrage.
|
Au premier lancement, `ensure_admin` crée le compte à partir de `ADMIN_*`. Si le compte existe déjà, il **ne change pas son mot de passe** à chaque redémarrage.
|
||||||
|
|
||||||
@ -29,6 +53,7 @@ Au premier lancement, `ensure_admin` crée le compte à partir de `ADMIN_*`. Si
|
|||||||
├── .env # paramètres locaux, non versionné
|
├── .env # paramètres locaux, non versionné
|
||||||
├── .env.example # modèle sans secrets
|
├── .env.example # modèle sans secrets
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
|
├── start.sh # Démarrage Compose ou standalone / Pterodactyl
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ └── init_env.py
|
│ └── init_env.py
|
||||||
├── backend/
|
├── backend/
|
||||||
@ -43,17 +68,29 @@ Au premier lancement, `ensure_admin` crée le compte à partir de `ADMIN_*`. Si
|
|||||||
│ └── common/
|
│ └── common/
|
||||||
│ ├── urls.py
|
│ ├── urls.py
|
||||||
│ ├── views.py
|
│ ├── views.py
|
||||||
|
│ ├── admin_api.py # API sécurisée pour le tableau de bord React
|
||||||
│ └── management/commands/ensure_admin.py
|
│ └── management/commands/ensure_admin.py
|
||||||
└── frontend/
|
└── frontend/
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── nginx.conf
|
├── nginx.conf
|
||||||
├── package.json
|
├── package.json
|
||||||
|
├── .oxlintrc.json # Oxlint : JS/JSX, React et accessibilité
|
||||||
├── components.json
|
├── components.json
|
||||||
├── vite.config.js
|
├── vite.config.js
|
||||||
└── src/
|
└── src/
|
||||||
├── App.jsx
|
├── App.jsx # sélection des pages
|
||||||
├── index.css
|
├── index.css
|
||||||
└── components/ui/
|
├── pages/ # HomePage, AdminPage
|
||||||
|
├── hooks/ # logique React et états réutilisables
|
||||||
|
├── services/ # appels HTTP centralisés, AUCUN fetch dans JSX
|
||||||
|
│ ├── apiClient.js
|
||||||
|
│ ├── healthService.js
|
||||||
|
│ ├── authService.js
|
||||||
|
│ └── adminService.js
|
||||||
|
└── components/
|
||||||
|
├── admin/ # widgets de la page d'administration
|
||||||
|
├── layout/ # composants partagés
|
||||||
|
└── ui/ # composants shadcn/ui
|
||||||
```
|
```
|
||||||
|
|
||||||
## Origines CORS/CSRF et ports automatiques
|
## Origines CORS/CSRF et ports automatiques
|
||||||
@ -97,17 +134,30 @@ configuré si une autre application appelle directement l'API exposée sur le po
|
|||||||
- `db` : MySQL 8.4 avec volume `db_data` (non exposé sur le réseau hôte).
|
- `db` : MySQL 8.4 avec volume `db_data` (non exposé sur le réseau hôte).
|
||||||
- `backend` : Django + Gunicorn sur le port interne 8000, publié sur `BACKEND_PORT`.
|
- `backend` : Django + Gunicorn sur le port interne 8000, publié sur `BACKEND_PORT`.
|
||||||
- `frontend` : React compilé puis servi par Nginx sur le port interne 80, publié sur `FRONTEND_PORT`.
|
- `frontend` : React compilé puis servi par Nginx sur le port interne 80, publié sur `FRONTEND_PORT`.
|
||||||
- Nginx relaie `/api/`, `/admin/` et `/static/` vers Django.
|
- Nginx relaie `/api/`, `/django-admin/` et `/static/` vers Django. `/admin/` est une page React.
|
||||||
|
|
||||||
Le port `MYSQL_PORT` est **le port de MySQL dans le réseau des conteneurs** (3306 avec le service `db`). Pour une base distante, adapte `MYSQL_HOST` et `MYSQL_PORT`. Le service `db` reste créé par Compose dans cette version du template.
|
Le port `MYSQL_PORT` est **le port de MySQL dans le réseau des conteneurs** (3306 avec le service `db`). Pour une base distante, adapte `MYSQL_HOST` et `MYSQL_PORT`. Le service `db` reste créé par Compose dans cette version du template.
|
||||||
|
|
||||||
## Ajouter tes fonctionnalités
|
## Ajouter tes fonctionnalités
|
||||||
|
|
||||||
- Django : nouvelle application via `python manage.py startapp monapp` ; l'ajouter dans `INSTALLED_APPS` et ses URL dans `core/urls.py`.
|
- Django : nouvelle application via `python manage.py startapp monapp` ; l'ajouter dans `INSTALLED_APPS` et ses URL dans `core/urls.py`.
|
||||||
- React : nouvelles pages/composants dans `frontend/src/`.
|
- React : nouvelles pages dans `frontend/src/pages/`, hooks dans `hooks/` et appels à l’API dans `services/`. Ne pas appeler `fetch` dans les composants JSX.
|
||||||
- shadcn/ui : le fichier `components.json` est présent. Ajouter les composants via la CLI shadcn (`npx shadcn@latest add dialog`, par exemple). Le template inclut déjà Button et Card.
|
- shadcn/ui : le fichier `components.json` est présent. Ajouter les composants via la CLI shadcn (`npx shadcn@latest add dialog`, par exemple). Le template inclut Button, Card, Input, Label, Table, Badge, Dialog, Separator et Alert.
|
||||||
- Variables supplémentaires : les ajouter au `.env.example` et les lire via `decouple.config` côté Django.
|
- Variables supplémentaires : les ajouter au `.env.example` et les lire via `decouple.config` côté Django.
|
||||||
|
|
||||||
|
## Qualité du code avec Oxlint
|
||||||
|
|
||||||
|
Oxlint est inclus dans les dépendances de développement du frontend. Il contrôle le JavaScript/JSX, les règles React, les erreurs fréquentes et certaines règles d’accessibilité. Il ne remplace pas un formateur de code.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run lint # Contrôle sans modifier les fichiers
|
||||||
|
npm run lint:fix # Applique les corrections automatiques sûres disponibles
|
||||||
|
```
|
||||||
|
|
||||||
|
La configuration se trouve dans `frontend/.oxlintrc.json`. Les dossiers `dist` et `coverage` sont ignorés. Oxlint est volontairement **indépendant du démarrage** : `start.sh` ne bloque pas le lancement du site sur un avertissement de lint.
|
||||||
|
|
||||||
## Vérifier et exploiter
|
## Vérifier et exploiter
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@ -120,6 +170,8 @@ docker compose exec backend python manage.py shell -c "from django.conf import s
|
|||||||
|
|
||||||
# Tests unitaires des origines (sans Django)
|
# Tests unitaires des origines (sans Django)
|
||||||
cd backend && python -m unittest discover -s tests
|
cd backend && python -m unittest discover -s tests
|
||||||
|
# Avec une base Django de test :
|
||||||
|
python manage.py test common
|
||||||
|
|
||||||
# Stopper sans détruire MySQL
|
# Stopper sans détruire MySQL
|
||||||
docker compose down
|
docker compose down
|
||||||
@ -134,3 +186,25 @@ docker compose down
|
|||||||
- La route de diagnostic `/api/health/` est volontairement publique ; les futures routes API sont protégées par défaut dans DRF.
|
- La route de diagnostic `/api/health/` est volontairement publique ; les futures routes API sont protégées par défaut dans DRF.
|
||||||
- Sur Internet, configure HTTPS, `ALLOWED_HOSTS`, origines, cookies sécurisés et reverse proxy avant mise en ligne.
|
- Sur Internet, configure HTTPS, `ALLOWED_HOSTS`, origines, cookies sécurisés et reverse proxy avant mise en ligne.
|
||||||
- En production, évite d'exposer directement le port backend si les appels passent seulement par Nginx.
|
- En production, évite d'exposer directement le port backend si les appels passent seulement par Nginx.
|
||||||
|
|
||||||
|
## Tableau de bord administrateur (shadcn/ui)
|
||||||
|
|
||||||
|
- `/admin/` : interface React avec connexion par session Django, thème clair/sombre,
|
||||||
|
statistiques, recherche, création de comptes et activation/désactivation.
|
||||||
|
- `/django-admin/` : administration Django native conservée pour la gestion avancée.
|
||||||
|
- Seuls les comptes `is_staff` accèdent au tableau de bord. Pour **créer ou désactiver**
|
||||||
|
des comptes, il faut en plus être **superutilisateur**. Le compte connecté et les
|
||||||
|
autres superutilisateurs sont protégés contre la désactivation.
|
||||||
|
- L'API se trouve dans `backend/common/admin_api.py`. La session Django et les jetons
|
||||||
|
CSRF protègent les actions, **les secrets ne sont jamais stockés dans le frontend**.
|
||||||
|
- Le frontend parle au backend par un chemin relatif `/api/`, proxifié en Compose
|
||||||
|
comme en standalone. `frontend/src/services/apiClient.js` centralise les erreurs,
|
||||||
|
cookies et en-têtes CSRF.
|
||||||
|
- L'interface ne remplace pas les permissions fines de Django Admin pour les modèles
|
||||||
|
que tu ajouteras : la page React sert de base claire et extensible.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Le template inclut les tests `backend/common/tests.py` pour l'authentification,
|
||||||
|
les autorisations administrateur et la CSRF. Pour les lancer, une base Django de test
|
||||||
|
accessible est requise : `python manage.py test common`.
|
||||||
|
|||||||
151
backend/common/admin_api.py
Normal file
151
backend/common/admin_api.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
"""API d'administration pour l'interface React (authentification Django par session)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.contrib.auth import authenticate, get_user_model, login, logout
|
||||||
|
from django.contrib.auth.password_validation import validate_password
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.middleware.csrf import get_token
|
||||||
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
|
|
||||||
|
|
||||||
|
def _error(message, status=400, **extra):
|
||||||
|
return JsonResponse({"detail": message, **extra}, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_body(request):
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body or "{}")
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return None
|
||||||
|
return payload if isinstance(payload, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_only(request, superuser=False):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return _error("Connexion requise.", 401)
|
||||||
|
if not request.user.is_active or not request.user.is_staff:
|
||||||
|
return _error("Accès administrateur requis.", 403)
|
||||||
|
if superuser and not request.user.is_superuser:
|
||||||
|
return _error("Action réservée aux superutilisateurs.", 403)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _user_data(user):
|
||||||
|
return {
|
||||||
|
"id": user.pk,
|
||||||
|
"username": user.get_username(),
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"email": user.email,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"is_staff": user.is_staff,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"last_login": user.last_login.isoformat() if user.last_login else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
@ensure_csrf_cookie
|
||||||
|
def csrf(request):
|
||||||
|
# Prépare un cookie CSRF avant la première requête POST (connexion incluse).
|
||||||
|
get_token(request)
|
||||||
|
return JsonResponse({"detail": "CSRF prêt."})
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def me(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return _error("Connexion requise.", 401)
|
||||||
|
return JsonResponse({"user": _user_data(request.user)})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def sign_in(request):
|
||||||
|
data = _json_body(request)
|
||||||
|
if data is None:
|
||||||
|
return _error("Données JSON invalides.")
|
||||||
|
username = str(data.get("username", "")).strip()
|
||||||
|
password = data.get("password", "")
|
||||||
|
if not isinstance(password, str):
|
||||||
|
return _error("Mot de passe invalide.")
|
||||||
|
user = authenticate(request, username=username, password=password)
|
||||||
|
if user is None:
|
||||||
|
return _error("Identifiants incorrects.", 401)
|
||||||
|
if not user.is_staff:
|
||||||
|
return _error("Accès administrateur requis.", 403)
|
||||||
|
login(request, user)
|
||||||
|
return JsonResponse({"user": _user_data(user)})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def sign_out(request):
|
||||||
|
logout(request)
|
||||||
|
return JsonResponse({"detail": "Déconnexion effectuée."})
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def dashboard(request):
|
||||||
|
denied = _admin_only(request)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
User = get_user_model()
|
||||||
|
users = User.objects.all()
|
||||||
|
return JsonResponse({
|
||||||
|
"stats": {
|
||||||
|
"total": users.count(),
|
||||||
|
"active": users.filter(is_active=True).count(),
|
||||||
|
"staff": users.filter(is_staff=True).count(),
|
||||||
|
},
|
||||||
|
"users": [_user_data(user) for user in users.order_by("-date_joined", "-pk")[:200]],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def create_user(request):
|
||||||
|
denied = _admin_only(request, superuser=True)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
data = _json_body(request)
|
||||||
|
if data is None:
|
||||||
|
return _error("Données JSON invalides.")
|
||||||
|
username = str(data.get("username", "")).strip()
|
||||||
|
password = data.get("password")
|
||||||
|
email = str(data.get("email", "")).strip()
|
||||||
|
if not username or len(username) > 150:
|
||||||
|
return _error("Nom d'utilisateur invalide.")
|
||||||
|
if not isinstance(password, str) or not password:
|
||||||
|
return _error("Mot de passe requis.")
|
||||||
|
User = get_user_model()
|
||||||
|
if User.objects.filter(username__iexact=username).exists():
|
||||||
|
return _error("Ce nom d'utilisateur existe déjà.", 409)
|
||||||
|
temp_user = User(username=username, email=email)
|
||||||
|
try:
|
||||||
|
temp_user.full_clean(exclude=["password"], validate_unique=False)
|
||||||
|
validate_password(password, user=temp_user)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return _error(" ".join(exc.messages))
|
||||||
|
user = User.objects.create_user(username=username, password=password, email=email)
|
||||||
|
return JsonResponse({"user": _user_data(user)}, status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def set_user_active(request, user_id):
|
||||||
|
denied = _admin_only(request, superuser=True)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
data = _json_body(request)
|
||||||
|
if data is None or type(data.get("is_active")) is not bool:
|
||||||
|
return _error("La propriété is_active doit être un booléen.")
|
||||||
|
User = get_user_model()
|
||||||
|
try:
|
||||||
|
target = User.objects.get(pk=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return _error("Utilisateur introuvable.", 404)
|
||||||
|
if target.pk == request.user.pk or target.is_superuser:
|
||||||
|
return _error("Impossible de modifier ce compte.", 403)
|
||||||
|
target.is_active = data["is_active"]
|
||||||
|
target.save(update_fields=["is_active"])
|
||||||
|
return JsonResponse({"user": _user_data(target)})
|
||||||
54
backend/common/tests.py
Normal file
54
backend/common/tests.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""Régressions des droits admin. Lancer avec : python manage.py test common."""
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
|
class AdminApiTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.superuser = User.objects.create_superuser(username="owner", password="TestPassword2026!")
|
||||||
|
self.staff = User.objects.create_user(username="staff", password="TestPassword2026!", is_staff=True)
|
||||||
|
self.member = User.objects.create_user(username="member", password="TestPassword2026!")
|
||||||
|
|
||||||
|
def test_dashboard_rejects_anonymous_and_regular_users(self):
|
||||||
|
self.assertEqual(self.client.get(reverse("admin-dashboard")).status_code, 401)
|
||||||
|
self.client.force_login(self.member)
|
||||||
|
self.assertEqual(self.client.get(reverse("admin-dashboard")).status_code, 403)
|
||||||
|
|
||||||
|
def test_staff_can_read_but_not_write(self):
|
||||||
|
self.client.force_login(self.staff)
|
||||||
|
response = self.client.get(reverse("admin-dashboard"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["stats"]["total"], 3)
|
||||||
|
self.assertEqual(self.client.post(reverse("admin-create-user"), data='{"username":"newuser", "password":"AnotherPassword2026!"}', content_type="application/json").status_code, 403)
|
||||||
|
|
||||||
|
def test_superuser_can_create_and_disable_standard_account(self):
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
response = self.client.post(reverse("admin-create-user"), data='{"username":"newuser", "password":"AnotherPassword2026!"}', content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
created = get_user_model().objects.get(username="newuser")
|
||||||
|
self.assertFalse(created.is_staff)
|
||||||
|
response = self.client.post(reverse("admin-user-active", kwargs={"user_id": created.pk}), data='{"is_active":false}', content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
created.refresh_from_db()
|
||||||
|
self.assertFalse(created.is_active)
|
||||||
|
|
||||||
|
def test_superuser_cannot_disable_self_or_other_superusers(self):
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
response = self.client.post(reverse("admin-user-active", kwargs={"user_id": self.superuser.pk}), data='{"is_active":false}', content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_csrf_required_for_login(self):
|
||||||
|
client = Client(enforce_csrf_checks=True)
|
||||||
|
client.get(reverse("auth-csrf"))
|
||||||
|
self.assertEqual(client.post(reverse("auth-login"), data='{"username":"owner", "password":"TestPassword2026!"}', content_type="application/json").status_code, 403)
|
||||||
|
token = client.cookies["csrftoken"].value
|
||||||
|
response = client.post(reverse("auth-login"), data='{"username":"owner", "password":"TestPassword2026!"}', content_type="application/json", HTTP_X_CSRFTOKEN=token)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["user"]["username"], "owner")
|
||||||
|
|
||||||
|
def test_ordinary_user_cannot_log_in_to_admin(self):
|
||||||
|
response = self.client.post(reverse("auth-login"), data='{"username":"member", "password":"TestPassword2026!"}', content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertNotIn("_auth_user_id", self.client.session)
|
||||||
@ -1,4 +1,15 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
|
from .admin_api import create_user, csrf, dashboard, me, set_user_active, sign_in, sign_out
|
||||||
from .views import health
|
from .views import health
|
||||||
|
|
||||||
urlpatterns = [path("health/", health, name="api-health")]
|
urlpatterns = [
|
||||||
|
path("health/", health, name="api-health"),
|
||||||
|
path("auth/csrf/", csrf, name="auth-csrf"),
|
||||||
|
path("auth/me/", me, name="auth-me"),
|
||||||
|
path("auth/login/", sign_in, name="auth-login"),
|
||||||
|
path("auth/logout/", sign_out, name="auth-logout"),
|
||||||
|
path("admin/dashboard/", dashboard, name="admin-dashboard"),
|
||||||
|
path("admin/users/", create_user, name="admin-create-user"),
|
||||||
|
path("admin/users/<int:user_id>/active/", set_user_active, name="admin-user-active"),
|
||||||
|
]
|
||||||
|
|||||||
@ -7,7 +7,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
|
|
||||||
SECRET_KEY = config("SECRET_KEY", default="")
|
SECRET_KEY = config("SECRET_KEY", default="")
|
||||||
if not SECRET_KEY:
|
if not SECRET_KEY:
|
||||||
raise RuntimeError("SECRET_KEY est vide. Exécuter : python scripts/init_env.py")
|
raise RuntimeError("SECRET_KEY est vide : renseigne SECRET_KEY dans le fichier .env")
|
||||||
DEBUG = config("DEBUG", default=False, cast=bool)
|
DEBUG = config("DEBUG", default=False, cast=bool)
|
||||||
|
|
||||||
FRONTEND_PORT = config("FRONTEND_PORT", default=1028, cast=int)
|
FRONTEND_PORT = config("FRONTEND_PORT", default=1028, cast=int)
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
|
admin.site.site_header = "Administration avancée"
|
||||||
|
admin.site.site_title = "Administration Django"
|
||||||
|
admin.site.index_title = "Gestion technique"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
# /admin/ est le tableau de bord React basé sur shadcn/ui.
|
||||||
|
# Django Admin reste accessible pour les fonctions avancées.
|
||||||
|
path("django-admin/", admin.site.urls),
|
||||||
path("api/", include("common.urls")),
|
path("api/", include("common.urls")),
|
||||||
]
|
]
|
||||||
|
|||||||
28
frontend/.oxlintrc.json
Normal file
28
frontend/.oxlintrc.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": [
|
||||||
|
"eslint",
|
||||||
|
"unicorn",
|
||||||
|
"typescript",
|
||||||
|
"oxc",
|
||||||
|
"react",
|
||||||
|
"jsx-a11y",
|
||||||
|
"import"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error",
|
||||||
|
"suspicious": "warn"
|
||||||
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"dist/**",
|
||||||
|
"coverage/**"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react/prop-types": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,13 +9,14 @@ server {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /admin/ {
|
location /django-admin/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /static/ {
|
location /static/ {
|
||||||
@ -23,6 +24,7 @@ server {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# /admin/ et toutes les autres pages sont gérées par React.
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
"lint": "oxlint src vite.config.js",
|
||||||
|
"lint:fix": "oxlint src vite.config.js --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.0",
|
"@fontsource-variable/geist": "^5.2.0",
|
||||||
@ -16,11 +18,13 @@
|
|||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tailwind-merge": "^3.3.0"
|
"tailwind-merge": "^3.3.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.0",
|
"@tailwindcss/vite": "^4.1.0",
|
||||||
"@vitejs/plugin-react": "^5.1.0",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
|
"oxlint": "^1.76.0",
|
||||||
"tailwindcss": "^4.1.0",
|
"tailwindcss": "^4.1.0",
|
||||||
"vite": "^7.2.0"
|
"vite": "^7.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,81 +1,9 @@
|
|||||||
import { useEffect, useState } from "react"
|
import HomePage from "@/pages/HomePage"
|
||||||
import { Activity, ArrowUpRight, Blocks, Database, Moon, RefreshCw, Server, Sun } from "lucide-react"
|
import AdminPage from "@/pages/AdminPage"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
|
|
||||||
|
/** Le template garde un routeur minimal ; React Router pourra être ajouté plus tard. */
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [theme, setTheme] = useState(() => localStorage.getItem("theme") || "light")
|
return window.location.pathname === "/admin" || window.location.pathname.startsWith("/admin/")
|
||||||
const [api, setApi] = useState({ loading: true, ok: false, message: "Connexion en cours..." })
|
? <AdminPage />
|
||||||
|
: <HomePage />
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.classList.toggle("dark", theme === "dark")
|
|
||||||
localStorage.setItem("theme", theme)
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
async function checkApi() {
|
|
||||||
setApi({ loading: true, ok: false, message: "Vérification..." })
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/health/", { cache: "no-store" })
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const body = await res.json()
|
|
||||||
setApi({ loading: false, ok: body.status === "ok", message: body.message })
|
|
||||||
} catch (error) {
|
|
||||||
setApi({ loading: false, ok: false, message: error.message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { checkApi() }, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
|
||||||
<header className="border-b border-border bg-card/80">
|
|
||||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="grid size-9 place-items-center rounded-xl bg-primary text-primary-foreground"><Blocks className="size-5" /></div>
|
|
||||||
<span className="font-semibold tracking-tight">Mon Projet</span>
|
|
||||||
<span className="hidden rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground sm:inline">Template</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button aria-label="Changer de thème" variant="ghost" size="icon" onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
|
|
||||||
{theme === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" asChild><a href="/admin/">Administration <ArrowUpRight className="size-4" /></a></Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto max-w-6xl px-5 py-14">
|
|
||||||
<div className="mb-10 max-w-2xl">
|
|
||||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1 text-xs font-medium text-muted-foreground">
|
|
||||||
<span className="size-2 rounded-full bg-emerald-500"/> Django · React · MySQL
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">Ton nouveau projet commence ici.</h1>
|
|
||||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground">Une base propre avec Docker, API Django, React + Vite, composants shadcn et thème clair/sombre. Tu peux remplacer cette page par ton application.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-5 md:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardHeader><div className="mb-2 text-primary"><Server className="size-6"/></div><CardTitle>API Django</CardTitle><CardDescription>Endpoint de vérification : /api/health/</CardDescription></CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="mb-4 flex items-center gap-2 text-sm"><span className={`size-2.5 rounded-full ${api.loading ? "bg-amber-500" : api.ok ? "bg-emerald-500" : "bg-red-500"}`}/>{api.loading ? "Connexion..." : api.ok ? "Connectée" : "Indisponible"}</div>
|
|
||||||
<p className="mb-4 min-h-12 break-words text-sm text-muted-foreground">{api.message}</p>
|
|
||||||
<Button variant="outline" onClick={checkApi}><RefreshCw className="size-4"/> Réessayer</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader><div className="mb-2 text-primary"><Database className="size-6"/></div><CardTitle>MySQL</CardTitle><CardDescription>MySQL 8, volume persistant et Django ORM</CardDescription></CardHeader>
|
|
||||||
<CardContent><p className="text-sm leading-relaxed text-muted-foreground">La connexion est définie dans <code>backend/core/settings.py</code> à partir des variables <code>MYSQL_*</code>.</p></CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader><div className="mb-2 text-primary"><Activity className="size-6"/></div><CardTitle>Frontend</CardTitle><CardDescription>React, Vite, Tailwind CSS v4 et shadcn/ui</CardDescription></CardHeader>
|
|
||||||
<CardContent><p className="text-sm leading-relaxed text-muted-foreground">Composants de départ prêts à personnaliser dans <code>frontend/src/components/ui/</code>.</p></CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-10 rounded-xl border border-dashed border-border bg-card/60 p-6 text-sm text-muted-foreground">
|
|
||||||
<strong className="text-foreground">Prochaine étape :</strong> crée ton application Django dans <code>backend/</code> et tes pages React dans <code>frontend/src/</code>.
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
38
frontend/src/components/admin/CreateUserDialog.jsx
Normal file
38
frontend/src/components/admin/CreateUserDialog.jsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { UserPlus } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Alert } from "@/components/ui/alert"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
export function CreateUserDialog({ onCreate }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [form, setForm] = useState({ username: "", email: "", password: "" })
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const update = (name, value) => setForm((current) => ({ ...current, [name]: value }))
|
||||||
|
async function submit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
setError("")
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await onCreate(form)
|
||||||
|
setForm({ username: "", email: "", password: "" })
|
||||||
|
setOpen(false)
|
||||||
|
} catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
return <Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild><Button size="sm"><UserPlus className="size-4"/> Ajouter un utilisateur</Button></DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Nouvel utilisateur</DialogTitle><DialogDescription>Crée un compte standard. Les permissions administrateur se gèrent dans Django.</DialogDescription></DialogHeader>
|
||||||
|
<form className="space-y-4" onSubmit={submit}>
|
||||||
|
<div className="space-y-2"><Label htmlFor="new-username">Identifiant</Label><Input id="new-username" required minLength={3} maxLength={150} autoComplete="off" value={form.username} onChange={(event) => update("username", event.target.value)}/></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="new-email">Adresse email (facultative)</Label><Input id="new-email" type="email" value={form.email} onChange={(event) => update("email", event.target.value)}/></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="new-password">Mot de passe</Label><Input id="new-password" type="password" required minLength={8} value={form.password} onChange={(event) => update("password", event.target.value)}/></div>
|
||||||
|
{error && <Alert variant="destructive">{error}</Alert>}
|
||||||
|
<div className="flex justify-end gap-2 pt-2"><Button type="button" variant="outline" onClick={() => setOpen(false)}>Annuler</Button><Button type="submit" disabled={busy}>{busy ? "Création..." : "Créer le compte"}</Button></div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
36
frontend/src/components/admin/LoginForm.jsx
Normal file
36
frontend/src/components/admin/LoginForm.jsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { ArrowRight, LockKeyhole, Shield } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Alert } from "@/components/ui/alert"
|
||||||
|
|
||||||
|
export function LoginForm({ onLogin, error }) {
|
||||||
|
const [username, setUsername] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
async function submit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
try { await onLogin(username.trim(), password) } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
return <div className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-md items-center px-4 py-12">
|
||||||
|
<Card className="w-full shadow-lg shadow-foreground/5">
|
||||||
|
<CardHeader className="pb-5">
|
||||||
|
<div className="mb-4 grid size-11 place-items-center rounded-xl bg-primary/10 text-primary"><LockKeyhole className="size-5"/></div>
|
||||||
|
<CardTitle className="text-2xl">Administration</CardTitle>
|
||||||
|
<CardDescription>Connecte-toi avec ton compte administrateur.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="space-y-5" onSubmit={submit}>
|
||||||
|
<div className="space-y-2"><Label htmlFor="admin-user">Nom d’utilisateur</Label><Input id="admin-user" value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" required autoFocus/></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="admin-pass">Mot de passe</Label><Input id="admin-pass" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" required/></div>
|
||||||
|
{error && <Alert variant="destructive">{error}</Alert>}
|
||||||
|
<Button className="w-full" disabled={busy} type="submit">{busy ? "Connexion..." : "Se connecter"}<ArrowRight className="size-4"/></Button>
|
||||||
|
</form>
|
||||||
|
<div className="mt-6 flex items-center gap-2 text-xs text-muted-foreground"><Shield className="size-4"/> Session sécurisée par Django et protection CSRF</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
21
frontend/src/components/admin/UsersTable.jsx
Normal file
21
frontend/src/components/admin/UsersTable.jsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
|
||||||
|
function initials(user) { return (user.first_name?.[0] || user.username?.[0] || "?").toUpperCase() }
|
||||||
|
|
||||||
|
export function UsersTable({ users, currentUser, onToggle, busyUser }) {
|
||||||
|
if (!users.length) return <p className="px-6 py-14 text-center text-sm text-muted-foreground">Aucun utilisateur trouvé.</p>
|
||||||
|
return <Table>
|
||||||
|
<TableHeader><TableRow><TableHead>Utilisateur</TableHead><TableHead>Rôle</TableHead><TableHead>Statut</TableHead><TableHead className="text-right">Action</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>{users.map((user) => {
|
||||||
|
const protectedUser = user.id === currentUser.id || user.is_superuser
|
||||||
|
return <TableRow key={user.id}>
|
||||||
|
<TableCell><div className="flex min-w-44 items-center gap-3"><span className="grid size-9 shrink-0 place-items-center rounded-full bg-primary/10 font-semibold text-primary">{initials(user)}</span><div className="min-w-0"><div className="truncate font-medium">{[user.first_name, user.last_name].filter(Boolean).join(" ") || user.username}</div><div className="truncate text-xs text-muted-foreground">{user.username}{user.email ? ` · ${user.email}` : ""}</div></div></div></TableCell>
|
||||||
|
<TableCell><Badge variant={user.is_staff ? "default" : "secondary"}>{user.is_superuser ? "Superadmin" : user.is_staff ? "Admin" : "Utilisateur"}</Badge></TableCell>
|
||||||
|
<TableCell><Badge variant={user.is_active ? "success" : "destructive"}><span className={`size-1.5 rounded-full ${user.is_active ? "bg-emerald-500" : "bg-red-500"}`}/>{user.is_active ? "Actif" : "Désactivé"}</Badge></TableCell>
|
||||||
|
<TableCell className="text-right"><Button variant="outline" size="sm" disabled={protectedUser || busyUser === user.id || !currentUser.is_superuser} title={protectedUser ? "Compte protégé" : undefined} onClick={() => onToggle(user)}>{busyUser === user.id ? "Patiente..." : user.is_active ? "Désactiver" : "Réactiver"}</Button></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
})}</TableBody>
|
||||||
|
</Table>
|
||||||
|
}
|
||||||
26
frontend/src/components/layout/AppShell.jsx
Normal file
26
frontend/src/components/layout/AppShell.jsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { ArrowUpRight, Blocks, LayoutDashboard, Moon, Sun } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { useTheme } from "@/hooks/useTheme"
|
||||||
|
|
||||||
|
export function AppShell({ children, admin = false }) {
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
return <div className="min-h-screen bg-background text-foreground">
|
||||||
|
<header className="sticky top-0 z-20 border-b border-border/75 bg-card/90 backdrop-blur-lg">
|
||||||
|
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
|
||||||
|
<a href="/" className="flex min-w-0 items-center gap-3 font-semibold tracking-tight">
|
||||||
|
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-primary text-primary-foreground shadow-sm"><Blocks className="size-5"/></span>
|
||||||
|
<span className="truncate">Mon Projet</span>
|
||||||
|
<Badge variant="secondary" className="hidden sm:inline-flex">Template</Badge>
|
||||||
|
</a>
|
||||||
|
<nav className="flex shrink-0 items-center gap-2" aria-label="Navigation principale">
|
||||||
|
<Button aria-label="Changer de thème" variant="ghost" size="icon" onClick={toggleTheme} title={theme === "dark" ? "Mode clair" : "Mode sombre"}>
|
||||||
|
{theme === "dark" ? <Sun className="size-4"/> : <Moon className="size-4"/>}
|
||||||
|
</Button>
|
||||||
|
{admin ? <Button asChild variant="outline" size="sm"><a href="/"><ArrowUpRight className="size-4"/> Retour au site</a></Button> : <Button asChild size="sm"><a href="/admin/"><LayoutDashboard className="size-4"/> Administration</a></Button>}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
5
frontend/src/components/ui/alert.jsx
Normal file
5
frontend/src/components/ui/alert.jsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function Alert({ className, variant = "default", ...props }) {
|
||||||
|
return <div role="alert" className={cn("rounded-lg border px-4 py-3 text-sm", variant === "destructive" ? "border-red-500/20 bg-red-500/5 text-red-700 dark:text-red-300" : "border-border bg-muted/60 text-foreground", className)} {...props} />
|
||||||
|
}
|
||||||
17
frontend/src/components/ui/badge.jsx
Normal file
17
frontend/src/components/ui/badge.jsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const variants = cva("inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium", {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-primary/10 text-primary",
|
||||||
|
secondary: "border-border bg-muted text-muted-foreground",
|
||||||
|
success: "border-emerald-500/15 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||||
|
destructive: "border-red-500/15 bg-red-500/10 text-red-700 dark:text-red-300",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "default" },
|
||||||
|
})
|
||||||
|
export function Badge({ className, variant, ...props }) {
|
||||||
|
return <span className={cn(variants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
19
frontend/src/components/ui/dialog.jsx
Normal file
19
frontend/src/components/ui/dialog.jsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export const Dialog = DialogPrimitive.Root
|
||||||
|
export const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
export const DialogClose = DialogPrimitive.Close
|
||||||
|
export function DialogContent({ className, children, ...props }) {
|
||||||
|
return <DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-slate-950/50 backdrop-blur-[2px] data-[state=open]:animate-in" />
|
||||||
|
<DialogPrimitive.Content className={cn("fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-card p-6 text-card-foreground shadow-2xl outline-none", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground" aria-label="Fermer"><X className="size-4" /></DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
}
|
||||||
|
export function DialogHeader({ className, ...props }) { return <div className={cn("mb-5 space-y-1.5", className)} {...props} /> }
|
||||||
|
export function DialogTitle({ className, ...props }) { return <DialogPrimitive.Title className={cn("text-lg font-semibold tracking-tight", className)} {...props} /> }
|
||||||
|
export function DialogDescription({ className, ...props }) { return <DialogPrimitive.Description className={cn("text-sm text-muted-foreground", className)} {...props} /> }
|
||||||
5
frontend/src/components/ui/input.jsx
Normal file
5
frontend/src/components/ui/input.jsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function Input({ className, type = "text", ...props }) {
|
||||||
|
return <input data-slot="input" type={type} className={cn("flex h-10 w-full min-w-0 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50", className)} {...props} />
|
||||||
|
}
|
||||||
5
frontend/src/components/ui/label.jsx
Normal file
5
frontend/src/components/ui/label.jsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function Label({ className, ...props }) {
|
||||||
|
return <label className={cn("text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)} {...props} />
|
||||||
|
}
|
||||||
5
frontend/src/components/ui/separator.jsx
Normal file
5
frontend/src/components/ui/separator.jsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function Separator({ className, orientation = "horizontal", ...props }) {
|
||||||
|
return <div role="separator" aria-orientation={orientation} className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-px w-full" : "h-full w-px", className)} {...props} />
|
||||||
|
}
|
||||||
8
frontend/src/components/ui/table.jsx
Normal file
8
frontend/src/components/ui/table.jsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function Table({ className, ...props }) { return <div className="relative w-full overflow-x-auto"><table className={cn("w-full caption-bottom text-sm", className)} {...props} /></div> }
|
||||||
|
export function TableHeader({ className, ...props }) { return <thead className={cn("[&_tr]:border-b", className)} {...props} /> }
|
||||||
|
export function TableBody({ className, ...props }) { return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} /> }
|
||||||
|
export function TableRow({ className, ...props }) { return <tr className={cn("border-b border-border transition-colors hover:bg-muted/35", className)} {...props} /> }
|
||||||
|
export function TableHead({ className, ...props }) { return <th className={cn("h-11 whitespace-nowrap px-4 text-left align-middle text-xs font-medium text-muted-foreground", className)} {...props} /> }
|
||||||
|
export function TableCell({ className, ...props }) { return <td className={cn("p-4 align-middle", className)} {...props} /> }
|
||||||
17
frontend/src/hooks/useHealth.js
Normal file
17
frontend/src/hooks/useHealth.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { healthService } from "@/services/healthService"
|
||||||
|
|
||||||
|
export function useHealth() {
|
||||||
|
const [health, setHealth] = useState({ loading: true, ok: false, message: "Connexion en cours..." })
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setHealth((current) => ({ ...current, loading: true }))
|
||||||
|
try {
|
||||||
|
const body = await healthService.check()
|
||||||
|
setHealth({ loading: false, ok: body.status === "ok", message: body.message || "Serveur disponible" })
|
||||||
|
} catch (error) {
|
||||||
|
setHealth({ loading: false, ok: false, message: error.message })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { refresh() }, [refresh])
|
||||||
|
return { health, refresh }
|
||||||
|
}
|
||||||
10
frontend/src/hooks/useTheme.js
Normal file
10
frontend/src/hooks/useTheme.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState(() => localStorage.getItem("theme") || "light")
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("dark", theme === "dark")
|
||||||
|
localStorage.setItem("theme", theme)
|
||||||
|
}, [theme])
|
||||||
|
return { theme, toggleTheme: () => setTheme((current) => current === "dark" ? "light" : "dark") }
|
||||||
|
}
|
||||||
@ -8,39 +8,61 @@
|
|||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-input: var(--input);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(.982 .007 86);
|
color-scheme: light;
|
||||||
--foreground: oklch(.23 .027 255);
|
--background: oklch(.982 .006 260);
|
||||||
|
--foreground: oklch(.22 .028 260);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: var(--foreground);
|
--card-foreground: var(--foreground);
|
||||||
--primary: oklch(.49 .119 262);
|
--popover: var(--card);
|
||||||
|
--popover-foreground: var(--foreground);
|
||||||
|
--primary: oklch(.50 .14 259);
|
||||||
--primary-foreground: oklch(.99 0 0);
|
--primary-foreground: oklch(.99 0 0);
|
||||||
--muted: oklch(.959 .010 85);
|
--secondary: oklch(.955 .012 260);
|
||||||
--muted-foreground: oklch(.52 .025 254);
|
--secondary-foreground: var(--foreground);
|
||||||
--border: oklch(.90 .013 251);
|
--muted: oklch(.957 .009 260);
|
||||||
|
--muted-foreground: oklch(.52 .028 260);
|
||||||
|
--input: oklch(.89 .013 260);
|
||||||
|
--border: oklch(.91 .012 260);
|
||||||
--ring: var(--primary);
|
--ring: var(--primary);
|
||||||
|
--destructive: oklch(.56 .19 24);
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(.17 .021 256);
|
color-scheme: dark;
|
||||||
--foreground: oklch(.97 .010 255);
|
--background: oklch(.16 .020 260);
|
||||||
--card: oklch(.22 .022 255);
|
--foreground: oklch(.96 .010 260);
|
||||||
|
--card: oklch(.205 .022 260);
|
||||||
--card-foreground: var(--foreground);
|
--card-foreground: var(--foreground);
|
||||||
--primary: oklch(.70 .125 255);
|
--popover: var(--card);
|
||||||
--primary-foreground: oklch(.18 .022 255);
|
--popover-foreground: var(--foreground);
|
||||||
--muted: oklch(.27 .02 255);
|
--primary: oklch(.73 .132 255);
|
||||||
--muted-foreground: oklch(.73 .018 255);
|
--primary-foreground: oklch(.17 .018 260);
|
||||||
--border: oklch(1 0 0 / 12%);
|
--secondary: oklch(.29 .026 260);
|
||||||
|
--secondary-foreground: var(--foreground);
|
||||||
|
--muted: oklch(.265 .020 260);
|
||||||
|
--muted-foreground: oklch(.73 .025 260);
|
||||||
|
--input: oklch(1 0 0 / 16%);
|
||||||
|
--border: oklch(1 0 0 / 11%);
|
||||||
--ring: var(--primary);
|
--ring: var(--primary);
|
||||||
|
--destructive: oklch(.7 .16 22);
|
||||||
}
|
}
|
||||||
@layer base {
|
@layer base {
|
||||||
*, ::before, ::after { box-sizing: border-box; border-color: var(--border); }
|
*, ::before, ::after { box-sizing: border-box; border-color: var(--border); }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
body { margin: 0; background: var(--background); color: var(--foreground); font-family: var(--font-sans); }
|
body { margin: 0; background: var(--background); color: var(--foreground); font-family: var(--font-sans); }
|
||||||
|
button, input { font: inherit; }
|
||||||
}
|
}
|
||||||
|
|||||||
129
frontend/src/pages/AdminPage.jsx
Normal file
129
frontend/src/pages/AdminPage.jsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { Activity, ArrowUpRight, Database, LayoutDashboard, LogOut, RefreshCw, Search, ShieldCheck, Users } from "lucide-react"
|
||||||
|
import { AppShell } from "@/components/layout/AppShell"
|
||||||
|
import { LoginForm } from "@/components/admin/LoginForm"
|
||||||
|
import { CreateUserDialog } from "@/components/admin/CreateUserDialog"
|
||||||
|
import { UsersTable } from "@/components/admin/UsersTable"
|
||||||
|
import { Alert } from "@/components/ui/alert"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { authService } from "@/services/authService"
|
||||||
|
import { adminService } from "@/services/adminService"
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [status, setStatus] = useState("loading")
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [loadingData, setLoadingData] = useState(false)
|
||||||
|
const [busyUser, setBusyUser] = useState(null)
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoadingData(true)
|
||||||
|
try { setData(await adminService.dashboard()); setError("") }
|
||||||
|
catch (err) { setError(err.message) }
|
||||||
|
finally { setLoadingData(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
authService.me().then(async ({ user: loggedUser }) => {
|
||||||
|
if (!mounted) return
|
||||||
|
if (!loggedUser.is_staff) { setStatus("forbidden"); return }
|
||||||
|
setUser(loggedUser)
|
||||||
|
setStatus("ready")
|
||||||
|
}).catch((err) => {
|
||||||
|
if (!mounted) return
|
||||||
|
setStatus(err.status === 401 ? "login" : "error")
|
||||||
|
setError(err.status === 401 ? "" : err.message)
|
||||||
|
})
|
||||||
|
return () => { mounted = false }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { if (status === "ready") reload() }, [status, reload])
|
||||||
|
|
||||||
|
async function handleLogin(username, password) {
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
const result = await authService.login(username, password)
|
||||||
|
setUser(result.user)
|
||||||
|
setStatus("ready")
|
||||||
|
} catch (err) { setError(err.message) }
|
||||||
|
}
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await authService.logout()
|
||||||
|
setUser(null); setData(null); setStatus("login"); setMessage(""); setError("")
|
||||||
|
} catch (err) { setError(err.message) }
|
||||||
|
}
|
||||||
|
async function handleCreate(form) {
|
||||||
|
await adminService.createUser(form)
|
||||||
|
setMessage("Utilisateur créé avec succès.")
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
async function handleToggle(target) {
|
||||||
|
setBusyUser(target.id)
|
||||||
|
setMessage("")
|
||||||
|
try {
|
||||||
|
await adminService.setUserActive(target.id, !target.is_active)
|
||||||
|
setMessage(target.is_active ? "Compte désactivé." : "Compte réactivé.")
|
||||||
|
await reload()
|
||||||
|
} catch (err) { setError(err.message) }
|
||||||
|
finally { setBusyUser(null) }
|
||||||
|
}
|
||||||
|
const filtered = useMemo(() => (data?.users || []).filter((item) => [item.username, item.first_name, item.last_name, item.email].join(" ").toLowerCase().includes(search.toLowerCase().trim())), [data, search])
|
||||||
|
|
||||||
|
return <AppShell admin>
|
||||||
|
{status === "loading" && <div className="mx-auto flex min-h-[70vh] items-center justify-center gap-3 text-sm text-muted-foreground"><RefreshCw className="size-4 animate-spin"/> Chargement de la session...</div>}
|
||||||
|
{status === "login" && <LoginForm onLogin={handleLogin} error={error}/>}
|
||||||
|
{status === "forbidden" && <main className="mx-auto max-w-xl px-5 py-20"><Alert variant="destructive">Ce compte ne dispose pas des droits d’administration.</Alert></main>}
|
||||||
|
{status === "error" && <main className="mx-auto max-w-xl space-y-4 px-5 py-20"><Alert variant="destructive">{error}</Alert><Button onClick={() => window.location.reload()}>Réessayer</Button></main>}
|
||||||
|
{status === "ready" && <div className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-8 sm:px-6 lg:flex-row lg:py-10">
|
||||||
|
<aside className="lg:w-56 lg:shrink-0">
|
||||||
|
<div className="mb-3 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Administration</div>
|
||||||
|
<div className="flex gap-2 lg:flex-col">
|
||||||
|
<a href="#vue-ensemble" className="flex items-center gap-2 rounded-lg bg-primary/10 px-3 py-2.5 text-sm font-medium text-primary"><LayoutDashboard className="size-4"/> Vue d’ensemble</a>
|
||||||
|
<a href="#utilisateurs" className="flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground"><Users className="size-4"/> Utilisateurs</a>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-5 hidden lg:block"/>
|
||||||
|
<div className="hidden px-3 lg:block"><p className="text-sm font-medium">{user.first_name || user.username}</p><p className="mt-1 text-xs text-muted-foreground">{user.is_superuser ? "Superadministrateur" : "Administrateur"}</p></div>
|
||||||
|
<Button variant="ghost" size="sm" className="mt-3 hidden w-full justify-start text-muted-foreground lg:flex" onClick={handleLogout}><LogOut className="size-4"/> Déconnexion</Button>
|
||||||
|
</aside>
|
||||||
|
<main className="min-w-0 flex-1 space-y-7">
|
||||||
|
<section id="vue-ensemble" className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div><Badge variant="secondary">Espace administrateur</Badge><h1 className="mt-3 text-3xl font-semibold tracking-tight">Vue d’ensemble</h1><p className="mt-2 text-sm text-muted-foreground">Gère les comptes et consulte l’activité générale de ton application.</p></div>
|
||||||
|
<div className="flex gap-2"><Button variant="outline" size="sm" disabled={loadingData} onClick={reload}><RefreshCw className={`size-4 ${loadingData ? "animate-spin" : ""}`}/> Actualiser</Button><Button variant="ghost" size="sm" className="lg:hidden" onClick={handleLogout}><LogOut className="size-4"/> Quitter</Button></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{error && <Alert variant="destructive">{error}</Alert>}
|
||||||
|
{message && <Alert>{message}</Alert>}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<StatCard icon={Users} label="Utilisateurs" value={data?.stats.total} helper="Comptes enregistrés"/>
|
||||||
|
<StatCard icon={Activity} label="Actifs" value={data?.stats.active} helper="Accès autorisés"/>
|
||||||
|
<StatCard icon={ShieldCheck} label="Administrateurs" value={data?.stats.staff} helper="Comptes avec accès admin"/>
|
||||||
|
</div>
|
||||||
|
<Card id="utilisateurs" className="overflow-hidden">
|
||||||
|
<CardHeader className="gap-4 border-b border-border pb-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div><CardTitle className="text-lg">Utilisateurs</CardTitle><CardDescription>Comptes et accès à l’application</CardDescription></div>
|
||||||
|
{user.is_superuser && <CreateUserDialog onCreate={handleCreate}/>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="p-4 sm:px-6"><div className="relative max-w-sm"><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/><Input className="pl-9" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Rechercher un utilisateur..." aria-label="Rechercher un utilisateur"/></div></div>
|
||||||
|
<UsersTable users={filtered} currentUser={user} onToggle={handleToggle} busyUser={busyUser}/>
|
||||||
|
<div className="border-t border-border bg-muted/20 px-6 py-3 text-xs text-muted-foreground">{filtered.length} compte(s) affiché(s) · Les comptes sensibles sont protégés.</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-dashed shadow-none"><CardContent className="flex flex-wrap items-center justify-between gap-4 py-5"><div className="flex items-center gap-3"><Database className="size-5 text-primary"/><div><p className="text-sm font-medium">Administration Django avancée</p><p className="text-xs text-muted-foreground">Groupes, permissions et modèles Django</p></div></div><Button size="sm" variant="outline" asChild><a href="/django-admin/">Ouvrir <ArrowUpRight className="size-4"/></a></Button></CardContent></Card>
|
||||||
|
</main>
|
||||||
|
</div>}
|
||||||
|
</AppShell>
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon: Icon, label, value, helper }) {
|
||||||
|
return <Card><CardContent className="flex items-start justify-between gap-3 p-5"><div><p className="text-sm font-medium text-muted-foreground">{label}</p><div className="mt-3 text-3xl font-semibold tabular-nums tracking-tight">{value ?? "—"}</div><p className="mt-2 text-xs text-muted-foreground">{helper}</p></div><span className="grid size-10 place-items-center rounded-xl bg-primary/10 text-primary"><Icon className="size-5"/></span></CardContent></Card>
|
||||||
|
}
|
||||||
45
frontend/src/pages/HomePage.jsx
Normal file
45
frontend/src/pages/HomePage.jsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Activity, ArrowRight, Code2, Database, RefreshCw, Server, ShieldCheck } from "lucide-react"
|
||||||
|
import { AppShell } from "@/components/layout/AppShell"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { useHealth } from "@/hooks/useHealth"
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { health, refresh } = useHealth()
|
||||||
|
return <AppShell>
|
||||||
|
<main className="mx-auto max-w-7xl space-y-10 px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<section className="relative overflow-hidden rounded-3xl border border-border bg-card px-7 py-10 shadow-sm sm:px-12 sm:py-14">
|
||||||
|
<div className="pointer-events-none absolute -right-28 -top-44 size-96 rounded-full bg-primary/8 blur-3xl" aria-hidden="true"/>
|
||||||
|
<div className="relative max-w-2xl">
|
||||||
|
<Badge variant="default"><Code2 className="size-3.5"/> Django + React + MySQL</Badge>
|
||||||
|
<h1 className="mt-6 text-3xl font-semibold tracking-tight sm:text-5xl">Une base propre pour tes prochains projets.</h1>
|
||||||
|
<p className="mt-5 max-w-xl text-base leading-relaxed text-muted-foreground">Docker Compose, Django REST Framework, React avec shadcn/ui, MySQL 8 et Oxlint. Une architecture modulaire, prête à être personnalisée.</p>
|
||||||
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
|
<Button asChild><a href="/admin/">Ouvrir l’administration <ArrowRight className="size-4"/></a></Button>
|
||||||
|
<Button variant="outline" asChild><a href="/django-admin/">Django avancé <ArrowRight className="size-4"/></a></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section aria-label="Services du template" className="grid gap-5 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><Server className="mb-3 size-5 text-primary"/><CardTitle>API Django</CardTitle><CardDescription>Connexion avec le backend</CardDescription></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="mb-3 flex items-center gap-2"><span className={`size-2.5 rounded-full ${health.loading ? "bg-amber-500" : health.ok ? "bg-emerald-500" : "bg-red-500"}`}/><span className="text-sm font-medium">{health.loading ? "Vérification" : health.ok ? "Connectée" : "Indisponible"}</span></div>
|
||||||
|
<p className="mb-5 min-h-10 break-words text-sm text-muted-foreground">{health.message}</p>
|
||||||
|
<Button variant="outline" size="sm" disabled={health.loading} onClick={refresh}><RefreshCw className={`size-4 ${health.loading ? "animate-spin" : ""}`}/> Actualiser</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader><Database className="mb-3 size-5 text-primary"/><CardTitle>MySQL 8</CardTitle><CardDescription>Volume persistant et ORM Django</CardDescription></CardHeader>
|
||||||
|
<CardContent><p className="text-sm leading-relaxed text-muted-foreground">La configuration est centralisée dans le fichier <code className="font-medium text-foreground">.env</code>, sans identifiants en dur dans le code.</p></CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader><Activity className="mb-3 size-5 text-primary"/><CardTitle>React & shadcn/ui</CardTitle><CardDescription>Interface réutilisable et responsive</CardDescription></CardHeader>
|
||||||
|
<CardContent><p className="text-sm leading-relaxed text-muted-foreground">Pages, composants, hooks et services séparés pour faire évoluer ton projet facilement.</p></CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/40 p-4 text-sm text-muted-foreground"><ShieldCheck className="size-5 shrink-0 text-primary"/><span>Pour commencer, ajoute tes modules Django dans <code>backend/</code> et tes nouvelles pages React dans <code>frontend/src/pages/</code>.</span></div>
|
||||||
|
</main>
|
||||||
|
</AppShell>
|
||||||
|
}
|
||||||
7
frontend/src/services/adminService.js
Normal file
7
frontend/src/services/adminService.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { apiRequest } from "@/services/apiClient"
|
||||||
|
|
||||||
|
export const adminService = {
|
||||||
|
dashboard: () => apiRequest("admin/dashboard/"),
|
||||||
|
createUser: (data) => apiRequest("admin/users/", { method: "POST", data }),
|
||||||
|
setUserActive: (userId, isActive) => apiRequest(`admin/users/${userId}/active/`, { method: "POST", data: { is_active: isActive } }),
|
||||||
|
}
|
||||||
49
frontend/src/services/apiClient.js
Normal file
49
frontend/src/services/apiClient.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/** Client HTTP partagé : aucune requête réseau dans les composants JSX. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message, status) {
|
||||||
|
super(message)
|
||||||
|
this.name = "ApiError"
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookie(name) {
|
||||||
|
const key = `${name}=`
|
||||||
|
const found = document.cookie.split(";").map((cookie) => cookie.trim()).find((cookie) => cookie.startsWith(key))
|
||||||
|
return found ? decodeURIComponent(found.slice(key.length)) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
let csrfRequest = null
|
||||||
|
async function ensureCsrf() {
|
||||||
|
if (readCookie("csrftoken")) return
|
||||||
|
if (!csrfRequest) {
|
||||||
|
csrfRequest = fetch("/api/auth/csrf/", { credentials: "same-origin" }).finally(() => { csrfRequest = null })
|
||||||
|
}
|
||||||
|
const response = await csrfRequest
|
||||||
|
if (!response.ok || !readCookie("csrftoken")) throw new ApiError("Impossible d'initialiser la sécurité CSRF.", response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequest(path, { method = "GET", data, ...options } = {}) {
|
||||||
|
const isMutation = !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())
|
||||||
|
if (isMutation) await ensureCsrf()
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await fetch(`/api/${path.replace(/^\//, "")}`, {
|
||||||
|
method,
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(data !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...(isMutation ? { "X-CSRFToken": readCookie("csrftoken") || "" } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
...(data !== undefined ? { body: JSON.stringify(data) } : {}),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new ApiError("Impossible de joindre le serveur. Vérifie ta connexion.", 0)
|
||||||
|
}
|
||||||
|
const payload = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) throw new ApiError(payload.detail || `Erreur HTTP ${response.status}`, response.status)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
7
frontend/src/services/authService.js
Normal file
7
frontend/src/services/authService.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { apiRequest } from "@/services/apiClient"
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
me: () => apiRequest("auth/me/"),
|
||||||
|
login: (username, password) => apiRequest("auth/login/", { method: "POST", data: { username, password } }),
|
||||||
|
logout: () => apiRequest("auth/logout/", { method: "POST" }),
|
||||||
|
}
|
||||||
5
frontend/src/services/healthService.js
Normal file
5
frontend/src/services/healthService.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { apiRequest } from "@/services/apiClient"
|
||||||
|
|
||||||
|
export const healthService = {
|
||||||
|
check: () => apiRequest("health/"),
|
||||||
|
}
|
||||||
@ -1,17 +1,24 @@
|
|||||||
import { defineConfig } from "vite"
|
import { defineConfig, loadEnv } from "vite"
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
import { fileURLToPath, URL } from "node:url"
|
import { fileURLToPath, URL } from "node:url"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react(), tailwindcss()],
|
// Variable fournie par start.sh, ou par un .env local au frontend.
|
||||||
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
const env = loadEnv(mode, process.cwd(), "")
|
||||||
server: {
|
const backendPort = process.env.BACKEND_PORT || env.BACKEND_PORT || "1029"
|
||||||
host: "0.0.0.0",
|
const proxyTarget = `http://127.0.0.1:${backendPort}`
|
||||||
proxy: {
|
const proxy = Object.fromEntries(
|
||||||
"/api": { target: "http://localhost:1029", changeOrigin: true },
|
["/api", "/django-admin", "/static"].map((path) => [
|
||||||
"/admin": { target: "http://localhost:1029", changeOrigin: true },
|
path,
|
||||||
"/static": { target: "http://localhost:1029", changeOrigin: true },
|
{ target: proxyTarget, changeOrigin: true },
|
||||||
},
|
]),
|
||||||
},
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
||||||
|
server: { host: "0.0.0.0", proxy },
|
||||||
|
preview: { host: "0.0.0.0", proxy },
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
100
start.sh
Normal file
100
start.sh
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /home/container
|
||||||
|
|
||||||
|
echo "======================================"
|
||||||
|
echo "Template"
|
||||||
|
echo "======================================"
|
||||||
|
|
||||||
|
# --------------------------------------
|
||||||
|
# Mise à jour du projet
|
||||||
|
# --------------------------------------
|
||||||
|
|
||||||
|
if [ ! -d ".git" ]; then
|
||||||
|
echo "Dépôt Git absent → clonage..."
|
||||||
|
|
||||||
|
git clone https://gitea.louismazin.ovh/LouisMazin/GooDrive.git /tmp/goodrive
|
||||||
|
|
||||||
|
cp -a /tmp/goodrive/. /home/container/
|
||||||
|
rm -rf /tmp/goodrive
|
||||||
|
else
|
||||||
|
echo "Mise à jour du dépôt Git..."
|
||||||
|
|
||||||
|
git fetch origin
|
||||||
|
git reset --hard origin/main
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Git : $(git rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
# --------------------------------------
|
||||||
|
# Backend
|
||||||
|
# --------------------------------------
|
||||||
|
|
||||||
|
cd /home/container/backend
|
||||||
|
|
||||||
|
python3 manage.py check
|
||||||
|
|
||||||
|
echo "Démarrage Django sur :1035"
|
||||||
|
|
||||||
|
python3 manage.py migrate --noinput
|
||||||
|
|
||||||
|
python manage.py ensure_admin
|
||||||
|
|
||||||
|
# Purge au démarrage puis une fois par heure (éléments de plus de 5 jours).
|
||||||
|
trash_cleanup_loop() {
|
||||||
|
while true; do
|
||||||
|
python3 manage.py cleanup_trash --days 5 || echo "Échec du nettoyage de la corbeille (nouvel essai dans 1 heure)." >&2
|
||||||
|
sleep 3600
|
||||||
|
done
|
||||||
|
}
|
||||||
|
trash_cleanup_loop &
|
||||||
|
CLEANER_PID=$!
|
||||||
|
|
||||||
|
python3 manage.py runserver 0.0.0.0:1035 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
# --------------------------------------
|
||||||
|
# Frontend
|
||||||
|
# --------------------------------------
|
||||||
|
|
||||||
|
cd /home/container/frontend
|
||||||
|
|
||||||
|
echo "Installation des dépendances frontend..."
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
|
||||||
|
echo "Build React..."
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "Démarrage frontend sur :1034"
|
||||||
|
|
||||||
|
npm run preview -- --host 0.0.0.0 --port 1034 &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
|
# --------------------------------------
|
||||||
|
# Arrêt propre
|
||||||
|
# --------------------------------------
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo "Arrêt des applications..."
|
||||||
|
|
||||||
|
kill "$BACKEND_PID" 2>/dev/null || true
|
||||||
|
kill "$FRONTEND_PID" 2>/dev/null || true
|
||||||
|
kill "$CLEANER_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
wait "$BACKEND_PID" 2>/dev/null || true
|
||||||
|
wait "$FRONTEND_PID" 2>/dev/null || true
|
||||||
|
wait "$CLEANER_PID" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup SIGTERM SIGINT EXIT
|
||||||
|
|
||||||
|
echo "======================================"
|
||||||
|
echo " Frontend : :1034"
|
||||||
|
echo " Backend : :1035"
|
||||||
|
echo "======================================"
|
||||||
|
|
||||||
|
wait -n "$BACKEND_PID" "$FRONTEND_PID"
|
||||||
Loading…
x
Reference in New Issue
Block a user