Refresh
This commit is contained in:
@@ -51,6 +51,24 @@ OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=
|
||||
OLLAMA_TIMEOUT_SECONDS=60
|
||||
|
||||
# Microsoft Entra ID (Azure AD) SSO. Laat ENTRA_ID_ENABLED=0 om SSO uit te zetten.
|
||||
# Registreer een app in Entra ID en zet de redirect-URI op <host>/auth/entra/callback/.
|
||||
ENTRA_ID_ENABLED=0
|
||||
ENTRA_CLIENT_ID=
|
||||
ENTRA_CLIENT_SECRET=CHANGE_ME_entra_client_secret
|
||||
ENTRA_TENANT_ID=
|
||||
# Optioneel: overschrijf de redirect-URI (anders automatisch afgeleid van de host).
|
||||
ENTRA_REDIRECT_URI=
|
||||
# Optioneel: beperk tot bepaalde e-maildomeinen, komma-gescheiden (bv. organisatie.nl).
|
||||
ENTRA_ALLOWED_DOMAINS=
|
||||
ENTRA_SCOPES=User.Read
|
||||
|
||||
# Demo-toegang: knop "Bekijk demo" logt in als afgeschermd gastaccount met een
|
||||
# fictieve, gevulde demo-omgeving. Zet op 0 om de knop te verbergen.
|
||||
DEMO_MODE_ENABLED=1
|
||||
DEMO_USERNAME=demo
|
||||
DEMO_EMAIL=demo@vacatureradar.local
|
||||
|
||||
# Beveiliging
|
||||
SESSION_COOKIE_SECURE=0
|
||||
CSRF_COOKIE_SECURE=0
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
)
|
||||
@@ -190,6 +190,28 @@ LOGIN_URL = "login"
|
||||
LOGIN_REDIRECT_URL = "dashboard:today"
|
||||
LOGOUT_REDIRECT_URL = "login"
|
||||
|
||||
# Inloggen kan met gebruikersnaam of e-mailadres. ModelBackend blijft als fallback.
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"apps.core.auth_backends.EmailOrUsernameModelBackend",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
]
|
||||
PASSWORD_RESET_TIMEOUT = int(os.getenv("PASSWORD_RESET_TIMEOUT", str(3 * 24 * 3600)))
|
||||
|
||||
# Microsoft Entra ID (Azure AD) SSO. Uit tenzij expliciet geconfigureerd.
|
||||
ENTRA_ID_ENABLED = env_bool("ENTRA_ID_ENABLED", False)
|
||||
ENTRA_CLIENT_ID = os.getenv("ENTRA_CLIENT_ID", "")
|
||||
ENTRA_CLIENT_SECRET = os.getenv("ENTRA_CLIENT_SECRET", "")
|
||||
ENTRA_TENANT_ID = os.getenv("ENTRA_TENANT_ID", "")
|
||||
ENTRA_REDIRECT_URI = os.getenv("ENTRA_REDIRECT_URI", "")
|
||||
ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read")
|
||||
ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS")
|
||||
|
||||
# Demo-toegang: logt in als afgeschermd gastaccount met een fictieve, gevulde
|
||||
# demo-omgeving. Standaard aan zodat de knop 'Bekijk demo' zichtbaar is.
|
||||
DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", True)
|
||||
DEMO_USERNAME = os.getenv("DEMO_USERNAME", "demo")
|
||||
DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local")
|
||||
|
||||
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "VacatureRadar <vacatureradar@localhost>")
|
||||
EMAIL_HOST = os.getenv("SMTP_HOST", "")
|
||||
|
||||
+31
-1
@@ -8,7 +8,12 @@ from django.templatetags.static import static as static_url
|
||||
from django.urls import include, path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from apps.core.views import SecurityAwareLoginView
|
||||
from apps.core.views import (
|
||||
SecurityAwareLoginView,
|
||||
demo_login,
|
||||
entra_callback,
|
||||
entra_login,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
@@ -23,6 +28,31 @@ urlpatterns = [
|
||||
name="login",
|
||||
),
|
||||
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||
# Wachtwoordherstel (Django's ingebouwde flow, eigen templates).
|
||||
path(
|
||||
"wachtwoord/herstellen/",
|
||||
auth_views.PasswordResetView.as_view(),
|
||||
name="password_reset",
|
||||
),
|
||||
path(
|
||||
"wachtwoord/herstellen/verzonden/",
|
||||
auth_views.PasswordResetDoneView.as_view(),
|
||||
name="password_reset_done",
|
||||
),
|
||||
path(
|
||||
"wachtwoord/herstellen/<uidb64>/<token>/",
|
||||
auth_views.PasswordResetConfirmView.as_view(),
|
||||
name="password_reset_confirm",
|
||||
),
|
||||
path(
|
||||
"wachtwoord/herstellen/klaar/",
|
||||
auth_views.PasswordResetCompleteView.as_view(),
|
||||
name="password_reset_complete",
|
||||
),
|
||||
# Entra ID SSO en demo-toegang.
|
||||
path("auth/entra/login/", entra_login, name="entra-login"),
|
||||
path("auth/entra/callback/", entra_callback, name="entra-callback"),
|
||||
path("auth/demo/", demo_login, name="demo-login"),
|
||||
path("", include(("apps.core.urls", "dashboard"), namespace="dashboard")),
|
||||
path("jobs/", include(("apps.jobs.urls", "jobs"), namespace="jobs")),
|
||||
path("profiles/", include(("apps.profiles.urls", "profiles"), namespace="profiles")),
|
||||
|
||||
@@ -22,6 +22,7 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"whitenoise==6.11.0",
|
||||
"cryptography==49.0.0",
|
||||
"msal==1.37.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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.
@@ -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.
@@ -12,6 +12,9 @@
|
||||
<symbol id="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/></symbol>
|
||||
<symbol id="icon-logout" viewBox="0 0 24 24"><path d="M10 4H4v16h6M14 8l4 4-4 4M8 12h10"/></symbol>
|
||||
<symbol id="icon-arrow" viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></symbol>
|
||||
<symbol id="icon-eye" viewBox="0 0 24 24"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="icon-eye-off" viewBox="0 0 24 24"><path d="M3 3l18 18M10.6 10.6a3 3 0 0 0 4.2 4.2M9.9 5.2A9.5 9.5 0 0 1 12 5c6.4 0 10 7 10 7a17.3 17.3 0 0 1-3.3 4.1M6.1 6.6A17.6 17.6 0 0 0 2 12s3.6 7 10 7a9.6 9.6 0 0 0 3-.5"/></symbol>
|
||||
<symbol id="icon-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6l7-3Z"/><path d="m9 12 2 2 4-4"/></symbol>
|
||||
<symbol id="icon-briefcase" viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V4h6v3M3 12h18"/></symbol>
|
||||
<symbol id="icon-location" viewBox="0 0 24 24"><path d="M20 10c0 5-8 12-8 12S4 15 4 10a8 8 0 1 1 16 0Z"/><circle cx="12" cy="10" r="2.5"/></symbol>
|
||||
<symbol id="icon-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -1,19 +1,149 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Aanmelden · VacatureRadar{% endblock %}
|
||||
{% block body_class %}login-page{% endblock %}
|
||||
{% block content %}
|
||||
<div class="login-cockpit">
|
||||
<section class="login-intelligence" aria-labelledby="login-brand-title">
|
||||
<div class="login-brandline"><span class="brand-radar"><svg class="icon"><use href="#icon-radar"></use></svg></span><span><strong>VacatureRadar</strong><small>Intelligence cockpit</small></span></div>
|
||||
<div class="login-radar" aria-hidden="true"><span></span><span></span><span></span><i></i></div>
|
||||
<div class="login-intro"><p class="eyebrow">Persoonlijke vacature-intelligence</p><h1 id="login-brand-title">Van bronruis naar beslisbare IT-matches.</h1><p>Breng vacatures, werkgevers, skillsvraag en opvolging samen in één betrouwbare radar.</p></div>
|
||||
<ul class="login-signals" aria-label="Platformmogelijkheden"><li><svg class="icon"><use href="#icon-check"></use></svg>Deterministische IT-selectie</li><li><svg class="icon"><use href="#icon-check"></use></svg>Herleidbare matchonderbouwing</li><li><svg class="icon"><use href="#icon-check"></use></svg>Bron- en automatiseringscontrole</li></ul>
|
||||
</section>
|
||||
<section class="auth-card" aria-labelledby="login-title">
|
||||
<div class="auth-brand"><span class="brand-mark"><svg class="icon"><use href="#icon-profile"></use></svg></span><div><p class="eyebrow">Beveiligde toegang</p><h2 id="login-title">Welkom terug</h2><p>Open je persoonlijke intelligence cockpit.</p></div></div>
|
||||
{% if form.errors %}<div class="message error" role="alert">Gebruikersnaam of wachtwoord klopt niet.</div>{% endif %}
|
||||
<form method="post" class="stack">{% csrf_token %}<label for="{{ form.username.id_for_label }}">Gebruikersnaam</label>{{ form.username }}<label for="{{ form.password.id_for_label }}">Wachtwoord</label>{{ form.password }}{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}<button class="button button-primary button-block" type="submit">Cockpit openen <svg class="icon"><use href="#icon-arrow"></use></svg></button></form>
|
||||
<div class="auth-trust"><span class="status-beacon is-live" aria-hidden="true"></span><span><strong>Lokale, afgeschermde sessie</strong><small>Je gegevens blijven binnen de geconfigureerde omgeving.</small></span></div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% load static %}<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="theme-color" content="#fbf8ff">
|
||||
<title>Inloggen · VacatureRadar</title>
|
||||
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{% static 'css/auth.css' %}?v={{ app_version }}">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
{% include "components/icon_sprite.html" %}
|
||||
<main class="auth-shell">
|
||||
<div class="auth-brand">
|
||||
<img class="auth-brand-logo" src="{% static 'img/vacatureradar-logo.svg' %}" alt="VacatureRadar logo" width="64" height="64">
|
||||
<p class="auth-brand-name">VacatureRadar</p>
|
||||
<p class="auth-brand-tagline">Intelligence cockpit</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<h1>Inloggen</h1>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="auth-alert {% if message.tags == 'error' %}is-error{% elif message.tags == 'success' %}is-success{% else %}is-info{% endif %}" role="alert">
|
||||
<svg class="icon" aria-hidden="true"><use href="{% if message.tags == 'error' %}#icon-alert{% else %}#icon-check{% endif %}"></use></svg>
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if form.errors and not messages %}
|
||||
<div class="auth-alert is-error" role="alert">
|
||||
<svg class="icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
|
||||
<span>E-mailadres of wachtwoord klopt niet. Probeer het opnieuw.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="auth-form" novalidate>
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="id_username">E-mailadres</label>
|
||||
<input class="auth-input" id="id_username" name="username" type="email"
|
||||
autocomplete="username" autocapitalize="none" spellcheck="false"
|
||||
placeholder="naam@organisatie.nl" required
|
||||
{% if form.errors %}aria-invalid="true"{% endif %}
|
||||
value="{{ form.username.value|default:'' }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label-row">
|
||||
<label for="id_password">Wachtwoord</label>
|
||||
<a class="field-link" href="{% url 'password_reset' %}">Wachtwoord vergeten?</a>
|
||||
</div>
|
||||
<div class="input-wrap">
|
||||
<input class="auth-input" id="id_password" name="password" type="password"
|
||||
autocomplete="current-password" placeholder="••••••••" required
|
||||
{% if form.errors %}aria-invalid="true"{% endif %}>
|
||||
<button class="pw-toggle" type="button" data-pw-toggle aria-label="Wachtwoord tonen">
|
||||
<svg class="icon" aria-hidden="true" data-eye><use href="#icon-eye"></use></svg>
|
||||
<svg class="icon" aria-hidden="true" data-eye-off hidden><use href="#icon-eye-off"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||
|
||||
<button class="btn btn-primary" type="submit" data-submit>
|
||||
<span data-label>Inloggen</span>
|
||||
<svg class="icon" aria-hidden="true" data-arrow><use href="#icon-arrow"></use></svg>
|
||||
</button>
|
||||
|
||||
{% if entra_enabled %}
|
||||
<div class="auth-separator"><span>Of ga door met</span></div>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if entra_enabled %}
|
||||
<form method="post" action="{% url 'entra-login' %}">
|
||||
{% csrf_token %}
|
||||
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||
<button class="btn btn-sso" type="submit">
|
||||
<svg viewBox="0 0 23 23" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M1 1h10v10H1z" fill="#f35325"></path>
|
||||
<path d="M12 1h10v10H12z" fill="#81bc06"></path>
|
||||
<path d="M1 12h10v10H1z" fill="#05a6f0"></path>
|
||||
<path d="M12 12h10v10H12z" fill="#ffba08"></path>
|
||||
</svg>
|
||||
Inloggen met Entra ID
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if demo_enabled %}
|
||||
<div class="auth-secondary">
|
||||
<p>Nog geen account?</p>
|
||||
<form method="post" action="{% url 'demo-login' %}" class="btn-ghost inline-form">
|
||||
{% csrf_token %}
|
||||
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||
<button class="btn-ghost" type="submit">
|
||||
<svg class="icon" aria-hidden="true"><use href="#icon-eye"></use></svg>
|
||||
Bekijk demo
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<footer class="auth-footer">
|
||||
<div class="auth-footer-links">
|
||||
<a href="{% url 'password_reset' %}">Wachtwoord vergeten</a>
|
||||
</div>
|
||||
<div class="auth-badge">
|
||||
<svg class="icon" aria-hidden="true"><use href="#icon-shield"></use></svg>
|
||||
<span>Local-first secure node</span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var toggle = document.querySelector('[data-pw-toggle]');
|
||||
if (toggle) {
|
||||
var pw = document.getElementById('id_password');
|
||||
var eye = toggle.querySelector('[data-eye]');
|
||||
var eyeOff = toggle.querySelector('[data-eye-off]');
|
||||
toggle.addEventListener('click', function () {
|
||||
var show = pw.type === 'password';
|
||||
pw.type = show ? 'text' : 'password';
|
||||
toggle.setAttribute('aria-label', show ? 'Wachtwoord verbergen' : 'Wachtwoord tonen');
|
||||
if (eye && eyeOff) { eye.hidden = show; eyeOff.hidden = !show; }
|
||||
});
|
||||
}
|
||||
var form = document.querySelector('form.auth-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function () {
|
||||
var btn = form.querySelector('[data-submit]');
|
||||
if (!btn || btn.dataset.busy) return;
|
||||
if (typeof form.checkValidity === 'function' && !form.checkValidity()) return;
|
||||
btn.dataset.busy = '1';
|
||||
var label = btn.querySelector('[data-label]');
|
||||
if (label) label.textContent = 'Bezig met inloggen…';
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{% load static %}<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Wachtwoord gewijzigd · VacatureRadar</title>
|
||||
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{% static 'css/auth.css' %}?v={{ app_version }}">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
{% include "components/icon_sprite.html" %}
|
||||
<main class="auth-shell">
|
||||
<div class="auth-brand">
|
||||
<img class="auth-brand-logo" src="{% static 'img/vacatureradar-logo.svg' %}" alt="VacatureRadar logo" width="64" height="64">
|
||||
<p class="auth-brand-name">VacatureRadar</p>
|
||||
<p class="auth-brand-tagline">Intelligence cockpit</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<h1>Wachtwoord gewijzigd</h1>
|
||||
<div class="auth-alert is-success" role="status">
|
||||
<svg class="icon" aria-hidden="true"><use href="#icon-check"></use></svg>
|
||||
<span>Je wachtwoord is bijgewerkt. Je kunt nu inloggen met je nieuwe wachtwoord.</span>
|
||||
</div>
|
||||
<form method="get" action="{% url 'login' %}" class="auth-form">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
Naar inloggen
|
||||
<svg class="icon" aria-hidden="true"><use href="#icon-arrow"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
{% load static %}<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Nieuw wachtwoord · VacatureRadar</title>
|
||||
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{% static 'css/auth.css' %}?v={{ app_version }}">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
{% include "components/icon_sprite.html" %}
|
||||
<main class="auth-shell">
|
||||
<div class="auth-brand">
|
||||
<img class="auth-brand-logo" src="{% static 'img/vacatureradar-logo.svg' %}" alt="VacatureRadar logo" width="64" height="64">
|
||||
<p class="auth-brand-name">VacatureRadar</p>
|
||||
<p class="auth-brand-tagline">Intelligence cockpit</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
{% if validlink %}
|
||||
<h1>Kies een nieuw wachtwoord</h1>
|
||||
<p class="auth-lead">Gebruik een uniek, sterk wachtwoord dat je nergens anders gebruikt.</p>
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="auth-alert is-error" role="alert">
|
||||
<svg class="icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
|
||||
<span>Controleer de gemarkeerde velden en probeer het opnieuw.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="auth-form" novalidate>
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="id_new_password1">Nieuw wachtwoord</label>
|
||||
<input class="auth-input" id="id_new_password1" name="new_password1" type="password"
|
||||
autocomplete="new-password" required
|
||||
{% if form.new_password1.errors %}aria-invalid="true"{% endif %}>
|
||||
{% if form.new_password1.errors %}<p class="field-error">{{ form.new_password1.errors|join:" " }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_new_password2">Herhaal wachtwoord</label>
|
||||
<input class="auth-input" id="id_new_password2" name="new_password2" type="password"
|
||||
autocomplete="new-password" required
|
||||
{% if form.new_password2.errors %}aria-invalid="true"{% endif %}>
|
||||
{% if form.new_password2.errors %}<p class="field-error">{{ form.new_password2.errors|join:" " }}</p>{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">
|
||||
Wachtwoord opslaan
|
||||
<svg class="icon" aria-hidden="true"><use href="#icon-check"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<h1>Link is verlopen</h1>
|
||||
<div class="auth-alert is-error" role="alert">
|
||||
<svg class="icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
|
||||
<span>Deze herstel-link is ongeldig of al gebruikt. Vraag een nieuwe link aan.</span>
|
||||
</div>
|
||||
<a class="auth-back" href="{% url 'password_reset' %}"><svg class="icon" aria-hidden="true"><use href="#icon-arrow"></use></svg>Nieuwe link aanvragen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{% load static %}<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Herstel-link verstuurd · VacatureRadar</title>
|
||||
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{% static 'css/auth.css' %}?v={{ app_version }}">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
{% include "components/icon_sprite.html" %}
|
||||
<main class="auth-shell">
|
||||
<div class="auth-brand">
|
||||
<img class="auth-brand-logo" src="{% static 'img/vacatureradar-logo.svg' %}" alt="VacatureRadar logo" width="64" height="64">
|
||||
<p class="auth-brand-name">VacatureRadar</p>
|
||||
<p class="auth-brand-tagline">Intelligence cockpit</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<h1>Controleer je e-mail</h1>
|
||||
<div class="auth-alert is-info" role="status">
|
||||
<svg class="icon" aria-hidden="true"><use href="#icon-mail"></use></svg>
|
||||
<span>Bestaat er een account met dit e-mailadres, dan is er een herstel-link verstuurd. Volg de instructies in de mail om een nieuw wachtwoord in te stellen.</span>
|
||||
</div>
|
||||
<a class="auth-back" href="{% url 'login' %}"><svg class="icon" aria-hidden="true"><use href="#icon-arrow"></use></svg>Terug naar inloggen</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
{% autoescape off %}Hallo,
|
||||
|
||||
Je ontving deze e-mail omdat er een wachtwoordherstel is aangevraagd voor je
|
||||
VacatureRadar-account ({{ user.get_username }}).
|
||||
|
||||
Ga naar de volgende pagina om een nieuw wachtwoord te kiezen:
|
||||
|
||||
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
|
||||
|
||||
Heb je dit niet aangevraagd? Dan kun je deze e-mail negeren; je wachtwoord blijft
|
||||
ongewijzigd.
|
||||
|
||||
Met vriendelijke groet,
|
||||
VacatureRadar
|
||||
{% endautoescape %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% load static %}<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Wachtwoord herstellen · VacatureRadar</title>
|
||||
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{% static 'css/auth.css' %}?v={{ app_version }}">
|
||||
</head>
|
||||
<body class="auth-page">
|
||||
{% include "components/icon_sprite.html" %}
|
||||
<main class="auth-shell">
|
||||
<div class="auth-brand">
|
||||
<img class="auth-brand-logo" src="{% static 'img/vacatureradar-logo.svg' %}" alt="VacatureRadar logo" width="64" height="64">
|
||||
<p class="auth-brand-name">VacatureRadar</p>
|
||||
<p class="auth-brand-tagline">Intelligence cockpit</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<h1>Wachtwoord herstellen</h1>
|
||||
<p class="auth-lead">Vul je e-mailadres in. Als er een account bij hoort, sturen we een herstel-link.</p>
|
||||
|
||||
<form method="post" class="auth-form" novalidate>
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="id_email">E-mailadres</label>
|
||||
<input class="auth-input" id="id_email" name="email" type="email"
|
||||
autocomplete="email" autocapitalize="none" spellcheck="false"
|
||||
placeholder="naam@organisatie.nl" required
|
||||
{% if form.email.errors %}aria-invalid="true"{% endif %}
|
||||
value="{{ form.email.value|default:'' }}">
|
||||
{% if form.email.errors %}<p class="field-error">{{ form.email.errors|join:" " }}</p>{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">
|
||||
Stuur herstel-link
|
||||
<svg class="icon" aria-hidden="true"><use href="#icon-mail"></use></svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<a class="auth-back" href="{% url 'login' %}"><svg class="icon" aria-hidden="true"><use href="#icon-arrow"></use></svg>Terug naar inloggen</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
VacatureRadar — wachtwoord herstellen
|
||||
@@ -183,12 +183,12 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
|
||||
page = context.new_page()
|
||||
|
||||
try:
|
||||
# Login.
|
||||
# Login (e-mailadres + wachtwoord, Stitch-ontwerp).
|
||||
page.goto(login_url)
|
||||
_assert_playwright_accessibility(page, expected_h1="Welkom terug")
|
||||
page.get_by_label("Gebruikersnaam").fill(user.username)
|
||||
_assert_playwright_accessibility(page, expected_h1="Inloggen")
|
||||
page.get_by_label("E-mailadres").fill(user.email)
|
||||
page.get_by_label("Wachtwoord").fill("correct-horse-battery-staple")
|
||||
page.get_by_role("button", name="Aanmelden").click()
|
||||
page.get_by_role("button", name="Inloggen").click()
|
||||
page.wait_for_url(dashboard_url)
|
||||
_assert_playwright_accessibility(page, expected_h1="Goedemorgen")
|
||||
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user