Skip to content

Commit

Permalink
auth: Use standalone models for invitations
Browse files Browse the repository at this point in the history
- 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 WeblateOrg#9261
Fixes WeblateOrg#9131
Fixes WeblateOrg#7412
  • Loading branch information
nijel committed Jul 31, 2023
1 parent 6bd1ea9 commit 3bb2bd5
Show file tree
Hide file tree
Showing 32 changed files with 813 additions and 259 deletions.
19 changes: 10 additions & 9 deletions docs/admin/access.rst
Original file line number Diff line number Diff line change
Expand Up @@ -168,23 +168,24 @@ 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 create.

.. versionadded:: 3.11
.. versionchanged:: 5.0

It is possible to resend the e-mail for user invitations (invalidating any
previously sent invitation).
Weblate now does not automatically create accounts or add users to the
teams. This is only done after confirmation from the user.

The same kind of invitations are available site-wide from the
:ref:`management interface <management-interface>` on the :guilabel:`Users` tab.
Expand Down
4 changes: 3 additions & 1 deletion docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Not yet released.

**New features**

* Inviting users now works with all authentication methods.

**Improvements**

* Plurals handling in machine translation.
Expand All @@ -31,7 +33,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.

`All changes in detail <https://github.com/WeblateOrg/weblate/milestone/99?closed=1>`__.
48 changes: 48 additions & 0 deletions weblate/accounts/migrations/0028_alter_auditlog_activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Copyright © Michal Čihař <michal@weblate.org>
#
# 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", "0027_alter_subscription_notification"),
]

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,
),
),
]
1 change: 1 addition & 0 deletions weblate/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
49 changes: 39 additions & 10 deletions weblate/accounts/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import annotations

import re
import time
import unicodedata
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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":
Expand All @@ -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
):
Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions weblate/accounts/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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/<uuid:pk>/",
weblate.auth.views.InvitationView.as_view(),
name="invitation",
),
path("", include((social_urls, "social_auth"), namespace="social")),
]

Expand Down
51 changes: 29 additions & 22 deletions weblate/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -754,7 +751,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")


Expand All @@ -763,15 +759,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"]:
Expand All @@ -782,18 +795,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(
Expand All @@ -805,6 +812,7 @@ def register(request):
"title": gettext("User registration"),
"form": form,
"captcha_form": captcha,
"invitation": invitation,
},
)

Expand Down Expand Up @@ -951,7 +959,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"]
Expand Down Expand Up @@ -1103,12 +1111,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
Expand Down
Loading

0 comments on commit 3bb2bd5

Please sign in to comment.