diff --git a/README.md b/README.md index a0addc3..51a8ba2 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,46 @@ # Template Django + React + MySQL 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 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. -3. Démarrer : +3. Démarrer avec le script principal : ```sh -python scripts/init_env.py -docker compose up -d --build +chmod +x start.sh +./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`) - **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. @@ -29,6 +53,7 @@ Au premier lancement, `ensure_admin` crée le compte à partir de `ADMIN_*`. Si ├── .env # paramètres locaux, non versionné ├── .env.example # modèle sans secrets ├── docker-compose.yml +├── start.sh # Démarrage Compose ou standalone / Pterodactyl ├── scripts/ │ └── init_env.py ├── backend/ @@ -43,17 +68,29 @@ Au premier lancement, `ensure_admin` crée le compte à partir de `ADMIN_*`. Si │ └── common/ │ ├── urls.py │ ├── views.py +│ ├── admin_api.py # API sécurisée pour le tableau de bord React │ └── management/commands/ensure_admin.py └── frontend/ ├── Dockerfile ├── nginx.conf ├── package.json + ├── .oxlintrc.json # Oxlint : JS/JSX, React et accessibilité ├── components.json ├── vite.config.js └── src/ - ├── App.jsx + ├── App.jsx # sélection des pages ├── 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 @@ -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). - `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`. -- 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. ## 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`. -- React : nouvelles pages/composants dans `frontend/src/`. -- 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. +- 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 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. +## 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 ```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) cd backend && python -m unittest discover -s tests +# Avec une base Django de test : +python manage.py test common # Stopper sans détruire MySQL 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. - 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. + +## 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`. diff --git a/backend/common/admin_api.py b/backend/common/admin_api.py new file mode 100644 index 0000000..a479c4b --- /dev/null +++ b/backend/common/admin_api.py @@ -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)}) diff --git a/backend/common/tests.py b/backend/common/tests.py new file mode 100644 index 0000000..a7516ca --- /dev/null +++ b/backend/common/tests.py @@ -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) diff --git a/backend/common/urls.py b/backend/common/urls.py index cda1017..9a235fd 100644 --- a/backend/common/urls.py +++ b/backend/common/urls.py @@ -1,4 +1,15 @@ 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 -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//active/", set_user_active, name="admin-user-active"), +] diff --git a/backend/core/settings.py b/backend/core/settings.py index b7ccc9c..776c35e 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -7,7 +7,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = config("SECRET_KEY", default="") 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) FRONTEND_PORT = config("FRONTEND_PORT", default=1028, cast=int) diff --git a/backend/core/urls.py b/backend/core/urls.py index d996e77..71a4969 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -1,7 +1,13 @@ from django.contrib import admin 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 = [ - 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")), ] diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..d0bb6ea --- /dev/null +++ b/frontend/.oxlintrc.json @@ -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" + } +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 2d2a1fe..4f65a60 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -9,13 +9,14 @@ server { 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-Proto $scheme; } - location /admin/ { + location /django-admin/ { proxy_pass http://backend:8000; 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-Proto $scheme; } location /static/ { @@ -23,6 +24,7 @@ server { proxy_set_header Host $http_host; } + # /admin/ et toutes les autres pages sont gérées par React. location / { try_files $uri $uri/ /index.html; } diff --git a/frontend/package.json b/frontend/package.json index ad07990..3536e38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite --host 0.0.0.0", "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": { "@fontsource-variable/geist": "^5.2.0", @@ -16,11 +18,13 @@ "lucide-react": "^0.468.0", "react": "^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": { "@tailwindcss/vite": "^4.1.0", "@vitejs/plugin-react": "^5.1.0", + "oxlint": "^1.76.0", "tailwindcss": "^4.1.0", "vite": "^7.2.0" } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 916647f..1e2cca4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,81 +1,9 @@ -import { useEffect, useState } from "react" -import { Activity, ArrowUpRight, Blocks, Database, Moon, RefreshCw, Server, Sun } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import HomePage from "@/pages/HomePage" +import AdminPage from "@/pages/AdminPage" +/** Le template garde un routeur minimal ; React Router pourra être ajouté plus tard. */ export default function App() { - const [theme, setTheme] = useState(() => localStorage.getItem("theme") || "light") - const [api, setApi] = useState({ loading: true, ok: false, message: "Connexion en cours..." }) - - 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 ( -
-
-
-
-
- Mon Projet - Template -
-
- - -
-
-
- -
-
-
- Django · React · MySQL -
-

Ton nouveau projet commence ici.

-

Une base propre avec Docker, API Django, React + Vite, composants shadcn et thème clair/sombre. Tu peux remplacer cette page par ton application.

-
- -
- -
API DjangoEndpoint de vérification : /api/health/
- -
{api.loading ? "Connexion..." : api.ok ? "Connectée" : "Indisponible"}
-

{api.message}

- -
-
- -
MySQLMySQL 8, volume persistant et Django ORM
-

La connexion est définie dans backend/core/settings.py à partir des variables MYSQL_*.

-
- -
FrontendReact, Vite, Tailwind CSS v4 et shadcn/ui
-

Composants de départ prêts à personnaliser dans frontend/src/components/ui/.

-
-
- -
- Prochaine étape : crée ton application Django dans backend/ et tes pages React dans frontend/src/. -
-
-
- ) + return window.location.pathname === "/admin" || window.location.pathname.startsWith("/admin/") + ? + : } diff --git a/frontend/src/components/admin/CreateUserDialog.jsx b/frontend/src/components/admin/CreateUserDialog.jsx new file mode 100644 index 0000000..48cf4b8 --- /dev/null +++ b/frontend/src/components/admin/CreateUserDialog.jsx @@ -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 + + + Nouvel utilisateurCrée un compte standard. Les permissions administrateur se gèrent dans Django. +
+
update("username", event.target.value)}/>
+
update("email", event.target.value)}/>
+
update("password", event.target.value)}/>
+ {error && {error}} +
+
+
+
+} diff --git a/frontend/src/components/admin/LoginForm.jsx b/frontend/src/components/admin/LoginForm.jsx new file mode 100644 index 0000000..48a1299 --- /dev/null +++ b/frontend/src/components/admin/LoginForm.jsx @@ -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
+ + +
+ Administration + Connecte-toi avec ton compte administrateur. +
+ +
+
setUsername(event.target.value)} autoComplete="username" required autoFocus/>
+
setPassword(event.target.value)} autoComplete="current-password" required/>
+ {error && {error}} + +
+
Session sécurisée par Django et protection CSRF
+
+
+
+} diff --git a/frontend/src/components/admin/UsersTable.jsx b/frontend/src/components/admin/UsersTable.jsx new file mode 100644 index 0000000..1f6cf85 --- /dev/null +++ b/frontend/src/components/admin/UsersTable.jsx @@ -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

Aucun utilisateur trouvé.

+ return + UtilisateurRôleStatutAction + {users.map((user) => { + const protectedUser = user.id === currentUser.id || user.is_superuser + return +
{initials(user)}
{[user.first_name, user.last_name].filter(Boolean).join(" ") || user.username}
{user.username}{user.email ? ` · ${user.email}` : ""}
+ {user.is_superuser ? "Superadmin" : user.is_staff ? "Admin" : "Utilisateur"} + {user.is_active ? "Actif" : "Désactivé"} + +
+ })}
+
+} diff --git a/frontend/src/components/layout/AppShell.jsx b/frontend/src/components/layout/AppShell.jsx new file mode 100644 index 0000000..7ec447d --- /dev/null +++ b/frontend/src/components/layout/AppShell.jsx @@ -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
+
+
+ + + Mon Projet + Template + + +
+
+ {children} +
+} diff --git a/frontend/src/components/ui/alert.jsx b/frontend/src/components/ui/alert.jsx new file mode 100644 index 0000000..23d4add --- /dev/null +++ b/frontend/src/components/ui/alert.jsx @@ -0,0 +1,5 @@ +import { cn } from "@/lib/utils" + +export function Alert({ className, variant = "default", ...props }) { + return
+} diff --git a/frontend/src/components/ui/badge.jsx b/frontend/src/components/ui/badge.jsx new file mode 100644 index 0000000..7786e7d --- /dev/null +++ b/frontend/src/components/ui/badge.jsx @@ -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 +} diff --git a/frontend/src/components/ui/dialog.jsx b/frontend/src/components/ui/dialog.jsx new file mode 100644 index 0000000..657c85c --- /dev/null +++ b/frontend/src/components/ui/dialog.jsx @@ -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 + + + {children} + + + +} +export function DialogHeader({ className, ...props }) { return
} +export function DialogTitle({ className, ...props }) { return } +export function DialogDescription({ className, ...props }) { return } diff --git a/frontend/src/components/ui/input.jsx b/frontend/src/components/ui/input.jsx new file mode 100644 index 0000000..4b87145 --- /dev/null +++ b/frontend/src/components/ui/input.jsx @@ -0,0 +1,5 @@ +import { cn } from "@/lib/utils" + +export function Input({ className, type = "text", ...props }) { + return +} diff --git a/frontend/src/components/ui/label.jsx b/frontend/src/components/ui/label.jsx new file mode 100644 index 0000000..e8df9a1 --- /dev/null +++ b/frontend/src/components/ui/label.jsx @@ -0,0 +1,5 @@ +import { cn } from "@/lib/utils" + +export function Label({ className, ...props }) { + return