From 5a078c202d260f4f883832c07b23d02b68049fed Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 8 Jan 2025 13:45:40 +0100 Subject: [PATCH 1/8] Implement team invitation lookup and rejection --- .../site/memberships/reject_invitation.ex | 13 ++++++- lib/plausible/teams/invitations.ex | 34 +++++++++++++++++-- lib/plausible_web/email.ex | 19 ++++++++--- ...ex => guest_invitation_rejected.html.heex} | 0 .../email/team_invitation_rejected.html.heex | 2 ++ 5 files changed, 61 insertions(+), 7 deletions(-) rename lib/plausible_web/templates/email/{invitation_rejected.html.heex => guest_invitation_rejected.html.heex} (100%) create mode 100644 lib/plausible_web/templates/email/team_invitation_rejected.html.heex diff --git a/lib/plausible/site/memberships/reject_invitation.ex b/lib/plausible/site/memberships/reject_invitation.ex index d1cca277f8da..2ecff342a6af 100644 --- a/lib/plausible/site/memberships/reject_invitation.ex +++ b/lib/plausible/site/memberships/reject_invitation.ex @@ -17,6 +17,12 @@ defmodule Plausible.Site.Memberships.RejectInvitation do end end + defp do_reject(%Teams.Invitation{} = team_invitation) do + Teams.Invitations.remove_team_invitation(team_invitation) + + notify_team_invitation_rejected(team_invitation) + end + defp do_reject(%Teams.GuestInvitation{} = guest_invitation) do Teams.Invitations.remove_guest_invitation(guest_invitation) @@ -35,7 +41,12 @@ defmodule Plausible.Site.Memberships.RejectInvitation do end defp notify_guest_invitation_rejected(guest_invitation) do - PlausibleWeb.Email.invitation_rejected(guest_invitation) + PlausibleWeb.Email.guest_invitation_rejected(guest_invitation) + |> Plausible.Mailer.send() + end + + defp notify_team_invitation_rejected(team_invitation) do + PlausibleWeb.Email.team_invitation_rejected(team_invitation) |> Plausible.Mailer.send() end end diff --git a/lib/plausible/teams/invitations.ex b/lib/plausible/teams/invitations.ex index 9f5129ca12f3..36c2cad889ae 100644 --- a/lib/plausible/teams/invitations.ex +++ b/lib/plausible/teams/invitations.ex @@ -11,7 +11,9 @@ defmodule Plausible.Teams.Invitations do def find_for_user(invitation_or_transfer_id, user) do with {:error, :invitation_not_found} <- - find_invitation_for_user(invitation_or_transfer_id, user) do + find_team_invitation_for_user(invitation_or_transfer_id, user), + {:error, :invitation_not_found} <- + find_guest_invitation_for_user(invitation_or_transfer_id, user) do find_transfer_for_user(invitation_or_transfer_id, user) end end @@ -23,7 +25,26 @@ defmodule Plausible.Teams.Invitations do end end - defp find_invitation_for_user(guest_invitation_id, user) do + defp find_team_invitation_for_user(team_invitation_id, user) do + invitation_query = + from ti in Teams.Invitation, + inner_join: inviter in assoc(ti, :inviter), + inner_join: team in assoc(ti, :team), + where: ti.invitation_id == ^team_invitation_id, + where: ti.email == ^user.email, + where: ti.role != :guest, + preload: [inviter: inviter, team: team] + + case Repo.one(invitation_query) do + nil -> + {:error, :invitation_not_found} + + invitation -> + {:ok, invitation} + end + end + + defp find_guest_invitation_for_user(guest_invitation_id, user) do invitation_query = from gi in Teams.GuestInvitation, inner_join: s in assoc(gi, :site), @@ -110,6 +131,15 @@ defmodule Plausible.Teams.Invitations do end end + def remove_team_invitation(team_invitation) do + Repo.delete_all( + from ti in Teams.Invitation, + where: ti.id == ^team_invitation.id + ) + + :ok + end + def remove_guest_invitation(guest_invitation) do site = Repo.preload(guest_invitation, site: :team).site diff --git a/lib/plausible_web/email.ex b/lib/plausible_web/email.ex index af0bc5f8a1da..aa5a9ad9f843 100644 --- a/lib/plausible_web/email.ex +++ b/lib/plausible_web/email.ex @@ -312,19 +312,30 @@ defmodule PlausibleWeb.Email do ) end - def invitation_rejected(guest_invitation) do + def guest_invitation_rejected(guest_invitation) do priority_email() |> to(guest_invitation.team_invitation.inviter.email) - |> tag("invitation-rejected") + |> tag("guest-invitation-rejected") |> subject( "[#{Plausible.product_name()}] #{guest_invitation.team_invitation.email} rejected your invitation to #{guest_invitation.site.domain}" ) - |> render("invitation_rejected.html", - user: guest_invitation.team_invitation.inviter, + |> render("guest_invitation_rejected.html", guest_invitation: guest_invitation ) end + def team_invitation_rejected(team_invitation) do + priority_email() + |> to(team_invitation.inviter.email) + |> tag("team-invitation-rejected") + |> subject( + "[#{Plausible.product_name()}] #{team_invitation.email} rejected your invitation to \"#{team_invitation.team.name}\" team" + ) + |> render("team_invitation_rejected.html", + team_invitation: team_invitation + ) + end + def ownership_transfer_accepted(new_owner_email, inviter_email, site) do priority_email() |> to(inviter_email) diff --git a/lib/plausible_web/templates/email/invitation_rejected.html.heex b/lib/plausible_web/templates/email/guest_invitation_rejected.html.heex similarity index 100% rename from lib/plausible_web/templates/email/invitation_rejected.html.heex rename to lib/plausible_web/templates/email/guest_invitation_rejected.html.heex diff --git a/lib/plausible_web/templates/email/team_invitation_rejected.html.heex b/lib/plausible_web/templates/email/team_invitation_rejected.html.heex new file mode 100644 index 000000000000..980f5dcccc12 --- /dev/null +++ b/lib/plausible_web/templates/email/team_invitation_rejected.html.heex @@ -0,0 +1,2 @@ +<%= @team_invitation.email %> has rejected your invitation to \"<%= @team_invitation.team.name %>\" team. +Click here to view team settings. From a367afc523ba1e4243983f4d4213d217d89ece18 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 8 Jan 2025 13:46:57 +0100 Subject: [PATCH 2/8] Implement skeleton for team invitation accept service --- lib/plausible/site/memberships/accept_invitation.ex | 11 +++++++++-- lib/plausible/teams/invitations/accept_team_invite.ex | 9 +++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 lib/plausible/teams/invitations/accept_team_invite.ex diff --git a/lib/plausible/site/memberships/accept_invitation.ex b/lib/plausible/site/memberships/accept_invitation.ex index 8c4136d0dc03..1d4618becdd6 100644 --- a/lib/plausible/site/memberships/accept_invitation.ex +++ b/lib/plausible/site/memberships/accept_invitation.ex @@ -59,8 +59,11 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do %Teams.SiteTransfer{} = site_transfer -> do_accept_ownership_transfer(site_transfer, user) + %Teams.Invitation{} = team_invitation -> + do_accept_team_invitation(team_invitation, user) + %Teams.GuestInvitation{} = guest_invitation -> - do_accept_invitation(guest_invitation, user) + do_accept_guest_invitation(guest_invitation, user) end end end @@ -103,7 +106,11 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do end end - defp do_accept_invitation(guest_invitation, user) do + defp do_accept_guest_invitation(guest_invitation, user) do Teams.Invitations.accept_guest_invitation(guest_invitation, user) end + + defp do_accept_team_invitation(team_invitation, user) do + Teams.Invitations.AcceptTeamInvite.accept(team_invitation, user) + end end diff --git a/lib/plausible/teams/invitations/accept_team_invite.ex b/lib/plausible/teams/invitations/accept_team_invite.ex new file mode 100644 index 000000000000..3a07937e90ea --- /dev/null +++ b/lib/plausible/teams/invitations/accept_team_invite.ex @@ -0,0 +1,9 @@ +defmodule Plausible.Teams.Invitations.AcceptTeamInvite do + @moduledoc """ + Service for accepting a team invite. + """ + + def accept(_team_invitation, _user) do + {:ok, nil} + end +end From 9964574d88c8afd2eeebd05b70a2e39b8f765410 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 8 Jan 2025 14:45:27 +0100 Subject: [PATCH 3/8] Implement accepting team invite within the existing flow --- .../site/memberships/accept_invitation.ex | 13 +++++- lib/plausible/teams/invitations.ex | 41 +++++++++++++++---- .../teams/invitations/accept_team_invite.ex | 9 ---- lib/plausible/teams/users.ex | 13 ++++++ .../controllers/invitation_controller.ex | 5 +++ lib/plausible_web/email.ex | 19 +++++++-- ...ex => guest_invitation_accepted.html.heex} | 0 .../email/team_invitation_accepted.html.heex | 2 + 8 files changed, 80 insertions(+), 22 deletions(-) delete mode 100644 lib/plausible/teams/invitations/accept_team_invite.ex rename lib/plausible_web/templates/email/{invitation_accepted.html.heex => guest_invitation_accepted.html.heex} (100%) create mode 100644 lib/plausible_web/templates/email/team_invitation_accepted.html.heex diff --git a/lib/plausible/site/memberships/accept_invitation.ex b/lib/plausible/site/memberships/accept_invitation.ex index 1d4618becdd6..528fd7667ebd 100644 --- a/lib/plausible/site/memberships/accept_invitation.ex +++ b/lib/plausible/site/memberships/accept_invitation.ex @@ -28,6 +28,7 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do @type accept_error() :: :invitation_not_found + | :already_other_team_member | Billing.Quota.Limits.over_limits_error() | Ecto.Changeset.t() | :no_plan @@ -111,6 +112,16 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do end defp do_accept_team_invitation(team_invitation, user) do - Teams.Invitations.AcceptTeamInvite.accept(team_invitation, user) + with :ok <- ensure_no_team_membership(team_invitation.team, user) do + Teams.Invitations.accept_team_invitation(team_invitation, user) + end + end + + defp ensure_no_team_membership(team, user) do + if Teams.Users.team_member?(user, except: [team.id]) do + {:error, :already_other_team_member} + else + :ok + end end end diff --git a/lib/plausible/teams/invitations.ex b/lib/plausible/teams/invitations.ex index 36c2cad889ae..10e932306bef 100644 --- a/lib/plausible/teams/invitations.ex +++ b/lib/plausible/teams/invitations.ex @@ -192,11 +192,14 @@ defmodule Plausible.Teams.Invitations do now = NaiveDateTime.utc_now(:second) - with {:ok, team_membership} <- - do_accept(team_invitation, user, now, guest_invitations: [guest_invitation]) do - prune_guest_invitations(team_invitation.team) - {:ok, team_membership} - end + do_accept(team_invitation, user, now, guest_invitations: [guest_invitation]) + end + + def accept_team_invitation(team_invitation, user) do + team_invitation = Repo.preload(team_invitation, [:team, :inviter]) + now = NaiveDateTime.utc_now(:second) + + do_accept(team_invitation, user, now, guest_invitations: []) end @doc false @@ -262,8 +265,17 @@ defmodule Plausible.Teams.Invitations do # Clean up guest invitations after accepting guest_invitation_ids = Enum.map(guest_invitations, & &1.id) Repo.delete_all(from gi in Teams.GuestInvitation, where: gi.id in ^guest_invitation_ids) + + if team_membership.role != :guest do + Repo.delete_all(from ti in Teams.Invitation, where: ti.id == ^team_invitation.id) + end + prune_guest_invitations(team_invitation.team) + # Prune guest memberships if any exist when team membership role + # is other than guest + maybe_prune_guest_memberships(team_membership) + if send_email? do send_invitation_accepted_email(team_invitation, guest_invitations) end @@ -275,6 +287,16 @@ defmodule Plausible.Teams.Invitations do end) end + defp maybe_prune_guest_memberships(%Teams.Membership{role: :guest}), do: :ok + + defp maybe_prune_guest_memberships(%Teams.Membership{} = team_membership) do + team_membership + |> Ecto.assoc(:guest_memberships) + |> Repo.delete_all() + + :ok + end + defp transfer_site_ownership(site, team, now) do site = Repo.preload(site, [ @@ -643,14 +665,15 @@ defmodule Plausible.Teams.Invitations do end) end - defp send_invitation_accepted_email(_team_invitation, []) do - # NOOP for now - :ok + defp send_invitation_accepted_email(team_invitation, []) do + team_invitation.inviter.email + |> PlausibleWeb.Email.team_invitation_accepted(team_invitation.email, team_invitation.team) + |> Plausible.Mailer.send() end defp send_invitation_accepted_email(team_invitation, [guest_invitation | _]) do team_invitation.inviter.email - |> PlausibleWeb.Email.invitation_accepted(team_invitation.email, guest_invitation.site) + |> PlausibleWeb.Email.guest_invitation_accepted(team_invitation.email, guest_invitation.site) |> Plausible.Mailer.send() end diff --git a/lib/plausible/teams/invitations/accept_team_invite.ex b/lib/plausible/teams/invitations/accept_team_invite.ex deleted file mode 100644 index 3a07937e90ea..000000000000 --- a/lib/plausible/teams/invitations/accept_team_invite.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Plausible.Teams.Invitations.AcceptTeamInvite do - @moduledoc """ - Service for accepting a team invite. - """ - - def accept(_team_invitation, _user) do - {:ok, nil} - end -end diff --git a/lib/plausible/teams/users.ex b/lib/plausible/teams/users.ex index e870127268cb..bd2144bde886 100644 --- a/lib/plausible/teams/users.ex +++ b/lib/plausible/teams/users.ex @@ -8,6 +8,19 @@ defmodule Plausible.Teams.Users do alias Plausible.Repo alias Plausible.Teams + def team_member?(user, opts \\ []) do + excluded_team_ids = Keyword.get(opts, :except, []) + + Repo.exists?( + from( + tm in Teams.Membership, + where: tm.user_id == ^user.id, + where: tm.role != :guest, + where: tm.team_id not in ^excluded_team_ids + ) + ) + end + def has_sites?(user, opts \\ []) do include_pending? = Keyword.get(opts, :include_pending?, false) diff --git a/lib/plausible_web/controllers/invitation_controller.ex b/lib/plausible_web/controllers/invitation_controller.ex index 4fc675e49bb8..34a45b05a500 100644 --- a/lib/plausible_web/controllers/invitation_controller.ex +++ b/lib/plausible_web/controllers/invitation_controller.ex @@ -27,6 +27,11 @@ defmodule PlausibleWeb.InvitationController do |> put_flash(:error, "Invitation missing or already accepted") |> redirect(to: "/sites") + {:error, :already_other_team_member} -> + conn + |> put_flash(:error, "You already are a team member in another team") + |> redirect(to: "/sites") + {:error, :no_plan} -> conn |> put_flash(:error, "No existing subscription") diff --git a/lib/plausible_web/email.ex b/lib/plausible_web/email.ex index aa5a9ad9f843..260c5f23cc08 100644 --- a/lib/plausible_web/email.ex +++ b/lib/plausible_web/email.ex @@ -299,19 +299,32 @@ defmodule PlausibleWeb.Email do ) end - def invitation_accepted(inviter_email, invitee_email, site) do + def guest_invitation_accepted(inviter_email, invitee_email, site) do priority_email() |> to(inviter_email) - |> tag("invitation-accepted") + |> tag("guest-invitation-accepted") |> subject( "[#{Plausible.product_name()}] #{invitee_email} accepted your invitation to #{site.domain}" ) - |> render("invitation_accepted.html", + |> render("guest_invitation_accepted.html", invitee_email: invitee_email, site: site ) end + def team_invitation_accepted(inviter_email, invitee_email, team) do + priority_email() + |> to(inviter_email) + |> tag("team-invitation-accepted") + |> subject( + "[#{Plausible.product_name()}] #{invitee_email} accepted your invitation to \"#{team.name}\" team" + ) + |> render("team_invitation_accepted.html", + invitee_email: invitee_email, + team: team + ) + end + def guest_invitation_rejected(guest_invitation) do priority_email() |> to(guest_invitation.team_invitation.inviter.email) diff --git a/lib/plausible_web/templates/email/invitation_accepted.html.heex b/lib/plausible_web/templates/email/guest_invitation_accepted.html.heex similarity index 100% rename from lib/plausible_web/templates/email/invitation_accepted.html.heex rename to lib/plausible_web/templates/email/guest_invitation_accepted.html.heex diff --git a/lib/plausible_web/templates/email/team_invitation_accepted.html.heex b/lib/plausible_web/templates/email/team_invitation_accepted.html.heex new file mode 100644 index 000000000000..d608ac8a8ca0 --- /dev/null +++ b/lib/plausible_web/templates/email/team_invitation_accepted.html.heex @@ -0,0 +1,2 @@ +<%= @invitee_email %> has accepted your invitation to "<%= @team.name %>" team. +Click here to view team settings. From 1b26cd75245bca4ab39e2103401e474d41d7518b Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 8 Jan 2025 15:40:21 +0100 Subject: [PATCH 4/8] [WIP] Test accepting team invites --- .../memberships/accept_invitation_test.exs | 64 ++++++++++++++++++- test/support/teams/test.ex | 2 +- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/test/plausible/site/memberships/accept_invitation_test.exs b/test/plausible/site/memberships/accept_invitation_test.exs index 2c84c16ae2a2..e678ad8db834 100644 --- a/test/plausible/site/memberships/accept_invitation_test.exs +++ b/test/plausible/site/memberships/accept_invitation_test.exs @@ -103,7 +103,69 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do end end - describe "accept_invitation/3 - invitations" do + describe "accept_invitation/3 - team invitations" do + test "converts an invitation into a membership" do + inviter = new_user() + invitee = new_user() + _site = new_site(owner: inviter) + team = team_of(inviter) + + invitation = invite_member(team, invitee, inviter: inviter, role: :editor) + + assert {:ok, _} = + AcceptInvitation.accept_invitation(invitation.invitation_id, invitee) + + assert_team_membership(invitee, team, :editor) + + assert_email_delivered_with( + to: [nil: inviter.email], + subject: + @subject_prefix <> "#{invitee.email} accepted your invitation to \"#{team.name}\" team" + ) + + refute Repo.reload(invitation) + end + + test "does not degrade role when trying to invite self as an owner" do + user = new_user() + _site = new_site(owner: user) + team = team_of(user) + + invitation = invite_member(team, user, inviter: user, role: :editor) + + assert {:ok, _} = + AcceptInvitation.accept_invitation(invitation.invitation_id, user) + + assert_team_membership(user, team, :owner) + + refute Repo.reload(invitation) + end + + test "handles accepting invitation as already a member gracefully" do + inviter = new_user() + invitee = new_user() + _site = new_site(owner: inviter) + team = team_of(inviter) + add_member(team, user: invitee, role: :editor) + + existing_team_membership = + Plausible.Teams.Membership + |> Repo.get_by(user_id: invitee.id) + + invitation = invite_member(team, invitee, inviter: inviter, role: :viewer) + + assert {:ok, %{team_membership: new_team_membership, guest_memberships: []}} = + AcceptInvitation.accept_invitation(invitation.invitation_id, invitee) + + new_team_membership = Repo.reload!(new_team_membership) + assert existing_team_membership.id == new_team_membership.id + assert existing_team_membership.user_id == new_team_membership.user_id + assert new_team_membership.role == :editor + refute Repo.reload(invitation) + end + end + + describe "accept_invitation/3 - guest invitations" do test "converts an invitation into a membership" do inviter = new_user() invitee = new_user() diff --git a/test/support/teams/test.ex b/test/support/teams/test.ex index ef420db9ad1c..0ed359fc06d9 100644 --- a/test/support/teams/test.ex +++ b/test/support/teams/test.ex @@ -281,7 +281,7 @@ defmodule Plausible.Teams.Test do end def assert_team_membership(user, team, role \\ :owner) do - if role == :owner do + if role != :guest do assert membership = Repo.get_by(Teams.Membership, team_id: team.id, From 60cdc055584196563c437953cc79f4a78bf876aa Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 9 Jan 2025 10:15:44 +0100 Subject: [PATCH 5/8] Clean up and fix team member assertions --- .../memberships/accept_invitation_test.exs | 2 +- .../site/membership_controller_test.exs | 10 +++--- test/support/teams/test.ex | 33 +++++-------------- 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/test/plausible/site/memberships/accept_invitation_test.exs b/test/plausible/site/memberships/accept_invitation_test.exs index e678ad8db834..cd80546faa4f 100644 --- a/test/plausible/site/memberships/accept_invitation_test.exs +++ b/test/plausible/site/memberships/accept_invitation_test.exs @@ -176,7 +176,7 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do assert {:ok, _} = AcceptInvitation.accept_invitation(invitation.invitation_id, invitee) - assert_team_membership(invitee, site.team, :editor) + assert_guest_membership(site.team, site, invitee, :editor) assert_email_delivered_with( to: [nil: inviter.email], diff --git a/test/plausible_web/controllers/site/membership_controller_test.exs b/test/plausible_web/controllers/site/membership_controller_test.exs index e4a7df580157..45a73ed6e878 100644 --- a/test/plausible_web/controllers/site/membership_controller_test.exs +++ b/test/plausible_web/controllers/site/membership_controller_test.exs @@ -293,11 +293,11 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do test "updates a site member's role by user id", %{conn: conn, user: user} do site = new_site(owner: user) collaborator = add_guest(site, role: :editor) - assert_team_membership(collaborator, site.team, :editor) + assert_guest_membership(site.team, site, collaborator, :editor) put(conn, "/sites/#{site.domain}/memberships/u/#{collaborator.id}/role/viewer") - assert_team_membership(collaborator, site.team, :viewer) + assert_guest_membership(site.team, site, collaborator, :viewer) end test "can downgrade yourself from admin to viewer, redirects to stats", %{ @@ -323,7 +323,7 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do conn = put(conn, "/sites/#{site.domain}/memberships/u/#{admin.id}/role/owner") - assert_team_membership(user, site.team, :editor) + assert_guest_membership(site.team, site, user, :editor) assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You are not allowed to grant the owner role" @@ -356,7 +356,7 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do conn = put(conn, "/sites/#{site.domain}/memberships/u/#{viewer.id}/role/editor") - assert_team_membership(viewer, site.team, :editor) + assert_guest_membership(site.team, site, viewer, :editor) assert redirected_to(conn) == "/#{URI.encode_www_form(site.domain)}/settings/people" end @@ -366,7 +366,7 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do conn = put(conn, "/sites/#{site.domain}/memberships/u/#{user.id}/role/owner") - assert_team_membership(user, site.team, :editor) + assert_guest_membership(site.team, site, user, :editor) assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You are not allowed to grant the owner role" diff --git a/test/support/teams/test.ex b/test/support/teams/test.ex index 0ed359fc06d9..3e744fbdb965 100644 --- a/test/support/teams/test.ex +++ b/test/support/teams/test.ex @@ -281,31 +281,14 @@ defmodule Plausible.Teams.Test do end def assert_team_membership(user, team, role \\ :owner) do - if role != :guest do - assert membership = - Repo.get_by(Teams.Membership, - team_id: team.id, - user_id: user.id, - role: role - ) - - membership - else - assert team_membership = - Repo.get_by(Teams.Membership, - team_id: team.id, - user_id: user.id, - role: :guest - ) - - assert membership = - Repo.get_by(Teams.GuestMembership, - team_membership_id: team_membership.id, - role: role - ) - - membership - end + assert membership = + Repo.get_by(Teams.Membership, + team_id: team.id, + user_id: user.id, + role: role + ) + + membership end def assert_team_attached(site, team_id \\ nil) do From 6ac50eb1145af246277f4063cb84d5bce80204d1 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 9 Jan 2025 11:01:00 +0100 Subject: [PATCH 6/8] Rename predicate check in accept invitation flow --- lib/plausible/site/memberships/accept_invitation.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/plausible/site/memberships/accept_invitation.ex b/lib/plausible/site/memberships/accept_invitation.ex index 528fd7667ebd..b03ec97ec3a7 100644 --- a/lib/plausible/site/memberships/accept_invitation.ex +++ b/lib/plausible/site/memberships/accept_invitation.ex @@ -112,12 +112,12 @@ defmodule Plausible.Site.Memberships.AcceptInvitation do end defp do_accept_team_invitation(team_invitation, user) do - with :ok <- ensure_no_team_membership(team_invitation.team, user) do + with :ok <- ensure_no_other_team_membership(team_invitation.team, user) do Teams.Invitations.accept_team_invitation(team_invitation, user) end end - defp ensure_no_team_membership(team, user) do + defp ensure_no_other_team_membership(team, user) do if Teams.Users.team_member?(user, except: [team.id]) do {:error, :already_other_team_member} else From 5ac3de9be4105bf2b6fbe31625df47509b2ee65d Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 9 Jan 2025 11:01:36 +0100 Subject: [PATCH 7/8] Extend tests further --- .../memberships/accept_invitation_test.exs | 27 ++++++++---- .../memberships/reject_invitation_test.exs | 43 ++++++++++++++++++- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/test/plausible/site/memberships/accept_invitation_test.exs b/test/plausible/site/memberships/accept_invitation_test.exs index cd80546faa4f..329812d8d3cf 100644 --- a/test/plausible/site/memberships/accept_invitation_test.exs +++ b/test/plausible/site/memberships/accept_invitation_test.exs @@ -126,19 +126,28 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do refute Repo.reload(invitation) end - test "does not degrade role when trying to invite self as an owner" do - user = new_user() - _site = new_site(owner: user) - team = team_of(user) + @roles Plausible.Teams.Membership.roles() -- [:guest] + @roles_with_downgrades @roles + |> Enum.zip([nil] ++ @roles) + |> Enum.drop(1) - invitation = invite_member(team, user, inviter: user, role: :editor) + for {old_role, new_role} <- @roles_with_downgrades do + test "does not degrade role when trying to invite existing #{old_role} as a(n) #{new_role}" do + user = new_user() + _site = new_site(owner: user) + team = team_of(user) + member = add_member(team, role: unquote(old_role)) + team = team_of(user) - assert {:ok, _} = - AcceptInvitation.accept_invitation(invitation.invitation_id, user) + invitation = invite_member(team, member, inviter: user, role: unquote(new_role)) - assert_team_membership(user, team, :owner) + assert {:ok, _} = + AcceptInvitation.accept_invitation(invitation.invitation_id, member) - refute Repo.reload(invitation) + assert_team_membership(user, team, unquote(old_role)) + + refute Repo.reload(invitation) + end end test "handles accepting invitation as already a member gracefully" do diff --git a/test/plausible/site/memberships/reject_invitation_test.exs b/test/plausible/site/memberships/reject_invitation_test.exs index 2c436fea3f6e..3d17da6eadb2 100644 --- a/test/plausible/site/memberships/reject_invitation_test.exs +++ b/test/plausible/site/memberships/reject_invitation_test.exs @@ -8,7 +8,7 @@ defmodule Plausible.Site.Memberships.RejectInvitationTest do alias Plausible.Site.Memberships.RejectInvitation - test "rejects invitation and sends email to inviter" do + test "rejects guest invitation and sends email to inviter" do inviter = new_user() invitee = new_user() site = new_site(owner: inviter) @@ -27,6 +27,47 @@ defmodule Plausible.Site.Memberships.RejectInvitationTest do ) end + test "rejects team invitation and sends email to inviter" do + inviter = new_user() + invitee = new_user() + _site = new_site(owner: inviter) + team = team_of(inviter) + + invitation = invite_member(team, invitee, inviter: inviter, role: :editor) + + assert {:ok, rejected_invitation} = + RejectInvitation.reject_invitation(invitation.invitation_id, invitee) + + assert rejected_invitation.id == invitation.id + refute Repo.reload(rejected_invitation) + + assert_email_delivered_with( + to: [nil: inviter.email], + subject: + @subject_prefix <> "#{invitee.email} rejected your invitation to \"#{team.name}\" team" + ) + end + + test "rejects site transfer and sends email to inviter" do + inviter = new_user() + invitee = new_user() + site = new_site(owner: inviter) + + site_transfer = invite_transfer(site, invitee, inviter: inviter) + + assert {:ok, rejected_transfer} = + RejectInvitation.reject_invitation(site_transfer.transfer_id, invitee) + + assert rejected_transfer.id == site_transfer.id + refute Repo.reload(rejected_transfer) + + assert_email_delivered_with( + to: [nil: inviter.email], + subject: + @subject_prefix <> "#{invitee.email} rejected the ownership transfer of #{site.domain}" + ) + end + test "returns error for non-existent invitation" do invitee = new_user() From dd63736b3b1fcfa85bfd748061298bfcafc2d207 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 9 Jan 2025 11:36:32 +0100 Subject: [PATCH 8/8] Finish tests and fix guest -> team role upgrade --- lib/plausible/teams/invitations.ex | 20 ++++- .../memberships/accept_invitation_test.exs | 86 ++++++++++++------- 2 files changed, 76 insertions(+), 30 deletions(-) diff --git a/lib/plausible/teams/invitations.ex b/lib/plausible/teams/invitations.ex index 10e932306bef..9d3cd32d60d1 100644 --- a/lib/plausible/teams/invitations.ex +++ b/lib/plausible/teams/invitations.ex @@ -629,11 +629,29 @@ defmodule Plausible.Teams.Invitations do Plausible.Mailer.send(email) end + @team_role_type Plausible.Teams.Membership.__schema__(:type, :role) + defp create_team_membership(team, role, user, now) do + conflict_query = + from(tm in Teams.Membership, + update: [ + set: [ + updated_at: ^now, + role: + fragment( + "CASE WHEN ? = 'guest' THEN ? ELSE ? END", + tm.role, + type(^role, ^@team_role_type), + tm.role + ) + ] + ] + ) + team |> Teams.Membership.changeset(user, role) |> Repo.insert( - on_conflict: [set: [updated_at: now]], + on_conflict: conflict_query, conflict_target: [:team_id, :user_id], returning: true ) diff --git a/test/plausible/site/memberships/accept_invitation_test.exs b/test/plausible/site/memberships/accept_invitation_test.exs index 329812d8d3cf..2814e2f33556 100644 --- a/test/plausible/site/memberships/accept_invitation_test.exs +++ b/test/plausible/site/memberships/accept_invitation_test.exs @@ -104,29 +104,33 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do end describe "accept_invitation/3 - team invitations" do - test "converts an invitation into a membership" do - inviter = new_user() - invitee = new_user() - _site = new_site(owner: inviter) - team = team_of(inviter) + @roles Plausible.Teams.Membership.roles() -- [:guest] - invitation = invite_member(team, invitee, inviter: inviter, role: :editor) + for role <- @roles do + test "converts an invitation into a #{role} membership" do + inviter = new_user() + invitee = new_user() + _site = new_site(owner: inviter) + team = team_of(inviter) - assert {:ok, _} = - AcceptInvitation.accept_invitation(invitation.invitation_id, invitee) + invitation = invite_member(team, invitee, inviter: inviter, role: unquote(role)) - assert_team_membership(invitee, team, :editor) + assert {:ok, _} = + AcceptInvitation.accept_invitation(invitation.invitation_id, invitee) - assert_email_delivered_with( - to: [nil: inviter.email], - subject: - @subject_prefix <> "#{invitee.email} accepted your invitation to \"#{team.name}\" team" - ) + assert_team_membership(invitee, team, unquote(role)) - refute Repo.reload(invitation) + assert_email_delivered_with( + to: [nil: inviter.email], + subject: + @subject_prefix <> + "#{invitee.email} accepted your invitation to \"#{team.name}\" team" + ) + + refute Repo.reload(invitation) + end end - @roles Plausible.Teams.Membership.roles() -- [:guest] @roles_with_downgrades @roles |> Enum.zip([nil] ++ @roles) |> Enum.drop(1) @@ -137,39 +141,63 @@ defmodule Plausible.Site.Memberships.AcceptInvitationTest do _site = new_site(owner: user) team = team_of(user) member = add_member(team, role: unquote(old_role)) - team = team_of(user) + + existing_team_membership = + Plausible.Teams.Membership + |> Repo.get_by(user_id: member.id) invitation = invite_member(team, member, inviter: user, role: unquote(new_role)) - assert {:ok, _} = + assert {:ok, %{team_membership: new_team_membership, guest_memberships: []}} = AcceptInvitation.accept_invitation(invitation.invitation_id, member) - assert_team_membership(user, team, unquote(old_role)) - + new_team_membership = Repo.reload!(new_team_membership) + assert existing_team_membership.id == new_team_membership.id + assert existing_team_membership.user_id == new_team_membership.user_id + assert new_team_membership.role == unquote(old_role) refute Repo.reload(invitation) end end - test "handles accepting invitation as already a member gracefully" do - inviter = new_user() - invitee = new_user() - _site = new_site(owner: inviter) - team = team_of(inviter) - add_member(team, user: invitee, role: :editor) + for role <- @roles do + test "does not allow accepting invite by a member of another team (role: #{role})" do + user = new_user() + _site = new_site(owner: user) + team = team_of(user) + another_site = new_site() + member = add_member(another_site.team, role: unquote(role)) + + invitation = invite_member(team, member, inviter: user, role: unquote(role)) + + assert {:error, :already_other_team_member} = + AcceptInvitation.accept_invitation(invitation.invitation_id, member) + end + end + + test "prunes guest memberships when promoting guest membership to full team membership" do + user = new_user() + member = new_user() + site1 = new_site(owner: user) + site2 = new_site(owner: user) + team = team_of(user) + + add_guest(site1, user: member, role: :viewer) + add_guest(site2, user: member, role: :editor) existing_team_membership = Plausible.Teams.Membership - |> Repo.get_by(user_id: invitee.id) + |> Repo.get_by(user_id: member.id) - invitation = invite_member(team, invitee, inviter: inviter, role: :viewer) + invitation = invite_member(team, member, inviter: user, role: :editor) assert {:ok, %{team_membership: new_team_membership, guest_memberships: []}} = - AcceptInvitation.accept_invitation(invitation.invitation_id, invitee) + AcceptInvitation.accept_invitation(invitation.invitation_id, member) new_team_membership = Repo.reload!(new_team_membership) assert existing_team_membership.id == new_team_membership.id assert existing_team_membership.user_id == new_team_membership.user_id assert new_team_membership.role == :editor + assert [] = Repo.preload(new_team_membership, :guest_memberships).guest_memberships refute Repo.reload(invitation) end end