diff --git a/lib/plausible/teams/invitation.ex b/lib/plausible/teams/invitation.ex index 5a5722a09fd9..71d8a977b19c 100644 --- a/lib/plausible/teams/invitation.ex +++ b/lib/plausible/teams/invitation.ex @@ -7,12 +7,14 @@ defmodule Plausible.Teams.Invitation do import Ecto.Changeset + @roles Plausible.Teams.Membership.roles() + @type t() :: %__MODULE__{} schema "team_invitations" do field :invitation_id, :string field :email, :string - field :role, Ecto.Enum, values: [:guest, :viewer, :editor, :admin, :owner] + field :role, Ecto.Enum, values: @roles belongs_to :inviter, Plausible.Auth.User belongs_to :team, Plausible.Teams.Team @@ -22,6 +24,8 @@ defmodule Plausible.Teams.Invitation do timestamps() end + def roles(), do: @roles + def changeset(team, opts) do email = Keyword.fetch!(opts, :email) role = Keyword.fetch!(opts, :role) diff --git a/lib/plausible/teams/invitations.ex b/lib/plausible/teams/invitations.ex index ff064e001092..9f5129ca12f3 100644 --- a/lib/plausible/teams/invitations.ex +++ b/lib/plausible/teams/invitations.ex @@ -87,7 +87,11 @@ defmodule Plausible.Teams.Invitations do end end - def invite(site, invitee_email, role, inviter) do + def invite(%Teams.Team{} = team, invitee_email, role, inviter) do + create_team_invitation(team, invitee_email, inviter, role: role) + end + + def invite(%Plausible.Site{} = site, invitee_email, role, inviter) do site = Teams.load_for_site(site) if role == :owner do @@ -376,7 +380,27 @@ defmodule Plausible.Teams.Invitations do end @doc false - def check_invitation_permissions(site, inviter, invitation_role, opts) do + def check_invitation_permissions(%Teams.Team{} = team, inviter, invitation_role, opts) do + check_permissions? = Keyword.get(opts, :check_permissions, true) + + if check_permissions? do + case Teams.Memberships.team_role(team, inviter) do + {:ok, :owner} when invitation_role == :owner -> + :ok + + {:ok, inviter_role} + when inviter_role in [:owner, :admin] and invitation_role != :owner -> + :ok + + _ -> + {:error, :forbidden} + end + else + :ok + end + end + + def check_invitation_permissions(%Plausible.Site{} = site, inviter, invitation_role, opts) do check_permissions? = Keyword.get(opts, :check_permissions, true) if check_permissions? do @@ -411,11 +435,19 @@ defmodule Plausible.Teams.Invitations do end @doc false - def ensure_new_membership(_site, nil, _role), do: :ok + def ensure_new_membership(_site_or_team, nil, _role), do: :ok + + def ensure_new_membership(_site_or_team, _invitee, :owner), do: :ok - def ensure_new_membership(_site, _invitee, :owner), do: :ok + def ensure_new_membership(%Teams.Team{} = team, invitee, _role) do + case Teams.Memberships.team_role(team, invitee) do + {:ok, :guest} -> :ok + {:error, :not_a_member} -> :ok + {:ok, _} -> {:error, :already_a_member} + end + end - def ensure_new_membership(site, invitee, _role) do + def ensure_new_membership(%Plausible.Site{} = site, invitee, _role) do if Teams.Memberships.site_role(site, invitee) == {:error, :not_a_member} do :ok else @@ -439,12 +471,13 @@ defmodule Plausible.Teams.Invitations do defp create_team_invitation(team, invitee_email, inviter, opts \\ []) do now = NaiveDateTime.utc_now(:second) + role = Keyword.get(opts, :role, :guest) result = Ecto.Multi.new() |> Ecto.Multi.put( :changeset, - Teams.Invitation.changeset(team, email: invitee_email, role: :guest, inviter: inviter) + Teams.Invitation.changeset(team, email: invitee_email, role: role, inviter: inviter) ) |> Ecto.Multi.run(:ensure_no_site_transfers, fn _repo, %{changeset: changeset} -> ensure_no_site_transfers(changeset, opts[:ensure_no_site_transfers_for], invitee_email) @@ -452,10 +485,19 @@ defmodule Plausible.Teams.Invitations do |> Ecto.Multi.insert( :team_invitation, & &1.changeset, - on_conflict: [set: [updated_at: now]], + on_conflict: [set: [updated_at: now, role: role]], conflict_target: [:team_id, :email], returning: true ) + |> Ecto.Multi.run(:prune_guest_entries, fn _repo, %{team_invitation: team_invitation} -> + if team_invitation.role != :guest do + team_invitation + |> Ecto.assoc(:guest_invitations) + |> Repo.delete_all() + end + + {:ok, nil} + end) |> Repo.transaction() case result do @@ -493,6 +535,26 @@ defmodule Plausible.Teams.Invitations do Plausible.Mailer.send(email) end + def send_invitation_email(%Teams.Invitation{} = team_invitation, invitee) do + email = + if invitee do + PlausibleWeb.Email.existing_user_team_invitation( + team_invitation.email, + team_invitation.team, + team_invitation.inviter + ) + else + PlausibleWeb.Email.new_user_team_invitation( + team_invitation.email, + team_invitation.invitation_id, + team_invitation.team, + team_invitation.inviter + ) + end + + Plausible.Mailer.send(email) + end + def send_invitation_email(%Teams.GuestInvitation{} = guest_invitation, invitee) do team_invitation = guest_invitation.team_invitation diff --git a/lib/plausible/teams/invitations/invite_to_team.ex b/lib/plausible/teams/invitations/invite_to_team.ex new file mode 100644 index 000000000000..c20dddcab540 --- /dev/null +++ b/lib/plausible/teams/invitations/invite_to_team.ex @@ -0,0 +1,53 @@ +defmodule Plausible.Teams.Invitations.InviteToTeam do + @moduledoc """ + Service for inviting new or existing users to team. + """ + + alias Plausible.Teams + alias Plausible.Repo + + @valid_roles Plausible.Teams.Invitation.roles() -- [:guest] + @valid_roles @valid_roles ++ Enum.map(@valid_roles, &to_string/1) + + def invite(team, inviter, invitee_email, role, opts \\ []) + + def invite(team, inviter, invitee_email, role, opts) when role in @valid_roles do + with team <- Repo.preload(team, [:owner]), + :ok <- + Teams.Invitations.check_invitation_permissions( + team, + inviter, + role, + opts + ), + :ok <- + Teams.Invitations.check_team_member_limit( + team, + role, + invitee_email + ), + invitee = Plausible.Auth.find_user_by(email: invitee_email), + :ok <- + Teams.Invitations.ensure_new_membership( + team, + invitee, + role + ), + {:ok, invitation} <- + Teams.Invitations.invite(team, invitee_email, role, inviter) do + send_invitation_email(invitation, invitee) + + {:ok, invitation} + end + end + + def invite(_team, _inviter, _invitee_email, role, _opts) do + raise "Invalid role passed: #{inspect(role)}" + end + + defp send_invitation_email(invitation, invitee) do + invitation + |> Repo.preload([:team, :inviter]) + |> Teams.Invitations.send_invitation_email(invitee) + end +end diff --git a/lib/plausible/teams/membership.ex b/lib/plausible/teams/membership.ex index afc740565def..efb4f7def3f6 100644 --- a/lib/plausible/teams/membership.ex +++ b/lib/plausible/teams/membership.ex @@ -7,10 +7,12 @@ defmodule Plausible.Teams.Membership do import Ecto.Changeset + @roles [:guest, :viewer, :editor, :admin, :owner] + @type t() :: %__MODULE__{} schema "team_memberships" do - field :role, Ecto.Enum, values: [:guest, :viewer, :editor, :admin, :owner] + field :role, Ecto.Enum, values: @roles belongs_to :user, Plausible.Auth.User belongs_to :team, Plausible.Teams.Team @@ -20,6 +22,8 @@ defmodule Plausible.Teams.Membership do timestamps() end + def roles(), do: @roles + def changeset(team, user, role) do %__MODULE__{} |> change() diff --git a/lib/plausible_web/email.ex b/lib/plausible_web/email.ex index 1a38fb8c7e9f..af0bc5f8a1da 100644 --- a/lib/plausible_web/email.ex +++ b/lib/plausible_web/email.ex @@ -263,6 +263,29 @@ defmodule PlausibleWeb.Email do ) end + def new_user_team_invitation(email, invitation_id, team, inviter) do + priority_email() + |> to(email) + |> tag("new-user-team-invitation") + |> subject("[#{Plausible.product_name()}] You've been invited to \"#{team.name}\" team") + |> render("new_user_team_invitation.html", + invitation_id: invitation_id, + team: team, + inviter: inviter + ) + end + + def existing_user_team_invitation(email, team, inviter) do + priority_email() + |> to(email) + |> tag("existing-user-team-invitation") + |> subject("[#{Plausible.product_name()}] You've been invited to \"#{team.name}\" team") + |> render("existing_user_team_invitation.html", + team: team, + inviter: inviter + ) + end + def ownership_transfer_request(email, invitation_id, site, inviter, new_owner_account) do priority_email() |> to(email) diff --git a/lib/plausible_web/live/register_form.ex b/lib/plausible_web/live/register_form.ex index dc7a4aea79cc..60a9ba0bdce3 100644 --- a/lib/plausible_web/live/register_form.ex +++ b/lib/plausible_web/live/register_form.ex @@ -284,7 +284,7 @@ defmodule PlausibleWeb.Live.RegisterForm do |> Map.put("email", invitation.email) |> Auth.User.new() - with_team? = invitation.role == :owner + with_team? = invitation.type == :site_transfer add_user(socket, user, with_team?: with_team?) else @@ -349,6 +349,8 @@ defmodule PlausibleWeb.Live.RegisterForm do defp find_by_id_unified(invitation_or_transfer_id) do result = with {:error, :invitation_not_found} <- + find_team_invitation_by_id_unified(invitation_or_transfer_id), + {:error, :invitation_not_found} <- find_invitation_by_id_unified(invitation_or_transfer_id) do find_transfer_by_id_unified(invitation_or_transfer_id) end @@ -359,6 +361,25 @@ defmodule PlausibleWeb.Live.RegisterForm do end end + defp find_team_invitation_by_id_unified(id) do + invitation = + Teams.Invitation + |> Repo.get_by(invitation_id: id) + |> Repo.preload(:inviter) + + case invitation do + nil -> + {:error, :invitation_not_found} + + team_invitation -> + {:ok, + %{ + type: :team_invitation, + email: team_invitation.email + }} + end + end + defp find_invitation_by_id_unified(id) do invitation = Teams.GuestInvitation @@ -372,7 +393,7 @@ defmodule PlausibleWeb.Live.RegisterForm do guest_invitation -> {:ok, %{ - role: guest_invitation.role, + type: :guest_invitation, email: guest_invitation.team_invitation.email }} end @@ -391,7 +412,7 @@ defmodule PlausibleWeb.Live.RegisterForm do transfer -> {:ok, %{ - role: :owner, + type: :site_transfer, email: transfer.email }} end diff --git a/lib/plausible_web/templates/email/existing_user_team_invitation.html.heex b/lib/plausible_web/templates/email/existing_user_team_invitation.html.heex new file mode 100644 index 000000000000..bde0c94b911b --- /dev/null +++ b/lib/plausible_web/templates/email/existing_user_team_invitation.html.heex @@ -0,0 +1,3 @@ +<%= @inviter.email %> has invited you to the "<%= @team.name %>" team on <%= Plausible.product_name() %>. +Click here to view and respond to the invitation. The invitation +will expire 48 hours after this email is sent. diff --git a/lib/plausible_web/templates/email/new_user_team_invitation.html.heex b/lib/plausible_web/templates/email/new_user_team_invitation.html.heex new file mode 100644 index 000000000000..c6e8a0122ce2 --- /dev/null +++ b/lib/plausible_web/templates/email/new_user_team_invitation.html.heex @@ -0,0 +1,10 @@ +<%= @inviter.email %> has invited you to join the "<%= @team.name %>" team on <%= Plausible.product_name() %>. +Click here to create your account. The link is valid for 48 hours after this email is sent. +

+Plausible is a lightweight and open-source website analytics tool. We hope you like our simple and ethical approach to tracking website visitors. diff --git a/test/plausible/teams/invitations/invite_to_team_test.exs b/test/plausible/teams/invitations/invite_to_team_test.exs new file mode 100644 index 000000000000..e75d944eaa78 --- /dev/null +++ b/test/plausible/teams/invitations/invite_to_team_test.exs @@ -0,0 +1,220 @@ +defmodule Plausible.Teams.Invitations.InviteToTeamTest do + use Plausible.DataCase + use Plausible + use Bamboo.Test + use Plausible.Teams.Test + + alias Plausible.Repo + alias Plausible.Teams + alias Plausible.Teams.Invitations.InviteToTeam + + @subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] " + + describe "invite/4,5" do + for role <- Teams.Invitation.roles() -- [:guest] do + test "creates an invitation with role #{role} for existing user" do + inviter = new_user() + invitee = new_user() + _site = new_site(owner: inviter) + team = team_of(inviter) + + assert {:ok, %Plausible.Teams.Invitation{} = team_invitation} = + InviteToTeam.invite(team, inviter, invitee.email, unquote(role)) + + assert team_invitation.team_id == team.id + assert team_invitation.role == unquote(role) + assert team_invitation.email == invitee.email + assert team_invitation.inviter_id == inviter.id + assert is_binary(team_invitation.invitation_id) + assert [] = Repo.preload(team_invitation, :guest_invitations).guest_invitations + + assert_email_delivered_with( + to: [nil: invitee.email], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" + ) + end + end + + for role <- Teams.Invitation.roles() -- [:guest] do + test "creates an invitation with role #{role} for new user" do + inviter = new_user() + invitee = build(:user) + _site = new_site(owner: inviter) + team = team_of(inviter) + + assert {:ok, %Plausible.Teams.Invitation{} = team_invitation} = + InviteToTeam.invite(team, inviter, invitee.email, unquote(role)) + + assert team_invitation.team_id == team.id + assert team_invitation.role == unquote(role) + assert team_invitation.email == invitee.email + assert team_invitation.inviter_id == inviter.id + assert is_binary(team_invitation.invitation_id) + assert [] = Repo.preload(team_invitation, :guest_invitations).guest_invitations + + assert_email_delivered_with( + to: [nil: invitee.email], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team", + html_body: ~r/#{team_invitation.invitation_id}/ + ) + end + end + + for role <- Enum.map(Teams.Invitation.roles() -- [:guest], &to_string/1) do + test "creates an invitation with role #{role} as a string" do + inviter = new_user() + invitee = new_user() + _site = new_site(owner: inviter) + team = team_of(inviter) + + assert {:ok, %Plausible.Teams.Invitation{} = team_invitation} = + InviteToTeam.invite(team, inviter, invitee.email, unquote(role)) + + assert team_invitation.team_id == team.id + assert team_invitation.role == unquote(String.to_existing_atom(role)) + assert team_invitation.email == invitee.email + assert team_invitation.inviter_id == inviter.id + assert is_binary(team_invitation.invitation_id) + assert [] = Repo.preload(team_invitation, :guest_invitations).guest_invitations + + assert_email_delivered_with( + to: [nil: invitee.email], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" + ) + end + end + + test "crashes on attempt to invite guest on a team level" do + inviter = new_user() + invitee = new_user() + _site = new_site(owner: inviter) + team = team_of(inviter) + + assert_raise RuntimeError, ~r/Invalid role passed/, fn -> + InviteToTeam.invite(team, inviter, invitee.email, :guest) + end + + assert_raise RuntimeError, ~r/Invalid role passed/, fn -> + InviteToTeam.invite(team, inviter, invitee.email, "guest") + end + end + + test "overwrites existing invitation" do + inviter = new_user() + invitee = new_user() + _site = new_site(owner: inviter) + team = team_of(inviter) + existing_invitation = invite_member(team, invitee.email, role: :viewer, inviter: inviter) + + assert {:ok, team_invitation} = + InviteToTeam.invite(team, inviter, invitee.email, :editor) + + assert team_invitation.id == existing_invitation.id + assert team_invitation.team_id == existing_invitation.team_id + assert team_invitation.email == invitee.email + assert team_invitation.role == :editor + end + + test "overwrites existing guest invitation and prunes guest invitation entries" do + inviter = new_user() + invitee = new_user() + site = new_site(owner: inviter) + team = team_of(inviter) + existing_invitation = invite_guest(site, invitee.email, role: :viewer, inviter: inviter) + + assert {:ok, team_invitation} = + InviteToTeam.invite(team, inviter, invitee.email, :viewer) + + assert team_invitation.id == existing_invitation.team_invitation.id + assert team_invitation.team_id == existing_invitation.team_invitation.team_id + assert team_invitation.email == invitee.email + assert team_invitation.role == :viewer + assert [] = Repo.preload(team_invitation, :guest_invitations).guest_invitations + end + + test "returns validation errors" do + inviter = new_user() + _site = new_site(owner: inviter) + team = team_of(inviter) + + assert {:error, changeset} = InviteToTeam.invite(team, inviter, "", :viewer) + assert {"can't be blank", _} = changeset.errors[:email] + end + + for role <- Teams.Invitation.roles() -- [:guest] do + test "returns error when existing user is already a member (role #{role})" do + inviter = new_user() + invitee = new_user() + _site = new_site(owner: inviter) + team = team_of(inviter) + add_member(team, user: invitee, role: unquote(role)) + + assert {:error, :already_a_member} = + InviteToTeam.invite(team, inviter, invitee.email, :editor) + end + end + + test "succeeds when existing user is only a guest member" do + inviter = new_user() + invitee = new_user() + site = new_site(owner: inviter) + team = team_of(inviter) + add_guest(site, user: invitee, role: :viewer) + + assert {:ok, _team_invitation} = + InviteToTeam.invite(team, inviter, invitee.email, :viewer) + end + + @tag :ee_only + test "returns error when owner is over their team member limit" do + [owner, inviter, invitee] = for _ <- 1..3, do: new_user() + _site = new_site(owner: owner) + team = team_of(owner) + inviter = add_member(team, user: inviter, role: :admin) + for _ <- 1..2, do: add_member(team, role: :viewer) + + assert {:error, {:over_limit, 3}} = + InviteToTeam.invite(team, inviter, invitee.email, :viewer) + end + + @tag :ee_only + test "allows creating an ownership transfer even when at team member limit" do + inviter = new_user() + invitee = build(:user) + _site = new_site(owner: inviter) + team = team_of(inviter) + for _ <- 1..3, do: add_member(team, role: :viewer) + + assert {:ok, _team_invitation} = + InviteToTeam.invite(team, inviter, invitee.email, :owner) + end + + for role <- Teams.Invitation.roles() -- [:guest, :owner] do + test "allows admins to invite new members except owners (invite role: #{role})" do + owner = new_user() + inviter = new_user() + invitee = build(:user) + _site = new_site(owner: owner) + team = team_of(owner) + add_member(team, user: inviter, role: :admin) + + assert {:ok, _team_invitation} = + InviteToTeam.invite(team, inviter, invitee.email, unquote(role)) + end + end + + for role <- Teams.Invitation.roles() -- [:owner] do + test "only allows owners to invite new owners (inviter role: #{role})" do + owner = new_user() + inviter = new_user() + invitee = build(:user) + _site = new_site(owner: owner) + team = team_of(owner) + add_member(team, user: inviter, role: unquote(role)) + + assert {:error, :forbidden} = + InviteToTeam.invite(team, inviter, invitee.email, :owner) + end + end + end +end diff --git a/test/plausible_web/live/register_form_test.exs b/test/plausible_web/live/register_form_test.exs index b4b05037ba79..d500e89305e4 100644 --- a/test/plausible_web/live/register_form_test.exs +++ b/test/plausible_web/live/register_form_test.exs @@ -143,16 +143,16 @@ defmodule PlausibleWeb.Live.RegisterFormTest do inviter = new_user() site = new_site(owner: inviter) - invitation = + guest_invitation = invite_guest(site, "user@email.co", role: :editor, inviter: inviter) - {:ok, %{site: site, invitation: invitation, inviter: inviter}} + {:ok, %{site: site, guest_invitation: guest_invitation, inviter: inviter}} end - test "registers user from invitation", %{conn: conn, invitation: invitation} do + test "registers user from guest invitation", %{conn: conn, guest_invitation: guest_invitation} do mock_captcha_success() - lv = get_liveview(conn, "/register/invitation/#{invitation.invitation_id}") + lv = get_liveview(conn, "/register/invitation/#{guest_invitation.invitation_id}") type_into_input(lv, "user[name]", "Mary Sue") type_into_input(lv, "user[password]", "very-long-and-very-secret-123") @@ -192,6 +192,43 @@ defmodule PlausibleWeb.Live.RegisterFormTest do assert String.length(password_hash) > 0 end + test "registers user from team invitation", %{conn: conn, inviter: inviter} do + mock_captcha_success() + + team = team_of(inviter) + + team_invitation = + invite_member(team, "team-user@email.co", role: :editor, inviter: inviter) + + lv = get_liveview(conn, "/register/invitation/#{team_invitation.invitation_id}") + + type_into_input(lv, "user[name]", "Mary Sue") + type_into_input(lv, "user[password]", "very-long-and-very-secret-123") + type_into_input(lv, "user[password_confirmation]", "very-long-and-very-secret-123") + + html = lv |> element("form") |> render_submit() + + on_ee do + assert_push_event(lv, "send-metrics", %{event_name: "Signup via invitation"}) + end + + assert [ + csrf_input, + action_input, + email_input, + name_input, + password_input, + password_confirmation_input | _ + ] = find(html, "input") + + assert String.length(text_of_attr(csrf_input, "value")) > 0 + assert text_of_attr(action_input, "value") == "register_from_invitation_form" + assert text_of_attr(name_input, "value") == "Mary Sue" + assert text_of_attr(email_input, "value") == "team-user@email.co" + assert text_of_attr(password_input, "value") == "very-long-and-very-secret-123" + assert text_of_attr(password_confirmation_input, "value") == "very-long-and-very-secret-123" + end + test "preserves trial_expiry_date when invitation role is :owner", %{ conn: conn, site: site, @@ -199,9 +236,9 @@ defmodule PlausibleWeb.Live.RegisterFormTest do } do mock_captcha_success() - invitation = invite_transfer(site, "owner_user@email.co", inviter: inviter) + site_transfer = invite_transfer(site, "owner_user@email.co", inviter: inviter) - lv = get_liveview(conn, "/register/invitation/#{invitation.transfer_id}") + lv = get_liveview(conn, "/register/invitation/#{site_transfer.transfer_id}") type_into_input(lv, "user[name]", "Mary Sue") type_into_input(lv, "user[password]", "very-long-and-very-secret-123") @@ -214,10 +251,13 @@ defmodule PlausibleWeb.Live.RegisterFormTest do assert team_of(user).trial_expiry_date != nil end - test "always uses original email from the invitation", %{conn: conn, invitation: invitation} do + test "always uses original email from the invitation", %{ + conn: conn, + guest_invitation: guest_invitation + } do mock_captcha_success() - lv = get_liveview(conn, "/register/invitation/#{invitation.invitation_id}") + lv = get_liveview(conn, "/register/invitation/#{guest_invitation.invitation_id}") type_into_input(lv, "user[name]", "Mary Sue") type_into_input(lv, "user[email]", "mary.sue@plausible.test") @@ -247,10 +287,10 @@ defmodule PlausibleWeb.Live.RegisterFormTest do assert html =~ "Your invitation has expired or been revoked" end - test "renders error on failed captcha", %{conn: conn, invitation: invitation} do + test "renders error on failed captcha", %{conn: conn, guest_invitation: guest_invitation} do mock_captcha_failure() - lv = get_liveview(conn, "/register/invitation/#{invitation.invitation_id}") + lv = get_liveview(conn, "/register/invitation/#{guest_invitation.invitation_id}") type_into_input(lv, "user[name]", "Mary Sue") type_into_input(lv, "user[password]", "very-long-and-very-secret-123") @@ -265,9 +305,9 @@ defmodule PlausibleWeb.Live.RegisterFormTest do test "pushing send-metrics-after event submits the form", %{ conn: conn, - invitation: invitation + guest_invitation: guest_invitation } do - lv = get_liveview(conn, "/register/invitation/#{invitation.invitation_id}") + lv = get_liveview(conn, "/register/invitation/#{guest_invitation.invitation_id}") refute render(lv) =~ ~s|phx-trigger-action="phx-trigger-action"| diff --git a/test/support/teams/test.ex b/test/support/teams/test.ex index bc435e223441..ef420db9ad1c 100644 --- a/test/support/teams/test.ex +++ b/test/support/teams/test.ex @@ -110,6 +110,15 @@ defmodule Plausible.Teams.Test do user |> Repo.preload(:team_memberships) end + def add_member(team, args \\ []) do + user = Keyword.get(args, :user, new_user()) + role = Keyword.fetch!(args, :role) + + insert(:team_membership, team: team, user: user, role: role) + + user |> Repo.preload(:team_memberships) + end + def invite_guest(site, invitee_or_email, args \\ []) when not is_nil(invitee_or_email) do {role, args} = Keyword.pop!(args, :role) {inviter, args} = Keyword.pop!(args, :inviter) @@ -149,6 +158,30 @@ defmodule Plausible.Teams.Test do ) end + def invite_member(team, invitee_or_email, args \\ []) when not is_nil(invitee_or_email) do + {role, args} = Keyword.pop!(args, :role) + {inviter, args} = Keyword.pop!(args, :inviter) + + email = + case invitee_or_email do + %{email: email} -> email + email when is_binary(email) -> email + end + + insert( + :team_invitation, + Keyword.merge( + [ + team: team, + email: email, + inviter: inviter, + role: role + ], + args + ) + ) + end + def invite_transfer(site, invitee_or_email, args \\ []) do {inviter, args} = Keyword.pop!(args, :inviter)