Add public buyer self-registration

A UK buyer can now create their own account at /portal/signup/. The screen
creates a Keycloak user through a least-privilege service account
(ixg-provisioning: manage-users, view-users) and the matching Django row as
a buyer, then sends them through the normal sign-in flow to the portal.

Staff status is derived from the role in the OIDC backend rather than
asserted, so a self-registered buyer is not staff and does not become staff
on a later sign-in. The platform root and the RBAC middleware now route
each role to its own portal, which also fixes a redirect loop that the
staff fix exposed for non-staff buyers.
This commit is contained in:
ixgadmin 2026-09-21 11:30:29 +00:00
parent 5b6c4c48e4
commit 19d67555da
14 changed files with 675 additions and 14 deletions

69
apps/buyer/forms.py Normal file
View file

@ -0,0 +1,69 @@
"""The public buyer sign-up form.
This is the only place on the platform a password minimum is enforced — the
realm has no password policy set and no mail service exists — so the rules live
here and are deliberately plain: long enough, not the email or the company name,
and typed twice the same. The form is also the first line of duplicate-email
handling; Keycloak refuses a second account with the same address, and the view
turns that into the same sentence.
"""
from django import forms
from apps.users.models import User
MIN_PASSWORD_LENGTH = 12
DUPLICATE_EMAIL_MESSAGE = "An account with this email already exists — sign in instead."
class BuyerSignupForm(forms.Form):
full_name = forms.CharField(
max_length=255,
label="Full name",
widget=forms.TextInput(attrs={"class": "form-control", "autocomplete": "name"}),
)
company_name = forms.CharField(
max_length=255,
label="Company name",
widget=forms.TextInput(attrs={"class": "form-control", "autocomplete": "organization"}),
)
email = forms.EmailField(
label="Email",
widget=forms.EmailInput(attrs={"class": "form-control", "autocomplete": "email"}),
)
password = forms.CharField(
label="Password",
widget=forms.PasswordInput(attrs={"class": "form-control", "autocomplete": "new-password"}),
help_text=f"At least {MIN_PASSWORD_LENGTH} characters.",
)
confirm_password = forms.CharField(
label="Confirm password",
widget=forms.PasswordInput(attrs={"class": "form-control", "autocomplete": "new-password"}),
)
def clean_email(self):
email = self.cleaned_data["email"].strip().lower()
if User.objects.filter(email__iexact=email).exists():
raise forms.ValidationError(DUPLICATE_EMAIL_MESSAGE)
return email
def clean(self):
cleaned = super().clean()
password = cleaned.get("password")
confirm = cleaned.get("confirm_password")
if password and confirm and password != confirm:
self.add_error("confirm_password", "The two passwords do not match.")
if password:
if len(password) < MIN_PASSWORD_LENGTH:
self.add_error(
"password",
f"Use at least {MIN_PASSWORD_LENGTH} characters.",
)
email = (cleaned.get("email") or "").strip().lower()
company = (cleaned.get("company_name") or "").strip().lower()
if password.lower() in (email, company) and password:
self.add_error(
"password",
"Choose a password that is not your email address or company name.",
)
return cleaned

122
apps/buyer/tests.py Normal file
View file

@ -0,0 +1,122 @@
"""The public buyer sign-up screen.
Two things matter: the page is reachable without signing in, and the account it
creates is a plain buyer — the same kind of account everything else uses, with
no platform privileges. Keycloak is patched here; the live Admin API path is
exercised by the smoke test, not by the suite.
"""
from unittest.mock import patch
from urllib.parse import urlencode
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from apps.users.keycloak import DuplicateEmailError, KeycloakUnavailableError
User = get_user_model()
SIGNUP_URL = "/portal/signup/"
VALID = {
"full_name": "Ada Greenfield",
"company_name": "Greenfield Foods Ltd",
"email": "signup-smoke-01@example.com",
"password": "a-long-enough-passphrase",
"confirm_password": "a-long-enough-passphrase",
}
class SignupViewTests(TestCase):
def test_the_page_is_public(self):
response = self.client.get(SIGNUP_URL)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Create your buyer account")
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0001")
def test_successful_signup_creates_both_records(self, create):
response = self.client.post(SIGNUP_URL, VALID)
expected = reverse("oidc_authentication_init") + "?" + urlencode({"next": "/portal/?welcome=1"})
self.assertRedirects(response, expected, fetch_redirect_response=False)
create.assert_called_once_with(
email=VALID["email"], full_name=VALID["full_name"], password=VALID["password"]
)
user = User.objects.get(email=VALID["email"])
self.assertEqual(user.role, "buyer")
self.assertFalse(user.is_staff)
self.assertTrue(user.is_active)
self.assertEqual(user.company_name, "Greenfield Foods Ltd")
self.assertEqual(user.keycloak_id, "kc-sub-0001")
def test_a_duplicate_email_is_refused_without_calling_keycloak(self):
User.objects.create_user(email=VALID["email"], role="buyer")
with patch("apps.buyer.views.create_keycloak_user") as create:
response = self.client.post(SIGNUP_URL, VALID)
create.assert_not_called()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "An account with this email already exists")
@patch("apps.buyer.views.create_keycloak_user", side_effect=DuplicateEmailError("x"))
def test_a_keycloak_duplicate_is_refused_the_same_way(self, create):
response = self.client.post(SIGNUP_URL, VALID)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "An account with this email already exists")
self.assertFalse(User.objects.filter(email=VALID["email"]).exists())
@patch("apps.buyer.views.create_keycloak_user", side_effect=KeycloakUnavailableError("down"))
def test_an_unreachable_keycloak_is_reported_not_a_500(self, create):
response = self.client.post(SIGNUP_URL, VALID)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "could not create your account")
self.assertFalse(User.objects.filter(email=VALID["email"]).exists())
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0002")
def test_mismatched_confirmation_is_refused(self, create):
response = self.client.post(SIGNUP_URL, {**VALID, "confirm_password": "something-else-entirely"})
create.assert_not_called()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "The two passwords do not match")
self.assertFalse(User.objects.filter(email=VALID["email"]).exists())
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0003")
def test_a_short_password_is_refused(self, create):
response = self.client.post(SIGNUP_URL, {**VALID, "password": "short", "confirm_password": "short"})
create.assert_not_called()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "at least 12 characters")
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0004")
def test_a_password_equal_to_the_email_is_refused(self, create):
password = VALID["email"]
response = self.client.post(
SIGNUP_URL, {**VALID, "password": password, "confirm_password": password}
)
create.assert_not_called()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "not your email address or company name")
def test_a_signed_in_user_is_sent_to_their_portal(self):
User.objects.create_user(email="already-in@example.com", role="buyer", is_staff=False)
self.client.force_login(User.objects.get(email="already-in@example.com"))
response = self.client.get(SIGNUP_URL)
self.assertRedirects(response, "/", fetch_redirect_response=False)
class DashboardWelcomeTests(TestCase):
def test_the_welcome_message_shows_after_signup(self):
User.objects.create_user(
email="welcome-buyer@example.com", role="buyer", is_staff=False
)
self.client.force_login(User.objects.get(email="welcome-buyer@example.com"))
response = self.client.get("/portal/?welcome=1")
self.assertContains(response, "Your account is ready")
def test_no_welcome_message_on_a_normal_visit(self):
User.objects.create_user(
email="quiet-buyer@example.com", role="buyer", is_staff=False
)
self.client.force_login(User.objects.get(email="quiet-buyer@example.com"))
response = self.client.get("/portal/")
self.assertNotContains(response, "Your account is ready")

View file

@ -4,6 +4,7 @@ from . import views
app_name = 'portal' app_name = 'portal'
urlpatterns = [ urlpatterns = [
path("signup/", views.SignupView.as_view(), name="signup"),
path("chat/", views.ChatView.as_view(), name="chat"), path("chat/", views.ChatView.as_view(), name="chat"),
path("chat/embed/", views.ChatEmbedView.as_view(), name="chat_embed"), path("chat/embed/", views.ChatEmbedView.as_view(), name="chat_embed"),
path('', views.DashboardView.as_view(), name='dashboard'), path('', views.DashboardView.as_view(), name='dashboard'),

View file

@ -1,8 +1,11 @@
from urllib.parse import urlencode
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views import View from django.views import View
from django.views.generic import TemplateView, ListView, DetailView from django.views.generic import TemplateView, ListView, DetailView, FormView
from apps.batches.models import ProcessingBatch from apps.batches.models import ProcessingBatch
from apps.shipments.models import Shipment from apps.shipments.models import Shipment
@ -12,6 +15,14 @@ from apps.esg.models import ESGFieldReport
from apps.subscriptions.models import Subscription from apps.subscriptions.models import Subscription
from apps.guides.views import GuideDetailView as BaseGuideDetailView from apps.guides.views import GuideDetailView as BaseGuideDetailView
from apps.guides.views import GuideIndexView as BaseGuideIndexView from apps.guides.views import GuideIndexView as BaseGuideIndexView
from apps.users.keycloak import (
DuplicateEmailError,
KeycloakError,
create_keycloak_user,
)
from apps.users.models import User
from .forms import DUPLICATE_EMAIL_MESSAGE, BuyerSignupForm
class BuyerRequiredMixin(UserPassesTestMixin): class BuyerRequiredMixin(UserPassesTestMixin):
@ -20,11 +31,64 @@ class BuyerRequiredMixin(UserPassesTestMixin):
return user.is_authenticated and (user.role in ("buyer", "admin") or user.is_superuser) return user.is_authenticated and (user.role in ("buyer", "admin") or user.is_superuser)
class SignupView(FormView):
"""Public buyer self-registration.
Creates the Keycloak user first — the Django row stores its subject id —
then the matching ``users`` row as a buyer, and sends the new buyer through
the normal sign-in flow. There is one sign-in mechanism platform-wide; the
sign-up screen only creates the account.
A duplicate is refused with the same sentence whether Django or Keycloak
spots it, and an unreachable Keycloak is reported, never a 500.
"""
template_name = "portal/signup.html"
form_class = BuyerSignupForm
def dispatch(self, request, *args, **kwargs):
if request.user.is_authenticated:
return redirect("home")
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
data = form.cleaned_data
try:
keycloak_id = create_keycloak_user(
email=data["email"],
full_name=data["full_name"],
password=data["password"],
)
except DuplicateEmailError:
form.add_error("email", DUPLICATE_EMAIL_MESSAGE)
return self.form_invalid(form)
except KeycloakError:
form.add_error(
None,
"We could not create your account just now. Please try again shortly.",
)
return self.form_invalid(form)
User.objects.create_user(
email=data["email"],
full_name=data["full_name"],
company_name=data["company_name"],
role=User.Roles.BUYER,
is_active=True,
is_staff=False,
keycloak_id=keycloak_id,
)
sign_in = reverse("oidc_authentication_init")
return redirect(f"{sign_in}?{urlencode({'next': '/portal/?welcome=1'})}")
class DashboardView(BuyerRequiredMixin, TemplateView): class DashboardView(BuyerRequiredMixin, TemplateView):
template_name = "portal/dashboard.html" template_name = "portal/dashboard.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx["welcome"] = self.request.GET.get("welcome") == "1"
ctx["total_batches"] = ProcessingBatch.objects.count() ctx["total_batches"] = ProcessingBatch.objects.count()
ctx["recent_batches"] = ProcessingBatch.objects.order_by("-created_at")[:5] ctx["recent_batches"] = ProcessingBatch.objects.order_by("-created_at")[:5]
ctx["documents"] = Document.objects.filter(visibility="buyer").order_by("-uploaded_at")[:5] ctx["documents"] = Document.objects.filter(visibility="buyer").order_by("-uploaded_at")[:5]

View file

@ -1,6 +1,7 @@
import re import re
from django.shortcuts import redirect
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect
class RBACMiddleware: class RBACMiddleware:
@ -14,11 +15,23 @@ class RBACMiddleware:
self.get_response = get_response self.get_response = get_response
def __call__(self, request): def __call__(self, request):
if request.user.is_authenticated and not request.user.is_staff: user = request.user
if user.is_authenticated and not user.is_staff and not user.is_superuser:
path = request.path_info path = request.path_info
user_role = getattr(request.user, "role", None) user_role = getattr(request.user, "role", None)
for role, patterns in self.ROLE_ROUTES.items(): allowed = self.ROLE_ROUTES.get(user_role, [])
for pattern in patterns: if any(re.match(pattern, path) for pattern in allowed):
if re.match(pattern, path) and user_role != role and not request.user.is_superuser: return self.get_response(request)
return redirect(settings.LOGIN_REDIRECT_URL) # The path belongs to a portal this role may not enter. Send them to
# the role-aware landing view rather than straight into a portal they
# cannot use. It used to redirect if *any* role's pattern matched the
# path without belonging to this role, which bounced a buyer off
# /portal/ itself (the admin patterns include /portal/), and looped
# with the landing redirect.
if any(
re.match(pattern, path)
for patterns in self.ROLE_ROUTES.values()
for pattern in patterns
):
return redirect(settings.LOGIN_REDIRECT_URL)
return self.get_response(request) return self.get_response(request)

View file

@ -142,6 +142,60 @@ class PortalPagesRenderTests(TestCase):
def test_buyer_pages_render(self): def test_buyer_pages_render(self):
self.sweep(self.make_user("buyer-sweep@ixg.local", "buyer"), PORTAL_ROUTES, "portal.ixgltd.com") self.sweep(self.make_user("buyer-sweep@ixg.local", "buyer"), PORTAL_ROUTES, "portal.ixgltd.com")
def test_the_public_signup_page_renders_for_visitors(self):
response = Client(SERVER_NAME="portal.ixgltd.com").get("/portal/signup/")
self.assertEqual(response.status_code, 200)
class RoleRoutingTests(TestCase):
"""A buyer is not staff, so the middleware must not bounce them off /portal/.
The old middleware redirected if any role's pattern matched the path without
belonging to the user's role, and the admin patterns include /portal/ — so a
real (non-staff) buyer was sent to the landing view, which sent them back.
These pin the corrected routing: each role reaches its own portal and is
turned away from the others via the landing view.
"""
def make_user(self, email, role):
return User.objects.create_user(
email=email, password="pass1234", role=role, is_staff=False
)
def test_a_buyer_reaches_the_buyer_portal(self):
client = Client(SERVER_NAME="portal.ixgltd.com")
client.force_login(self.make_user("route-buyer@ixg.local", "buyer"))
self.assertEqual(client.get("/portal/").status_code, 200)
def test_a_buyer_is_turned_away_from_ops_and_admin(self):
client = Client(SERVER_NAME="portal.ixgltd.com")
client.force_login(self.make_user("route-buyer2@ixg.local", "buyer"))
for path in ("/ops/", "/admin/"):
with self.subTest(path=path):
response = client.get(path)
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], "/")
def test_ops_reaches_ops(self):
client = Client(SERVER_NAME="ops.ixgltd.com")
client.force_login(self.make_user("route-ops@ixg.local", "ops"))
self.assertEqual(client.get("/ops/").status_code, 200)
def test_the_landing_view_sends_each_role_to_its_portal(self):
cases = [("buyer", "/portal/"), ("ops", "/ops/"), ("admin", "/admin/")]
for role, destination in cases:
with self.subTest(role=role):
client = Client(SERVER_NAME="app.ixgltd.com")
client.force_login(self.make_user(f"landing-{role}@ixg.local", role))
response = client.get("/")
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], destination)
def test_a_visitor_is_sent_to_sign_in(self):
response = Client(SERVER_NAME="app.ixgltd.com").get("/")
self.assertEqual(response.status_code, 302)
self.assertIn("/oidc/authenticate/", response["Location"])
class ApiPermissionBoundaryTests(TestCase): class ApiPermissionBoundaryTests(TestCase):
"""The API permission boundary, pinned for every viewset. """The API permission boundary, pinned for every viewset.

27
apps/core/views.py Normal file
View file

@ -0,0 +1,27 @@
from django.contrib.auth.views import redirect_to_login
from django.shortcuts import redirect
from django.views import View
class RoleRedirectView(View):
"""Send a signed-in user to their own portal; send visitors to sign-in.
The platform root is shared by all three portals, so it cannot point at one
of them. It used to hard-code ``/admin/``, which bounced a buyer off an area
they cannot enter; the destination now depends on the role.
``/`` deliberately sits outside every RBAC route prefix, so
``RBACMiddleware`` can redirect a wrong-role request here (via
``LOGIN_REDIRECT_URL``) without looping back into a route it would redirect
again.
"""
def get(self, request):
user = request.user
if not user.is_authenticated:
return redirect_to_login(request.get_full_path())
if user.role == "admin" or user.is_superuser:
return redirect("/admin/")
if user.role == "ops":
return redirect("/ops/")
return redirect("/portal/")

View file

@ -1,25 +1,38 @@
from mozilla_django_oidc.auth import OIDCAuthenticationBackend from mozilla_django_oidc.auth import OIDCAuthenticationBackend
# Roles that carry platform privileges. A buyer is not staff: staff status is
# derived from the role, never asserted, so a self-registered buyer cannot come
# out as staff (or flip to staff on a later sign-in).
STAFF_ROLES = {"ops", "admin"}
def is_staff_for_role(role, *, is_superuser=False):
return bool(is_superuser) or role in STAFF_ROLES
class KeycloakOIDCBackend(OIDCAuthenticationBackend): class KeycloakOIDCBackend(OIDCAuthenticationBackend):
def create_user(self, claims): def create_user(self, claims):
email = claims.get("email", "") email = claims.get("email", "")
role = claims.get("role", "buyer")
if role not in dict(self.UserModel.Roles.choices):
role = self.UserModel.Roles.BUYER
user = self.UserModel.objects.create_user( user = self.UserModel.objects.create_user(
email=email, email=email,
full_name=claims.get("name", ""), full_name=claims.get("name", ""),
keycloak_id=claims.get("sub", ""), keycloak_id=claims.get("sub", ""),
role=claims.get("role", "buyer"), role=role,
is_staff=True, is_staff=is_staff_for_role(role),
) )
return user return user
def update_user(self, user, claims): def update_user(self, user, claims):
user.email = claims.get("email", user.email) user.email = claims.get("email", user.email)
user.full_name = claims.get("name", user.full_name) user.full_name = claims.get("name", user.full_name)
user.is_staff = True
role = claims.get("role", user.role) role = claims.get("role", user.role)
if role in dict(self.UserModel.Roles.choices): if role in dict(self.UserModel.Roles.choices):
user.role = role user.role = role
user.is_staff = is_staff_for_role(user.role, is_superuser=user.is_superuser)
user.save() user.save()
return user return user

120
apps/users/keycloak.py Normal file
View file

@ -0,0 +1,120 @@
"""Create Keycloak users from the platform.
The public buyer sign-up screen needs a Keycloak account before it can write the
matching Django row, because that row stores the Keycloak subject id. Realm
self-registration is off (``registrationAllowed = false``) and could not capture
the company name anyway, so the platform creates the user itself through the
Keycloak Admin API, using a dedicated service account
(``KEYCLOAK_PROVISIONING_CLIENT_ID``) that holds only ``manage-users`` and
``view-users`` on ``realm-management``.
The account it creates is deliberately bare: **no realm roles and no client
roles**, so it grants nothing on the platform. The Django row's ``role`` is the
authority; Keycloak is only the identity store.
Credentials are read from settings (django-environ), never hard-coded, and no
token or secret is ever logged.
"""
import requests
from django.conf import settings
class KeycloakError(Exception):
"""Base class for provisioning failures the sign-up screen can report."""
class DuplicateEmailError(KeycloakError):
"""Keycloak already has a user with this email address."""
class KeycloakUnavailableError(KeycloakError):
"""Keycloak could not be reached, or refused the service account."""
def _realm_url():
return f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}/realms/{settings.KEYCLOAK_REALM}"
def _admin_url(path):
return (
f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}"
f"/admin/realms/{settings.KEYCLOAK_REALM}{path}"
)
def _split_name(full_name):
"""Split a full name for Keycloak's required firstName/lastName fields.
The realm's user profile marks both as required for users, and Keycloak
inserts a "Update Account Information" (``VERIFY_PROFILE``) step at sign-in
whenever one is empty. A single-word name therefore fills both fields: the
platform shows ``full_name`` from its own row, so the split only has to
satisfy Keycloak, not be a perfect decomposition.
"""
parts = full_name.strip().split()
if not parts:
return "", ""
if len(parts) == 1:
return parts[0], parts[0]
return " ".join(parts[:-1]), parts[-1]
def _service_account_token(timeout=10):
"""Exchange the provisioning client's credentials for an admin token."""
try:
response = requests.post(
f"{_realm_url()}/protocol/openid-connect/token",
data={
"grant_type": "client_credentials",
"client_id": settings.KEYCLOAK_PROVISIONING_CLIENT_ID,
"client_secret": settings.KEYCLOAK_PROVISIONING_CLIENT_SECRET,
},
timeout=timeout,
)
except requests.RequestException as exc:
raise KeycloakUnavailableError("Could not reach Keycloak.") from exc
if response.status_code != 200:
raise KeycloakUnavailableError("Keycloak refused the provisioning account.")
return response.json()["access_token"]
def create_keycloak_user(*, email, full_name, password, timeout=10):
"""Create an enabled, unverified Keycloak user and return its subject id.
``emailVerified`` is left false on purpose: the realm has ``verifyEmail``
off and no mail service exists, so an unverified user can still sign in.
A duplicate email (``duplicateEmailsAllowed`` is off) surfaces as
:class:`DuplicateEmailError` rather than a raw 409.
"""
token = _service_account_token(timeout=timeout)
first_name, last_name = _split_name(full_name)
payload = {
"username": email,
"email": email,
"firstName": first_name,
"lastName": last_name,
"enabled": True,
"emailVerified": False,
"credentials": [
{"type": "password", "value": password, "temporary": False}
],
}
try:
response = requests.post(
_admin_url("/users"),
json=payload,
headers={"Authorization": f"Bearer {token}"},
timeout=timeout,
)
except requests.RequestException as exc:
raise KeycloakUnavailableError("Could not reach Keycloak.") from exc
if response.status_code == 409:
raise DuplicateEmailError(email)
if response.status_code not in (201, 204):
raise KeycloakUnavailableError("Keycloak could not create the account.")
location = response.headers.get("Location", "")
if not location:
raise KeycloakUnavailableError("Keycloak did not return the new account.")
return location.rstrip("/").rsplit("/", 1)[-1]

107
apps/users/tests.py Normal file
View file

@ -0,0 +1,107 @@
"""Staff status is derived from the role, never asserted.
The bug these pin: ``create_user`` and ``update_user`` used to set
``is_staff=True`` on every account, so a self-registered buyer came out as staff
— and, because ``update_user`` runs on every sign-in, a buyer row created
correctly would still flip to staff the first time they signed in. The second
case is the one that passes a first test and fails later, so it is covered
directly.
"""
from django.contrib.auth import get_user_model
from django.test import TestCase
from .auth import KeycloakOIDCBackend, is_staff_for_role
from .keycloak import _split_name
User = get_user_model()
class SplitNameTests(TestCase):
"""Both Keycloak name fields must be non-empty, or sign-in shows the
"Update Account Information" (VERIFY_PROFILE) step."""
def test_a_two_part_name_splits(self):
self.assertEqual(_split_name("Ada Greenfield"), ("Ada", "Greenfield"))
def test_a_multi_part_name_keeps_the_last_word_as_the_surname(self):
self.assertEqual(_split_name("Ada Mary Greenfield"), ("Ada Mary", "Greenfield"))
def test_a_single_word_name_fills_both_fields(self):
self.assertEqual(_split_name("Ada"), ("Ada", "Ada"))
def test_an_empty_name_yields_empty_fields(self):
self.assertEqual(_split_name(" "), ("", ""))
class StaffRoleTests(TestCase):
def setUp(self):
self.backend = KeycloakOIDCBackend()
def test_only_ops_and_admin_are_staff(self):
self.assertFalse(is_staff_for_role("buyer"))
self.assertTrue(is_staff_for_role("ops"))
self.assertTrue(is_staff_for_role("admin"))
self.assertTrue(is_staff_for_role("buyer", is_superuser=True))
def test_create_user_derives_staff_from_the_claims_role(self):
buyer = self.backend.create_user(
{"email": "signup-buyer@example.com", "name": "Buyer One", "sub": "sub-buyer", "role": "buyer"}
)
self.assertEqual(buyer.role, "buyer")
self.assertFalse(buyer.is_staff)
ops = self.backend.create_user(
{"email": "signup-ops@example.com", "name": "Ops One", "sub": "sub-ops", "role": "ops"}
)
self.assertTrue(ops.is_staff)
def test_create_user_defaults_to_buyer_without_a_role_claim(self):
user = self.backend.create_user(
{"email": "signup-norole@example.com", "name": "No Role", "sub": "sub-norole"}
)
self.assertEqual(user.role, "buyer")
self.assertFalse(user.is_staff)
def test_update_user_does_not_staff_a_buyer(self):
user = User.objects.create_user(
email="buyer-stays@example.com", role="buyer", is_staff=False, keycloak_id="sub-1"
)
self.backend.update_user(
user, {"email": "buyer-stays@example.com", "name": "Buyer", "sub": "sub-1", "role": "buyer"}
)
user.refresh_from_db()
self.assertEqual(user.role, "buyer")
self.assertFalse(user.is_staff)
def test_update_user_staffs_ops_and_admin(self):
for role in ("ops", "admin"):
user = User.objects.create_user(
email=f"{role}-signin@example.com", role="buyer", is_staff=False,
keycloak_id=f"sub-{role}",
)
self.backend.update_user(
user, {"email": user.email, "name": role, "sub": user.keycloak_id, "role": role}
)
user.refresh_from_db()
self.assertEqual(user.role, role)
self.assertTrue(user.is_staff)
def test_update_user_keeps_the_existing_role_when_the_claim_is_absent(self):
user = User.objects.create_user(
email="ops-no-role-claim@example.com", role="ops", is_staff=True, keycloak_id="sub-2"
)
self.backend.update_user(user, {"email": user.email, "name": "Ops", "sub": "sub-2"})
user.refresh_from_db()
self.assertEqual(user.role, "ops")
self.assertTrue(user.is_staff)
def test_update_user_preserves_a_superuser(self):
user = User.objects.create_superuser(
email="super@example.com", password="pass1234", role="buyer", keycloak_id="sub-3"
)
self.backend.update_user(
user, {"email": user.email, "name": "Super", "sub": "sub-3", "role": "buyer"}
)
user.refresh_from_db()
self.assertTrue(user.is_superuser)
self.assertTrue(user.is_staff)

View file

@ -179,11 +179,21 @@ OIDC_STORE_ACCESS_TOKEN = True
OIDC_STORE_ID_TOKEN = True OIDC_STORE_ID_TOKEN = True
LOGIN_URL = "oidc_authentication_init" LOGIN_URL = "oidc_authentication_init"
LOGOUT_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/"
LOGIN_REDIRECT_URL = "/admin/" # The root view dispatches by role (apps/core/views.RoleRedirectView), so a
# buyer who signs in is not aimed at the admin area, and the RBAC middleware
# can redirect a wrong-role request here without looping.
LOGIN_REDIRECT_URL = "/"
KEYCLOAK_SERVER_URL = env("KEYCLOAK_SERVER_URL", default="http://194.164.95.50:8443") KEYCLOAK_SERVER_URL = env("KEYCLOAK_SERVER_URL", default="http://194.164.95.50:8443")
KEYCLOAK_REALM = env("KEYCLOAK_REALM", default="ixg") KEYCLOAK_REALM = env("KEYCLOAK_REALM", default="ixg")
# Service account used by the public buyer sign-up screen to create Keycloak
# users through the Admin API (apps/users/keycloak.py). It holds only
# realm-management's manage-users and view-users. Secrets come from the
# environment and are never written to code, logs or the wiki.
KEYCLOAK_PROVISIONING_CLIENT_ID = env("KEYCLOAK_PROVISIONING_CLIENT_ID", default="")
KEYCLOAK_PROVISIONING_CLIENT_SECRET = env("KEYCLOAK_PROVISIONING_CLIENT_SECRET", default="")
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [ "DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.SessionAuthentication",

View file

@ -1,14 +1,14 @@
from django.contrib import admin from django.contrib import admin
from django.http import HttpResponseRedirect
from django.urls import path, include from django.urls import path, include
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from rest_framework.authtoken import views as drf_auth from rest_framework.authtoken import views as drf_auth
from apps.core.tiles import tile_view from apps.core.tiles import tile_view
from apps.core.views import RoleRedirectView
urlpatterns = [ urlpatterns = [
path("", lambda r: HttpResponseRedirect("/admin/")), path("", RoleRedirectView.as_view(), name="home"),
path("tiles/<int:z>/<int:x>/<int:y>.png", tile_view, name="tile"), path("tiles/<int:z>/<int:x>/<int:y>.png", tile_view, name="tile"),
path("grappelli/", include("grappelli.urls")), path("grappelli/", include("grappelli.urls")),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),

View file

@ -2,6 +2,12 @@
{% block title %}Dashboard - IXG Buyer Portal{% endblock %} {% block title %}Dashboard - IXG Buyer Portal{% endblock %}
{% block content %} {% block content %}
{% if welcome %}
<div class="alert alert-success" role="alert">
<i class="bi bi-check-circle"></i> Your account is ready. Welcome to the IXG buyer portal.
</div>
{% endif %}
<div class="page-header d-flex justify-content-between align-items-center"> <div class="page-header d-flex justify-content-between align-items-center">
<h2>Buyer Dashboard</h2> <h2>Buyer Dashboard</h2>
<span class="text-secondary">Batches available: <strong>{{ total_batches }}</strong></span> <span class="text-secondary">Batches available: <strong>{{ total_batches }}</strong></span>

View file

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create a buyer account - IXG</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { font-size: .875rem; background: #f0fdf4; }
.signup-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
.signup-card { background: #fff; border: 1px solid #e2e8f0; border-radius: .75rem; padding: 2rem; width: 100%; max-width: 460px; }
.signup-brand { font-size: 1.25rem; font-weight: 700; color: #065f46; }
</style>
</head>
<body>
<div class="signup-wrap">
<div class="signup-card">
<div class="text-center mb-4">
<div class="signup-brand"><i class="bi bi-box-seam"></i> IXG Buyer Portal</div>
<p class="text-secondary mb-0 mt-2">Create your buyer account</p>
</div>
{% if form.non_field_errors %}
{% for e in form.non_field_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
{% for field in form %}
<div class="mb-3">
<label class="form-label" for="{{ field.id_for_label }}">
{{ field.label }} <span class="text-danger">*</span>
</label>
{{ field }}
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
{% endfor %}
<button type="submit" class="btn w-100 text-white" style="background:#065f46;">Create account</button>
</form>
<p class="text-center text-secondary mt-4 mb-0">
Already have an account?
<a href="{% url 'oidc_authentication_init' %}?next=/portal/">Sign in</a>
</p>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>