diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 276ad95a5..6e8c4bf2b 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -133,10 +133,25 @@ def retry_send_messages_action(__, request, queryset): ) +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,56 @@ 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(): + email = form.cleaned_data["email"] + user = models.User.objects.filter(email=email).first() + if user is None: + user = models.User(email=email) + user.set_unusable_password() + user.save() + messages.success( + request, + f"Passwordless user created: {user.email}", + ) + else: + messages.info( + request, + f"User already exists: {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""" diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 1e7b3f27a..028cbcaab 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1085,23 +1085,23 @@ class Meta: 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" ) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 50d2c93d6..1fcaea650 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -117,6 +117,14 @@ def get_user_by_sub_or_email(self, sub, email): 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) diff --git a/src/backend/core/templates/admin/core/user/add_passwordless.html b/src/backend/core/templates/admin/core/user/add_passwordless.html new file mode 100644 index 000000000..ba37f97bb --- /dev/null +++ b/src/backend/core/templates/admin/core/user/add_passwordless.html @@ -0,0 +1,35 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls %} + +{% block breadcrumbs %} +
+{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/src/backend/core/templates/admin/core/user/change_list.html b/src/backend/core/templates/admin/core/user/change_list.html new file mode 100644 index 000000000..f02108495 --- /dev/null +++ b/src/backend/core/templates/admin/core/user/change_list.html @@ -0,0 +1,11 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls %} + +{% block object-tools-items %} +{{ block.super }} +