This commit is contained in:
Jens
2026-07-24 22:46:37 +02:00
parent 0101660bd7
commit bdf44eec04
26 changed files with 1770 additions and 24 deletions
+18
View File
@@ -51,6 +51,24 @@ OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=
OLLAMA_TIMEOUT_SECONDS=60
# Microsoft Entra ID (Azure AD) SSO. Laat ENTRA_ID_ENABLED=0 om SSO uit te zetten.
# Registreer een app in Entra ID en zet de redirect-URI op <host>/auth/entra/callback/.
ENTRA_ID_ENABLED=0
ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET=CHANGE_ME_entra_client_secret
ENTRA_TENANT_ID=
# Optioneel: overschrijf de redirect-URI (anders automatisch afgeleid van de host).
ENTRA_REDIRECT_URI=
# Optioneel: beperk tot bepaalde e-maildomeinen, komma-gescheiden (bv. organisatie.nl).
ENTRA_ALLOWED_DOMAINS=
ENTRA_SCOPES=User.Read
# Demo-toegang: knop "Bekijk demo" logt in als afgeschermd gastaccount met een
# fictieve, gevulde demo-omgeving. Zet op 0 om de knop te verbergen.
DEMO_MODE_ENABLED=1
DEMO_USERNAME=demo
DEMO_EMAIL=demo@vacatureradar.local
# Beveiliging
SESSION_COOKIE_SECURE=0
CSRF_COOKIE_SECURE=0
+48
View File
@@ -0,0 +1,48 @@
"""Authenticatie-backends voor VacatureRadar.
Sta inloggen toe met e-mailadres *of* gebruikersnaam, zodat het Stitch-loginscherm
(dat om een e-mailadres vraagt) blijft werken zonder het bestaande admin-login op
gebruikersnaam te breken.
"""
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.db.models import Q
class EmailOrUsernameModelBackend(ModelBackend):
"""Zoekt de gebruiker op via gebruikersnaam of (case-insensitief) e-mailadres."""
def authenticate(self, request, username=None, password=None, **kwargs):
user_model = get_user_model()
if username is None:
username = kwargs.get(user_model.USERNAME_FIELD)
if username is None or password is None:
return None
try:
user = user_model.objects.get(
Q(**{f"{user_model.USERNAME_FIELD}__iexact": username})
| Q(email__iexact=username)
)
except user_model.DoesNotExist:
# Draai toch een hash om timing-aanvallen (user-enumeratie) te beperken.
user_model().set_password(password)
return None
except user_model.MultipleObjectsReturned:
# Meerdere accounts met dezelfde e-mail: val terug op exacte username-match.
user = (
user_model.objects.filter(
**{f"{user_model.USERNAME_FIELD}__iexact": username}
)
.order_by("id")
.first()
)
if user is None:
return None
if user.check_password(password) and self.user_can_authenticate(user):
return user
return None
+107
View File
@@ -0,0 +1,107 @@
"""Microsoft Entra ID (Azure AD) SSO via de OAuth2 authorization-code flow.
De ``msal``-bibliotheek wordt bewust *lazy* geïmporteerd: zolang SSO uit staat
(``ENTRA_ID_ENABLED=0``) hoeft het pakket niet geïnstalleerd te zijn en blijven de
bestaande tests en de kernapplicatie volledig werken.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.conf import settings
class EntraConfigError(RuntimeError):
"""Entra ID is ingeschakeld maar niet (volledig) geconfigureerd."""
@dataclass(frozen=True)
class EntraConfig:
client_id: str
client_secret: str
tenant_id: str
scopes: list[str]
allowed_domains: tuple[str, ...]
redirect_uri: str | None
@property
def authority(self) -> str:
return f"https://login.microsoftonline.com/{self.tenant_id}"
def entra_enabled() -> bool:
return bool(getattr(settings, "ENTRA_ID_ENABLED", False))
def load_config() -> EntraConfig:
client_id = getattr(settings, "ENTRA_CLIENT_ID", "") or ""
client_secret = getattr(settings, "ENTRA_CLIENT_SECRET", "") or ""
tenant_id = getattr(settings, "ENTRA_TENANT_ID", "") or ""
if not (client_id and client_secret and tenant_id):
raise EntraConfigError(
"Entra ID staat aan maar ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET en "
"ENTRA_TENANT_ID zijn niet allemaal ingesteld."
)
scopes = list(getattr(settings, "ENTRA_SCOPES", ["User.Read"]))
allowed = tuple(
d.lower().lstrip("@")
for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", [])
if d
)
return EntraConfig(
client_id=client_id,
client_secret=client_secret,
tenant_id=tenant_id,
scopes=scopes,
allowed_domains=allowed,
redirect_uri=getattr(settings, "ENTRA_REDIRECT_URI", "") or None,
)
def _build_app(config: EntraConfig):
try:
import msal # lazy import: alleen nodig als SSO aan staat
except ModuleNotFoundError as exc: # pragma: no cover - hangt van omgeving af
raise EntraConfigError(
"Het pakket 'msal' is niet geïnstalleerd. Voeg het toe (zie pyproject.toml) "
"om Entra ID SSO te gebruiken."
) from exc
return msal.ConfidentialClientApplication(
client_id=config.client_id,
client_credential=config.client_secret,
authority=config.authority,
)
def build_auth_flow(config: EntraConfig, redirect_uri: str) -> dict:
"""Start de authorization-code flow en geef het flow-dict terug (bewaar in sessie)."""
app = _build_app(config)
return app.initiate_auth_code_flow(config.scopes, redirect_uri=redirect_uri)
def redeem_auth_code(config: EntraConfig, flow: dict, auth_response: dict) -> dict:
"""Wissel de teruggekeerde code in voor tokens en claims."""
app = _build_app(config)
return app.acquire_token_by_auth_code_flow(flow, auth_response)
def extract_identity(token_result: dict) -> tuple[str, str]:
"""Haal (e-mailadres, weergavenaam) uit de id-token-claims."""
claims = token_result.get("id_token_claims") or {}
email = (
claims.get("preferred_username")
or claims.get("email")
or claims.get("upn")
or ""
).strip()
name = (claims.get("name") or "").strip()
return email, name
def domain_allowed(config: EntraConfig, email: str) -> bool:
if not config.allowed_domains:
return True
domain = email.rsplit("@", 1)[-1].lower()
return domain in config.allowed_domains
+33
View File
@@ -0,0 +1,33 @@
"""Formulieren voor authenticatie."""
from __future__ import annotations
from django.contrib.auth.forms import AuthenticationForm
from django.utils.translation import gettext_lazy as _
class EmailAuthenticationForm(AuthenticationForm):
"""Loginformulier dat het ``username``-veld presenteert als e-mailadres.
Het veld blijft ``username`` heten zodat ``LoginView`` en de authenticatie-backend
onveranderd werken; alleen label, invoertype en foutmelding zijn aangepast.
"""
error_messages = {
**AuthenticationForm.error_messages,
"invalid_login": _("E-mailadres of wachtwoord klopt niet."),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
field = self.fields["username"]
field.label = _("E-mailadres")
field.widget.attrs.update(
{
"type": "email",
"autocomplete": "username",
"autocapitalize": "none",
"spellcheck": "false",
"placeholder": "naam@organisatie.nl",
}
)
+168
View File
@@ -1,12 +1,21 @@
from __future__ import annotations
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model, login
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView
from django.http import JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from django.views.generic import TemplateView
from apps.core import entra as entra_sso
from apps.core.forms import EmailAuthenticationForm
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
from apps.jobs.services.cockpit import build_dashboard_cockpit
from apps.profiles.models import SearchProfile
@@ -15,9 +24,32 @@ from apps.sources.services.health import collect_source_health
from .health import readiness
from .services.automation import build_automation_cockpit
logger = logging.getLogger(__name__)
SSO_BACKEND = "apps.core.auth_backends.EmailOrUsernameModelBackend"
SESSION_ENTRA_FLOW = "entra_auth_flow"
SESSION_ENTRA_NEXT = "entra_next"
def _safe_next(request, candidate: str | None) -> str | None:
if candidate and url_has_allowed_host_and_scheme(
candidate,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return candidate
return None
class SecurityAwareLoginView(LoginView):
template_name = "registration/login.html"
authentication_form = EmailAuthenticationForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["entra_enabled"] = entra_sso.entra_enabled()
context["demo_enabled"] = bool(getattr(settings, "DEMO_MODE_ENABLED", False))
return context
@staticmethod
def _remaining_minutes(seconds: int) -> str:
@@ -68,6 +100,142 @@ class SecurityAwareLoginView(LoginView):
return super().form_valid(form)
def _split_name(full_name: str) -> tuple[str, str]:
parts = full_name.split()
if not parts:
return "", ""
if len(parts) == 1:
return parts[0][:150], ""
return parts[0][:150], " ".join(parts[1:])[:150]
def _unique_username(base: str) -> str:
user_model = get_user_model()
base = (base or "gebruiker")[:140].strip() or "gebruiker"
candidate = base
suffix = 1
while user_model.objects.filter(username__iexact=candidate).exists():
suffix += 1
candidate = f"{base}-{suffix}"
return candidate
def _get_or_create_sso_user(email: str, full_name: str):
user_model = get_user_model()
user = user_model.objects.filter(email__iexact=email).order_by("id").first()
first, last = _split_name(full_name)
if user is None:
user = user_model(
username=_unique_username(email.split("@", 1)[0]),
email=email,
first_name=first,
last_name=last,
)
user.set_unusable_password()
user.save()
elif full_name and not (user.first_name or user.last_name):
user.first_name, user.last_name = first, last
user.save(update_fields=["first_name", "last_name"])
return user
@require_POST
def entra_login(request):
"""Start de Entra ID authorization-code flow."""
if not entra_sso.entra_enabled():
messages.error(request, "Inloggen via Entra ID is niet ingeschakeld.")
return redirect("login")
try:
config = entra_sso.load_config()
redirect_uri = config.redirect_uri or request.build_absolute_uri(
reverse("entra-callback")
)
flow = entra_sso.build_auth_flow(config, redirect_uri)
except entra_sso.EntraConfigError as exc:
logger.warning("Entra ID niet beschikbaar: %s", exc)
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
return redirect("login")
request.session[SESSION_ENTRA_FLOW] = flow
next_url = _safe_next(request, request.POST.get("next"))
if next_url:
request.session[SESSION_ENTRA_NEXT] = next_url
return redirect(flow["auth_uri"])
def entra_callback(request):
"""Verwerk de terugkeer van Microsoft na de SSO-flow."""
if not entra_sso.entra_enabled():
return redirect("login")
flow = request.session.pop(SESSION_ENTRA_FLOW, None)
next_url = request.session.pop(SESSION_ENTRA_NEXT, None)
if not flow:
messages.error(request, "De Entra ID-sessie is verlopen. Probeer opnieuw.")
return redirect("login")
try:
config = entra_sso.load_config()
result = entra_sso.redeem_auth_code(config, flow, request.GET.dict())
except entra_sso.EntraConfigError as exc:
logger.warning("Entra ID niet beschikbaar: %s", exc)
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
return redirect("login")
if "error" in result:
logger.warning(
"Entra ID-fout: %s", result.get("error_description") or result.get("error")
)
messages.error(request, "Inloggen via Entra ID is mislukt.")
return redirect("login")
email, name = entra_sso.extract_identity(result)
if not email:
messages.error(request, "Entra ID gaf geen e-mailadres terug.")
return redirect("login")
if not entra_sso.domain_allowed(config, email):
messages.error(request, "Dit Entra ID-account mag hier niet inloggen.")
return redirect("login")
user = _get_or_create_sso_user(email, name)
login(request, user, backend=SSO_BACKEND)
clear_rate_limit(request, namespace="login")
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
@require_POST
def demo_login(request):
"""Log in als afgeschermd demo-account."""
if not getattr(settings, "DEMO_MODE_ENABLED", False):
messages.error(request, "De demomodus is niet ingeschakeld.")
return redirect("login")
user_model = get_user_model()
user, created = user_model.objects.get_or_create(
username=settings.DEMO_USERNAME,
defaults={
"email": settings.DEMO_EMAIL,
"first_name": "Demo",
"last_name": "Gebruiker",
},
)
if created:
user.set_unusable_password()
user.save()
try:
from apps.jobs.services.demo_seed import seed_demo_environment
seed_demo_environment(user)
except Exception:
# De demo mag nooit de login blokkeren.
logger.exception("Kon de demo-omgeving niet seeden")
login(request, user, backend=SSO_BACKEND)
next_url = _safe_next(request, request.POST.get("next"))
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
def health_live(request):
return JsonResponse({"ok": True, "service": "vacatureradar"})
+269
View File
@@ -0,0 +1,269 @@
"""Vult een fictieve demo-omgeving voor het demo-account.
Wordt aangeroepen bij 'Bekijk demo' zodat de gebruiker meteen een geloofwaardige,
gevulde cockpit ziet (profiel, vacatures, matchscores en een sollicitatiepijplijn)
in plaats van een leeg dashboard. Volledig idempotent: eenmaal geseed, wordt er
niet opnieuw geseed.
"""
from __future__ import annotations
import hashlib
from datetime import timedelta
from decimal import Decimal
from django.utils import timezone
from apps.profiles.models import SearchProfile
_DEMO_PROFILE_NAME = "Demo · IT-infrastructuur Limburg"
# (titel, werkgever, domein, gemeente, regio, werkplek, skills_required,
# skills_preferred, score, recommendation, positives, concerns)
_DEMO_JOBS = [
(
"Infrastructure Engineer",
"CloudNexus",
"cloudnexus.example",
"Hasselt",
"Limburg",
"hybrid",
["Microsoft 365", "VMware", "Azure"],
["Terraform"],
Decimal("92.4"),
"strong",
["Dichtbij Hasselt", "Sterke match op Microsoft 365 en VMware", "Hybride werk"],
[],
),
(
"Cloud Architect",
"Skylink Solutions",
"skylink.example",
"Remote",
"Vlaanderen",
"remote",
["Azure", "Kubernetes"],
["Bicep", "Landing Zones"],
Decimal("88.1"),
"strong",
["Volledig remote", "Azure-zwaartepunt sluit aan"],
["Iets seniorder dan gevraagd"],
),
(
"Systeembeheerder Microsoft 365",
"Gemeente Genk",
"genk.example",
"Genk",
"Limburg",
"on_site",
["Microsoft 365", "Active Directory"],
["Intune"],
Decimal("83.6"),
"strong",
["Publieke sector met stabiliteit", "M365-focus"],
["Volledig op locatie"],
),
(
"DevOps Engineer",
"Datastroom BV",
"datastroom.example",
"Leuven",
"Vlaams-Brabant",
"hybrid",
["CI/CD", "Docker", "Azure"],
["GitLab", "Observability"],
Decimal("77.9"),
"possible",
["Moderne toolchain", "Hybride"],
["Reistijd naar Leuven"],
),
(
"Network Engineer",
"Noorder Telecom",
"noordertelecom.example",
"Antwerpen",
"Antwerpen",
"on_site",
["Cisco", "Netwerkbeheer"],
["Fortinet"],
Decimal("70.4"),
"possible",
["Stevige netwerkrol"],
["Weinig cloudcomponent", "Op locatie in Antwerpen"],
),
(
"IT Support Specialist",
"MediCare Groep",
"medicare.example",
"Diepenbeek",
"Limburg",
"on_site",
["Servicedesk", "Windows"],
["ITIL"],
Decimal("62.3"),
"weak",
["Dichtbij"],
["Sterk supportgericht", "Weinig infrastructuurverdieping"],
),
]
def _digest(*parts: str) -> str:
return hashlib.sha256("::".join(parts).encode("utf-8")).hexdigest()
def demo_environment_seeded(user) -> bool:
profile = SearchProfile.objects.filter(user=user, name=_DEMO_PROFILE_NAME).first()
if profile is None:
return False
from apps.jobs.models import ScoreRun
return ScoreRun.objects.filter(profile=profile).exists()
def seed_demo_environment(user) -> None:
"""Maak (idempotent) een fictieve demo-omgeving voor ``user``."""
if demo_environment_seeded(user):
return
from apps.jobs.models import Application, Employer, JobPosting, ScoreRun
profile, _ = SearchProfile.objects.get_or_create(
user=user,
name=_DEMO_PROFILE_NAME,
defaults={
"is_active": True,
"home_municipality": "Hasselt",
"home_latitude": Decimal("50.930700"),
"home_longitude": Decimal("5.332500"),
"max_distance_km": 60,
"experience_years": 5,
"learning_enabled": True,
"desired_titles": ["infrastructure engineer", "systeembeheerder", "cloud engineer"],
"excluded_titles": ["sales", "recruiter"],
"desired_skills": ["Microsoft 365", "VMware", "Azure"],
"allowed_employment_types": ["full_time", "permanent"],
"preferred_workplace": ["hybrid", "remote", "on_site"],
"preferred_regions": ["Limburg", "Vlaams-Brabant"],
},
)
SearchProfile.objects.filter(user=user).exclude(pk=profile.pk).update(is_active=False)
if not profile.is_active:
profile.is_active = True
profile.save(update_fields=["is_active"])
now = timezone.now()
workplace_map = {
"hybrid": JobPosting.Workplace.HYBRID,
"remote": JobPosting.Workplace.REMOTE,
"on_site": JobPosting.Workplace.ON_SITE,
}
created_jobs: list = []
for index, (
title,
employer_name,
domain,
municipality,
region,
workplace,
req_skills,
pref_skills,
score,
recommendation,
positives,
concerns,
) in enumerate(_DEMO_JOBS):
employer, _ = Employer.objects.get_or_create(
normalized_name=employer_name.lower(),
defaults={
"name": employer_name,
"domain": domain,
"is_direct_employer": True,
"is_recruiter": False,
"confidence": 0.92,
},
)
slug = _digest("demo-job", str(index), title, employer_name)
job, job_created = JobPosting.objects.get_or_create(
canonical_key=slug,
defaults={
"employer": employer,
"original_title": title,
"normalized_title": title.lower(),
"job_family": "infrastructure",
"canonical_url": f"https://{domain}/vacatures/{slug[:12]}",
"content_hash": _digest("demo-content", slug),
"description_text": (
f"{title} bij {employer_name} in {municipality}. "
"Fictieve demovacature voor de VacatureRadar-demo-omgeving."
),
"raw_location": f"{municipality}, {region}",
"region": region,
"municipality": municipality,
"workplace_type": workplace_map[workplace],
"employment_types": ["full_time", "permanent"],
"skills_required": req_skills,
"skills_preferred": pref_skills,
"direct_employer": True,
"recruiter": False,
"extraction_confidence": 0.9,
"analysis_features": {
"support_ratio": 0.1,
"public_sector_signal": 1.0 if "Gemeente" in employer_name else 0.0,
"experience_years_max": 6,
},
"status": JobPosting.Status.ACTIVE,
"first_seen": now - timedelta(days=index + 1),
"last_seen": now,
"date_posted": now - timedelta(days=index + 1),
},
)
created_jobs.append(job)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=score,
confidence=Decimal("0.78"),
recommendation=recommendation,
components={
"content": float(score) * 0.45,
"skills": float(score) * 0.30,
"location": float(score) * 0.25,
},
positives=positives,
concerns=concerns,
hard_exclusions=[],
evidence={"demo": True},
model_version="demo-seed",
prompt_version="1.0.0",
created_at=now - timedelta(hours=index),
)
# Een kleine sollicitatiepijplijn voor de demo.
if created_jobs:
Application.objects.get_or_create(
user=user,
job=created_jobs[0],
defaults={
"status": Application.Status.INTERVIEW,
"applied_at": now - timedelta(days=6),
"follow_up_date": (now + timedelta(days=2)).date(),
"contact_name": "Sofie Vandael",
"contact_email": "sofie.vandael@cloudnexus.example",
"notes": "Eerste gesprek gepland. Demo-data.",
},
)
if len(created_jobs) > 3:
Application.objects.get_or_create(
user=user,
job=created_jobs[3],
defaults={
"status": Application.Status.APPLIED,
"applied_at": now - timedelta(days=2),
"follow_up_date": (now + timedelta(days=5)).date(),
"notes": "Sollicitatie verstuurd. Demo-data.",
},
)
+22
View File
@@ -190,6 +190,28 @@ LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "dashboard:today"
LOGOUT_REDIRECT_URL = "login"
# Inloggen kan met gebruikersnaam of e-mailadres. ModelBackend blijft als fallback.
AUTHENTICATION_BACKENDS = [
"apps.core.auth_backends.EmailOrUsernameModelBackend",
"django.contrib.auth.backends.ModelBackend",
]
PASSWORD_RESET_TIMEOUT = int(os.getenv("PASSWORD_RESET_TIMEOUT", str(3 * 24 * 3600)))
# Microsoft Entra ID (Azure AD) SSO. Uit tenzij expliciet geconfigureerd.
ENTRA_ID_ENABLED = env_bool("ENTRA_ID_ENABLED", False)
ENTRA_CLIENT_ID = os.getenv("ENTRA_CLIENT_ID", "")
ENTRA_CLIENT_SECRET = os.getenv("ENTRA_CLIENT_SECRET", "")
ENTRA_TENANT_ID = os.getenv("ENTRA_TENANT_ID", "")
ENTRA_REDIRECT_URI = os.getenv("ENTRA_REDIRECT_URI", "")
ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read")
ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS")
# Demo-toegang: logt in als afgeschermd gastaccount met een fictieve, gevulde
# demo-omgeving. Standaard aan zodat de knop 'Bekijk demo' zichtbaar is.
DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", True)
DEMO_USERNAME = os.getenv("DEMO_USERNAME", "demo")
DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local")
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "VacatureRadar <vacatureradar@localhost>")
EMAIL_HOST = os.getenv("SMTP_HOST", "")
+31 -1
View File
@@ -8,7 +8,12 @@ from django.templatetags.static import static as static_url
from django.urls import include, path
from django.views.generic import RedirectView
from apps.core.views import SecurityAwareLoginView
from apps.core.views import (
SecurityAwareLoginView,
demo_login,
entra_callback,
entra_login,
)
urlpatterns = [
path(
@@ -23,6 +28,31 @@ urlpatterns = [
name="login",
),
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
# Wachtwoordherstel (Django's ingebouwde flow, eigen templates).
path(
"wachtwoord/herstellen/",
auth_views.PasswordResetView.as_view(),
name="password_reset",
),
path(
"wachtwoord/herstellen/verzonden/",
auth_views.PasswordResetDoneView.as_view(),
name="password_reset_done",
),
path(
"wachtwoord/herstellen/<uidb64>/<token>/",
auth_views.PasswordResetConfirmView.as_view(),
name="password_reset_confirm",
),
path(
"wachtwoord/herstellen/klaar/",
auth_views.PasswordResetCompleteView.as_view(),
name="password_reset_complete",
),
# Entra ID SSO en demo-toegang.
path("auth/entra/login/", entra_login, name="entra-login"),
path("auth/entra/callback/", entra_callback, name="entra-callback"),
path("auth/demo/", demo_login, name="demo-login"),
path("", include(("apps.core.urls", "dashboard"), namespace="dashboard")),
path("jobs/", include(("apps.jobs.urls", "jobs"), namespace="jobs")),
path("profiles/", include(("apps.profiles.urls", "profiles"), namespace="profiles")),
+1
View File
@@ -22,6 +22,7 @@ dependencies = [
"gunicorn==26.0.0",
"whitenoise==6.11.0",
"cryptography==49.0.0",
"msal==1.37.0",
]
[project.optional-dependencies]
+572
View File
@@ -0,0 +1,572 @@
/* VacatureRadar — Authenticatieschermen (login, wachtwoordherstel).
* Lokale reproductie van het Stitch "Intelligence Cockpit" ontwerp.
* Bewust CDN-vrij (geen Tailwind/Google Fonts) zodat de pagina local-first blijft
* en de toegankelijkheidstest (geen externe assets) blijft slagen.
*/
/* Zelf-gehoste fonts (Geist — © Vercel/basement.studio; JetBrains Mono — OFL).
* Woff2-bestanden in static/fonts/, geen externe CDN nodig. */
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("../fonts/geist-400.woff2") format("woff2");
}
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("../fonts/geist-600.woff2") format("woff2");
}
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("../fonts/geist-900.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("../fonts/jetbrains-mono-500.woff2") format("woff2");
}
:root {
/* Kleuren overgenomen uit het project (tokens.css, lichte thema). */
/* Surfaces */
--surface: #e6eff2;
--surface-dim: #cadadd;
--surface-container-lowest: #ffffff;
--surface-container-low: #edf4f5;
--surface-container: #f5f9fa;
--surface-container-high: #e3edef;
--surface-container-highest: #d6e3e5;
--on-surface: #12262b;
--on-surface-variant: #475e63;
--outline: #64787d;
--outline-variant: #b7c9cc;
/* Brand / primary (cyaan) */
--primary: #007c83;
--on-primary: #ffffff;
--primary-container: #007c83;
--primary-fixed-dim: #006b71;
/* Secondary + semantic */
--secondary-container: #d6f5e7;
--on-secondary-container: #087a55;
--error: #a82d27;
--on-error: #ffffff;
--error-container: #fbe6e4;
--on-error-container: #7a1f1a;
--success: #087a55;
--success-container: #d6f5e7;
--on-success-container: #053d2c;
/* Type */
--font-sans: "Geist", "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* Shape */
--r-input: 4px;
--r-card: 8px;
--r-pill: 999px;
/* Rhythm */
--stack-sm: 8px;
--stack-md: 16px;
--stack-lg: 24px;
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.05);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
font-family: var(--font-sans);
color: var(--on-surface);
background-color: var(--surface);
background-image: radial-gradient(var(--surface-container-highest) 0.5px, transparent 0.5px);
background-size: 24px 24px;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.auth-shell {
width: 100%;
max-width: 28rem; /* 448px, komt overeen met max-w-md */
}
/* Merkidentiteit boven de kaart */
.auth-brand {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
text-align: center;
}
.auth-brand-logo {
display: block;
width: 64px;
height: 64px;
margin-bottom: 16px;
border-radius: 16px;
box-shadow: 0 10px 24px rgba(0, 124, 131, 0.28), 0 2px 6px rgba(18, 38, 43, 0.08);
}
.auth-brand-name {
margin: 0;
font-size: 30px;
line-height: 38px;
letter-spacing: -0.02em;
font-weight: 900;
color: var(--primary);
}
.auth-brand-tagline {
margin: 4px 0 0;
font-size: 14px;
line-height: 20px;
color: var(--on-surface-variant);
}
/* Kaart */
.auth-card {
background: var(--surface-container-lowest);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
padding: 24px;
}
@media (min-width: 640px) {
.auth-card {
padding: 40px;
}
}
.auth-card h1,
.auth-card h2.auth-heading {
margin: 0 0 24px;
font-size: 20px;
line-height: 28px;
letter-spacing: -0.01em;
font-weight: 600;
color: var(--on-surface);
}
.auth-lead {
margin: -12px 0 24px;
font-size: 14px;
line-height: 20px;
color: var(--on-surface-variant);
}
/* Formulier */
.auth-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.field {
display: flex;
flex-direction: column;
}
.field-label-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.field > label,
.field-label-row > label {
font-family: var(--font-mono);
font-size: 12px;
line-height: 16px;
letter-spacing: 0.02em;
font-weight: 500;
color: var(--on-surface-variant);
}
.field > label {
margin-bottom: 8px;
}
.field-link {
font-family: var(--font-mono);
font-size: 11px;
line-height: 14px;
letter-spacing: 0.02em;
color: var(--primary);
text-decoration: none;
}
.field-link:hover {
text-decoration: underline;
}
.input-wrap {
position: relative;
display: flex;
align-items: center;
}
.auth-input {
width: 100%;
padding: 10px 16px;
font-family: var(--font-sans);
font-size: 14px;
line-height: 20px;
color: var(--on-surface);
background: var(--surface-container-low);
border: 1px solid var(--outline-variant);
border-radius: var(--r-input);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.input-wrap .auth-input {
padding-right: 44px;
}
.auth-input::placeholder {
color: var(--outline);
}
.auth-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(0, 124, 131, 0.2);
}
.auth-input[aria-invalid="true"] {
border-color: var(--error);
}
.pw-toggle {
position: absolute;
right: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border: none;
background: transparent;
color: var(--outline);
border-radius: var(--r-input);
cursor: pointer;
}
.pw-toggle:hover {
color: var(--on-surface-variant);
background: var(--surface-container-high);
}
.pw-toggle .icon {
width: 18px;
height: 18px;
}
.field-error {
margin-top: 6px;
font-size: 12px;
line-height: 16px;
color: var(--error);
}
/* Knoppen */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 24px;
font-family: var(--font-sans);
font-size: 16px;
font-weight: 600;
border-radius: var(--r-input);
border: 1px solid transparent;
cursor: pointer;
transition: background-color 0.15s ease, transform 0.1s ease, border-color 0.15s ease;
}
.btn:active {
transform: scale(0.98);
}
.btn .icon {
width: 18px;
height: 18px;
}
.btn-primary {
background: var(--primary-container);
color: var(--on-primary);
}
.btn-primary:hover {
background: var(--primary);
}
.btn-sso {
background: var(--surface);
color: var(--on-surface);
border-color: var(--outline-variant);
font-size: 14px;
font-weight: 400;
gap: 12px;
}
.btn-sso:hover {
background: var(--surface-container-high);
}
.btn-sso svg {
width: 20px;
height: 20px;
}
.btn .spinner {
width: 18px;
height: 18px;
animation: auth-spin 0.8s linear infinite;
}
@keyframes auth-spin {
to {
transform: rotate(360deg);
}
}
/* Scheidingslijn */
.auth-separator {
position: relative;
text-align: center;
padding: 4px 0;
}
.auth-separator::before {
content: "";
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: var(--outline-variant);
}
.auth-separator span {
position: relative;
padding: 0 8px;
background: var(--surface-container-lowest);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--on-surface-variant);
}
/* Demo / secundaire actie */
.auth-secondary {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid var(--outline-variant);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.auth-secondary p {
margin: 0;
font-size: 14px;
color: var(--on-surface-variant);
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 6px;
width: auto;
padding: 6px 14px;
border: none;
background: transparent;
border-radius: var(--r-pill);
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.02em;
color: var(--primary);
cursor: pointer;
transition: background-color 0.15s ease;
}
.btn-ghost:hover {
background: rgba(0, 124, 131, 0.12);
}
.btn-ghost .icon {
width: 18px;
height: 18px;
}
.btn-ghost.inline-form {
margin: 0;
}
/* Meldingen */
.auth-alert {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 20px;
padding: 12px 14px;
border-radius: var(--r-input);
font-size: 14px;
line-height: 20px;
}
.auth-alert .icon {
width: 18px;
height: 18px;
flex: 0 0 auto;
margin-top: 1px;
}
.auth-alert.is-error {
background: var(--error-container);
color: var(--on-error-container);
}
.auth-alert.is-success {
background: var(--success-container);
color: var(--on-success-container);
}
.auth-alert.is-info {
background: var(--secondary-container);
color: var(--on-secondary-container);
}
/* Voettekst */
.auth-footer {
margin-top: 32px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.auth-footer-links {
display: flex;
align-items: center;
gap: 16px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.02em;
}
.auth-footer-links a {
color: var(--on-surface-variant);
text-decoration: none;
transition: color 0.15s ease;
}
.auth-footer-links a:hover {
color: var(--primary);
}
.auth-footer-links .sep {
color: var(--outline-variant);
}
.auth-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: var(--r-pill);
background: var(--secondary-container);
color: var(--on-secondary-container);
}
.auth-badge .icon {
width: 16px;
height: 16px;
}
.auth-badge span {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* Terug-link op subpagina's */
.auth-back {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 20px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.02em;
color: var(--on-surface-variant);
text-decoration: none;
}
.auth-back:hover {
color: var(--primary);
}
.auth-back .icon {
width: 16px;
height: 16px;
transform: rotate(180deg);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.btn,
.btn:active,
.btn .spinner,
.btn-ghost,
.auth-input {
transition: none;
animation: none;
transform: none;
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="VacatureRadar">
<rect width="64" height="64" rx="12" fill="#007c83"/>
<g fill="none" stroke="#f2ffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4">
<circle cx="32" cy="32" r="20"/>
<circle cx="32" cy="32" r="11"/>
<path d="m32 32 15-15M32 8v5M8 32h5M32 51v5M51 32h5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.
+3
View File
@@ -12,6 +12,9 @@
<symbol id="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/></symbol>
<symbol id="icon-logout" viewBox="0 0 24 24"><path d="M10 4H4v16h6M14 8l4 4-4 4M8 12h10"/></symbol>
<symbol id="icon-arrow" viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></symbol>
<symbol id="icon-eye" viewBox="0 0 24 24"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="icon-eye-off" viewBox="0 0 24 24"><path d="M3 3l18 18M10.6 10.6a3 3 0 0 0 4.2 4.2M9.9 5.2A9.5 9.5 0 0 1 12 5c6.4 0 10 7 10 7a17.3 17.3 0 0 1-3.3 4.1M6.1 6.6A17.6 17.6 0 0 0 2 12s3.6 7 10 7a9.6 9.6 0 0 0 3-.5"/></symbol>
<symbol id="icon-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6l7-3Z"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="icon-briefcase" viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V4h6v3M3 12h18"/></symbol>
<symbol id="icon-location" viewBox="0 0 24 24"><path d="M20 10c0 5-8 12-8 12S4 15 4 10a8 8 0 1 1 16 0Z"/><circle cx="12" cy="10" r="2.5"/></symbol>
<symbol id="icon-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

+149 -19
View File
@@ -1,19 +1,149 @@
{% extends "base.html" %}
{% block title %}Aanmelden · VacatureRadar{% endblock %}
{% block body_class %}login-page{% endblock %}
{% block content %}
<div class="login-cockpit">
<section class="login-intelligence" aria-labelledby="login-brand-title">
<div class="login-brandline"><span class="brand-radar"><svg class="icon"><use href="#icon-radar"></use></svg></span><span><strong>VacatureRadar</strong><small>Intelligence cockpit</small></span></div>
<div class="login-radar" aria-hidden="true"><span></span><span></span><span></span><i></i></div>
<div class="login-intro"><p class="eyebrow">Persoonlijke vacature-intelligence</p><h1 id="login-brand-title">Van bronruis naar beslisbare IT-matches.</h1><p>Breng vacatures, werkgevers, skillsvraag en opvolging samen in één betrouwbare radar.</p></div>
<ul class="login-signals" aria-label="Platformmogelijkheden"><li><svg class="icon"><use href="#icon-check"></use></svg>Deterministische IT-selectie</li><li><svg class="icon"><use href="#icon-check"></use></svg>Herleidbare matchonderbouwing</li><li><svg class="icon"><use href="#icon-check"></use></svg>Bron- en automatiseringscontrole</li></ul>
</section>
<section class="auth-card" aria-labelledby="login-title">
<div class="auth-brand"><span class="brand-mark"><svg class="icon"><use href="#icon-profile"></use></svg></span><div><p class="eyebrow">Beveiligde toegang</p><h2 id="login-title">Welkom terug</h2><p>Open je persoonlijke intelligence cockpit.</p></div></div>
{% if form.errors %}<div class="message error" role="alert">Gebruikersnaam of wachtwoord klopt niet.</div>{% endif %}
<form method="post" class="stack">{% csrf_token %}<label for="{{ form.username.id_for_label }}">Gebruikersnaam</label>{{ form.username }}<label for="{{ form.password.id_for_label }}">Wachtwoord</label>{{ form.password }}{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}<button class="button button-primary button-block" type="submit">Cockpit openen <svg class="icon"><use href="#icon-arrow"></use></svg></button></form>
<div class="auth-trust"><span class="status-beacon is-live" aria-hidden="true"></span><span><strong>Lokale, afgeschermde sessie</strong><small>Je gegevens blijven binnen de geconfigureerde omgeving.</small></span></div>
</section>
</div>
{% endblock %}
{% load static %}<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#fbf8ff">
<title>Inloggen · VacatureRadar</title>
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
<link rel="stylesheet" href="{% static 'css/auth.css' %}?v={{ app_version }}">
</head>
<body class="auth-page">
{% include "components/icon_sprite.html" %}
<main class="auth-shell">
<div class="auth-brand">
<img class="auth-brand-logo" src="{% static 'img/vacatureradar-logo.svg' %}" alt="VacatureRadar logo" width="64" height="64">
<p class="auth-brand-name">VacatureRadar</p>
<p class="auth-brand-tagline">Intelligence cockpit</p>
</div>
<div class="auth-card">
<h1>Inloggen</h1>
{% if messages %}
{% for message in messages %}
<div class="auth-alert {% if message.tags == 'error' %}is-error{% elif message.tags == 'success' %}is-success{% else %}is-info{% endif %}" role="alert">
<svg class="icon" aria-hidden="true"><use href="{% if message.tags == 'error' %}#icon-alert{% else %}#icon-check{% endif %}"></use></svg>
<span>{{ message }}</span>
</div>
{% endfor %}
{% endif %}
{% if form.errors and not messages %}
<div class="auth-alert is-error" role="alert">
<svg class="icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
<span>E-mailadres of wachtwoord klopt niet. Probeer het opnieuw.</span>
</div>
{% endif %}
<form method="post" class="auth-form" novalidate>
{% csrf_token %}
<div class="field">
<label for="id_username">E-mailadres</label>
<input class="auth-input" id="id_username" name="username" type="email"
autocomplete="username" autocapitalize="none" spellcheck="false"
placeholder="naam@organisatie.nl" required
{% if form.errors %}aria-invalid="true"{% endif %}
value="{{ form.username.value|default:'' }}">
</div>
<div class="field">
<div class="field-label-row">
<label for="id_password">Wachtwoord</label>
<a class="field-link" href="{% url 'password_reset' %}">Wachtwoord vergeten?</a>
</div>
<div class="input-wrap">
<input class="auth-input" id="id_password" name="password" type="password"
autocomplete="current-password" placeholder="••••••••" required
{% if form.errors %}aria-invalid="true"{% endif %}>
<button class="pw-toggle" type="button" data-pw-toggle aria-label="Wachtwoord tonen">
<svg class="icon" aria-hidden="true" data-eye><use href="#icon-eye"></use></svg>
<svg class="icon" aria-hidden="true" data-eye-off hidden><use href="#icon-eye-off"></use></svg>
</button>
</div>
</div>
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<button class="btn btn-primary" type="submit" data-submit>
<span data-label>Inloggen</span>
<svg class="icon" aria-hidden="true" data-arrow><use href="#icon-arrow"></use></svg>
</button>
{% if entra_enabled %}
<div class="auth-separator"><span>Of ga door met</span></div>
{% endif %}
</form>
{% if entra_enabled %}
<form method="post" action="{% url 'entra-login' %}">
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<button class="btn btn-sso" type="submit">
<svg viewBox="0 0 23 23" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M1 1h10v10H1z" fill="#f35325"></path>
<path d="M12 1h10v10H12z" fill="#81bc06"></path>
<path d="M1 12h10v10H1z" fill="#05a6f0"></path>
<path d="M12 12h10v10H12z" fill="#ffba08"></path>
</svg>
Inloggen met Entra ID
</button>
</form>
{% endif %}
{% if demo_enabled %}
<div class="auth-secondary">
<p>Nog geen account?</p>
<form method="post" action="{% url 'demo-login' %}" class="btn-ghost inline-form">
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<button class="btn-ghost" type="submit">
<svg class="icon" aria-hidden="true"><use href="#icon-eye"></use></svg>
Bekijk demo
</button>
</form>
</div>
{% endif %}
</div>
<footer class="auth-footer">
<div class="auth-footer-links">
<a href="{% url 'password_reset' %}">Wachtwoord vergeten</a>
</div>
<div class="auth-badge">
<svg class="icon" aria-hidden="true"><use href="#icon-shield"></use></svg>
<span>Local-first secure node</span>
</div>
</footer>
</main>
<script>
(function () {
var toggle = document.querySelector('[data-pw-toggle]');
if (toggle) {
var pw = document.getElementById('id_password');
var eye = toggle.querySelector('[data-eye]');
var eyeOff = toggle.querySelector('[data-eye-off]');
toggle.addEventListener('click', function () {
var show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
toggle.setAttribute('aria-label', show ? 'Wachtwoord verbergen' : 'Wachtwoord tonen');
if (eye && eyeOff) { eye.hidden = show; eyeOff.hidden = !show; }
});
}
var form = document.querySelector('form.auth-form');
if (form) {
form.addEventListener('submit', function () {
var btn = form.querySelector('[data-submit]');
if (!btn || btn.dataset.busy) return;
if (typeof form.checkValidity === 'function' && !form.checkValidity()) return;
btn.dataset.busy = '1';
var label = btn.querySelector('[data-label]');
if (label) label.textContent = 'Bezig met inloggen…';
});
}
})();
</script>
</body>
</html>
@@ -0,0 +1,35 @@
{% load static %}<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Wachtwoord gewijzigd · VacatureRadar</title>
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
<link rel="stylesheet" href="{% static 'css/auth.css' %}?v={{ app_version }}">
</head>
<body class="auth-page">
{% include "components/icon_sprite.html" %}
<main class="auth-shell">
<div class="auth-brand">
<img class="auth-brand-logo" src="{% static 'img/vacatureradar-logo.svg' %}" alt="VacatureRadar logo" width="64" height="64">
<p class="auth-brand-name">VacatureRadar</p>
<p class="auth-brand-tagline">Intelligence cockpit</p>
</div>
<div class="auth-card">
<h1>Wachtwoord gewijzigd</h1>
<div class="auth-alert is-success" role="status">
<svg class="icon" aria-hidden="true"><use href="#icon-check"></use></svg>
<span>Je wachtwoord is bijgewerkt. Je kunt nu inloggen met je nieuwe wachtwoord.</span>
</div>
<form method="get" action="{% url 'login' %}" class="auth-form">
<button class="btn btn-primary" type="submit">
Naar inloggen
<svg class="icon" aria-hidden="true"><use href="#icon-arrow"></use></svg>
</button>
</form>
</div>
</main>
</body>
</html>
@@ -0,0 +1,64 @@
{% load static %}<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Nieuw wachtwoord · VacatureRadar</title>
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
<link rel="stylesheet" href="{% static 'css/auth.css' %}?v={{ app_version }}">
</head>
<body class="auth-page">
{% include "components/icon_sprite.html" %}
<main class="auth-shell">
<div class="auth-brand">
<img class="auth-brand-logo" src="{% static 'img/vacatureradar-logo.svg' %}" alt="VacatureRadar logo" width="64" height="64">
<p class="auth-brand-name">VacatureRadar</p>
<p class="auth-brand-tagline">Intelligence cockpit</p>
</div>
<div class="auth-card">
{% if validlink %}
<h1>Kies een nieuw wachtwoord</h1>
<p class="auth-lead">Gebruik een uniek, sterk wachtwoord dat je nergens anders gebruikt.</p>
{% if form.errors %}
<div class="auth-alert is-error" role="alert">
<svg class="icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
<span>Controleer de gemarkeerde velden en probeer het opnieuw.</span>
</div>
{% endif %}
<form method="post" class="auth-form" novalidate>
{% csrf_token %}
<div class="field">
<label for="id_new_password1">Nieuw wachtwoord</label>
<input class="auth-input" id="id_new_password1" name="new_password1" type="password"
autocomplete="new-password" required
{% if form.new_password1.errors %}aria-invalid="true"{% endif %}>
{% if form.new_password1.errors %}<p class="field-error">{{ form.new_password1.errors|join:" " }}</p>{% endif %}
</div>
<div class="field">
<label for="id_new_password2">Herhaal wachtwoord</label>
<input class="auth-input" id="id_new_password2" name="new_password2" type="password"
autocomplete="new-password" required
{% if form.new_password2.errors %}aria-invalid="true"{% endif %}>
{% if form.new_password2.errors %}<p class="field-error">{{ form.new_password2.errors|join:" " }}</p>{% endif %}
</div>
<button class="btn btn-primary" type="submit">
Wachtwoord opslaan
<svg class="icon" aria-hidden="true"><use href="#icon-check"></use></svg>
</button>
</form>
{% else %}
<h1>Link is verlopen</h1>
<div class="auth-alert is-error" role="alert">
<svg class="icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
<span>Deze herstel-link is ongeldig of al gebruikt. Vraag een nieuwe link aan.</span>
</div>
<a class="auth-back" href="{% url 'password_reset' %}"><svg class="icon" aria-hidden="true"><use href="#icon-arrow"></use></svg>Nieuwe link aanvragen</a>
{% endif %}
</div>
</main>
</body>
</html>
@@ -0,0 +1,30 @@
{% load static %}<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Herstel-link verstuurd · VacatureRadar</title>
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
<link rel="stylesheet" href="{% static 'css/auth.css' %}?v={{ app_version }}">
</head>
<body class="auth-page">
{% include "components/icon_sprite.html" %}
<main class="auth-shell">
<div class="auth-brand">
<img class="auth-brand-logo" src="{% static 'img/vacatureradar-logo.svg' %}" alt="VacatureRadar logo" width="64" height="64">
<p class="auth-brand-name">VacatureRadar</p>
<p class="auth-brand-tagline">Intelligence cockpit</p>
</div>
<div class="auth-card">
<h1>Controleer je e-mail</h1>
<div class="auth-alert is-info" role="status">
<svg class="icon" aria-hidden="true"><use href="#icon-mail"></use></svg>
<span>Bestaat er een account met dit e-mailadres, dan is er een herstel-link verstuurd. Volg de instructies in de mail om een nieuw wachtwoord in te stellen.</span>
</div>
<a class="auth-back" href="{% url 'login' %}"><svg class="icon" aria-hidden="true"><use href="#icon-arrow"></use></svg>Terug naar inloggen</a>
</div>
</main>
</body>
</html>
@@ -0,0 +1,15 @@
{% autoescape off %}Hallo,
Je ontving deze e-mail omdat er een wachtwoordherstel is aangevraagd voor je
VacatureRadar-account ({{ user.get_username }}).
Ga naar de volgende pagina om een nieuw wachtwoord te kiezen:
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
Heb je dit niet aangevraagd? Dan kun je deze e-mail negeren; je wachtwoord blijft
ongewijzigd.
Met vriendelijke groet,
VacatureRadar
{% endautoescape %}
@@ -0,0 +1,45 @@
{% load static %}<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Wachtwoord herstellen · VacatureRadar</title>
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
<link rel="stylesheet" href="{% static 'css/auth.css' %}?v={{ app_version }}">
</head>
<body class="auth-page">
{% include "components/icon_sprite.html" %}
<main class="auth-shell">
<div class="auth-brand">
<img class="auth-brand-logo" src="{% static 'img/vacatureradar-logo.svg' %}" alt="VacatureRadar logo" width="64" height="64">
<p class="auth-brand-name">VacatureRadar</p>
<p class="auth-brand-tagline">Intelligence cockpit</p>
</div>
<div class="auth-card">
<h1>Wachtwoord herstellen</h1>
<p class="auth-lead">Vul je e-mailadres in. Als er een account bij hoort, sturen we een herstel-link.</p>
<form method="post" class="auth-form" novalidate>
{% csrf_token %}
<div class="field">
<label for="id_email">E-mailadres</label>
<input class="auth-input" id="id_email" name="email" type="email"
autocomplete="email" autocapitalize="none" spellcheck="false"
placeholder="naam@organisatie.nl" required
{% if form.email.errors %}aria-invalid="true"{% endif %}
value="{{ form.email.value|default:'' }}">
{% if form.email.errors %}<p class="field-error">{{ form.email.errors|join:" " }}</p>{% endif %}
</div>
<button class="btn btn-primary" type="submit">
Stuur herstel-link
<svg class="icon" aria-hidden="true"><use href="#icon-mail"></use></svg>
</button>
</form>
<a class="auth-back" href="{% url 'login' %}"><svg class="icon" aria-hidden="true"><use href="#icon-arrow"></use></svg>Terug naar inloggen</a>
</div>
</main>
</body>
</html>
@@ -0,0 +1 @@
VacatureRadar — wachtwoord herstellen
@@ -183,12 +183,12 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
page = context.new_page()
try:
# Login.
# Login (e-mailadres + wachtwoord, Stitch-ontwerp).
page.goto(login_url)
_assert_playwright_accessibility(page, expected_h1="Welkom terug")
page.get_by_label("Gebruikersnaam").fill(user.username)
_assert_playwright_accessibility(page, expected_h1="Inloggen")
page.get_by_label("E-mailadres").fill(user.email)
page.get_by_label("Wachtwoord").fill("correct-horse-battery-staple")
page.get_by_role("button", name="Aanmelden").click()
page.get_by_role("button", name="Inloggen").click()
page.wait_for_url(dashboard_url)
_assert_playwright_accessibility(page, expected_h1="Goedemorgen")
+147
View File
@@ -0,0 +1,147 @@
"""Tests voor het herwerkte loginscherm en de bijbehorende authenticatie-flows."""
from __future__ import annotations
import pytest
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import override_settings
from django.urls import reverse
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_login_page_renders_stitch_design(client):
response = client.get(reverse("login"))
assert response.status_code == 200
body = response.content.decode("utf-8")
assert "<h1>Inloggen</h1>" in body
assert 'name="username"' in body and 'type="email"' in body
assert "Wachtwoord vergeten?" in body
# Local-first: geen externe CDN-assets op het loginscherm.
assert "cdn.tailwindcss.com" not in body
assert "https://fonts.googleapis.com" not in body
assert reverse("password_reset") in body
def test_login_with_email(client, user):
response = client.post(
reverse("login"),
{"username": user.email, "password": "correct-horse-battery-staple"},
)
assert response.status_code == 302
assert response.url == reverse("dashboard:today")
assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_with_username_still_works(client, user):
response = client.post(
reverse("login"),
{"username": user.username, "password": "correct-horse-battery-staple"},
)
assert response.status_code == 302
assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_email_case_insensitive(client, user):
response = client.post(
reverse("login"),
{"username": user.email.upper(), "password": "correct-horse-battery-staple"},
)
assert response.status_code == 302
assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_wrong_password_shows_error(client, user):
response = client.post(
reverse("login"),
{"username": user.email, "password": "verkeerd-wachtwoord"},
)
assert response.status_code == 200
assert "klopt niet" in response.content.decode("utf-8")
def test_password_reset_sends_email(client, user):
response = client.post(reverse("password_reset"), {"email": user.email})
assert response.status_code == 302
assert response.url == reverse("password_reset_done")
assert len(mail.outbox) == 1
message = mail.outbox[0]
assert user.email in message.to
assert "wachtwoord" in message.subject.lower()
assert "/wachtwoord/herstellen/" in message.body
def test_password_reset_unknown_email_is_silent(client):
response = client.post(reverse("password_reset"), {"email": "onbekend@example.invalid"})
assert response.status_code == 302
assert len(mail.outbox) == 0
def test_demo_button_visible_by_default(client):
assert reverse("demo-login") in client.get(reverse("login")).content.decode("utf-8")
@override_settings(DEMO_MODE_ENABLED=False)
def test_demo_login_disabled_redirects(client):
response = client.post(reverse("demo-login"))
assert response.status_code == 302
assert response.url == reverse("login")
assert client.session.get("_auth_user_id") is None
@override_settings(DEMO_MODE_ENABLED=False)
def test_demo_button_hidden_when_disabled(client):
assert "demo-login" not in client.get(reverse("login")).content.decode("utf-8")
def test_demo_login_logs_in_and_seeds_environment(client):
from apps.jobs.models import Application, JobPosting, ScoreRun
from apps.profiles.models import SearchProfile
response = client.post(reverse("demo-login"))
assert response.status_code == 302
assert response.url == reverse("dashboard:today")
user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
assert not user.has_usable_password()
assert client.session.get("_auth_user_id") == str(user.pk)
# Fictieve, gevulde demo-omgeving.
profile = SearchProfile.objects.get(user=user, is_active=True)
assert JobPosting.objects.count() >= 5
assert ScoreRun.objects.filter(profile=profile).count() >= 5
assert Application.objects.filter(user=user).exists()
# Het gevulde dashboard rendert zonder fouten.
dashboard = client.get(reverse("dashboard:today"))
assert dashboard.status_code == 200
def test_demo_login_is_idempotent(client):
from apps.jobs.models import JobPosting
client.post(reverse("demo-login"))
first = JobPosting.objects.count()
client.post(reverse("demo-login"))
assert JobPosting.objects.count() == first
def test_entra_login_disabled_redirects(client):
response = client.post(reverse("entra-login"))
assert response.status_code == 302
assert response.url == reverse("login")
@override_settings(ENTRA_ID_ENABLED=True, ENTRA_CLIENT_ID="", ENTRA_TENANT_ID="")
def test_entra_login_enabled_but_unconfigured_is_handled(client):
# Ingeschakeld maar zonder credentials: nette redirect, geen 500.
response = client.post(reverse("entra-login"), follow=False)
assert response.status_code == 302
assert response.url == reverse("login")
@override_settings(ENTRA_ID_ENABLED=True)
def test_entra_button_visible_when_enabled(client):
assert reverse("entra-login") in client.get(reverse("login")).content.decode("utf-8")