diff --git a/ROADMAP.md b/ROADMAP.md index 10dad47..aa484d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,4 +123,8 @@ volgt. ## Later +- [ ] **Auto-rooster slimmer**: voorkeuren toevoegen (vak liever in de ochtend/ + middag, bepaalde dagen vrijhouden, max. blokken per dag per groep). +- [ ] **Algemene visuele opfris** (UX/uiterlijk). + - [ ] **Module: automatisch rooster genereren.** De gebruiker ge \ No newline at end of file diff --git a/backend/config/settings.py b/backend/config/settings.py index 810975e..d4763bb 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -157,6 +157,18 @@ STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles") # Geüploade bestanden (o.a. pictogrammen bij vakken). MEDIA_URL = "/media/" MEDIA_ROOT = os.environ.get("DJANGO_MEDIA_ROOT", str(BASE_DIR / "media")) + +# E-mail (voor o.a. wachtwoordreset). Zonder SMTP-config gaat e-mail naar de +# console-log (handig voor test); zet DJANGO_EMAIL_BACKEND/EMAIL_* in productie. +EMAIL_BACKEND = os.environ.get("DJANGO_EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend") +EMAIL_HOST = os.environ.get("EMAIL_HOST", "") +EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "587")) +EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") +EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1" +DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Roosterwijs ") +# Basis-URL van de frontend voor links in e-mails (leeg = afleiden uit het verzoek). +FRONTEND_BASE_URL = os.environ.get("FRONTEND_BASE_URL", "") DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # --- API ------------------------------------------------------------------ diff --git a/backend/core/urls.py b/backend/core/urls.py index 7b0f782..ab7b423 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -23,8 +23,5 @@ urlpatterns = [ path("info/", views.info, name="info"), path("conflicten/", views.conflicten, name="conflicten"), path("lokaalinstelling/", views.lokaalinstelling, name="lokaalinstelling"), - path("auth/me/", views.auth_me, name="auth-me"), - path("auth/login/", views.auth_login_view, name="auth-login"), - path("auth/logout/", views.auth_logout_view, name="auth-logout"), path("", include(router.urls)), ] diff --git a/backend/core/views.py b/backend/core/views.py index 4459fa3..3a9c477 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1,8 +1,4 @@ -import logging - -from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout -from django.views.decorators.csrf import ensure_csrf_cookie -from rest_framework import status, viewsets +from rest_framework import viewsets from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -22,7 +18,6 @@ from .serializers import ( SubgroepSerializer, TijdslotSerializer, ) -logger = logging.getLogger("roosterwijs.auth") @api_view(["GET"]) @@ -47,57 +42,6 @@ def info(request): ) -# --- Authenticatie ---------------------------------------------------------- - -def _user_payload(user): - return { - "authenticated": True, - "username": user.get_username(), - "is_staff": user.is_staff, - } - - -@api_view(["GET"]) -@permission_classes([AllowAny]) -@ensure_csrf_cookie -def auth_me(request): - """Wie ben ik? Zet tegelijk de CSRF-cookie voor de frontend.""" - if request.user.is_authenticated: - return Response(_user_payload(request.user)) - return Response({"authenticated": False}) - - -@api_view(["POST"]) -@permission_classes([AllowAny]) -def auth_login_view(request): - """Inloggen met gebruikersnaam + wachtwoord (sessie-cookie).""" - username = str(request.data.get("username", "")).strip() - password = str(request.data.get("password", "")) - if not username or not password: - return Response( - {"detail": "Vul gebruikersnaam en wachtwoord in."}, - status=status.HTTP_400_BAD_REQUEST, - ) - user = authenticate(request, username=username, password=password) - if user is None: - logger.warning("Mislukte inlogpoging voor gebruiker %r", username) - return Response( - {"detail": "Onjuiste gebruikersnaam of wachtwoord."}, - status=status.HTTP_401_UNAUTHORIZED, - ) - auth_login(request, user) - logger.info("Gebruiker %r ingelogd", username) - return Response(_user_payload(user)) - - -@api_view(["POST"]) -def auth_logout_view(request): - """Uitloggen: sessie beëindigen.""" - logger.info("Gebruiker %r uitgelogd", request.user.get_username()) - auth_logout(request) - return Response({"authenticated": False}) - - class GroepViewSet(viewsets.ModelViewSet): queryset = Groep.objects.all() serializer_class = GroepSerializer diff --git a/backend/modules/accounts/apps.py b/backend/modules/accounts/apps.py index 8d0c9a8..e149c03 100644 --- a/backend/modules/accounts/apps.py +++ b/backend/modules/accounts/apps.py @@ -15,14 +15,14 @@ class AccountsConfig(AppConfig): key="accounts", name="Multi-user & toegang", description=( - "Inloggen met rollen: leerling, ouder, leerkracht, " - "ondersteuner en collega-lagen (directie, administratie), " - "elk met eigen rechten en weergave." + "Inloggen, gebruikersbeheer, wachtwoordreset (door beheerder of " + "via e-maillink) en koppeling van logins aan personen. " + "Login werkt altijd; dit scherm beheert de toegang." ), version="0.1.0", category="Toegang", core=False, - default_enabled=False, + default_enabled=True, menu_items=( MenuItem("Gebruikers", "/gebruikers", icon="key", order=300, group="Instellingen"), ), diff --git a/backend/modules/accounts/urls.py b/backend/modules/accounts/urls.py index 7d560c1..ab30b5d 100644 --- a/backend/modules/accounts/urls.py +++ b/backend/modules/accounts/urls.py @@ -6,4 +6,12 @@ from . import views router = DefaultRouter() router.register("gebruikers", views.GebruikerViewSet, basename="gebruiker") -urlpatterns = [path("", include(router.urls))] +urlpatterns = [ + path("auth/me/", views.auth_me, name="auth-me"), + path("auth/login/", views.auth_login_view, name="auth-login"), + path("auth/logout/", views.auth_logout_view, name="auth-logout"), + path("auth/wachtwoord-vergeten/", views.wachtwoord_vergeten, name="wachtwoord-vergeten"), + path("auth/wachtwoord-reset/", views.wachtwoord_reset, name="wachtwoord-reset"), + path("gebruikers//wachtwoord/", views.wachtwoord_zet, name="wachtwoord-zet"), + path("", include(router.urls)), +] diff --git a/backend/modules/accounts/views.py b/backend/modules/accounts/views.py index d785aa0..b24282a 100644 --- a/backend/modules/accounts/views.py +++ b/backend/modules/accounts/views.py @@ -1,12 +1,125 @@ -from django.contrib.auth import get_user_model -from rest_framework import viewsets -from rest_framework.permissions import IsAdminUser +"""Accounts-module: login/logout, wie-ben-ik, gebruikersbeheer en wachtwoordreset. + +De login-endpoints horen hier thuis (de module is in de praktijk altijd aan). +""" +import logging +import secrets + +from django.contrib.auth import authenticate, get_user_model +from django.contrib.auth import login as auth_login +from django.contrib.auth import logout as auth_logout +from django.contrib.auth.tokens import default_token_generator +from django.core.mail import send_mail +from django.db.models import Q +from django.utils.encoding import force_bytes, force_str +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.views.decorators.csrf import ensure_csrf_cookie +from rest_framework import status, viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.response import Response from .serializers import GebruikerSerializer +logger = logging.getLogger("roosterwijs.auth") User = get_user_model() +def _user_payload(user): + return { + "authenticated": True, + "username": user.get_username(), + "is_staff": user.is_staff, + } + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +@ensure_csrf_cookie +def auth_me(request): + """Wie ben ik? Zet tegelijk de CSRF-cookie voor de frontend.""" + if request.user.is_authenticated: + return Response(_user_payload(request.user)) + return Response({"authenticated": False}) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def auth_login_view(request): + """Inloggen met gebruikersnaam of e-mailadres + wachtwoord (sessie-cookie).""" + naam = str(request.data.get("username", "")).strip() + wachtwoord = str(request.data.get("password", "")) + if not naam or not wachtwoord: + return Response({"detail": "Vul gebruikersnaam en wachtwoord in."}, status=status.HTTP_400_BAD_REQUEST) + # Sta ook inloggen met e-mailadres toe: zoek de bijbehorende gebruikersnaam op. + gebruikersnaam = naam + if "@" in naam: + match = User.objects.filter(email__iexact=naam).first() + if match: + gebruikersnaam = match.get_username() + user = authenticate(request, username=gebruikersnaam, password=wachtwoord) + if user is None: + logger.warning("Mislukte inlogpoging voor %r", naam) + return Response({"detail": "Onjuiste gebruikersnaam/e-mail of wachtwoord."}, status=status.HTTP_401_UNAUTHORIZED) + auth_login(request, user) + logger.info("Gebruiker %r ingelogd", user.get_username()) + return Response(_user_payload(user)) + + +@api_view(["POST"]) +def auth_logout_view(request): + """Uitloggen: sessie beëindigen.""" + logger.info("Gebruiker %r uitgelogd", request.user.get_username()) + auth_logout(request) + return Response({"authenticated": False}) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def wachtwoord_vergeten(request): + """Stuur (indien het account bestaat) een resetlink per e-mail.""" + ident = str(request.data.get("identifier", "")).strip() + if ident: + user = User.objects.filter(Q(username__iexact=ident) | Q(email__iexact=ident)).first() + if user and user.email and user.is_active: + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = default_token_generator.make_token(user) + from django.conf import settings + basis = (getattr(settings, "FRONTEND_BASE_URL", "") or request.build_absolute_uri("/")).rstrip("/") + link = f"{basis}/?reset=1&uid={uid}&token={token}" + send_mail( + "Wachtwoord opnieuw instellen — Roosterwijs", + f"Hallo {user.get_username()},\n\n" + f"Stel je wachtwoord opnieuw in via deze link:\n{link}\n\n" + f"Heb je dit niet aangevraagd? Dan kun je deze e-mail negeren.", + None, [user.email], fail_silently=True, + ) + # Altijd hetzelfde antwoord: lek niet of een account bestaat. + return Response({"detail": "Als het account bestaat, is er een e-mail met instructies verstuurd."}) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def wachtwoord_reset(request): + """Zet een nieuw wachtwoord op basis van een geldige resetlink (uid + token).""" + uidb64 = request.data.get("uid") + token = request.data.get("token") + nieuw = str(request.data.get("wachtwoord", "")) + if len(nieuw) < 8: + return Response({"detail": "Het wachtwoord moet minstens 8 tekens zijn."}, status=400) + user = None + try: + user = User.objects.get(pk=force_str(urlsafe_base64_decode(uidb64))) + except Exception: + user = None + if not user or not default_token_generator.check_token(user, token): + return Response({"detail": "Deze resetlink is ongeldig of verlopen."}, status=400) + user.set_password(nieuw) + user.save() + logger.info("Wachtwoord gereset voor %r", user.get_username()) + return Response({"detail": "Je wachtwoord is aangepast. Je kunt nu inloggen."}) + + class GebruikerViewSet(viewsets.ModelViewSet): """Gebruikersbeheer — alleen voor beheerders (is_staff).""" @@ -15,3 +128,20 @@ class GebruikerViewSet(viewsets.ModelViewSet): def get_queryset(self): return User.objects.select_related("gebruikersprofiel", "gebruikersprofiel__persoon").order_by("username") + + +@api_view(["POST"]) +@permission_classes([IsAdminUser]) +def wachtwoord_zet(request, pk): + """Beheerder zet/genereert een (tijdelijk) wachtwoord; geeft het terug.""" + try: + user = User.objects.get(pk=pk) + except User.DoesNotExist: + return Response({"detail": "Gebruiker niet gevonden."}, status=404) + nieuw = str(request.data.get("wachtwoord", "")).strip() or secrets.token_urlsafe(9) + if len(nieuw) < 8: + return Response({"detail": "Het wachtwoord moet minstens 8 tekens zijn."}, status=400) + user.set_password(nieuw) + user.save() + logger.info("Beheerder %r zette nieuw wachtwoord voor %r", request.user.get_username(), user.get_username()) + return Response({"wachtwoord": nieuw, "gebruiker": user.get_username()}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3e4207e..aeeff54 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { getMe, login, logout, getModules, setModuleState } from "./api.js"; +import { getMe, login, logout, getModules, setModuleState, wachtwoordVergeten } from "./api.js"; import Logo from "./Logo.jsx"; import { PersonenPage, FunctiesPage, @@ -7,7 +7,7 @@ import { RoosterWeergavePage, RoostermakerPage, AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage, LeerlingRoosterPage, OuderRoosterPage, - VakWizardPage, PersonenWizardPage, AutoRoosterPage, + VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage, } from "./pages.jsx"; const ICONS = { @@ -44,6 +44,14 @@ const PAGES = { "/auto-rooster": AutoRoosterPage, }; +const RESET_PARAMS = (() => { + try { + const q = new URLSearchParams(window.location.search); + if (q.get("reset") && q.get("uid") && q.get("token")) return { uid: q.get("uid"), token: q.get("token") }; + } catch (e) { /* geen window */ } + return null; +})(); + export default function App() { // null = nog aan het controleren; {authenticated:false} = login tonen. const [user, setUser] = useState(null); @@ -56,6 +64,7 @@ export default function App() { .finally(() => setChecking(false)); }, []); + if (RESET_PARAMS) return ; if (checking) return

Laden…

; if (!user || !user.authenticated) { return ; @@ -69,6 +78,9 @@ function LoginPage({ onLogin }) { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [vergeten, setVergeten] = useState(false); + const [ident, setIdent] = useState(""); + const [verzonden, setVerzonden] = useState(false); async function submit(e) { e.preventDefault(); @@ -82,6 +94,44 @@ function LoginPage({ onLogin }) { setBusy(false); } } + async function vergetenSubmit(e) { + e.preventDefault(); + setBusy(true); setError(null); + try { + const r = await wachtwoordVergeten(ident); + setVerzonden(true); + setError(null); + void r; + } catch (err) { setError(err.message); } + finally { setBusy(false); } + } + + if (vergeten) { + return ( +
+
+
+ + Roosterwijs +
+ {verzonden ? ( + <> +

Als het account bestaat, is er een e-mail met een resetlink verstuurd. Controleer je mailbox.

+ + + ) : ( + <> +

Vul je gebruikersnaam of e-mailadres in; je ontvangt een resetlink.

+ {error &&
⚠ {error}
} + setIdent(e.target.value)} /> + + + + )} +
+
+ ); + } return (
@@ -111,6 +161,7 @@ function LoginPage({ onLogin }) { +
); diff --git a/frontend/src/api.js b/frontend/src/api.js index 4a25fb5..22413a6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -82,3 +82,8 @@ export async function uploadBestand(resource, id, veld, file) { } return res.json(); } + +// --- Wachtwoordreset -------------------------------------------------------- +export const wachtwoordVergeten = (identifier) => request("POST", "/auth/wachtwoord-vergeten/", { identifier }); +export const wachtwoordReset = (uid, token, wachtwoord) => request("POST", "/auth/wachtwoord-reset/", { uid, token, wachtwoord }); +export const adminWachtwoordReset = (id, wachtwoord) => request("POST", `/gebruikers/${id}/wachtwoord/`, wachtwoord ? { wachtwoord } : {}); diff --git a/frontend/src/pages.jsx b/frontend/src/pages.jsx index 494e78a..daafe3c 100644 --- a/frontend/src/pages.jsx +++ b/frontend/src/pages.jsx @@ -8,6 +8,8 @@ import { getLokaalinstelling, setLokaalinstelling, uploadBestand, + wachtwoordReset, + adminWachtwoordReset, } from "./api.js"; const ROLLEN = [ @@ -1398,9 +1400,36 @@ export function GebruikersPage() { const leeg = { username: "", password: "", first_name: "", last_name: "", email: "", rol: "medewerker", persoon: "", is_staff: false, is_active: true }; const [form, setForm] = useState(leeg); const [editId, setEditId] = useState(null); + const [gegenereerd, setGegenereerd] = useState(null); useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []); + function kiesPersoon(id) { + const pp = personen.find((x) => x.id === Number(id)); + setForm((f) => { + const nf = { ...f, persoon: id }; + if (pp) { + if (!f.first_name) nf.first_name = pp.voornaam || ""; + if (!f.last_name) nf.last_name = pp.achternaam || ""; + if (!f.username) nf.username = `${pp.voornaam || ""}.${pp.achternaam || ""}`.toLowerCase().replace(/\s+/g, ""); + } + return nf; + }); + } + async function genereerWachtwoord() { + setError(null); setGegenereerd(null); + if (editId) { + try { const r = await adminWachtwoordReset(editId); setGegenereerd(r.wachtwoord); } + catch (e) { setError(e.message); } + } else { + const tekens = "abcdefghjkmnpqrstuvwxyz23456789"; + const bytes = (window.crypto || {}).getRandomValues ? window.crypto.getRandomValues(new Uint8Array(10)) : Array.from({ length: 10 }, () => Math.floor(Math.random() * 256)); + const w = Array.from(bytes).map((b) => tekens[b % tekens.length]).join(""); + setForm((f) => ({ ...f, password: w })); + setGegenereerd(w); + } + } + async function opslaan(e) { e.preventDefault(); const payload = { @@ -1423,18 +1452,21 @@ export function GebruikersPage() { } function bewerk(u) { setEditId(u.id); + setGegenereerd(null); setForm({ username: u.username, password: "", first_name: u.first_name || "", last_name: u.last_name || "", email: u.email || "", rol: u.rol || "medewerker", persoon: u.persoon || "", is_staff: u.is_staff, is_active: u.is_active }); } return (

Gebruikers

-

Beheer inloggers en hun rol. Alleen beheerders kunnen dit scherm gebruiken.

+

Beheer inloggers en hun rol; koppel een login aan een persoon. Alleen beheerders kunnen dit scherm gebruiken.

+ {gegenereerd &&
Tijdelijk wachtwoord: {gegenereerd} — geef dit door aan de gebruiker; laat het daarna wijzigen.
}
setForm({ ...form, username: e.target.value })} /> - setForm({ ...form, password: e.target.value })} /> + setForm({ ...form, password: e.target.value })} /> + @@ -1445,7 +1477,7 @@ export function GebruikersPage() { setForm({ ...form, email: e.target.value })} />
- kiesPersoon(e.target.value)}> {personen.map((p) => )} @@ -3338,3 +3370,42 @@ export function AutoRoosterPage() {
); } + +/* --------------------------- Wachtwoord opnieuw --------------------------- */ +export function WachtwoordResetPage({ uid, token }) { + const [w1, setW1] = useState(""); + const [w2, setW2] = useState(""); + const [error, setError] = useState(null); + const [klaar, setKlaar] = useState(false); + const [busy, setBusy] = useState(false); + async function submit(e) { + e.preventDefault(); + if (w1.length < 8) { setError("Het wachtwoord moet minstens 8 tekens zijn."); return; } + if (w1 !== w2) { setError("De twee wachtwoorden zijn niet gelijk."); return; } + setBusy(true); setError(null); + try { await wachtwoordReset(uid, token, w1); setKlaar(true); } + catch (err) { setError(err.message); } + finally { setBusy(false); } + } + return ( +
+ +
Roosterwijs
+ {klaar ? ( + <> +

Je wachtwoord is aangepast. Je kunt nu inloggen.

+ Naar inloggen + + ) : ( + <> +

Kies een nieuw wachtwoord (minstens 8 tekens).

+ {error &&
⚠ {error}
} + setW1(e.target.value)} /> + setW2(e.target.value)} /> + + + )} + +
+ ); +}