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'
urlpatterns = [
path("signup/", views.SignupView.as_view(), name="signup"),
path("chat/", views.ChatView.as_view(), name="chat"),
path("chat/embed/", views.ChatEmbedView.as_view(), name="chat_embed"),
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.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.generic import TemplateView, ListView, DetailView
from django.views.generic import TemplateView, ListView, DetailView, FormView
from apps.batches.models import ProcessingBatch
from apps.shipments.models import Shipment
@ -12,6 +15,14 @@ from apps.esg.models import ESGFieldReport
from apps.subscriptions.models import Subscription
from apps.guides.views import GuideDetailView as BaseGuideDetailView
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):
@ -20,11 +31,64 @@ class BuyerRequiredMixin(UserPassesTestMixin):
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):
template_name = "portal/dashboard.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["welcome"] = self.request.GET.get("welcome") == "1"
ctx["total_batches"] = ProcessingBatch.objects.count()
ctx["recent_batches"] = ProcessingBatch.objects.order_by("-created_at")[:5]
ctx["documents"] = Document.objects.filter(visibility="buyer").order_by("-uploaded_at")[:5]

View file

@ -1,6 +1,7 @@
import re
from django.shortcuts import redirect
from django.conf import settings
from django.shortcuts import redirect
class RBACMiddleware:
@ -14,11 +15,23 @@ class RBACMiddleware:
self.get_response = get_response
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
user_role = getattr(request.user, "role", None)
for role, patterns in self.ROLE_ROUTES.items():
for pattern in patterns:
if re.match(pattern, path) and user_role != role and not request.user.is_superuser:
return redirect(settings.LOGIN_REDIRECT_URL)
allowed = self.ROLE_ROUTES.get(user_role, [])
if any(re.match(pattern, path) for pattern in allowed):
return self.get_response(request)
# 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)

View file

@ -142,6 +142,60 @@ class PortalPagesRenderTests(TestCase):
def test_buyer_pages_render(self):
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):
"""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
# 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):
def create_user(self, claims):
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(
email=email,
full_name=claims.get("name", ""),
keycloak_id=claims.get("sub", ""),
role=claims.get("role", "buyer"),
is_staff=True,
role=role,
is_staff=is_staff_for_role(role),
)
return user
def update_user(self, user, claims):
user.email = claims.get("email", user.email)
user.full_name = claims.get("name", user.full_name)
user.is_staff = True
role = claims.get("role", user.role)
if role in dict(self.UserModel.Roles.choices):
user.role = role
user.is_staff = is_staff_for_role(user.role, is_superuser=user.is_superuser)
user.save()
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
LOGIN_URL = "oidc_authentication_init"
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_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 = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",

View file

@ -1,14 +1,14 @@
from django.contrib import admin
from django.http import HttpResponseRedirect
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from rest_framework.authtoken import views as drf_auth
from apps.core.tiles import tile_view
from apps.core.views import RoleRedirectView
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("grappelli/", include("grappelli.urls")),
path("admin/", admin.site.urls),

View file

@ -2,6 +2,12 @@
{% block title %}Dashboard - IXG Buyer Portal{% endblock %}
{% 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">
<h2>Buyer Dashboard</h2>
<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>