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_MODEL=
OLLAMA_TIMEOUT_SECONDS=60 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 # Beveiliging
SESSION_COOKIE_SECURE=0 SESSION_COOKIE_SECURE=0
CSRF_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 from __future__ import annotations
import logging
from django.conf import settings from django.conf import settings
from django.contrib import messages 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.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView from django.contrib.auth.views import LoginView
from django.http import JsonResponse 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 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.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.jobs.services.cockpit import build_dashboard_cockpit
from apps.profiles.models import SearchProfile from apps.profiles.models import SearchProfile
@@ -15,9 +24,32 @@ from apps.sources.services.health import collect_source_health
from .health import readiness from .health import readiness
from .services.automation import build_automation_cockpit 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): class SecurityAwareLoginView(LoginView):
template_name = "registration/login.html" 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 @staticmethod
def _remaining_minutes(seconds: int) -> str: def _remaining_minutes(seconds: int) -> str:
@@ -68,6 +100,142 @@ class SecurityAwareLoginView(LoginView):
return super().form_valid(form) 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): def health_live(request):
return JsonResponse({"ok": True, "service": "vacatureradar"}) 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" LOGIN_REDIRECT_URL = "dashboard:today"
LOGOUT_REDIRECT_URL = "login" 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") EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "VacatureRadar <vacatureradar@localhost>") DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "VacatureRadar <vacatureradar@localhost>")
EMAIL_HOST = os.getenv("SMTP_HOST", "") 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.urls import include, path
from django.views.generic import RedirectView 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 = [ urlpatterns = [
path( path(
@@ -23,6 +28,31 @@ urlpatterns = [
name="login", name="login",
), ),
path("logout/", auth_views.LogoutView.as_view(), name="logout"), 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("", include(("apps.core.urls", "dashboard"), namespace="dashboard")),
path("jobs/", include(("apps.jobs.urls", "jobs"), namespace="jobs")), path("jobs/", include(("apps.jobs.urls", "jobs"), namespace="jobs")),
path("profiles/", include(("apps.profiles.urls", "profiles"), namespace="profiles")), path("profiles/", include(("apps.profiles.urls", "profiles"), namespace="profiles")),
+1
View File
@@ -22,6 +22,7 @@ dependencies = [
"gunicorn==26.0.0", "gunicorn==26.0.0",
"whitenoise==6.11.0", "whitenoise==6.11.0",
"cryptography==49.0.0", "cryptography==49.0.0",
"msal==1.37.0",
] ]
[project.optional-dependencies] [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-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-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-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-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-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> <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" %} {% load static %}<!doctype html>
{% block title %}Aanmelden · VacatureRadar{% endblock %} <html lang="nl">
{% block body_class %}login-page{% endblock %} <head>
{% block content %} <meta charset="utf-8">
<div class="login-cockpit"> <meta name="viewport" content="width=device-width, initial-scale=1">
<section class="login-intelligence" aria-labelledby="login-brand-title"> <meta name="color-scheme" content="light">
<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> <meta name="theme-color" content="#fbf8ff">
<div class="login-radar" aria-hidden="true"><span></span><span></span><span></span><i></i></div> <title>Inloggen · VacatureRadar</title>
<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> <link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
<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> <link rel="stylesheet" href="{% static 'css/auth.css' %}?v={{ app_version }}">
</section> </head>
<section class="auth-card" aria-labelledby="login-title"> <body class="auth-page">
<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> {% include "components/icon_sprite.html" %}
{% if form.errors %}<div class="message error" role="alert">Gebruikersnaam of wachtwoord klopt niet.</div>{% endif %} <main class="auth-shell">
<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-brand">
<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> <img class="auth-brand-logo" src="{% static 'img/vacatureradar-logo.svg' %}" alt="VacatureRadar logo" width="64" height="64">
</section> <p class="auth-brand-name">VacatureRadar</p>
</div> <p class="auth-brand-tagline">Intelligence cockpit</p>
{% endblock %} </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() page = context.new_page()
try: try:
# Login. # Login (e-mailadres + wachtwoord, Stitch-ontwerp).
page.goto(login_url) page.goto(login_url)
_assert_playwright_accessibility(page, expected_h1="Welkom terug") _assert_playwright_accessibility(page, expected_h1="Inloggen")
page.get_by_label("Gebruikersnaam").fill(user.username) page.get_by_label("E-mailadres").fill(user.email)
page.get_by_label("Wachtwoord").fill("correct-horse-battery-staple") 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) page.wait_for_url(dashboard_url)
_assert_playwright_accessibility(page, expected_h1="Goedemorgen") _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")