mirror of
https://github.com/suitenumerique/messages.git
synced 2026-04-25 17:15:21 +02:00
✨(invites) enable inviting users that haven't logged in yet
No invitation email for now
This commit is contained in:
@@ -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"""
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url 'admin:core_user_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› {% 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 %}
|
||||
11
src/backend/core/templates/admin/core/user/change_list.html
Normal file
11
src/backend/core/templates/admin/core/user/change_list.html
Normal 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 %}
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user