accounts-module: login+e-mail, wachtwoordreset (admin + e-maillink), persoon-integratie
This commit is contained in:
parent
48a72fd199
commit
e38eacefcb
10 changed files with 295 additions and 73 deletions
|
|
@ -123,4 +123,8 @@ volgt.
|
||||||
|
|
||||||
## Later
|
## 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
|
- [ ] **Module: automatisch rooster genereren.** De gebruiker ge
|
||||||
|
|
@ -157,6 +157,18 @@ STATIC_ROOT = os.environ.get("DJANGO_STATIC_ROOT", str(BASE_DIR / "staticfiles")
|
||||||
# Geüploade bestanden (o.a. pictogrammen bij vakken).
|
# Geüploade bestanden (o.a. pictogrammen bij vakken).
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "/media/"
|
||||||
MEDIA_ROOT = os.environ.get("DJANGO_MEDIA_ROOT", str(BASE_DIR / "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"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# --- API ------------------------------------------------------------------
|
# --- API ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,5 @@ urlpatterns = [
|
||||||
path("info/", views.info, name="info"),
|
path("info/", views.info, name="info"),
|
||||||
path("conflicten/", views.conflicten, name="conflicten"),
|
path("conflicten/", views.conflicten, name="conflicten"),
|
||||||
path("lokaalinstelling/", views.lokaalinstelling, name="lokaalinstelling"),
|
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)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
import logging
|
from rest_framework import viewsets
|
||||||
|
|
||||||
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.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
@ -22,7 +18,6 @@ from .serializers import (
|
||||||
SubgroepSerializer, TijdslotSerializer,
|
SubgroepSerializer, TijdslotSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("roosterwijs.auth")
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@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):
|
class GroepViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Groep.objects.all()
|
queryset = Groep.objects.all()
|
||||||
serializer_class = GroepSerializer
|
serializer_class = GroepSerializer
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,14 @@ class AccountsConfig(AppConfig):
|
||||||
key="accounts",
|
key="accounts",
|
||||||
name="Multi-user & toegang",
|
name="Multi-user & toegang",
|
||||||
description=(
|
description=(
|
||||||
"Inloggen met rollen: leerling, ouder, leerkracht, "
|
"Inloggen, gebruikersbeheer, wachtwoordreset (door beheerder of "
|
||||||
"ondersteuner en collega-lagen (directie, administratie), "
|
"via e-maillink) en koppeling van logins aan personen. "
|
||||||
"elk met eigen rechten en weergave."
|
"Login werkt altijd; dit scherm beheert de toegang."
|
||||||
),
|
),
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
category="Toegang",
|
category="Toegang",
|
||||||
core=False,
|
core=False,
|
||||||
default_enabled=False,
|
default_enabled=True,
|
||||||
menu_items=(
|
menu_items=(
|
||||||
MenuItem("Gebruikers", "/gebruikers", icon="key", order=300, group="Instellingen"),
|
MenuItem("Gebruikers", "/gebruikers", icon="key", order=300, group="Instellingen"),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,12 @@ from . import views
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register("gebruikers", views.GebruikerViewSet, basename="gebruiker")
|
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)),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,125 @@
|
||||||
from django.contrib.auth import get_user_model
|
"""Accounts-module: login/logout, wie-ben-ik, gebruikersbeheer en wachtwoordreset.
|
||||||
from rest_framework import viewsets
|
|
||||||
from rest_framework.permissions import IsAdminUser
|
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
|
from .serializers import GebruikerSerializer
|
||||||
|
|
||||||
|
logger = logging.getLogger("roosterwijs.auth")
|
||||||
User = get_user_model()
|
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):
|
class GebruikerViewSet(viewsets.ModelViewSet):
|
||||||
"""Gebruikersbeheer — alleen voor beheerders (is_staff)."""
|
"""Gebruikersbeheer — alleen voor beheerders (is_staff)."""
|
||||||
|
|
||||||
|
|
@ -15,3 +128,20 @@ class GebruikerViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return User.objects.select_related("gebruikersprofiel", "gebruikersprofiel__persoon").order_by("username")
|
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()})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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 Logo from "./Logo.jsx";
|
||||||
import {
|
import {
|
||||||
PersonenPage, FunctiesPage,
|
PersonenPage, FunctiesPage,
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
RoosterWeergavePage, RoostermakerPage,
|
RoosterWeergavePage, RoostermakerPage,
|
||||||
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
|
AfwezigheidPage, GroepsindelingPage, StagePage, GebruikersPage, UrenPage,
|
||||||
LeerlingRoosterPage, OuderRoosterPage,
|
LeerlingRoosterPage, OuderRoosterPage,
|
||||||
VakWizardPage, PersonenWizardPage, AutoRoosterPage,
|
VakWizardPage, PersonenWizardPage, AutoRoosterPage, WachtwoordResetPage,
|
||||||
} from "./pages.jsx";
|
} from "./pages.jsx";
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
|
|
@ -44,6 +44,14 @@ const PAGES = {
|
||||||
"/auto-rooster": AutoRoosterPage,
|
"/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() {
|
export default function App() {
|
||||||
// null = nog aan het controleren; {authenticated:false} = login tonen.
|
// null = nog aan het controleren; {authenticated:false} = login tonen.
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
|
|
@ -56,6 +64,7 @@ export default function App() {
|
||||||
.finally(() => setChecking(false));
|
.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 (checking) return <p className="center-msg">Laden…</p>;
|
||||||
if (!user || !user.authenticated) {
|
if (!user || !user.authenticated) {
|
||||||
return <LoginPage onLogin={setUser} />;
|
return <LoginPage onLogin={setUser} />;
|
||||||
|
|
@ -69,6 +78,9 @@ function LoginPage({ onLogin }) {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [vergeten, setVergeten] = useState(false);
|
||||||
|
const [ident, setIdent] = useState("");
|
||||||
|
const [verzonden, setVerzonden] = useState(false);
|
||||||
|
|
||||||
async function submit(e) {
|
async function submit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -82,6 +94,44 @@ function LoginPage({ onLogin }) {
|
||||||
setBusy(false);
|
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 (
|
return (
|
||||||
<div className="login-wrap">
|
<div className="login-wrap">
|
||||||
|
|
@ -111,6 +161,7 @@ function LoginPage({ onLogin }) {
|
||||||
<button className="btn primary" type="submit" disabled={busy}>
|
<button className="btn primary" type="submit" disabled={busy}>
|
||||||
{busy ? "Bezig…" : "Inloggen"}
|
{busy ? "Bezig…" : "Inloggen"}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="link" onClick={() => { setVergeten(true); setError(null); }}>Wachtwoord vergeten?</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -82,3 +82,8 @@ export async function uploadBestand(resource, id, veld, file) {
|
||||||
}
|
}
|
||||||
return res.json();
|
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 } : {});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
getLokaalinstelling,
|
getLokaalinstelling,
|
||||||
setLokaalinstelling,
|
setLokaalinstelling,
|
||||||
uploadBestand,
|
uploadBestand,
|
||||||
|
wachtwoordReset,
|
||||||
|
adminWachtwoordReset,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
const ROLLEN = [
|
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 leeg = { username: "", password: "", first_name: "", last_name: "", email: "", rol: "medewerker", persoon: "", is_staff: false, is_active: true };
|
||||||
const [form, setForm] = useState(leeg);
|
const [form, setForm] = useState(leeg);
|
||||||
const [editId, setEditId] = useState(null);
|
const [editId, setEditId] = useState(null);
|
||||||
|
const [gegenereerd, setGegenereerd] = useState(null);
|
||||||
|
|
||||||
useEffect(() => { listResource("personen").then(setPersonen).catch(() => {}); }, []);
|
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) {
|
async function opslaan(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
@ -1423,18 +1452,21 @@ export function GebruikersPage() {
|
||||||
}
|
}
|
||||||
function bewerk(u) {
|
function bewerk(u) {
|
||||||
setEditId(u.id);
|
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 });
|
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 (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1>Gebruikers</h1>
|
<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} />
|
<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}>
|
<form className="form-card" onSubmit={opslaan}>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input placeholder="Gebruikersnaam" value={form.username} required onChange={(e) => setForm({ ...form, username: e.target.value })} />
|
<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 })}>
|
<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>)}
|
{GEBRUIKERSROLLEN.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||||
</select>
|
</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 })} />
|
<input type="email" placeholder="E-mail" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<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>
|
<option value="">— koppel aan persoon (optioneel) —</option>
|
||||||
{personen.map((p) => <option key={p.id} value={p.id}>{p.volledige_naam} ({p.rol_label})</option>)}
|
{personen.map((p) => <option key={p.id} value={p.id}>{p.volledige_naam} ({p.rol_label})</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -3338,3 +3370,42 @@ export function AutoRoosterPage() {
|
||||||
</section>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue