accounts-module: login+e-mail, wachtwoordreset (admin + e-maillink), persoon-integratie

This commit is contained in:
bes-r 2026-06-17 15:14:36 +02:00
parent 48a72fd199
commit e38eacefcb
10 changed files with 295 additions and 73 deletions

View file

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

View file

@ -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 <noreply@roosterwijs.local>")
# 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 ------------------------------------------------------------------

View file

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

View file

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

View file

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

View file

@ -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/<int:pk>/wachtwoord/", views.wachtwoord_zet, name="wachtwoord-zet"),
path("", include(router.urls)),
]

View file

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

View file

@ -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 <WachtwoordResetPage uid={RESET_PARAMS.uid} token={RESET_PARAMS.token} />;
if (checking) return <p className="center-msg">Laden</p>;
if (!user || !user.authenticated) {
return <LoginPage onLogin={setUser} />;
@ -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 (
<div className="login-wrap">
<form className="login-card" onSubmit={vergetenSubmit}>
<div className="brand login-brand">
<span className="brand-logo"><Logo size={36} /></span>
<span className="brand-name">Roosterwijs</span>
</div>
{verzonden ? (
<>
<p className="muted">Als het account bestaat, is er een e-mail met een resetlink verstuurd. Controleer je mailbox.</p>
<button type="button" className="btn primary" onClick={() => { setVergeten(false); setVerzonden(false); setIdent(""); }}>Terug naar inloggen</button>
</>
) : (
<>
<p className="muted">Vul je gebruikersnaam of e-mailadres in; je ontvangt een resetlink.</p>
{error && <div className="banner error"> {error}</div>}
<input placeholder="Gebruikersnaam of e-mail" value={ident} required autoFocus onChange={(e) => setIdent(e.target.value)} />
<button className="btn primary" type="submit" disabled={busy}>{busy ? "Bezig…" : "Stuur resetlink"}</button>
<button type="button" className="link" onClick={() => { setVergeten(false); setError(null); }}>Terug naar inloggen</button>
</>
)}
</form>
</div>
);
}
return (
<div className="login-wrap">
@ -111,6 +161,7 @@ function LoginPage({ onLogin }) {
<button className="btn primary" type="submit" disabled={busy}>
{busy ? "Bezig…" : "Inloggen"}
</button>
<button type="button" className="link" onClick={() => { setVergeten(true); setError(null); }}>Wachtwoord vergeten?</button>
</form>
</div>
);

View file

@ -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 } : {});

View file

@ -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 (
<section>
<h1>Gebruikers</h1>
<p className="muted">Beheer inloggers en hun rol. Alleen beheerders kunnen dit scherm gebruiken.</p>
<p className="muted">Beheer inloggers en hun rol; koppel een login aan een persoon. Alleen beheerders kunnen dit scherm gebruiken.</p>
<Foutmelding msg={error} />
{gegenereerd && <div className="banner ok">Tijdelijk wachtwoord: <code>{gegenereerd}</code> geef dit door aan de gebruiker; laat het daarna wijzigen.</div>}
<form className="form-card" onSubmit={opslaan}>
<div className="form-row">
<input placeholder="Gebruikersnaam" value={form.username} required onChange={(e) => setForm({ ...form, username: e.target.value })} />
<input type="password" placeholder={editId ? "Wachtwoord (leeg = ongewijzigd)" : "Wachtwoord"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
<input type="text" placeholder={editId ? "Wachtwoord (leeg = ongewijzigd)" : "Wachtwoord"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
<button type="button" className="btn" onClick={genereerWachtwoord} title="Genereer een tijdelijk wachtwoord">Genereer</button>
<select value={form.rol} onChange={(e) => setForm({ ...form, rol: e.target.value })}>
{GEBRUIKERSROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
@ -1445,7 +1477,7 @@ export function GebruikersPage() {
<input type="email" placeholder="E-mail" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div className="form-row">
<select value={form.persoon || ""} onChange={(e) => setForm({ ...form, persoon: e.target.value })}>
<select value={form.persoon || ""} onChange={(e) => kiesPersoon(e.target.value)}>
<option value=""> koppel aan persoon (optioneel) </option>
{personen.map((p) => <option key={p.id} value={p.id}>{p.volledige_naam} ({p.rol_label})</option>)}
</select>
@ -3338,3 +3370,42 @@ export function AutoRoosterPage() {
</section>
);
}
/* --------------------------- 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 (
<div className="login-wrap">
<form className="login-card" onSubmit={submit}>
<div className="brand login-brand"><span className="brand-name">Roosterwijs</span></div>
{klaar ? (
<>
<p className="muted">Je wachtwoord is aangepast. Je kunt nu inloggen.</p>
<a className="btn primary" href="/">Naar inloggen</a>
</>
) : (
<>
<p className="muted">Kies een nieuw wachtwoord (minstens 8 tekens).</p>
{error && <div className="banner error"> {error}</div>}
<input type="password" placeholder="Nieuw wachtwoord" value={w1} required autoFocus onChange={(e) => setW1(e.target.value)} />
<input type="password" placeholder="Herhaal wachtwoord" value={w2} required onChange={(e) => setW2(e.target.value)} />
<button className="btn primary" type="submit" disabled={busy}>{busy ? "Bezig…" : "Wachtwoord opslaan"}</button>
</>
)}
</form>
</div>
);
}