From 7a2c3db07e8d842949cb778f7ed8efb98cf35265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Fri, 14 Jul 2023 10:30:34 +0200 Subject: [PATCH] auth: Use standalone models for invitations - remove invite hacks from the social pipeline - rewritten invitation to send invites directly - invitations now work regardless registration open - project admins can only invite outside users with registration open - improved mail templates for invitations - user profile view of pending invitations - user has to accept the invitation to become a team member - add admin view of pending invitations - add copy button to copy full invitation URL in admin Fixes #9261 Fixes #9131 Fixes #7412 --- docs/admin/access.rst | 18 +- docs/changes.rst | 4 +- .../0032_alter_auditlog_activity.py | 48 ++++++ weblate/accounts/models.py | 1 + weblate/accounts/pipeline.py | 49 ++++-- weblate/accounts/urls.py | 6 + weblate/accounts/views.py | 51 +++--- weblate/auth/forms.py | 157 ++++++++---------- weblate/auth/migrations/0029_invitation.py | 78 +++++++++ weblate/auth/models.py | 79 +++++++++ weblate/auth/tasks.py | 16 +- weblate/auth/views.py | 79 ++++++++- weblate/settings_docker.py | 1 + weblate/settings_example.py | 1 + weblate/templates/accounts/profile.html | 30 ++++ weblate/templates/accounts/register.html | 8 +- weblate/templates/mail/invite.html | 11 +- .../templates/mail/shared-registration.html | 5 +- weblate/templates/manage/users.html | 59 +++++++ weblate/templates/snippets/invite-info.html | 7 + .../templates/trans/project-access-row.html | 13 +- weblate/templates/trans/project-access.html | 50 +++++- .../weblate_auth/invitation_detail.html | 31 ++++ weblate/trans/forms.py | 25 +-- weblate/trans/models/change.py | 9 +- weblate/trans/tests/test_acl.py | 74 +++++++-- weblate/trans/views/acl.py | 62 +++---- weblate/urls.py | 5 - weblate/utils/forms.py | 39 +++++ weblate/utils/pii.py | 23 +++ weblate/utils/tests/test_pii.py | 15 ++ weblate/wladmin/tests.py | 38 +---- weblate/wladmin/views.py | 17 +- 33 files changed, 817 insertions(+), 292 deletions(-) create mode 100644 weblate/accounts/migrations/0032_alter_auditlog_activity.py create mode 100644 weblate/auth/migrations/0029_invitation.py create mode 100644 weblate/templates/snippets/invite-info.html create mode 100644 weblate/templates/weblate_auth/invitation_detail.html create mode 100644 weblate/utils/pii.py create mode 100644 weblate/utils/tests/test_pii.py diff --git a/docs/admin/access.rst b/docs/admin/access.rst index 68583121d134..00ff33309eba 100644 --- a/docs/admin/access.rst +++ b/docs/admin/access.rst @@ -162,22 +162,28 @@ team. This is useful in case you want to build self-governed teams. New user invitation ^^^^^^^^^^^^^^^^^^^ -Also, besides adding an existing user to the project, it is possible to invite -new ones. Any new user will be created immediately, but the account will -remain inactive until signing in with a link in the invitation sent via an e-mail. +Adding existing users will send them invitation to confirm. With +:setting:`REGISTRATION_OPEN` the administrator can also invite new users using +e-mail. Invited users have to complete the registration process to get access +to the project. + It is not required to have any site-wide privileges in order to do so, access management permission on the project’s scope (e.g. a membership in the `Administration` team) would be sufficient. .. hint:: - If the invited user missed the validity of the invitation, they can set their - password using invited e-mail address in the password reset form as the account - is created already. + If the invited user missed the validity of the invitation, a new invitation + has to be created. The same kind of invitations are available site-wide from the :ref:`management interface ` on the :guilabel:`Users` tab. +.. versionchanged:: 5.0 + + Weblate now does not automatically create accounts or add users to the + teams. This is only done after confirmation from the user. + .. _block-user: Blocking users diff --git a/docs/changes.rst b/docs/changes.rst index 1d3ba81303f8..46d360e58856 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,6 +7,7 @@ Not yet released. * :doc:`/formats/markdown` support, thanks to Anders Kaplan. * :doc:`/formats/fluent` now has better syntax checks thanks to Henry Wilkes. +* Inviting users now works with all authentication methods. **Improvements** @@ -23,6 +24,7 @@ Not yet released. * Faster adding terms to glossary. * Better preserve translation on source file change in :doc:`/formats/html` and :doc:`/formats/txt`. * Added indication of automatic assignment to team listing. +* Users now have to confirm invitations to become team members. **Bug fixes** @@ -39,7 +41,7 @@ Not yet released. Please follow :ref:`generic-upgrade-instructions` in order to perform update. -* There are several changes in :file:`settings_example.py`, most notable is change in ``CACHES``, please adjust your settings accordingly. +* There are several changes in :file:`settings_example.py`, most notable is changes in ``CACHES`` and ``SOCIAL_AUTH_PIPELINE``, please adjust your settings accordingly. * Several previously optional dependencies are now required. * The database upgrade can take considerable time on larger sites due to structure changes. diff --git a/weblate/accounts/migrations/0032_alter_auditlog_activity.py b/weblate/accounts/migrations/0032_alter_auditlog_activity.py new file mode 100644 index 000000000000..08add22db3be --- /dev/null +++ b/weblate/accounts/migrations/0032_alter_auditlog_activity.py @@ -0,0 +1,48 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Generated by Django 4.2.3 on 2023-07-31 10:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0031_rename_params_new_auditlog_params"), + ] + + operations = [ + migrations.AlterField( + model_name="auditlog", + name="activity", + field=models.CharField( + choices=[ + ("accepted", "accepted"), + ("auth-connect", "auth-connect"), + ("auth-disconnect", "auth-disconnect"), + ("autocreated", "autocreated"), + ("blocked", "blocked"), + ("connect", "connect"), + ("email", "email"), + ("failed-auth", "failed-auth"), + ("full_name", "full_name"), + ("invited", "invited"), + ("locked", "locked"), + ("login", "login"), + ("login-new", "login-new"), + ("password", "password"), + ("register", "register"), + ("removed", "removed"), + ("reset", "reset"), + ("reset-request", "reset-request"), + ("sent-email", "sent-email"), + ("tos", "tos"), + ("trial", "trial"), + ("username", "username"), + ], + db_index=True, + max_length=20, + ), + ), + ] diff --git a/weblate/accounts/models.py b/weblate/accounts/models.py index 92e613e543c5..fac501f97981 100644 --- a/weblate/accounts/models.py +++ b/weblate/accounts/models.py @@ -156,6 +156,7 @@ def __str__(self): "removed": gettext_lazy("Account and all private data removed."), "tos": gettext_lazy("Agreement with Terms of Service {date}."), "invited": gettext_lazy("Invited to {site_title} by {username}."), + "accepted": gettext_lazy("Accepted invitation from {username}."), "trial": gettext_lazy("Started trial period."), "sent-email": gettext_lazy("Sent confirmation mail to {email}."), "autocreated": gettext_lazy( diff --git a/weblate/accounts/pipeline.py b/weblate/accounts/pipeline.py index cf493f16a113..6bc02a18a0af 100644 --- a/weblate/accounts/pipeline.py +++ b/weblate/accounts/pipeline.py @@ -2,6 +2,8 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + import re import time import unicodedata @@ -23,7 +25,7 @@ cycle_session_keys, invalidate_reset_codes, ) -from weblate.auth.models import User +from weblate.auth.models import Invitation, User from weblate.trans.defines import FULLNAME_LENGTH from weblate.utils import messages from weblate.utils.ratelimit import reset_rate_limit @@ -149,10 +151,6 @@ def send_validation(strategy, backend, code, partial_token): template = "reset" elif session.get("account_remove"): template = "remove" - elif session.get("user_invite"): - template = "invite" - context.update(session["invitation_context"]) - user = User.objects.get(pk=session["social_auth_user"]) # Create audit log, it might be for anonymous at this point for new registrations AuditLog.objects.create( @@ -196,7 +194,14 @@ def password_reset( @partial def remove_account( - strategy, backend, user, social, details, weblate_action, current_partial, **kwargs + strategy, + backend, + user, + social, + details, + weblate_action: str, + current_partial, + **kwargs, ): """Set unusable password on reset.""" if strategy.request is not None and user is not None and weblate_action == "remove": @@ -213,12 +218,20 @@ def remove_account( return None -def verify_open(strategy, backend, user, weblate_action, **kwargs): +def verify_open( + strategy, + backend, + user: User, + weblate_action: str, + invitation_link: Invitation | None, + **kwargs, +): """Check whether it is possible to create new user.""" # Check whether registration is open if ( not user - and weblate_action not in ("reset", "remove", "invite") + and weblate_action not in ("reset", "remove") + and not invitation_link and (not settings.REGISTRATION_OPEN or settings.REGISTRATION_ALLOW_BACKENDS) and backend.name not in settings.REGISTRATION_ALLOW_BACKENDS ): @@ -255,15 +268,23 @@ def store_params(strategy, user, **kwargs): action = "reset" elif session.get("account_remove"): action = "remove" - elif session.get("user_invite"): - action = "invite" else: action = "activation" + invitation = None + if invitation_pk := session.get("invitation_link"): + try: + invitation = Invitation.objects.get(pk=invitation_pk) + except Invitation.DoesNotExist: + del session["invitation_link"] + invitation_pk = None + return { "weblate_action": action, "registering_user": registering_user, "weblate_expires": int(time.monotonic() + settings.AUTH_TOKEN_VALID), + "invitation_link": invitation, + "invitation_pk": str(invitation_pk) if invitation_pk else None, } @@ -391,6 +412,14 @@ def store_email(strategy, backend, user, social, details, **kwargs): verified.save() +def handle_invite(strategy, backend, user: User, social, invitation_pk: str, **kwargs): + # Accept triggering invitation + if invitation_pk: + Invitation.objects.get(pk=invitation_pk).accept(strategy.request, user) + # Merge possibly pending invitations for this e-mail address + Invitation.objects.filter(email=user.email).update(user=user, email="") + + def notify_connect( strategy, details, diff --git a/weblate/accounts/urls.py b/weblate/accounts/urls.py index 6c5ef3201cdf..07d5e9a0d675 100644 --- a/weblate/accounts/urls.py +++ b/weblate/accounts/urls.py @@ -6,6 +6,7 @@ from django.urls import include, path import weblate.accounts.views +import weblate.auth.views from weblate.utils.urls import register_weblate_converters register_weblate_converters() @@ -72,6 +73,11 @@ path("login/", weblate.accounts.views.WeblateLoginView.as_view(), name="login"), path("register/", weblate.accounts.views.register, name="register"), path("email/", weblate.accounts.views.email_login, name="email_login"), + path( + "invitation//", + weblate.auth.views.InvitationView.as_view(), + name="invitation", + ), path("", include((social_urls, "social_auth"), namespace="social")), ] diff --git a/weblate/accounts/views.py b/weblate/accounts/views.py index e06246fe343b..f2870c08e6ac 100644 --- a/weblate/accounts/views.py +++ b/weblate/accounts/views.py @@ -96,7 +96,7 @@ from weblate.accounts.pipeline import EmailAlreadyAssociated, UsernameAlreadyAssociated from weblate.accounts.utils import remove_user from weblate.auth.forms import UserEditForm -from weblate.auth.models import User, get_auth_keys +from weblate.auth.models import Invitation, User, get_auth_keys from weblate.auth.utils import format_address from weblate.logger import LOGGER from weblate.trans.models import Change, Component, Suggestion, Translation @@ -150,8 +150,6 @@ def get_context_data(self, **kwargs): context["validity"] = settings.AUTH_TOKEN_VALID // 3600 context["is_reset"] = False context["is_remove"] = False - # This view is not visible for invitation that's - # why don't handle user_invite here if self.request.flags["password_reset"]: context["title"] = gettext("Password reset") context["is_reset"] = True @@ -170,7 +168,6 @@ def get(self, request, *args, **kwargs): request.flags = { "password_reset": request.session["password_reset"], "account_remove": request.session["account_remove"], - "user_invite": request.session["user_invite"], } # Remove session for not authenticated user here. @@ -761,7 +758,6 @@ def fake_email_sent(request, reset=False): request.session["registration-email-sent"] = True request.session["password_reset"] = reset request.session["account_remove"] = False - request.session["user_invite"] = False return redirect("email-sent") @@ -770,15 +766,32 @@ def register(request): """Registration form.""" captcha = None - if request.method == "POST": + # Fetch invitation + invitation = None + initial = {} + if invitation_pk := request.session.get("invitation_link"): + try: + invitation = Invitation.objects.get(pk=invitation_pk) + except Invitation.DoesNotExist: + del request.session["invitation_link"] + else: + initial["email"] = invitation.email + + # Allow registration at all? + registration_open = settings.REGISTRATION_OPEN or bool(invitation) + + # Get list of allowed backends + backends = get_auth_keys() + if settings.REGISTRATION_ALLOW_BACKENDS and not invitation: + backends = backends & set(settings.REGISTRATION_ALLOW_BACKENDS) + elif not registration_open: + backends = set() + + if request.method == "POST" and "email" in backends: form = RegistrationForm(request, request.POST) if settings.REGISTRATION_CAPTCHA: captcha = CaptchaForm(request, form, request.POST) - if ( - (captcha is None or captcha.is_valid()) - and form.is_valid() - and settings.REGISTRATION_OPEN - ): + if (captcha is None or captcha.is_valid()) and form.is_valid(): if captcha: captcha.cleanup_session(request) if form.cleaned_data["email_user"]: @@ -789,18 +802,12 @@ def register(request): store_userid(request) return social_complete(request, "email") else: - form = RegistrationForm(request) + form = RegistrationForm(request, initial=initial) if settings.REGISTRATION_CAPTCHA: captcha = CaptchaForm(request) - backends = get_auth_keys() - if settings.REGISTRATION_ALLOW_BACKENDS: - backends = backends & set(settings.REGISTRATION_ALLOW_BACKENDS) - elif not settings.REGISTRATION_OPEN: - backends = set() - # Redirect if there is only one backend - if len(backends) == 1 and "email" not in backends: + if len(backends) == 1 and "email" not in backends and not invitation: return redirect_single(request, backends.pop()) return render( @@ -812,6 +819,7 @@ def register(request): "title": gettext("User registration"), "form": form, "captcha_form": captcha, + "invitation": invitation, }, ) @@ -958,7 +966,7 @@ def reset_password(request): form.cleaned_data["email_user"], request, "reset-request" ) if not audit.check_rate_limit(request): - store_userid(request, True) + store_userid(request, reset=True) return social_complete(request, "email") else: email = form.cleaned_data["email"] @@ -1110,12 +1118,11 @@ def get_context_data(self, *, object_list=None, **kwargs): return result -def store_userid(request, reset=False, remove=False, invite=False): +def store_userid(request, *, reset: bool = False, remove: bool = False): """Store user ID in the session.""" request.session["social_auth_user"] = request.user.pk request.session["password_reset"] = reset request.session["account_remove"] = remove - request.session["user_invite"] = invite @require_POST diff --git a/weblate/auth/forms.py b/weblate/auth/forms.py index bc05f15e4f99..cd8676f2bf96 100644 --- a/weblate/auth/forms.py +++ b/weblate/auth/forms.py @@ -2,120 +2,95 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -import social_core.backends.utils from crispy_forms.helper import FormHelper from django import forms -from django.conf import settings from django.core.exceptions import ValidationError -from django.http import HttpRequest -from django.utils.translation import gettext, gettext_lazy -from social_core.backends.email import EmailAuth -from social_django.views import complete +from django.utils.translation import gettext from weblate.accounts.forms import UniqueEmailMixin from weblate.accounts.models import AuditLog -from weblate.accounts.strategy import create_session from weblate.auth.data import GLOBAL_PERM_NAMES, SELECTION_MANUAL -from weblate.auth.models import Group, Role, User, get_anonymous +from weblate.auth.models import Group, Invitation, Role, User from weblate.trans.models import Change from weblate.utils import messages -from weblate.utils.errors import report_error - - -def send_invitation(request: HttpRequest, project_name: str, user: User): - """Send invitation to user to join project.""" - from weblate.accounts.views import store_userid - - fake = HttpRequest() - fake.user = get_anonymous() - fake.method = "POST" - fake.session = create_session() - fake.session["invitation_context"] = { - "from_user": request.user.full_name, - "project_name": project_name, - } - fake.POST["email"] = user.email - fake.META = request.META - store_userid(fake, invite=True) - - # Make sure the email backend is there for the invitation - email_auth = "social_core.backends.email.EmailAuth" - has_email = email_auth in settings.AUTHENTICATION_BACKENDS - backup_backends = settings.AUTHENTICATION_BACKENDS - backup_cache = social_core.backends.utils.BACKENDSCACHE - if not has_email: - social_core.backends.utils.BACKENDSCACHE["email"] = EmailAuth - settings.AUTHENTICATION_BACKENDS += (email_auth,) - - # Send invitation - complete(fake, "email") - - # Revert temporary settings override - if not has_email: - social_core.backends.utils.BACKENDSCACHE = backup_cache - settings.AUTHENTICATION_BACKENDS = backup_backends - - -class InviteUserForm(forms.ModelForm, UniqueEmailMixin): - create = True +from weblate.utils.forms import UserField - class Meta: - model = User - fields = ["email", "username", "full_name"] - def save(self, request, project=None): - self.instance.set_unusable_password() - user = super().save() +class InviteUserForm(forms.ModelForm): + class Meta: + model = Invitation + fields = ["user", "group"] + field_classes = {"user": UserField} + + def __init__( + self, + data=None, + files=None, + project=None, + **kwargs, + ): + self.project = project + super().__init__(data=data, files=files, **kwargs) if project: - project.add_user(user) - send_email = self.cleaned_data.get("send_email", True) - if self.create: + self.fields["group"].queryset = project.group_set.all() + else: + self.fields["group"].queryset = Group.objects.filter(defining_project=None) + for field in ("user", "email"): + if field in self.fields: + self.fields[field].required = True + + def save(self, request, commit: bool = True): + self.instance.author = author = request.user + # Migrate to user if e-mail matches + if self.instance.email: + try: + self.instance.user = ( + User.objects.filter( + social_auth__verifiedemail__email=self.instance.email + ) + .distinct() + .get() + ) + except (User.DoesNotExist, User.MultipleObjectsReturned): + pass + else: + self.instance.email = "" + super().save(commit=commit) + if commit: + if self.instance.user: + details = {"username": self.instance.user.username} + else: + details = {"email": self.instance.email} Change.objects.create( - project=project, + project=self.project, action=Change.ACTION_INVITE_USER, - user=request.user, - details={"username": user.username}, - ) - if self.create or send_email: - AuditLog.objects.create( - user=user, - request=request, - activity="invited", - username=request.user.username, + user=author, + details=details, ) - if send_email: - try: - send_invitation( - request, project.name if project else settings.SITE_TITLE, user + if self.instance.user: + AuditLog.objects.create( + user=self.instance.user, + request=request, + activity="invited", + username=request.user.username, ) - messages.success(request, gettext("User invitation e-mail was sent.")) - except Exception: - report_error(cause="Could not send an invitation") - raise - return user - + self.instance.send_email() + messages.success(request, gettext("User invitation e-mail was sent.")) -class AdminInviteUserForm(InviteUserForm): - send_email = forms.BooleanField( - label=gettext_lazy("Send e-mail invitation to the user"), - initial=True, - required=False, - ) +class InviteEmailForm(InviteUserForm, UniqueEmailMixin): class Meta: - model = User - fields = ["email", "username", "full_name", "is_superuser"] + model = Invitation + fields = ["email", "group"] -class UserEditForm(AdminInviteUserForm): - create = False +class AdminInviteUserForm(InviteUserForm): + class Meta: + model = Invitation + fields = ["email", "group", "is_superuser"] - send_email = forms.BooleanField( - label=gettext_lazy("Resend e-mail invitation to the user"), - initial=False, - required=False, - ) +class UserEditForm(forms.ModelForm): class Meta: model = User fields = ["username", "full_name", "email", "is_superuser", "is_active"] diff --git a/weblate/auth/migrations/0029_invitation.py b/weblate/auth/migrations/0029_invitation.py new file mode 100644 index 000000000000..3f0fb97ad60f --- /dev/null +++ b/weblate/auth/migrations/0029_invitation.py @@ -0,0 +1,78 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Generated by Django 4.2.3 on 2023-07-27 09:06 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import weblate.utils.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("weblate_auth", "0028_alter_autogroup_match"), + ] + + operations = [ + migrations.CreateModel( + name="Invitation", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ( + "email", + weblate.utils.fields.EmailField( + blank=True, max_length=190, verbose_name="E-mail" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="User has all possible permissions.", + verbose_name="Superuser status", + ), + ), + ( + "group", + models.ForeignKey( + help_text="The user is granted all permissions included in membership of these groups.", + on_delete=django.db.models.deletion.CASCADE, + to="weblate_auth.group", + verbose_name="Group", + ), + ), + ( + "user", + models.ForeignKey( + help_text="Please type in an existing Weblate account name or e-mail address.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User to add", + ), + ), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="created_invitation_set", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/weblate/auth/models.py b/weblate/auth/models.py index 913a9b505cba..8905f7b1a103 100644 --- a/weblate/auth/models.py +++ b/weblate/auth/models.py @@ -5,6 +5,7 @@ from __future__ import annotations import re +import uuid from collections import defaultdict from functools import cache as functools_cache from itertools import chain @@ -906,6 +907,84 @@ def setup_project_groups( group.roles.add(Role.objects.get(name=ACL_GROUPS[group_name])) +class Invitation(models.Model): + """ + User invitation store. + + Either user or e-mail attribute is set, this is to invite current and new users. + """ + + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + timestamp = models.DateTimeField(auto_now_add=True) + author = models.ForeignKey( + User, on_delete=models.deletion.CASCADE, related_name="created_invitation_set" + ) + user = models.ForeignKey( + User, + on_delete=models.deletion.CASCADE, + null=True, + verbose_name=gettext_lazy("User to add"), + help_text=gettext_lazy( + "Please type in an existing Weblate account name or e-mail address." + ), + ) + group = models.ForeignKey( + Group, + verbose_name=gettext_lazy("Group"), + help_text=gettext_lazy( + "The user is granted all permissions included in " + "membership of these groups." + ), + on_delete=models.deletion.CASCADE, + ) + email = EmailField( + gettext_lazy("E-mail"), + blank=True, + ) + is_superuser = models.BooleanField( + gettext_lazy("Superuser status"), + default=False, + help_text=gettext_lazy("User has all possible permissions."), + ) + + def __str__(self): + return f"invitation {self.uuid} for {self.user or self.email} to {self.group}" + + def get_absolute_url(self): + return reverse("invitation", kwargs={"pk": self.uuid}) + + def send_email(self): + from weblate.accounts.notifications import send_notification_email + + send_notification_email( + None, + [self.email] if self.email else [self.user.email], + "invite", + info=f"{self}", + context={"invitation": self, "validity": settings.AUTH_TOKEN_VALID // 3600}, + ) + + def accept(self, request, user: User): + from weblate.accounts.models import AuditLog + + if self.user and self.user != user: + raise ValueError("User mismatch on accept!") + + user.groups.add(self.group) + + if self.is_superuser: + user.is_superuser = True + user.save(update_fields=["is_superuser"]) + + AuditLog.objects.create( + user=user, + request=request, + activity="accepted", + username=self.author.username, + ) + self.delete() + + class WeblateAuthConf(AppConf): """Authentication settings.""" diff --git a/weblate/auth/tasks.py b/weblate/auth/tasks.py index 3159a393387e..ea099f551486 100644 --- a/weblate/auth/tasks.py +++ b/weblate/auth/tasks.py @@ -2,9 +2,13 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +from datetime import timedelta + +from celery.schedules import crontab +from django.conf import settings from django.utils import timezone -from weblate.auth.models import User +from weblate.auth.models import Invitation, User from weblate.utils.celery import app @@ -15,6 +19,16 @@ def disable_expired(): ) +@app.task(trail=False) +def cleanup_invitations(): + Invitation.objects.filter( + timestamp__lte=timezone.now() - timedelta(seconds=settings.AUTH_TOKEN_VALID) + ).delete() + + @app.on_after_finalize.connect def setup_periodic_tasks(sender, **kwargs): sender.add_periodic_task(3600, disable_expired.s(), name="disable-expired") + sender.add_periodic_task( + crontab(hour=6, minute=6), cleanup_invitations.s(), name="cleanup_invitations" + ) diff --git a/weblate/auth/views.py b/weblate/auth/views.py index bcebc5b09a0d..48b5cf244ba9 100644 --- a/weblate/auth/views.py +++ b/weblate/auth/views.py @@ -2,15 +2,18 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import annotations + from django.core.exceptions import PermissionDenied from django.forms import inlineformset_factory -from django.http import HttpResponseRedirect +from django.http import Http404, HttpResponseRedirect +from django.shortcuts import redirect from django.urls import reverse from django.utils.translation import gettext -from django.views.generic import UpdateView +from django.views.generic import DetailView, UpdateView from weblate.auth.forms import ProjectTeamForm, SitewideTeamForm -from weblate.auth.models import AutoGroup, Group +from weblate.auth.models import AutoGroup, Group, Invitation, User from weblate.trans.forms import UserAddTeamForm, UserManageForm from weblate.trans.util import redirect_next from weblate.utils import messages @@ -140,3 +143,73 @@ def form_invalid(self, form, formset): return self.render_to_response( self.get_context_data(form=form, auto_formset=formset) ) + + +class InvitationView(DetailView): + model = Invitation + + def check_access(self): + invitation = self.object + user = self.request.user + if invitation.user: + if not user.is_authenticated: + raise PermissionDenied + if invitation.user != user: + raise Http404 + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + self.check_access() + if not self.object.user: + # When inviting new user go through registration + request.session["invitation_link"] = str(self.object.pk) + return redirect("register") + context = self.get_context_data(object=self.object) + return self.render_to_response(context) + + def post(self, request, **kwargs): + self.object = invitation = self.get_object() + + # Handle admin actions first + action = request.POST.get("action", "") + if action in ("resend", "remove"): + project = invitation.group.defining_project + # Permission check + if not request.user.has_perm( + "project.permissions" if project else "user.edit", project + ): + raise PermissionDenied + + # Perform admin action + if action == "resend": + invitation.send_email() + messages.success(request, gettext("User invitation e-mail was sent.")) + else: + invitation.delete() + messages.success(request, gettext("User invitation was removed.")) + + # Redirect + if project: + return redirect("manage-access", project=project.slug) + return redirect("manage-users") + + # Accept invitation + self.check_access() + invitation.accept(request, request.user) + + if invitation.group.defining_project: + return redirect(invitation.group.defining_project) + return redirect("home") + + +def accept_invitation(request, invitation: Invitation, user: User | None): + if user is None: + user = invitation.user + if user is None: + raise Http404 + + user.groups.add(invitation.group) + messages.success( + request, gettext("Accepted invitation to the %s team.") % invitation.group + ) + invitation.delete() diff --git a/weblate/settings_docker.py b/weblate/settings_docker.py index d5df070c511d..bd8e1ca346c1 100644 --- a/weblate/settings_docker.py +++ b/weblate/settings_docker.py @@ -562,6 +562,7 @@ "weblate.accounts.pipeline.user_full_name", "weblate.accounts.pipeline.store_email", "weblate.accounts.pipeline.notify_connect", + "weblate.accounts.pipeline.handle_invite", "weblate.accounts.pipeline.password_reset", ] SOCIAL_AUTH_DISCONNECT_PIPELINE = ( diff --git a/weblate/settings_example.py b/weblate/settings_example.py index 172fd28b7b60..72f9d0722479 100644 --- a/weblate/settings_example.py +++ b/weblate/settings_example.py @@ -279,6 +279,7 @@ "weblate.accounts.pipeline.user_full_name", "weblate.accounts.pipeline.store_email", "weblate.accounts.pipeline.notify_connect", + "weblate.accounts.pipeline.handle_invite", "weblate.accounts.pipeline.password_reset", ) SOCIAL_AUTH_DISCONNECT_PIPELINE = ( diff --git a/weblate/templates/accounts/profile.html b/weblate/templates/accounts/profile.html index 8b3d9a82a411..96dcad47ba6b 100644 --- a/weblate/templates/accounts/profile.html +++ b/weblate/templates/accounts/profile.html @@ -241,6 +241,36 @@ {% endfor %} + {% with invitations=user.invitation_set.all %} + {% if invitations %} + + {% for invitation in invitations %} + {% with group=invitation.group %} + + + + {{ group }} + {% translate "Pending invitation" %} + + + + {% include "auth/teams-roles.html" %} + + + {% include "auth/teams-projects.html" %} + + + {% include "auth/teams-languages.html" %} + + + {% include "auth/teams-components.html" %} + + + {% endwith %} + {% endfor %} + + {% endif %} + {% endwith %} diff --git a/weblate/templates/accounts/register.html b/weblate/templates/accounts/register.html index 3ef0061c8004..0a57375d5e94 100644 --- a/weblate/templates/accounts/register.html +++ b/weblate/templates/accounts/register.html @@ -10,11 +10,17 @@ {% block content %} - {% if registration_email or registration_backends %}
+{% if invitation %} +
+ {% include "snippets/invite-info.html" %} + {% translate "Please complete the registration to accept the invitation." %} +
+{% endif %} + {% if form.errors %} {% trans "Please fix errors in the registration form." as msg%} {% show_message "error" msg %} diff --git a/weblate/templates/mail/invite.html b/weblate/templates/mail/invite.html index 432cb51df69d..2efb0a02d988 100644 --- a/weblate/templates/mail/invite.html +++ b/weblate/templates/mail/invite.html @@ -4,8 +4,15 @@ {% block content %}

-{% blocktrans %}{{ from_user }} invites you to collaborate on the {{ project_name }} translation project at {{ site_title }}.{% endblocktrans %} + {% include "snippets/invite-info.html" %} +

-{% include "mail/shared-registration.html" %} +{% if invitation.user %} + +{% else %} + {% include "mail/shared-registration.html" with url=invitation.get_absolute_url action="invite" %} +{% endif %} {% endblock %} diff --git a/weblate/templates/mail/shared-registration.html b/weblate/templates/mail/shared-registration.html index fcef5263f448..2fddc65ddf64 100644 --- a/weblate/templates/mail/shared-registration.html +++ b/weblate/templates/mail/shared-registration.html @@ -9,9 +9,6 @@

{% blocktrans count count=validity %}The confirmation link will expire after {{ count }} hour.{% plural %}The confirmation link will expire after {{ count }} hours.{% endblocktrans %} -{% if action == "invite" %} - {% blocktrans %}In case the confirmation link is expired, you can use password reset feature to gain access to your account.{% endblocktrans %} -{% endif %}

@@ -19,6 +16,8 @@ {% trans "Confirm password reset" %} {% elif action == "remove" %} {% trans "Confirm account removal" %} +{% elif action == "invite" %} +{% trans "Confirm invitation" %} {% else %} {% trans "Confirm registration" %} {% endif %} diff --git a/weblate/templates/manage/users.html b/weblate/templates/manage/users.html index 767d0acafcef..eb98d93dfae2 100644 --- a/weblate/templates/manage/users.html +++ b/weblate/templates/manage/users.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% load i18n %} +{% load icons %} {% load translations %} {% load permissions %} {% load crispy_forms_tags %} @@ -35,6 +36,64 @@

+{% if invitations %} +
+

{% trans "Pending invitations" %}

+ + + + + + + + + + + + + {% for invitation in invitations %} + + {% if invitation.user %} + + + + {% else %} + + + + {% endif %} + + + + + {% endfor %} + +
{% trans "Username" %}{% trans "Full name" %}{% trans "E-mail" %}{% trans "Projects" %}{% trans "Teams" %}
{{ invitation.user.username }}{{ invitation.user.full_name }}{{ invitation.user.email }}{{ invitation.email }} + {% if invitation.group.defining_project %} + {{ invitation.group.defining_project }} + {% endif %} + + {{ invitation.group }} + +
+ {% csrf_token %} + + +
+ +
+ {% csrf_token %} + + +
+
+
+{% endif %} +

{% trans "Check user access" %}

diff --git a/weblate/templates/snippets/invite-info.html b/weblate/templates/snippets/invite-info.html new file mode 100644 index 000000000000..1ec76046ba47 --- /dev/null +++ b/weblate/templates/snippets/invite-info.html @@ -0,0 +1,7 @@ +{% load i18n %} + +{% if invitation.group.defining_project %} + {% blocktranslate with invitation_author=invitation.author.full_name project_name=invitation.group.defining_project team_name=invitation.group %}You were invited to {{ site_title }} by {{ invitation_author }} to collaborate on {{ project_name }} and join the {{ team_name }} team.{% endblocktranslate %} +{% else %} + {% blocktranslate with invitation_author=invitation.author.full_name team_name=invitation.group %}You were invited to {{ site_title }} by {{ invitation_author }} to join the {{ team_name }} team.{% endblocktranslate %} +{% endif %} diff --git a/weblate/templates/trans/project-access-row.html b/weblate/templates/trans/project-access-row.html index e23e1d97ca0f..6f8cd05ca5a3 100644 --- a/weblate/templates/trans/project-access-row.html +++ b/weblate/templates/trans/project-access-row.html @@ -22,7 +22,7 @@ {% if user.last_login %} {{ user.last_login|naturaltime }} {% else %} - {% trans "Not yet signed in" %} + {% trans "Not yet signed in" %} {% endif %} {% endif %} @@ -78,16 +78,5 @@
- - - {% if not user.last_login and not user.is_bot %} -
- {% csrf_token %} - - -
- {% endif %} diff --git a/weblate/templates/trans/project-access.html b/weblate/templates/trans/project-access.html index 097c80a7fd21..7b9c7e5f75c6 100644 --- a/weblate/templates/trans/project-access.html +++ b/weblate/templates/trans/project-access.html @@ -52,7 +52,13 @@

{{ userblock.user.username }} {{ userblock.user.full_name }} {{ userblock.user.email }} - {% if userblock.user.last_login %}{{ userblock.user.last_login|naturaltime }}{% else %}{% trans "Not yet signed in" %}{% endif %} + + {% if userblock.user.last_login %} + {{ userblock.user.last_login|naturaltime }} + {% else %} + {% trans "Not yet signed in" %} + {% endif %} + {% if userblock.expiry %} @@ -73,6 +79,42 @@

{% endfor %} + {% for invitation in invitations %} + + {% if invitation.user %} + {{ invitation.user.username }} + {{ invitation.user.full_name }} + {% if can_edit_user %} + {{ invitation.user.email }} + {% endif %} + {% else %} + + {% blocktrans with email=invitation.email %}Invitation for {{ email }}{% endblocktrans %} + + {% endif %} + {% trans "Pending invitation" %} + + {{ invitation.group }} + + +
+ {% csrf_token %} + + +
+ +
+ {% csrf_token %} + + +
+ + + {% endfor %}
@@ -90,7 +132,7 @@

{% trans "Add a user" %}

- {{ add_user_form|crispy }} + {{ invite_user_form|crispy }}
+ {% if invite_email_form %}
{% csrf_token %} @@ -122,7 +165,7 @@

{% trans "Invite new user" %}

- {{ invite_user_form|crispy }} + {{ invite_email_form|crispy }}
+ {% endif %} diff --git a/weblate/templates/weblate_auth/invitation_detail.html b/weblate/templates/weblate_auth/invitation_detail.html new file mode 100644 index 000000000000..eaa3fe514af9 --- /dev/null +++ b/weblate/templates/weblate_auth/invitation_detail.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% load i18n %} +{% load translations %} +{% load authnames %} +{% load crispy_forms_tags %} +{% load icons %} + +{% block breadcrumbs %} +
  • {% trans "Invitations" %}
  • +
  • {% trans "Invitation" %}
  • +{% endblock %} + +{% block content %} +
    + {% csrf_token %} +
    +
    +

    + {% blocktranslate with project_name=object.group.defining_project %}Invitation to {{ project_name }}{% endblocktranslate %} +

    +
    + {% include "snippets/invite-info.html" with invitation=object%} +
    + +
    +
    +
    + +{% endblock %} diff --git a/weblate/trans/forms.py b/weblate/trans/forms.py index 416818e54994..b52ada542ea5 100644 --- a/weblate/trans/forms.py +++ b/weblate/trans/forms.py @@ -62,6 +62,7 @@ SearchField, SortedSelect, SortedSelectMultiple, + UserField, UsernameField, ) from weblate.utils.hash import checksum_to_hash, hash_to_checksum @@ -140,30 +141,6 @@ def clean(self, value): raise ValidationError(gettext("Invalid checksum specified!")) -class UserField(forms.CharField): - def widget_attrs(self, widget): - attrs = super().widget_attrs(widget) - attrs["dir"] = "ltr" - attrs["class"] = "user-autocomplete" - attrs["spellcheck"] = "false" - attrs["autocorrect"] = "off" - attrs["autocomplete"] = "off" - attrs["autocapitalize"] = "off" - return attrs - - def clean(self, value): - if not value: - if self.required: - raise ValidationError(gettext("Missing username or e-mail.")) - return None - try: - return User.objects.get(Q(username=value) | Q(email=value)) - except User.DoesNotExist: - raise ValidationError(gettext("Could not find any such user.")) - except User.MultipleObjectsReturned: - raise ValidationError(gettext("More possible users were found.")) - - class FlagField(forms.CharField): default_validators = [validate_check_flags] diff --git a/weblate/trans/models/change.py b/weblate/trans/models/change.py index dfa48b66e744..b12692748c36 100644 --- a/weblate/trans/models/change.py +++ b/weblate/trans/models/change.py @@ -25,6 +25,7 @@ from weblate.trans.mixins import UserDisplayMixin from weblate.trans.models.alert import ALERTS from weblate.trans.models.project import Project +from weblate.utils.pii import mask_email from weblate.utils.state import STATE_LOOKUP @@ -727,9 +728,13 @@ def get_details_display(self): # noqa: C901 return name return "Unknown {}".format(details["access_control"]) if self.action in user_actions: + if "username" in details: + result = details["username"] + else: + result = mask_email(details["email"]) if "group" in details: - return "{username} ({group})".format(**details) - return details["username"] + result = "result ({details['group']})" + return result if self.action in ( self.ACTION_ADDED_LANGUAGE, self.ACTION_REQUESTED_LANGUAGE, diff --git a/weblate/trans/tests/test_acl.py b/weblate/trans/tests/test_acl.py index 946fb5e5df96..81bb27e6c015 100644 --- a/weblate/trans/tests/test_acl.py +++ b/weblate/trans/tests/test_acl.py @@ -6,15 +6,16 @@ from django.conf import settings from django.core import mail +from django.test.utils import override_settings from django.urls import reverse -from weblate.auth.models import Group, Role, User, get_anonymous +from weblate.auth.models import Group, Invitation, Role, User, get_anonymous from weblate.lang.models import Language from weblate.trans.models import Project -from weblate.trans.tests.test_views import FixtureTestCase +from weblate.trans.tests.test_views import FixtureTestCase, RegistrationTestMixin -class ACLTest(FixtureTestCase): +class ACLTest(FixtureTestCase, RegistrationTestMixin): def setUp(self): super().setUp() self.project.access_control = Project.ACCESS_PRIVATE @@ -90,7 +91,7 @@ def add_user(self): # Add user response = self.client.post( reverse("add-user", kwargs=self.kw_project), - {"user": self.second_user.username}, + {"user": self.second_user.username, "group": self.admin_group.pk}, ) self.assertRedirects(response, self.access_url) @@ -98,12 +99,20 @@ def add_user(self): response = self.client.get(self.access_url) self.assertContains(response, self.second_user.username) + # Accept invitation + invitation = Invitation.objects.get() + invitation.accept(None, self.second_user) + + # Ensure user is now listed + response = self.client.get(self.access_url) + self.assertContains(response, self.second_user.username) + def test_invite_invalid(self): """Test inviting invalid form.""" self.project.add_user(self.user, "Administration") response = self.client.post( reverse("invite-user", kwargs=self.kw_project), - {"email": "invalid", "username": "valid", "full_name": "name"}, + {"email": "invalid", "group": self.admin_group.pk}, follow=True, ) self.assertContains(response, "Enter a valid e-mail address.") @@ -113,41 +122,72 @@ def test_invite_existing(self): self.project.add_user(self.user, "Administration") response = self.client.post( reverse("invite-user", kwargs=self.kw_project), - { - "email": self.user.email, - "username": self.user.username, - "full_name": "name", - }, + {"email": self.user.email, "group": self.admin_group.pk}, follow=True, ) - self.assertContains(response, "A user with this e-mail already exists") + self.assertContains(response, "User invitation e-mail was sent.") + invitation = Invitation.objects.get() + # Ensure invitation was mapped to existing user + self.assertEqual(invitation.user, self.user) + @override_settings(REGISTRATION_OPEN=True, REGISTRATION_CAPTCHA=False) def test_invite_user(self): """Test inviting user.""" self.project.add_user(self.user, "Administration") response = self.client.post( reverse("invite-user", kwargs=self.kw_project), - {"email": "user@example.com", "username": "username", "full_name": "name"}, + {"email": "user@example.com", "group": self.admin_group.pk}, follow=True, ) - # Ensure user is now listed - self.assertContains(response, "username") + # Ensure user invitation is now listed + self.assertContains(response, "user@example.com") + self.assertNotContains(response, "example-username") # Check invitation mail self.assertEqual(len(mail.outbox), 1) message = mail.outbox[0] self.assertEqual(message.subject, "[Weblate] Invitation to Weblate") mail.outbox = [] + self.assertEqual(Invitation.objects.count(), 1) + + invitation = Invitation.objects.get() + # Resend invitation response = self.client.post( - reverse("resend_invitation", kwargs=self.kw_project), - {"user": "user@example.com"}, + invitation.get_absolute_url(), + {"action": "resend"}, follow=True, ) # Check invitation mail self.assertEqual(len(mail.outbox), 1) message = mail.outbox[0] self.assertEqual(message.subject, "[Weblate] Invitation to Weblate") + mail.outbox = [] + + user_client = self.client_class() + + # Follow the invitation list + response = user_client.get(invitation.get_absolute_url(), follow=True) + self.assertRedirects(response, reverse("register")) + self.assertContains(response, "user@example.com") + + # Perform registration + response = user_client.post( + reverse("register"), + { + "email": "user@example.com", + "username": "example-username", + "fullname": "name", + }, + follow=True, + ) + url = self.assert_registration_mailbox() + response = user_client.get(url, follow=True) + self.assertRedirects(response, reverse("password")) + + # Verify user was added + response = self.client.get(self.access_url) + self.assertContains(response, "example-username") def remove_user(self): # Remove user @@ -246,7 +286,7 @@ def test_nonexisting_user(self): self.project.add_user(self.user, "Administration") response = self.client.post( reverse("add-user", kwargs=self.kw_project), - {"user": "nonexisting"}, + {"user": "nonexisting", "group": self.admin_group.pk}, follow=True, ) self.assertContains(response, "Could not find any such user") diff --git a/weblate/trans/views/acl.py b/weblate/trans/views/acl.py index 9ff9e8f66ebc..52702511db30 100644 --- a/weblate/trans/views/acl.py +++ b/weblate/trans/views/acl.py @@ -5,6 +5,7 @@ from datetime import timedelta from itertools import chain +from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.db.models import Count, Prefetch @@ -17,8 +18,12 @@ from weblate.accounts.models import AuditLog from weblate.accounts.utils import remove_user from weblate.auth.data import SELECTION_ALL -from weblate.auth.forms import InviteUserForm, ProjectTeamForm, send_invitation -from weblate.auth.models import Group, User +from weblate.auth.forms import ( + InviteEmailForm, + InviteUserForm, + ProjectTeamForm, +) +from weblate.auth.models import Invitation, User from weblate.trans.forms import ( ProjectTokenCreateForm, ProjectUserGroupForm, @@ -45,7 +50,11 @@ def check_user_form( if not request.user.has_perm("project.permissions", obj): raise PermissionDenied - form = form_class(obj, request.POST) if pass_project else form_class(request.POST) + form = ( + form_class(project=obj, data=request.POST) + if pass_project + else form_class(data=request.POST) + ) if form.is_valid(): return obj, form @@ -100,23 +109,11 @@ def set_groups(request, project): def add_user(request, project): """Add user to a project.""" obj, form = check_user_form( - request, - project, + request, project, form_class=InviteUserForm, pass_project=True ) if form is not None: - try: - user = form.cleaned_data["user"] - obj.add_user(user) - Change.objects.create( - project=obj, - action=Change.ACTION_ADD_USER, - user=request.user, - details={"username": user.username}, - ) - messages.success(request, gettext("User has been added to this project.")) - except Group.DoesNotExist: - messages.error(request, gettext("Could not find group to add a user!")) + form.save(request) return redirect("manage-access", project=obj.slug) @@ -175,30 +172,12 @@ def unblock_user(request, project): @login_required def invite_user(request, project): """Invite user to a project.""" - obj, form = check_user_form(request, project, form_class=InviteUserForm) - - if form is not None: - try: - form.save(request, obj) - messages.success(request, gettext("User has been invited to this project.")) - except Group.DoesNotExist: - messages.error(request, gettext("Could not find group to add a user!")) - - return redirect("manage-access", project=obj.slug) - - -@require_POST -@login_required -def resend_invitation(request, project): - """Remove user from a project.""" obj, form = check_user_form( - request, - project, + request, project, form_class=InviteEmailForm, pass_project=True ) if form is not None: - send_invitation(request, obj.name, form.cleaned_data["user"]) - messages.success(request, gettext("User invitation e-mail was sent.")) + form.save(request, obj) return redirect("manage-access", project=obj.slug) @@ -296,7 +275,9 @@ def manage_access(request, project): "groups": groups, "all_users": users, "blocked_users": obj.userblock_set.select_related("user"), - "add_user_form": UserManageForm(), + "invitations": Invitation.objects.filter( + group__defining_project=obj + ).select_related("user"), "create_project_token_form": ProjectTokenCreateForm(obj), "create_team_form": ProjectTeamForm( project=obj, initial={"language_selection": SELECTION_ALL} @@ -304,7 +285,10 @@ def manage_access(request, project): "block_user_form": UserBlockForm( initial={"user": request.GET.get("block_user")} ), - "invite_user_form": InviteUserForm(), + "invite_user_form": InviteUserForm(project=obj), + "invite_email_form": InviteEmailForm(project=obj) + if settings.REGISTRATION_OPEN + else None, "public_ssh_keys": get_all_key_data(), }, ) diff --git a/weblate/urls.py b/weblate/urls.py index b5a9b248ea6b..807368e4b165 100644 --- a/weblate/urls.py +++ b/weblate/urls.py @@ -386,11 +386,6 @@ weblate.trans.views.acl.delete_user, name="delete-user", ), - path( - "access//resend/", - weblate.trans.views.acl.resend_invitation, - name="resend_invitation", - ), path( "access//set/", weblate.trans.views.acl.set_groups, diff --git a/weblate/utils/forms.py b/weblate/utils/forms.py index 355ae52176f1..ebaccca8ff8f 100644 --- a/weblate/utils/forms.py +++ b/weblate/utils/forms.py @@ -6,6 +6,7 @@ from crispy_forms.utils import TEMPLATE_PACK from django import forms from django.core.exceptions import ValidationError +from django.db.models import Q from django.template.loader import render_to_string from django.utils.translation import gettext, gettext_lazy @@ -58,6 +59,44 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **params) +class UserField(forms.CharField): + def __init__( + self, + queryset=None, + empty_label="---------", + to_field_name=None, + limit_choices_to=None, + blank=None, + **kwargs, + ): + # This swallows some parameters to mimic ModelChoiceField API + super().__init__(**kwargs) + + def widget_attrs(self, widget): + attrs = super().widget_attrs(widget) + attrs["dir"] = "ltr" + attrs["class"] = "user-autocomplete" + attrs["spellcheck"] = "false" + attrs["autocorrect"] = "off" + attrs["autocomplete"] = "off" + attrs["autocapitalize"] = "off" + return attrs + + def clean(self, value): + from weblate.auth.models import User + + if not value: + if self.required: + raise ValidationError(gettext("Missing username or e-mail.")) + return None + try: + return User.objects.get(Q(username=value) | Q(email=value)) + except User.DoesNotExist: + raise ValidationError(gettext("Could not find any such user.")) + except User.MultipleObjectsReturned: + raise ValidationError(gettext("More possible users were found.")) + + class EmailField(forms.EmailField): """ Slightly restricted EmailField. diff --git a/weblate/utils/pii.py b/weblate/utils/pii.py new file mode 100644 index 000000000000..1140481d0b02 --- /dev/null +++ b/weblate/utils/pii.py @@ -0,0 +1,23 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +def mask_email(email: str): + name, domain = email.rsplit("@", maxsplit=1) + if len(name) <= 3: + masked_name = len(name) * "*" + else: + first, *hidden, last = name + masked_name = f"{first}{'*' * len(hidden)}{last}" + + if len(domain) <= 4 or "." not in domain: + *hidden, last = domain + masked_domain = f"{len(hidden) * '*'}{last}" + else: + part, tld = domain.rsplit(".", maxsplit=1) + *hidden, last = tld + masked_tld = f"{len(hidden) * '*'}{last}" + masked_domain = f"{len(part) * '*'}.{masked_tld}" + + return f"{masked_name}@{masked_domain}" diff --git a/weblate/utils/tests/test_pii.py b/weblate/utils/tests/test_pii.py new file mode 100644 index 000000000000..fc84e73b0b15 --- /dev/null +++ b/weblate/utils/tests/test_pii.py @@ -0,0 +1,15 @@ +# Copyright © Michal Čihař +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +from django.test import SimpleTestCase + +from weblate.utils.pii import mask_email + + +class PIITestCase(SimpleTestCase): + def test_mask_email(self): + self.assertEqual(mask_email("michal@cihar.com"), "m****l@*****.**m") + self.assertEqual(mask_email("mic@locahost"), "***@*******t") + self.assertEqual(mask_email("michal@cz"), "m****l@*z") diff --git a/weblate/wladmin/tests.py b/weblate/wladmin/tests.py index 4c15c42ee8a0..57096fe75807 100644 --- a/weblate/wladmin/tests.py +++ b/weblate/wladmin/tests.py @@ -198,45 +198,11 @@ def test_invite_user(self): reverse("manage-users"), { "email": "noreply@example.com", - "username": "username", - "full_name": "name", - "send_email": 1, + "group": Group.objects.get(name="Users").pk, }, follow=True, ) - self.assertContains(response, "Created user account") - self.assertEqual(len(mail.outbox), 1) - - def test_invite_user_nosend(self): - response = self.client.get(reverse("manage-users")) - self.assertContains(response, "E-mail") - response = self.client.post( - reverse("manage-users"), - { - "email": "noreply@example.com", - "username": "username", - "full_name": "name", - }, - follow=True, - ) - self.assertContains(response, "Created user account") - self.assertEqual(len(mail.outbox), 0) - - @override_settings(AUTHENTICATION_BACKENDS=TEST_BACKENDS) - def test_invite_user_nomail(self): - response = self.client.get(reverse("manage-users")) - self.assertContains(response, "E-mail") - response = self.client.post( - reverse("manage-users"), - { - "email": "noreply@example.com", - "username": "username", - "full_name": "name", - "send_email": 1, - }, - follow=True, - ) - self.assertContains(response, "Created user account") + self.assertContains(response, "User invitation e-mail was sent") self.assertEqual(len(mail.outbox), 1) def test_check_user(self): diff --git a/weblate/wladmin/views.py b/weblate/wladmin/views.py index eedfaa105107..d5e651cb2c52 100644 --- a/weblate/wladmin/views.py +++ b/weblate/wladmin/views.py @@ -15,7 +15,6 @@ from django.urls import reverse from django.utils import timezone from django.utils.decorators import method_decorator -from django.utils.html import escape, format_html from django.utils.translation import gettext, gettext_lazy from django.views.decorators.http import require_POST from django.views.generic import ListView @@ -25,7 +24,7 @@ from weblate.accounts.views import UserList from weblate.auth.decorators import management_access from weblate.auth.forms import AdminInviteUserForm, SitewideTeamForm -from weblate.auth.models import Group, User +from weblate.auth.models import Group, Invitation, User from weblate.configuration.models import Setting from weblate.configuration.views import CustomCSSView from weblate.trans.forms import AnnouncementForm @@ -360,18 +359,7 @@ def post(self, request, **kwargs): if "email" in request.POST: invite_form = AdminInviteUserForm(request.POST) if invite_form.is_valid(): - user = invite_form.save(request) - messages.success( - request, - format_html( - escape(gettext("Created user account {}.")), - format_html( - '{}', - user.get_absolute_url(), - user.username, - ), - ), - ) + invite_form.save(request) return redirect("manage-users") return super().get(request, **kwargs) @@ -388,6 +376,7 @@ def get_context_data(self, **kwargs): result["menu_page"] = "users" result["invite_form"] = invite_form result["search_form"] = AdminUserSearchForm() + result["invitations"] = Invitation.objects.all().select_related("user") return result