(invites) enable inviting users that haven't logged in yet

No invitation email for now
This commit is contained in:
Sylvain Zimmer
2026-04-25 10:47:46 +02:00
parent 7a0f6fba80
commit 37c0c901fe
8 changed files with 215 additions and 18 deletions

View File

@@ -133,10 +133,25 @@ retry_send_messages_action.short_description = (
)
class PasswordlessUserForm(forms.Form):
"""Minimal form to create a passwordless (sub-less) User from the admin."""
email = forms.EmailField(label="Email", required=True)
def clean_email(self):
"""Reject emails that are already in use."""
email = self.cleaned_data["email"]
if models.User.objects.filter(email=email).exists():
raise forms.ValidationError("A user with this email already exists.")
return email
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
change_list_template = "admin/core/user/change_list.html"
fieldsets = (
(
None,
@@ -213,6 +228,48 @@ class UserAdmin(auth_admin.UserAdmin):
)
search_fields = ("id", "sub", "admin_email", "email", "full_name")
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"add-passwordless/",
self.admin_site.admin_view(self.add_passwordless_view),
name="core_user_add_passwordless",
),
]
return custom_urls + urls
def add_passwordless_view(self, request):
"""Create a passwordless (sub-less) user from a single email field.
These users cannot authenticate locally (unusable password, no
``admin_email``) and will be claimed on first OIDC login by
``UserManager.get_user_by_sub_or_email``.
"""
if request.method == "POST":
form = PasswordlessUserForm(request.POST)
if form.is_valid():
user = models.User(email=form.cleaned_data["email"])
user.set_unusable_password()
user.save()
messages.success(
request,
f"Passwordless user created: {user.email}",
)
return redirect("admin:core_user_changelist")
else:
form = PasswordlessUserForm()
context = {
**self.admin_site.each_context(request),
"title": "Add passwordless user",
"form": form,
"opts": self.model._meta, # noqa: SLF001
}
return TemplateResponse(
request, "admin/core/user/add_passwordless.html", context
)
class MailDomainAccessInline(admin.TabularInline):
"""Inline class for the MailDomainAccess model"""

View File

@@ -1085,23 +1085,23 @@ class MailboxAccessReadSerializer(serializers.ModelSerializer):
read_only_fields = fields # All fields are effectively read-only from this serializer's perspective
class UserField(serializers.PrimaryKeyRelatedField):
"""Custom field that accepts either UUID or email address for user lookup."""
class UserAccessWriteField(serializers.PrimaryKeyRelatedField):
"""Custom field that accepts either UUID or email address for user lookup.
When an email is provided and no matching user exists, a passwordless
"stub" user is created. The stub has no OIDC ``sub`` and will be claimed
on first OIDC login by ``UserManager.get_user_by_sub_or_email``.
"""
def to_internal_value(self, data):
"""Convert UUID string or email to User instance."""
if isinstance(data, str):
if "@" in data:
# It's an email address, look up the user
try:
return models.User.objects.get(email=data)
except models.User.DoesNotExist as e:
raise serializers.ValidationError(
f"No user found with email: {data}"
) from e
else:
# It's a UUID, use the parent method
return super().to_internal_value(data)
if isinstance(data, str) and "@" in data:
user = models.User.objects.filter(email=data).first()
if user is None:
user = models.User(email=data)
user.set_unusable_password()
user.save()
return user
return super().to_internal_value(data)
@@ -1111,7 +1111,7 @@ class MailboxAccessWriteSerializer(serializers.ModelSerializer):
"""
role = IntegerChoicesField(choices_class=models.MailboxRoleChoices)
user = UserField(
user = UserAccessWriteField(
queryset=models.User.objects.all(), help_text="User ID (UUID) or email address"
)
@@ -1201,7 +1201,7 @@ class MaildomainAccessWriteSerializer(serializers.ModelSerializer):
"""
role = IntegerChoicesField(choices_class=models.MailDomainAccessRoleChoices)
user = UserField(
user = UserAccessWriteField(
queryset=models.User.objects.all(), help_text="User ID (UUID) or email address"
)

View File

@@ -117,6 +117,14 @@ class UserManager(auth_models.UserManager):
if not email:
return None
# Always claim sub-less "stub" users (created via invite/admin) by email,
# regardless of OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION: a stub has no
# other way to ever be linked to its OIDC identity.
try:
return self.get(email=email, sub__isnull=True)
except self.model.DoesNotExist:
pass
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
try:
return self.get(email=email)

View File

@@ -0,0 +1,35 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url 'admin:core_user_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {% translate "Add passwordless user" %}
</div>
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
<fieldset class="module aligned">
<p class="help">
{% translate "Creates a user with no password and no OIDC sub. The account will be claimed automatically on first OIDC login that matches this email." %}
</p>
{% for field in form %}
<div class="form-row{% if field.errors %} errors{% endif %}">
{{ field.errors }}
<div>
{{ field.label_tag }}
{{ field }}
</div>
</div>
{% endfor %}
</fieldset>
<div class="submit-row">
<input type="submit" value="{% translate 'Create' %}" class="default">
<a href="{% url 'admin:core_user_changelist' %}" class="button cancel-link">{% translate 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls %}
{% block object-tools-items %}
{{ block.super }}
<li>
<a href="{% url 'admin:core_user_add_passwordless' %}" class="addlink">
{% translate "Add passwordless user" %}
</a>
</li>
{% endblock %}

View File

@@ -118,7 +118,7 @@ def fixture_access_m2d1_alpha(mailbox2_domain1, user_alpha):
)
class TestMailboxAccessViewSet:
class TestMailboxAccessViewSet: # pylint: disable=too-many-public-methods
"""Tests for the MailboxAccessViewSet API endpoints."""
BASE_URL_LIST_CREATE_SUFFIX = "-list"
@@ -309,6 +309,32 @@ class TestMailboxAccessViewSet:
mailbox=mailbox1_domain1, user=user_alpha, role=MailboxRoleChoices.EDITOR
).exists()
def test_admin_maildomain_mailbox_create_access_invites_unknown_email(
self,
api_client,
domain_admin_user,
mailbox1_domain1,
):
"""POSTing an unknown email should auto-create a passwordless stub user."""
api_client.force_authenticate(user=domain_admin_user)
unknown_email = "invitee@example.com"
assert not models.User.objects.filter(email=unknown_email).exists()
data = {"user": unknown_email, "role": "editor"}
response = api_client.post(
self.list_create_url(mailbox_id=mailbox1_domain1.pk), data
)
assert response.status_code == status.HTTP_201_CREATED
stub = models.User.objects.get(email=unknown_email)
assert stub.sub is None
assert not stub.has_usable_password()
assert response.data["user"] == stub.pk
assert models.MailboxAccess.objects.filter(
mailbox=mailbox1_domain1, user=stub, role=MailboxRoleChoices.EDITOR
).exists()
def test_admin_maildomain_mailbox_create_access_by_mailbox_admin_for_unmanaged_mailbox_forbidden(
self, api_client, mailbox1_admin_user, mailbox1_domain2, user_beta
):

View File

@@ -220,6 +220,31 @@ class TestMaildomainAccessViewSet:
role=MailDomainAccessRoleChoices.ADMIN,
).exists()
def test_admin_api_maildomain_accesses_create_invites_unknown_email(
self, api_client, super_user, maildomain_1
):
"""POSTing an unknown email should auto-create a passwordless stub user."""
api_client.force_authenticate(user=super_user)
unknown_email = "domain-invitee@example.com"
assert not models.User.objects.filter(email=unknown_email).exists()
data = {"user": unknown_email, "role": "admin"}
response = api_client.post(
self.list_create_url(maildomain_pk=maildomain_1.pk), data
)
assert response.status_code == status.HTTP_201_CREATED
stub = models.User.objects.get(email=unknown_email)
assert stub.sub is None
assert not stub.has_usable_password()
assert response.data["user"] == stub.pk
assert models.MailDomainAccess.objects.filter(
maildomain=maildomain_1,
user=stub,
role=MailDomainAccessRoleChoices.ADMIN,
).exists()
def test_admin_api_maildomain_accesses_create_access_by_maildomain_admin_for_unmanaged_maildomain_forbidden(
self, api_client, maildomain_1, md2_access, md2_admin_user, regular_user
):

View File

@@ -11,7 +11,7 @@ import responses
from cryptography.fernet import Fernet
from lasuite.oidc_login.backends import get_oidc_refresh_token
from core import models
from core import factories, models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
@@ -151,6 +151,41 @@ def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
assert models.User.objects.count() == 1
@override_settings(
MESSAGES_TESTDOMAIN=None,
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
OIDC_ALLOW_DUPLICATE_EMAILS=False,
OIDC_CREATE_USER=True,
)
def test_authentication_getter_claims_passwordless_stub_by_email(monkeypatch):
"""
A user with no sub (an "invited" stub created via the access-grant API or
the admin) should always be claimed on first OIDC login by email, even
when OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION is disabled. Pre-existing
accesses must remain attached to the same row.
"""
klass = OIDCAuthenticationBackend()
stub = UserFactory(sub=None, email="invitee@example.com", full_name=None)
mailbox = factories.MailboxFactory()
factories.MailboxAccessFactory(user=stub, mailbox=mailbox)
def get_userinfo_mocked(*args):
return {"sub": "oidc-sub-xyz", "email": stub.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.pk == stub.pk
assert user.sub == "oidc-sub-xyz"
assert models.User.objects.count() == 1
# The pre-existing access still references the merged user.
assert models.MailboxAccess.objects.filter(user=user, mailbox=mailbox).exists()
@override_settings(MESSAGES_TESTDOMAIN=None)
def test_authentication_getter_existing_user_with_email(monkeypatch):
"""