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 @@

{% trans "Pending requests" %}

{% include "account/team_list_item.html" with organization=invite.organization %} -
- {% csrf_token %} - - -
+ {{ invite.accept_form }}
{% endfor %} diff --git a/squarelet/templates/organizations/invitation_accept_form.html b/squarelet/templates/organizations/invitation_accept_form.html new file mode 100644 index 000000000..bf2c372d2 --- /dev/null +++ b/squarelet/templates/organizations/invitation_accept_form.html @@ -0,0 +1,20 @@ +{% load i18n %} +{% if requires_email_verification %} +
+ {% csrf_token %} + + +
+{% else %} +
+ {% csrf_token %} + + +
+{% endif %} diff --git a/squarelet/templates/organizations/invitation_detail.html b/squarelet/templates/organizations/invitation_detail.html index 6e623582a..a4baad16a 100644 --- a/squarelet/templates/organizations/invitation_detail.html +++ b/squarelet/templates/organizations/invitation_detail.html @@ -11,6 +11,10 @@ .container { max-width: 32rem; margin: 3rem auto; + padding: 2rem; + background: var(--white); + border-radius: 8px; + border: 1px solid var(--gray-2); } h2 { font-weight: var(--font-semibold, 600); @@ -22,6 +26,23 @@ gap: 1rem; align-items: center; } + + .control-group form { + display: contents; + } + .control-group .button { + flex-grow: 1; + } + .control-group .button.primary { + flex-grow: 2; + } + + .user-data { + font-weight: 500; + } + .user-data a { + text-decoration: underline; + } {% endblock %} @@ -35,67 +56,86 @@

{% trans "on MuckRock" %}

-
- {% csrf_token %} -

- {% blocktrans with name=invitation.organization.name %} - You have been invited you to join {{ name }}, an organization - account on MuckRock. If you have questions or think this might malicious, - please reach out to the organization directly to confirm. - {% endblocktrans %} -

- {% if request.user.is_authenticated %} -

- {% blocktrans with name=request.user.name email=request.user.email %} - You're currently logged in as {{ name }} with {{ email }}. If you'd - like to associate this invitation with another account, please sign - out and sign back in with the account you'd like to use. - {% endblocktrans %} -

- {% else %} -

- {% blocktrans %} - MuckRock is a non-profit organization that builds transparency tools - for journalists, researchers and the public. - {% endblocktrans %} -

-

- {% blocktrans %} - Before you can accept this account, you need to either create a new - account or log in with the account you'd like to join. - {% endblocktrans %} -

-

- {% blocktrans %} - Your MuckRock account will give you access to MuckRock, - DocumentCloud, FOIA Machine and Big Local News, as well as make it - easier to collaborate with your colleagues there. - {% endblocktrans %} -

- {% endif %} +

+ {% blocktrans with name=invitation.organization.name %} + You have been invited you to join {{ name }}, an organization + account on MuckRock. If you have questions or think this might malicious, + please reach out to the organization directly to confirm. + {% endblocktrans %} +

+ {% if request.user.is_authenticated %} +

+ {% url 'users:redirect' as user_url %} + {% blocktrans with name=request.user.name email=request.user.email %} + You're currently logged in as {{ name }} with {{ email }}. + {% endblocktrans %} +

+

+ {% blocktrans %} + If you'd like to associate this invitation with another account, + please sign out and sign back in with the account you'd like to use. + {% endblocktrans %} + {% if accept_form.requires_email_verification %} +

+ {% blocktrans %} + You must verify your email address before you can accept an admin invitation. + {% endblocktrans %} +

+ {% endif %} + {% else %} +

+ + {% blocktrans %} + Before you can accept this account, you need to either create a new + account or log in with the account you'd like to join. + {% endblocktrans %} + +

+

+ {% blocktrans %} + MuckRock is a non-profit organization that builds transparency tools + for journalists, researchers and the public. + {% endblocktrans %} +

+

+ {% blocktrans %} + Your MuckRock account will give you access to MuckRock, + DocumentCloud, FOIA Machine and Big Local News, as well as make it + easier to collaborate with your colleagues there. + {% endblocktrans %} +

+ {% endif %} - {% if request.user.is_authenticated %} -
- - -
- +
+ {% if request.user.is_authenticated %} + {% if accept_form.requires_email_verification %} + + {% csrf_token %} + + + {% else %} - -
+
+ {% csrf_token %} + + +
{% endif %} - + {% else %} + + {% trans "Sign Up" %} + + + {% trans "Log In" %} + + {% endif %} + {% endblock content %} diff --git a/squarelet/templates/organizations/organization_list.html b/squarelet/templates/organizations/organization_list.html index bec8fc644..e6e2a4c3e 100644 --- a/squarelet/templates/organizations/organization_list.html +++ b/squarelet/templates/organizations/organization_list.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% load static i18n avatar django_vite email_verification %} +{% load static i18n avatar django_vite %} {% block title %}Organizations{% endblock %} @@ -102,23 +102,6 @@

{% trans "Your organizations" %}

- {% if not request.user|has_verified_email %} -
-

- {% blocktrans %} - You must verify your email address before you can send requests - or receive invitations to join an organization. - {% endblocktrans %} -

- -
- {% else %} {% if potential_orgs %}
@@ -163,15 +146,7 @@

{% trans "Pending invitations" %}

{% include "account/team_list_item.html" with organization=invite.organization %} -
- {% csrf_token %} - - -
+ {{ invite.accept_form }}
{% endfor %}
@@ -213,7 +188,6 @@

{% trans "Pending requests" %}

{% endif %} - {% endif %}
diff --git a/squarelet/templates/organizations/organization_managemembers.html b/squarelet/templates/organizations/organization_managemembers.html index 57aa86a61..90e78b9cb 100644 --- a/squarelet/templates/organizations/organization_managemembers.html +++ b/squarelet/templates/organizations/organization_managemembers.html @@ -144,8 +144,8 @@

{% empty %} -
-

No pending requests

+
+

No pending requests

{% endfor %}
@@ -207,8 +207,8 @@

{% empty %} -
-

No pending invitations

+
+

No pending invitations

{% endfor %}
diff --git a/squarelet/templates/organizations/your_organizations.html b/squarelet/templates/organizations/your_organizations.html index 7cfb9d615..7b3a21ac0 100644 --- a/squarelet/templates/organizations/your_organizations.html +++ b/squarelet/templates/organizations/your_organizations.html @@ -30,15 +30,7 @@

{% trans "Open invitations" %}

{% for invite in pending_invitations %}
{% include "account/team_list_item.html" with organization=invite.organization %} -
- {% csrf_token %} - - -
+ {{ invite.accept_form }}
{% endfor %} diff --git a/squarelet/templates/users/user_invitations.html b/squarelet/templates/users/user_invitations.html index 3b6a1a0ff..7101d357c 100644 --- a/squarelet/templates/users/user_invitations.html +++ b/squarelet/templates/users/user_invitations.html @@ -53,15 +53,7 @@ {% if is_own_page %} {% if not invitation.accepted_at and not invitation.rejected_at and not invitation.withdrawn_at %} -
- {% csrf_token %} - - -
+ {{ invitation.accept_form }} {% elif invitation.rejected_at %}
{% csrf_token %} diff --git a/squarelet/users/onboarding.py b/squarelet/users/onboarding.py index 4a89f0c88..514ac1e50 100644 --- a/squarelet/users/onboarding.py +++ b/squarelet/users/onboarding.py @@ -16,6 +16,7 @@ from allauth.mfa.totp.internal.flows import activate_totp # Squarelet +from squarelet.organizations.forms import InvitationAcceptForm from squarelet.organizations.models.payment import Plan from squarelet.users.forms import PremiumSubscriptionForm @@ -161,6 +162,7 @@ def should_execute(self, request): def get_context_data(self, request): user = request.user invitations, potential_orgs = self._get_joinable_orgs(user) + InvitationAcceptForm.attach_to_invitations(invitations, user, request=request) return { "invitations": invitations, "potential_orgs": potential_orgs, diff --git a/squarelet/users/views.py b/squarelet/users/views.py index 15894972b..56c15c8bf 100644 --- a/squarelet/users/views.py +++ b/squarelet/users/views.py @@ -47,6 +47,7 @@ # Squarelet from squarelet.core.mixins import AdminLinkMixin from squarelet.core.utils import new_action +from squarelet.organizations.forms import InvitationAcceptForm from squarelet.organizations.models import Invitation, ReceiptEmail from squarelet.organizations.models.payment import Plan from squarelet.organizations.views import UpdateSubscription @@ -137,7 +138,9 @@ def get_context_data(self, **kwargs): ) context["is_own_page"] = user == 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 + ) context["pending_requests"] = list(user.get_pending_requests()) context["verified"] = user.verified_journalist() context["verified_organizations"] = list( @@ -605,6 +608,13 @@ class UserInvitationsView(BaseUserInvitationRequestView): is_request_view = False redirect_url_name = "users:invitations" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + InvitationAcceptForm.attach_to_invitations( + context["invitations"], self.request.user, request=self.request + ) + return context + class UserRequestsView(BaseUserInvitationRequestView): """View to display all requests sent by a user"""