diff --git a/.env.example b/.env.example index 5604a3a..0e17c91 100644 --- a/.env.example +++ b/.env.example @@ -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 /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 diff --git a/apps/core/auth_backends.py b/apps/core/auth_backends.py new file mode 100644 index 0000000..0bc2e37 --- /dev/null +++ b/apps/core/auth_backends.py @@ -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 diff --git a/apps/core/entra.py b/apps/core/entra.py new file mode 100644 index 0000000..6f45da3 --- /dev/null +++ b/apps/core/entra.py @@ -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 diff --git a/apps/core/forms.py b/apps/core/forms.py new file mode 100644 index 0000000..72aa676 --- /dev/null +++ b/apps/core/forms.py @@ -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", + } + ) diff --git a/apps/core/views.py b/apps/core/views.py index 53eac19..0f7e410 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -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"}) diff --git a/apps/jobs/services/demo_seed.py b/apps/jobs/services/demo_seed.py new file mode 100644 index 0000000..0c1a8e7 --- /dev/null +++ b/apps/jobs/services/demo_seed.py @@ -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.", + }, + ) diff --git a/config/settings.py b/config/settings.py index c621465..26d15fd 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 ") EMAIL_HOST = os.getenv("SMTP_HOST", "") diff --git a/config/urls.py b/config/urls.py index 6841b25..3c38df1 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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///", + 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")), diff --git a/pyproject.toml b/pyproject.toml index abe4c73..0afac4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "gunicorn==26.0.0", "whitenoise==6.11.0", "cryptography==49.0.0", + "msal==1.37.0", ] [project.optional-dependencies] diff --git a/static/css/auth.css b/static/css/auth.css new file mode 100644 index 0000000..c3e685d --- /dev/null +++ b/static/css/auth.css @@ -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; + } +} diff --git a/static/fonts/geist-400.woff2 b/static/fonts/geist-400.woff2 new file mode 100644 index 0000000..9867e87 Binary files /dev/null and b/static/fonts/geist-400.woff2 differ diff --git a/static/fonts/geist-600.woff2 b/static/fonts/geist-600.woff2 new file mode 100644 index 0000000..416789f Binary files /dev/null and b/static/fonts/geist-600.woff2 differ diff --git a/static/fonts/geist-900.woff2 b/static/fonts/geist-900.woff2 new file mode 100644 index 0000000..262fcc8 Binary files /dev/null and b/static/fonts/geist-900.woff2 differ diff --git a/static/fonts/jetbrains-mono-500.woff2 b/static/fonts/jetbrains-mono-500.woff2 new file mode 100644 index 0000000..be878e6 Binary files /dev/null and b/static/fonts/jetbrains-mono-500.woff2 differ diff --git a/static/img/vacatureradar-logo.svg b/static/img/vacatureradar-logo.svg new file mode 100644 index 0000000..03196d1 --- /dev/null +++ b/static/img/vacatureradar-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/stitch_vacatureradar_intelligence_cockpit (1).zip b/stitch_vacatureradar_intelligence_cockpit (1).zip new file mode 100644 index 0000000..1c90eaf Binary files /dev/null and b/stitch_vacatureradar_intelligence_cockpit (1).zip differ diff --git a/templates/components/icon_sprite.html b/templates/components/icon_sprite.html index 353e2a2..7b14612 100644 --- a/templates/components/icon_sprite.html +++ b/templates/components/icon_sprite.html @@ -12,6 +12,9 @@ + + + diff --git a/templates/registration/login.html b/templates/registration/login.html index 35475c3..5d554cf 100644 --- a/templates/registration/login.html +++ b/templates/registration/login.html @@ -1,19 +1,149 @@ -{% extends "base.html" %} -{% block title %}Aanmelden · VacatureRadar{% endblock %} -{% block body_class %}login-page{% endblock %} -{% block content %} - -{% endblock %} +{% load static %} + + + + + + + Inloggen · VacatureRadar + + + + + {% include "components/icon_sprite.html" %} +
+
+ +

VacatureRadar

+

Intelligence cockpit

+
+ +
+

Inloggen

+ + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + + {% if form.errors and not messages %} + + {% endif %} + +
+ {% csrf_token %} +
+ + +
+ +
+
+ + Wachtwoord vergeten? +
+
+ + +
+
+ + {% if next %}{% endif %} + + + + {% if entra_enabled %} +
Of ga door met
+ {% endif %} +
+ + {% if entra_enabled %} +
+ {% csrf_token %} + {% if next %}{% endif %} + +
+ {% endif %} + + {% if demo_enabled %} +
+

Nog geen account?

+
+ {% csrf_token %} + {% if next %}{% endif %} + +
+
+ {% endif %} +
+ + +
+ + + + diff --git a/templates/registration/password_reset_complete.html b/templates/registration/password_reset_complete.html new file mode 100644 index 0000000..2091f04 --- /dev/null +++ b/templates/registration/password_reset_complete.html @@ -0,0 +1,35 @@ +{% load static %} + + + + + + Wachtwoord gewijzigd · VacatureRadar + + + + + {% include "components/icon_sprite.html" %} +
+
+ +

VacatureRadar

+

Intelligence cockpit

+
+ +
+

Wachtwoord gewijzigd

+
+ + Je wachtwoord is bijgewerkt. Je kunt nu inloggen met je nieuwe wachtwoord. +
+
+ +
+
+
+ + diff --git a/templates/registration/password_reset_confirm.html b/templates/registration/password_reset_confirm.html new file mode 100644 index 0000000..8cc0ee7 --- /dev/null +++ b/templates/registration/password_reset_confirm.html @@ -0,0 +1,64 @@ +{% load static %} + + + + + + Nieuw wachtwoord · VacatureRadar + + + + + {% include "components/icon_sprite.html" %} +
+
+ +

VacatureRadar

+

Intelligence cockpit

+
+ +
+ {% if validlink %} +

Kies een nieuw wachtwoord

+

Gebruik een uniek, sterk wachtwoord dat je nergens anders gebruikt.

+ + {% if form.errors %} + + {% endif %} + +
+ {% csrf_token %} +
+ + + {% if form.new_password1.errors %}

{{ form.new_password1.errors|join:" " }}

{% endif %} +
+
+ + + {% if form.new_password2.errors %}

{{ form.new_password2.errors|join:" " }}

{% endif %} +
+ +
+ {% else %} +

Link is verlopen

+ + Nieuwe link aanvragen + {% endif %} +
+
+ + diff --git a/templates/registration/password_reset_done.html b/templates/registration/password_reset_done.html new file mode 100644 index 0000000..b14c593 --- /dev/null +++ b/templates/registration/password_reset_done.html @@ -0,0 +1,30 @@ +{% load static %} + + + + + + Herstel-link verstuurd · VacatureRadar + + + + + {% include "components/icon_sprite.html" %} +
+
+ +

VacatureRadar

+

Intelligence cockpit

+
+ +
+

Controleer je e-mail

+
+ + 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. +
+ Terug naar inloggen +
+
+ + diff --git a/templates/registration/password_reset_email.html b/templates/registration/password_reset_email.html new file mode 100644 index 0000000..d8b5d4c --- /dev/null +++ b/templates/registration/password_reset_email.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 %} diff --git a/templates/registration/password_reset_form.html b/templates/registration/password_reset_form.html new file mode 100644 index 0000000..86dd502 --- /dev/null +++ b/templates/registration/password_reset_form.html @@ -0,0 +1,45 @@ +{% load static %} + + + + + + Wachtwoord herstellen · VacatureRadar + + + + + {% include "components/icon_sprite.html" %} +
+
+ +

VacatureRadar

+

Intelligence cockpit

+
+ +
+

Wachtwoord herstellen

+

Vul je e-mailadres in. Als er een account bij hoort, sturen we een herstel-link.

+ +
+ {% csrf_token %} +
+ + + {% if form.email.errors %}

{{ form.email.errors|join:" " }}

{% endif %} +
+ +
+ + Terug naar inloggen +
+
+ + diff --git a/templates/registration/password_reset_subject.txt b/templates/registration/password_reset_subject.txt new file mode 100644 index 0000000..3a304eb --- /dev/null +++ b/templates/registration/password_reset_subject.txt @@ -0,0 +1 @@ +VacatureRadar — wachtwoord herstellen diff --git a/tests/e2e/test_vr114_browser_and_accessibility.py b/tests/e2e/test_vr114_browser_and_accessibility.py index b2a8d2f..e2ce92c 100644 --- a/tests/e2e/test_vr114_browser_and_accessibility.py +++ b/tests/e2e/test_vr114_browser_and_accessibility.py @@ -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") diff --git a/tests/integration/test_auth_login.py b/tests/integration/test_auth_login.py new file mode 100644 index 0000000..19ed253 --- /dev/null +++ b/tests/integration/test_auth_login.py @@ -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 "

Inloggen

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