This commit is contained in:
Louis Mazin 2026-10-15 14:52:54 +02:00
parent a2fda03471
commit 663268bbe9
32 changed files with 962 additions and 121 deletions

View File

@ -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
View 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
View 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)

View File

@ -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"),
]

View File

@ -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)

View File

@ -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
View 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"
}
}

View File

@ -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;
} }

View File

@ -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"
} }

View File

@ -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>
)
} }

View 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>
}

View 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>
}

View 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>
}

View 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>
}

View 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} />
}

View 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} />
}

View 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} /> }

View 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} />
}

View 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} />
}

View 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} />
}

View 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} /> }

View 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 }
}

View 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") }
}

View File

@ -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; }
} }

View 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>
}

View 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>
}

View 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 } }),
}

View 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
}

View 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" }),
}

View File

@ -0,0 +1,5 @@
import { apiRequest } from "@/services/apiClient"
export const healthService = {
check: () => apiRequest("health/"),
}

View File

@ -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
View 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"