diff --git a/e2e/organizations.spec.ts b/e2e/organizations.spec.ts index 2e7be738f..eb10df4de 100644 --- a/e2e/organizations.spec.ts +++ b/e2e/organizations.spec.ts @@ -469,8 +469,8 @@ test.describe("Member Management", () => { const anonContext = await browser.newContext({ ignoreHTTPSErrors: true }); const anonPage = await anonContext.newPage(); await anonPage.goto(`https://dev.squarelet.com${invitationPath}`); - await expect(anonPage.locator("form a:has-text('Sign Up')")).toBeVisible(); - await expect(anonPage.locator("form a:has-text('Log In')")).toBeVisible(); + await expect(anonPage.locator("a:has-text('Sign Up')")).toBeVisible(); + await expect(anonPage.locator("a:has-text('Log In')")).toBeVisible(); await anonContext.close(); // Verify the link invitation shows "Copy link" (not "Resend") on manage-members diff --git a/frontend/css/gps.css b/frontend/css/gps.css index 8285d3990..301fcac10 100644 --- a/frontend/css/gps.css +++ b/frontend/css/gps.css @@ -442,6 +442,7 @@ strong { .empty { display: flex; + margin: 0 auto; width: 16.5rem; padding: 1.5rem; flex-direction: column; @@ -453,12 +454,18 @@ strong { border: 1.282px solid var(--Gray-2, #d8dee2); } +.empty--no-border { + border: none; +} + .empty p { align-self: stretch; - margin: 0; + font-family: "Source Sans Pro"; + line-height: 1.4; color: var(--gray-4); + margin: 0 auto; text-align: center; - font-family: "Source Sans Pro"; + max-width: 60ch; } .card { diff --git a/squarelet/organizations/forms.py b/squarelet/organizations/forms.py index d2f6ce55d..f66370ee2 100644 --- a/squarelet/organizations/forms.py +++ b/squarelet/organizations/forms.py @@ -2,6 +2,7 @@ from django import forms from django.core.validators import validate_email from django.db.models.aggregates import Min +from django.middleware.csrf import get_token from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -282,6 +283,58 @@ class AddMemberForm(forms.Form): ) +class InvitationAcceptForm(forms.Form): + """ + Validates that a user can accept an invitation. + Admin-role invitations require the user to have a verified email address. + Exposes a class method to bind the class to an Invitation object. + """ + + template_name = "organizations/invitation_accept_form.html" + + def __init__(self, *args, invitation, user, request=None, **kwargs): + super().__init__(*args, **kwargs) + self.invitation = invitation + self.user = user + self.request = request + + @property + def requires_email_verification(self): + return ( + self.invitation.role == InvitationRole.admin + and not self.user.emailaddress_set.filter(verified=True).exists() + ) + + def clean(self): + cleaned_data = super().clean() + if self.requires_email_verification: + raise forms.ValidationError( + _( + "You must verify your email address before accepting " + "an admin invitation." + ) + ) + return cleaned_data + + def get_context(self): + context = super().get_context() + context["invitation"] = self.invitation + context["user"] = self.user + context["requires_email_verification"] = self.requires_email_verification + if self.request: + context["csrf_token"] = get_token(self.request) + return context + + @classmethod + def attach_to_invitations(cls, invitations, user, request=None): + """Attach an accept_form to each invitation in the list.""" + for invitation in invitations: + invitation.accept_form = cls( + invitation=invitation, user=user, request=request + ) + return invitations + + class MergeForm(forms.Form): """A form to merge two organizations""" diff --git a/squarelet/organizations/models/invitation.py b/squarelet/organizations/models/invitation.py index a5869022e..f25b70753 100644 --- a/squarelet/organizations/models/invitation.py +++ b/squarelet/organizations/models/invitation.py @@ -103,6 +103,10 @@ class Meta: def __str__(self): return f"Invitation: {self.uuid}" + @property + def is_admin_role(self): + return self.role == InvitationRole.admin + def send(self): if self.request: diff --git a/squarelet/organizations/tests/test_views.py b/squarelet/organizations/tests/test_views.py index 14fc99a9f..13b0b019a 100644 --- a/squarelet/organizations/tests/test_views.py +++ b/squarelet/organizations/tests/test_views.py @@ -1641,6 +1641,13 @@ class TestInvitationAccept(ViewTestMixin): view = views.InvitationAccept url = "/organizations/{uuid}/invitation/" + def _create_verified_email(self, user): + EmailAddress.objects.update_or_create( + user=user, + email=user.email, + defaults={"verified": True, "primary": True}, + ) + def test_accept(self, rf, invitation_factory, user_factory): invitation = invitation_factory() user = user_factory() @@ -1649,6 +1656,30 @@ def test_accept(self, rf, invitation_factory, user_factory): assert invitation.accepted_at is not None assert invitation.organization.has_member(user) + def test_accept_admin_requires_verified_email( + self, rf, invitation_factory, user_factory + ): + """Users without a verified email cannot accept admin invitations""" + invitation = invitation_factory(role=InvitationRole.admin) + user = user_factory() + response = self.call_view(rf, user, {"action": "accept"}, uuid=invitation.uuid) + invitation.refresh_from_db() + assert invitation.accepted_at is None + assert not invitation.organization.has_member(user) + assert response.status_code == 302 + + def test_accept_admin_with_verified_email( + self, rf, invitation_factory, user_factory + ): + """Users with a verified email can accept admin invitations""" + invitation = invitation_factory(role=InvitationRole.admin) + user = user_factory() + self._create_verified_email(user) + self.call_view(rf, user, {"action": "accept"}, uuid=invitation.uuid) + invitation.refresh_from_db() + assert invitation.accepted_at is not None + assert invitation.organization.has_member(user) + def test_reject(self, rf, invitation_factory, user_factory): invitation = invitation_factory() user = user_factory() diff --git a/squarelet/organizations/views/detail.py b/squarelet/organizations/views/detail.py index 7355d4685..711235147 100644 --- a/squarelet/organizations/views/detail.py +++ b/squarelet/organizations/views/detail.py @@ -18,6 +18,7 @@ # Squarelet from squarelet.core.mixins import AdminLinkMixin from squarelet.core.utils import get_redirect_url, is_rate_limited, new_action +from squarelet.organizations.forms import InvitationAcceptForm from squarelet.organizations.models import Invitation, Membership, Organization, Plan from squarelet.organizations.tasks import sync_wix @@ -353,7 +354,9 @@ def get_context_data(self, **kwargs): context["potential_orgs"] = [] if user.is_authenticated: context["pending_requests"] = list(user.get_pending_requests()) - context["pending_invitations"] = list(user.get_pending_invitations()) + context["pending_invitations"] = InvitationAcceptForm.attach_to_invitations( + list(user.get_pending_invitations()), user, request=self.request + ) context["potential_orgs"] = list(user.get_potential_organizations()) context["has_pending"] = bool( @@ -370,7 +373,9 @@ def get_context_data(self, **kwargs): ).get_viewable(self.request.user) ) context["potential_organizations"] = list(user.get_potential_organizations()) - context["pending_invitations"] = list(user.get_pending_invitations()) + context["pending_invitations"] = InvitationAcceptForm.attach_to_invitations( + list(user.get_pending_invitations()), user, request=self.request + ) return context diff --git a/squarelet/organizations/views/members.py b/squarelet/organizations/views/members.py index c602fc431..d7dde0f17 100644 --- a/squarelet/organizations/views/members.py +++ b/squarelet/organizations/views/members.py @@ -10,7 +10,7 @@ # Squarelet from squarelet.core.utils import get_redirect_url, new_action, pluralize from squarelet.organizations.choices import InvitationRole -from squarelet.organizations.forms import AddMemberForm +from squarelet.organizations.forms import AddMemberForm, InvitationAcceptForm from squarelet.organizations.mixins import OrganizationPermissionMixin from squarelet.organizations.models import Invitation, Membership, Organization @@ -263,6 +263,16 @@ class InvitationAccept(DetailView): slug_field = "uuid" slug_url_kwarg = "uuid" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.request.user.is_authenticated: + context["accept_form"] = InvitationAcceptForm( + invitation=self.object, + user=self.request.user, + request=self.request, + ) + return context + def dispatch(self, request, *args, **kwargs): """ If the user is not authenticated, store the invitation in the session, @@ -280,6 +290,13 @@ def post(self, request, *args, **kwargs): invitation = self.get_object() action = request.POST.get("action") if action == "accept": + form = InvitationAcceptForm( + request.POST, invitation=invitation, user=request.user + ) + if not form.is_valid(): + for error in form.non_field_errors(): + messages.error(request, error) + return redirect("organizations:invitation", uuid=invitation.uuid) invitation.accept(request.user) messages.success(request, "Invitation accepted") diff --git a/squarelet/templates/account/onboarding/join_org.html b/squarelet/templates/account/onboarding/join_org.html index e19de3aa4..b4c3357c4 100644 --- a/squarelet/templates/account/onboarding/join_org.html +++ b/squarelet/templates/account/onboarding/join_org.html @@ -50,15 +50,7 @@
- {% blocktrans %} - You must verify your email address before you can send requests - or receive invitations to join an organization. - {% endblocktrans %} -
-No pending requests
No pending invitations