diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex index 1e0e6a23c551..c8bbf2f916c7 100644 --- a/lib/plausible/application.ex +++ b/lib/plausible/application.ex @@ -15,6 +15,7 @@ defmodule Plausible.Application do children = [ + Plausible.PromEx, Plausible.Cache.Stats, Plausible.Repo, Plausible.ClickhouseRepo, @@ -104,7 +105,6 @@ defmodule Plausible.Application do end, endpoint, {Oban, Application.get_env(:plausible, Oban)}, - Plausible.PromEx, on_ee do help_scout_vault() end diff --git a/lib/plausible/prom_ex.ex b/lib/plausible/prom_ex.ex index 3cf68d84735b..8e22a4dc4309 100644 --- a/lib/plausible/prom_ex.ex +++ b/lib/plausible/prom_ex.ex @@ -3,23 +3,33 @@ defmodule Plausible.PromEx do alias PromEx.Plugins + @plugins [ + Plugins.Application, + Plugins.Beam, + Plugins.PhoenixLiveView, + {Plugins.Phoenix, router: PlausibleWeb.Router, endpoint: PlausibleWeb.Endpoint}, + {Plugins.Ecto, + repos: [ + Plausible.Repo, + Plausible.ClickhouseRepo, + Plausible.IngestRepo, + Plausible.AsyncInsertRepo + ]}, + Plausible.PromEx.Plugins.PlausibleMetrics + ] + @impl true - def plugins do - [ - Plugins.Application, - Plugins.Beam, - Plugins.PhoenixLiveView, - {Plugins.Phoenix, router: PlausibleWeb.Router, endpoint: PlausibleWeb.Endpoint}, - {Plugins.Ecto, - repos: [ - Plausible.Repo, - Plausible.ClickhouseRepo, - Plausible.IngestRepo, - Plausible.AsyncInsertRepo - ]}, - Plugins.Oban, - Plausible.PromEx.Plugins.PlausibleMetrics - ] + if Mix.env() in [:test, :ce_test] do + # PromEx tries to query Oban's DB tables in order to retrieve metrics. + # During tests, however, this is pointless as Oban is in manual mode, + # and that leads to connection ownership clashes. + def plugins do + @plugins + end + else + def plugins do + [Plugins.Oban | @plugins] + end end @impl true diff --git a/lib/plausible/teams.ex b/lib/plausible/teams.ex index 375464edf58c..d72a8d4262b0 100644 --- a/lib/plausible/teams.ex +++ b/lib/plausible/teams.ex @@ -273,39 +273,6 @@ defmodule Plausible.Teams do ) end - def setup_team(team, candidates) do - inviter = Repo.preload(team, :owner).owner - - setup_team_fn = fn {{email, _name}, role} -> - case Teams.Invitations.InviteToTeam.invite(team, inviter, email, role, send_email?: false) do - {:ok, invitation} -> invitation - {:error, error} -> Repo.rollback(error) - end - end - - result = - Repo.transaction(fn -> - team - |> Teams.Team.setup_changeset() - |> Repo.update!() - - Enum.map(candidates, setup_team_fn) - end) - - case result do - {:ok, invitations} -> - Enum.each(invitations, fn invitation -> - invitee = Auth.find_user_by(email: invitation.email) - Teams.Invitations.InviteToTeam.send_invitation_email(invitation, invitee) - end) - - {:ok, invitations} - - {:error, {:over_limit, _}} = error -> - error - end - end - defp create_my_team(user) do team = "My Team" diff --git a/lib/plausible/teams/invitations.ex b/lib/plausible/teams/invitations.ex index 42ce31b7057c..93becf11cc13 100644 --- a/lib/plausible/teams/invitations.ex +++ b/lib/plausible/teams/invitations.ex @@ -35,7 +35,16 @@ defmodule Plausible.Teams.Invitations do end end - def find_team_invitations(user) do + def all(%Teams.Team{} = team) do + Repo.all( + from ti in Teams.Invitation, + inner_join: inviter in assoc(ti, :inviter), + where: ti.team_id == ^team.id, + preload: [inviter: inviter] + ) + end + + def all(%Plausible.Auth.User{} = user) do Repo.all( from ti in Teams.Invitation, inner_join: inviter in assoc(ti, :inviter), diff --git a/lib/plausible/teams/invitations/candidates.ex b/lib/plausible/teams/invitations/candidates.ex deleted file mode 100644 index 1e12a361b380..000000000000 --- a/lib/plausible/teams/invitations/candidates.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule Plausible.Teams.Invitations.Candidates do - @moduledoc """ - Search through team's site guests looking for full membership candidates. - """ - import Ecto.Query - - alias Plausible.Repo - alias Plausible.Teams.GuestMembership - alias Plausible.Teams - - @spec search_site_guests(Teams.Team.t(), String.t(), Keyword.t()) :: [Plausible.Auth.User.t()] - def search_site_guests(%Teams.Team{} = team, name_or_email, opts \\ []) - when is_binary(name_or_email) do - limit = Keyword.get(opts, :limit, 50) - exclude = Keyword.get(opts, :exclude, []) - - all_site_ids = Teams.owned_sites_ids(team) - term = "%#{name_or_email}%" - - Repo.all( - from gm in GuestMembership, - inner_join: tm in assoc(gm, :team_membership), - inner_join: u in assoc(tm, :user), - where: gm.site_id in ^all_site_ids, - where: ilike(u.email, ^term) or ilike(u.name, ^term), - where: u.email not in ^exclude, - order_by: [asc: u.id], - select: u, - distinct: true, - limit: ^limit - ) - end - - def get_site_guest(%Teams.Team{} = team, email) do - all_site_ids = Teams.owned_sites_ids(team) - - Repo.one( - from gm in GuestMembership, - inner_join: tm in assoc(gm, :team_membership), - inner_join: u in assoc(tm, :user), - where: gm.site_id in ^all_site_ids, - where: u.email == ^email, - distinct: true, - select: u - ) - end -end diff --git a/lib/plausible/teams/management/layout.ex b/lib/plausible/teams/management/layout.ex new file mode 100644 index 000000000000..d8f3de048183 --- /dev/null +++ b/lib/plausible/teams/management/layout.ex @@ -0,0 +1,178 @@ +defmodule Plausible.Teams.Management.Layout do + @moduledoc """ + Abstraction for team membership(s) layout - provides a high level CRUD for + setting up team memberships, including invitations. Persisting the layout, + effectively takes care of delegating the operations to specialized services + and sending out e-mail notifications on success, if need be. + To be used in UIs allowing team memberships adjustments. + """ + alias Plausible.Teams + alias Plausible.Teams.Management.Layout.Entry + alias Plausible.Repo + alias Plausible.Auth.User + + @type t() :: %{String.t() => Entry.t()} + + @spec init(Teams.Team.t()) :: t() + def init(%Teams.Team{} = team) do + invitations_sent = Teams.Invitations.all(team) + all_members = Teams.Memberships.all(team) + build_by_email(invitations_sent ++ all_members) + end + + @spec build_by_email([Teams.Invitation.t() | Teams.Membership.t()]) :: + t() + def build_by_email(entities) do + Enum.reduce(entities, %{}, fn + %Teams.Invitation{} = invitation, acc -> + Map.put(acc, invitation.email, Entry.new(invitation)) + + %Teams.Membership{} = membership, acc -> + Map.put( + acc, + membership.user.email, + Entry.new(membership) + ) + end) + end + + @spec active_count(t()) :: non_neg_integer() + def active_count(layout) do + Enum.count(layout, fn {_, entry} -> entry.queued_op != :delete end) + end + + @spec owners_count(t()) :: non_neg_integer() + def owners_count(layout) do + Enum.count(layout, fn {_, entry} -> entry.queued_op != :delete and entry.role == :owner end) + end + + @spec has_guests?(t()) :: boolean() + def has_guests?(layout) do + not is_nil( + Enum.find( + layout, + fn + {_, entry} -> entry.role == :guest and entry.queued_op != :delete + end + ) + ) + end + + @spec update_role(t(), String.t(), atom()) :: t() + def update_role(layout, email, role) do + entry = Map.fetch!(layout, email) + Map.put(layout, email, Entry.patch(entry, role: role, queued_op: :update)) + end + + @spec schedule_send(t(), String.t(), atom(), Keyword.t()) :: t() + def schedule_send(layout, email, role, entry_attrs \\ []) do + invitation = %Teams.Invitation{email: email, role: role} + Map.put(layout, email, Entry.new(invitation, Keyword.merge(entry_attrs, queued_op: :send))) + end + + @spec schedule_delete(t(), String.t()) :: t() + def schedule_delete(layout, email) do + entry = Map.fetch!(layout, email) + Map.put(layout, email, Entry.patch(entry, queued_op: :delete)) + end + + @spec verify_removable(t(), String.t()) :: :ok | {:error, String.t()} + def verify_removable(layout, email) do + ensure_at_least_one_owner(layout, email) + end + + @spec removable?(t(), String.t()) :: boolean() + def removable?(layout, email) do + verify_removable(layout, email) == :ok + end + + @spec sorted_for_display(t()) :: [{String.t(), Entry.t()}] + def sorted_for_display(layout) do + layout + |> Enum.reject(fn {_, entry} -> entry.queued_op == :delete end) + |> Enum.sort_by(fn {email, entry} -> + primary_criterion = + case entry do + %{role: :guest, type: :invitation_pending} -> 10 + %{role: :guest, type: :invitation_sent} -> 11 + %{role: :guest, type: :membership} -> 12 + %{type: :invitation_pending} -> 0 + %{type: :invitation_sent} -> 1 + %{type: :membership} -> 2 + end + + secondary_criterion = entry.name + tertiary_criterion = email + {primary_criterion, secondary_criterion, tertiary_criterion} + end) + end + + @spec persist(t(), %{current_user: User.t(), my_team: Teams.Team.t()}) :: + {:ok, integer()} | {:error, any()} + def persist(layout, context) do + result = + Repo.transaction(fn -> + if not context.my_team.setup_complete do + context.my_team + |> Teams.Team.setup_changeset() + |> Repo.update!() + end + + layout + |> sorted_for_persistence() + |> Enum.reduce([], fn {_, entry}, acc -> + persist_entry(entry, context, acc) + end) + end) + + case result do + {:ok, persisted} -> + persisted + |> Enum.each(fn + {%Entry{type: :invitation_pending}, invitation} -> + invitee = Plausible.Auth.find_user_by(email: invitation.email) + Teams.Invitations.InviteToTeam.send_invitation_email(invitation, invitee) + + {%Entry{type: :membership, queued_op: :delete}, team_membership} -> + Teams.Memberships.Remove.send_team_member_removed_email(team_membership) + + _ -> + :noop + end) + + {:ok, length(persisted)} + + {:error, _} = error -> + error + end + end + + defp sorted_for_persistence(layout) do + # sort by deletions first, so team member limits are triggered accurately + Enum.sort_by(layout, fn {_email, entry} -> + case entry.queued_op do + :delete -> 0 + _ -> 1 + end + end) + end + + defp ensure_at_least_one_owner(layout, email) do + if Enum.find(layout, fn {_email, entry} -> + entry.email != email and + entry.role == :owner and + entry.type == :membership and + entry.queued_op != :delete + end), + do: :ok, + else: {:error, "The team has to have at least one owner"} + end + + def persist_entry(entry, context, acc) do + case Entry.persist(entry, context) do + {:ok, :ignore} -> acc + {:ok, persist_result} -> [{entry, persist_result} | acc] + {:error, error} -> Repo.rollback(error) + end + end +end diff --git a/lib/plausible/teams/management/layout/entry.ex b/lib/plausible/teams/management/layout/entry.ex new file mode 100644 index 000000000000..9b25c49e827e --- /dev/null +++ b/lib/plausible/teams/management/layout/entry.ex @@ -0,0 +1,120 @@ +defmodule Plausible.Teams.Management.Layout.Entry do + @moduledoc """ + Module representing a single Team Layout element and all its state + transitions, including persistence options. + """ + alias Plausible.Teams + + defstruct [:email, :name, :role, :type, :meta, :queued_op] + + @type t() :: %__MODULE__{} + + @spec new(Teams.Invitation.t() | Teams.Membership.t(), Keyword.t()) :: t() + def new(object, attrs \\ []) + + def new( + %Teams.Invitation{id: existing} = invitation, + attrs + ) + when is_integer(existing) do + %__MODULE__{ + name: "Invited User", + email: invitation.email, + role: invitation.role, + type: :invitation_sent, + meta: invitation + } + |> Map.merge(Enum.into(attrs, %{})) + end + + def new(%Teams.Invitation{id: nil} = pending, attrs) do + %__MODULE__{ + name: "Invited User", + email: pending.email, + role: pending.role, + type: :invitation_pending, + meta: pending + } + |> Map.merge(Enum.into(attrs, %{})) + end + + def new(%Teams.Membership{} = membership, attrs) do + %__MODULE__{ + name: membership.user.name, + role: membership.role, + email: membership.user.email, + type: :membership, + meta: membership + } + |> Map.merge(Enum.into(attrs, %{})) + end + + @spec patch(t(), Keyword.t()) :: t() + def patch(%__MODULE__{} = entry, attrs) do + struct!(entry, attrs) + end + + @spec persist(t(), map()) :: + {:ok, :ignore | Teams.Invitation.t() | Teams.Membership.t()} | {:error, any()} + def persist(%__MODULE__{queued_op: nil}, _context) do + {:ok, :ignore} + end + + def persist(%__MODULE__{type: :invitation_pending, queued_op: :delete}, _context) do + {:ok, :ignore} + end + + def persist( + %__MODULE__{email: email, role: role, type: :invitation_pending, queued_op: op}, + context + ) + when op in [:update, :send] do + Teams.Invitations.InviteToTeam.invite(context.my_team, context.current_user, email, role, + send_email?: false + ) + end + + def persist( + %__MODULE__{type: :invitation_sent, email: email, role: role, queued_op: :update}, + context + ) do + Teams.Invitations.InviteToTeam.invite(context.my_team, context.current_user, email, role, + send_email?: false + ) + end + + def persist( + %__MODULE__{type: :invitation_sent, queued_op: :delete, meta: meta}, + context + ) do + Plausible.Teams.Invitations.Remove.remove( + context.my_team, + meta.invitation_id, + context.current_user + ) + end + + def persist( + %__MODULE__{type: :membership, queued_op: :delete, meta: meta}, + context + ) do + Plausible.Teams.Memberships.Remove.remove( + context.my_team, + meta.user.id, + context.current_user, + send_email?: false + ) + end + + def persist( + %__MODULE__{type: :membership, queued_op: :update, role: role, meta: meta}, + context + ) do + Plausible.Teams.Memberships.UpdateRole.update( + context.my_team, + meta.user.id, + "#{role}", + context.current_user + ) + end +end diff --git a/lib/plausible/teams/memberships.ex b/lib/plausible/teams/memberships.ex index 41d4c5fee670..31e466fae357 100644 --- a/lib/plausible/teams/memberships.ex +++ b/lib/plausible/teams/memberships.ex @@ -7,6 +7,17 @@ defmodule Plausible.Teams.Memberships do alias Plausible.Repo alias Plausible.Teams + def all(team) do + query = + from tm in Teams.Membership, + inner_join: u in assoc(tm, :user), + where: tm.team_id == ^team.id, + order_by: [asc: u.id], + preload: [user: u] + + Repo.all(query) + end + def all_pending_site_transfers(email) do email |> pending_site_transfers_query() diff --git a/lib/plausible/teams/memberships/remove.ex b/lib/plausible/teams/memberships/remove.ex index 9aa1562e2c43..7ba440884d8e 100644 --- a/lib/plausible/teams/memberships/remove.ex +++ b/lib/plausible/teams/memberships/remove.ex @@ -8,14 +8,17 @@ defmodule Plausible.Teams.Memberships.Remove do def remove(nil, _, _), do: {:error, :permission_denied} - def remove(team, user_id, current_user) do + def remove(team, user_id, current_user, opts \\ []) do with {:ok, team_membership} <- Memberships.get_team_membership(team, user_id), {:ok, current_user_role} <- Memberships.team_role(team, current_user), :ok <- check_can_remove_membership(current_user_role, team_membership.role), :ok <- check_owner_can_get_removed(team, team_membership.role) do team_membership = Repo.preload(team_membership, [:team, :user]) Repo.delete!(team_membership) - send_team_member_removed_email(team_membership) + + if Keyword.get(opts, :send_email?, true) do + send_team_member_removed_email(team_membership) + end {:ok, team_membership} end @@ -35,7 +38,7 @@ defmodule Plausible.Teams.Memberships.Remove do defp check_owner_can_get_removed(_team, _role), do: :ok - defp send_team_member_removed_email(team_membership) do + def send_team_member_removed_email(team_membership) do team_membership |> PlausibleWeb.Email.team_member_removed() |> Plausible.Mailer.send() diff --git a/lib/plausible/teams/memberships/update_role.ex b/lib/plausible/teams/memberships/update_role.ex index 956f89ed9bf1..6f0294eb3d44 100644 --- a/lib/plausible/teams/memberships/update_role.ex +++ b/lib/plausible/teams/memberships/update_role.ex @@ -30,6 +30,8 @@ defmodule Plausible.Teams.Memberships.UpdateRole do |> Repo.update!() |> Repo.preload(:user) + :ok = maybe_prune_guest_memberships(team_membership) + {:ok, team_membership} end end @@ -86,4 +88,15 @@ defmodule Plausible.Teams.Memberships.UpdateRole do defp can_grant_role_to_other?(:admin, :viewer, :editor), do: true defp can_grant_role_to_other?(:admin, :viewer, :viewer), do: true defp can_grant_role_to_other?(_, _, _), do: false + + 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 end diff --git a/lib/plausible/teams/team.ex b/lib/plausible/teams/team.ex index ef4fe82b1604..78d74e9afe7e 100644 --- a/lib/plausible/teams/team.ex +++ b/lib/plausible/teams/team.ex @@ -63,9 +63,7 @@ defmodule Plausible.Teams.Team do |> validate_required(:name) end - def setup_changeset(team) do - now = NaiveDateTime.utc_now(:second) - + def setup_changeset(team, now \\ NaiveDateTime.utc_now(:second)) do team |> change( setup_complete: true, diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index 587d86c8b50b..513d32cad3e7 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -222,6 +222,7 @@ defmodule PlausibleWeb.Components.Generic do end attr :class, :string, default: "" + attr :id, :string, default: nil slot :button, required: true do attr(:class, :string) @@ -236,11 +237,17 @@ defmodule PlausibleWeb.Components.Generic do ~H"""
- -
+
- <.dropdown class="relative"> + <.dropdown> <:button class="bg-transparent text-gray-800 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate inline-flex items-center gap-x-2 font-medium rounded-md px-3.5 py-2.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700"> {membership.role |> to_string() |> String.capitalize()} diff --git a/test/plausible/teams/invitations/candidates_test.exs b/test/plausible/teams/invitations/candidates_test.exs deleted file mode 100644 index 49ef537bc743..000000000000 --- a/test/plausible/teams/invitations/candidates_test.exs +++ /dev/null @@ -1,79 +0,0 @@ -defmodule Plausible.Teams.Invitations.CandidatesTest do - use Plausible.DataCase, async: true - use Plausible.Teams.Test - - alias Plausible.Teams.Invitations.Candidates - - test "performs basic searches" do - owner = new_user() - site = new_site(owner: owner) - - add_guest(site, role: :viewer, user: new_user(email: "foo@example.com", name: "Jane Doe")) - add_guest(site, role: :viewer, user: new_user(email: "foo2@example.com", name: "Joe Doe")) - add_guest(site, role: :viewer, user: new_user(email: "moo@example.com", name: "Wu Tang")) - - assert [ - %{email: "foo@example.com"}, - %{email: "foo2@example.com"} - ] = Candidates.search_site_guests(team_of(owner), "doe") - - assert [ - %{email: "foo@example.com"}, - %{email: "foo2@example.com"} - ] = Candidates.search_site_guests(team_of(owner), "FOO") - - assert [ - %{email: "foo@example.com"}, - %{email: "foo2@example.com"}, - %{email: "moo@example.com"} - ] = Candidates.search_site_guests(team_of(owner), "") - - assert [] = Candidates.search_site_guests(team_of(owner), "WONTMATCH") - end - - test "searches across multiple sites" do - owner = new_user() - - site1 = new_site(owner: owner) - site2 = new_site(owner: owner) - - multi_site_guest = new_user(email: "foo@example.com", name: "Jane Doe") - - add_guest(site1, role: :viewer, user: multi_site_guest) - add_guest(site2, role: :viewer, user: new_user(email: "foo2@example.com", name: "Joe Doe")) - add_guest(site2, role: :viewer, user: multi_site_guest) - - assert [ - %{email: "foo@example.com"}, - %{email: "foo2@example.com"} - ] = Candidates.search_site_guests(team_of(owner), "doe") - end - - test "capable of limiting results" do - owner = new_user() - site = new_site(owner: owner) - - add_guest(site, role: :viewer) - add_guest(site, role: :viewer) - add_guest(site, role: :viewer) - - assert [_, _, _] = Candidates.search_site_guests(team_of(owner), "") - assert [_, _] = Candidates.search_site_guests(team_of(owner), "", limit: 2) - end - - test "capable of excluding e-mails" do - owner = new_user() - site = new_site(owner: owner) - - to_be_excluded = new_user(email: "foo@example.com", name: "Jane Doe") - - add_guest(site, role: :viewer, user: to_be_excluded) - add_guest(site, role: :viewer, user: new_user(email: "foo2@example.com", name: "Joe Doe")) - add_guest(site, role: :viewer, user: new_user(email: "moo@example.com", name: "Wu Tang")) - - assert [ - %{email: "foo2@example.com"} - ] = - Candidates.search_site_guests(team_of(owner), "doe", exclude: [to_be_excluded.email]) - end -end diff --git a/test/plausible/teams/management/layout_test.exs b/test/plausible/teams/management/layout_test.exs new file mode 100644 index 000000000000..642f6d696aea --- /dev/null +++ b/test/plausible/teams/management/layout_test.exs @@ -0,0 +1,446 @@ +defmodule Plausible.Teams.Management.LayoutTest do + use Plausible.DataCase, async: true + use Plausible.Teams.Test + use Bamboo.Test + use Plausible + + alias Plausible.Teams.Management.Layout + alias Plausible.Teams.Management.Layout.Entry + alias Plausible.Teams + + describe "no persistence" do + test "can be built of invitations and memberships" do + layout = sample_layout() + + assert %Entry{name: "Current User", role: :admin, type: :membership} = + layout["current@example.com"] + + assert %Entry{name: "Owner User", role: :owner, type: :membership} = + layout["owner@example.com"] + + assert %Entry{ + name: "Invited User", + role: :admin, + type: :invitation_sent + } = + layout["invitation-sent@example.com"] + + assert %Entry{ + name: "Invited User", + role: :admin, + type: :invitation_pending + } = + layout["invitation-pending@example.com"] + end + + test "can be sorted for display" do + layout = + sample_layout() + + assert [ + {"invitation-pending@example.com", %Entry{type: :invitation_pending}}, + {"invitation-sent@example.com", %Entry{type: :invitation_sent}}, + {"current@example.com", %Entry{type: :membership}}, + {"owner@example.com", %Entry{type: :membership}}, + {"guest-pending@example.com", %Entry{role: :guest, type: :invitation_pending}}, + {"invitation-sent-guest@example.com", + %Entry{role: :guest, type: :invitation_sent}}, + {"a-guest@example.com", %Entry{role: :guest, type: :membership}} + ] = Layout.sorted_for_display(layout) + + layout = + layout + |> put(membership(name: "Aa", email: "00-invitation-accepted@example.com", role: :viewer)) + |> put(invitation_pending("00-invitation-pending@example.com")) + |> put(invitation_sent("00-invitation-sent@example.com")) + |> Layout.schedule_delete("owner@example.com") + + assert [ + {"00-invitation-pending@example.com", %Entry{}}, + {"invitation-pending@example.com", %Entry{}}, + {"00-invitation-sent@example.com", %Entry{}}, + {"invitation-sent@example.com", %Entry{}}, + {"00-invitation-accepted@example.com", %Entry{}}, + {"current@example.com", %Entry{}}, + {"guest-pending@example.com", %Entry{role: :guest, type: :invitation_pending}}, + {"invitation-sent-guest@example.com", + %Entry{role: :guest, type: :invitation_sent}}, + {"a-guest@example.com", %Entry{role: :guest, type: :membership}} + ] = Layout.sorted_for_display(layout) + end + + test "removable?/2 + counters" do + layout = sample_layout() + assert Layout.removable?(layout, "invitation-pending@example.com") + assert Layout.removable?(layout, "current@example.com") + refute Layout.removable?(layout, "owner@example.com") + + layout = + put( + layout, + invitation_sent("maybe-owner@example.com", role: :owner) + ) + + refute Layout.removable?(layout, "owner@example.com") + + layout = + put( + layout, + membership( + email: "secondary-owner@example.com", + role: :owner + ) + ) + + assert Layout.removable?(layout, "owner@example.com") + assert Layout.removable?(layout, "secondary-owner@example.com") + + assert Layout.owners_count(layout) == 3 + assert Layout.active_count(layout) == 9 + + layout = Layout.schedule_delete(layout, "owner@example.com") + + assert Layout.owners_count(layout) == 2 + assert Layout.active_count(layout) == 8 + + refute Layout.removable?(layout, "secondary-owner@example.com") + end + + test "update_role/3" do + assert %Entry{queued_op: :update, role: :owner} = + sample_layout() + |> Layout.update_role("current@example.com", :owner) + |> Map.get("current@example.com") + + assert_raise KeyError, ~r/not found/, fn -> + Layout.update_role(sample_layout(), "x", :owner) + end + end + + test "schedule_send/3" do + assert %Entry{ + queued_op: :send, + name: "Invited User", + role: :admin, + meta: %{email: "new@example.com"} + } = + sample_layout() + |> Layout.schedule_send("new@example.com", :admin) + |> Map.get("new@example.com") + + assert %Entry{ + queued_op: :send, + name: "Joe Doe", + role: :admin, + meta: %{email: "new@example.com"} + } = + sample_layout() + |> Layout.schedule_send("new@example.com", :admin, name: "Joe Doe") + |> Map.get("new@example.com") + end + + test "schedule_delete/2" do + assert %Entry{queued_op: :delete} = + sample_layout() + |> Layout.schedule_delete("current@example.com") + |> Map.get("current@example.com") + + assert_raise KeyError, ~r/not found/, fn -> + Layout.schedule_delete(sample_layout(), "x") + end + end + + test "has_guests?/1" do + input = [ + invitation_pending("invitation-pending@example.com") + ] + + layout = Layout.build_by_email(input) + refute Layout.has_guests?(layout) + + layout = + put( + layout, + membership(email: "guest@example.com", role: :guest) + ) + + assert Layout.has_guests?(layout) + layout = Layout.schedule_delete(layout, "guest@example.com") + refute Layout.has_guests?(layout) + + layout = Layout.update_role(layout, "guest@example.com", :viewer) + + refute Layout.has_guests?(layout) + end + + test "overwrite" do + assert %Entry{queued_op: :send, type: :invitation_pending} = + sample_layout() + |> put( + membership( + name: "Aa", + email: "00-invitation-accepted@example.com", + role: :viewer + ) + ) + |> Layout.schedule_send("current@example.com", :admin) + |> Map.get("current@example.com") + end + + defp invitation_pending(email, attrs \\ []) do + build(:team_invitation, Keyword.merge([email: email], attrs)) + end + + defp invitation_sent(email, attrs \\ []) do + build( + :team_invitation, + Keyword.merge( + [email: email, id: Enum.random(1..1_000_000)], + attrs + ) + ) + end + + defp membership(attrs) do + build(:team_membership, + role: attrs[:role], + user: attrs[:user] || build(:user, name: attrs[:name], email: attrs[:email]) + ) + end + + defp sample_layout() do + current_user = build(:user, id: 777, name: "Current User", email: "current@example.com") + + input = [ + invitation_pending("invitation-pending@example.com"), + membership( + role: :owner, + name: "Owner User", + email: "owner@example.com" + ), + invitation_pending("guest-pending@example.com", role: :guest), + membership(role: :guest, name: "Guest User", email: "a-guest@example.com"), + membership(role: :admin, user: current_user), + invitation_sent("invitation-sent-guest@example.com", role: :guest), + invitation_sent("invitation-sent@example.com") + ] + + Layout.build_by_email(input) + end + end + + describe "persistence" do + @subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] " + setup [:create_user, :create_team] + + test "unchanged layout no-op", %{user: user, team: team} do + add_member(team, role: :admin) + invite_member(team, "invite@example.com", role: :viewer, inviter: user) + + assert {:ok, 0} = + team + |> Layout.init() + |> Layout.persist(%{current_user: user, my_team: team}) + + assert_no_emails_delivered() + end + + test "writes setup markers", %{user: user, team: team} do + refute team.setup_complete + refute team.setup_at + + team |> Layout.init() |> Layout.persist(%{current_user: user, my_team: team}) + + team = Repo.reload!(team) + + assert team.setup_complete + assert team.setup_at + end + + test "won't update setup_at", %{user: user, team: team} do + team = + team + |> Teams.Team.setup_changeset( + NaiveDateTime.utc_now(:second) + |> NaiveDateTime.shift(month: -1) + ) + |> Repo.update!() + + assert setup_at = team.setup_at + + team |> Layout.init() |> Layout.persist(%{current_user: user, my_team: team}) + + team = Repo.reload!(team) + + assert team.setup_at == setup_at + end + + test "invitation pending email goes out", %{user: user, team: team} do + assert {:ok, 1} = + team + |> Layout.init() + |> Layout.schedule_send("test@example.com", :admin) + |> Layout.persist(%{current_user: user, my_team: team}) + + assert_email_delivered_with( + to: [nil: "test@example.com"], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" + ) + + layout = Layout.init(team) + assert %{type: :invitation_sent} = Map.get(layout, "test@example.com") + end + + test "membership removal email goes out", %{user: user, team: team} do + add_member(team, role: :admin, user: new_user(email: "test@example.com")) + + assert {:ok, 1} = + team + |> Layout.init() + |> Layout.schedule_delete("test@example.com") + |> Layout.persist(%{current_user: user, my_team: team}) + + assert_email_delivered_with( + to: [nil: "test@example.com"], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) + end + + test "limits are checked", %{user: user, team: team} do + assert {:error, {:over_limit, 3}} = + team + |> Layout.init() + |> Layout.schedule_send("test1@example.com", :admin) + |> Layout.schedule_send("test2@example.com", :admin) + |> Layout.schedule_send("test3@example.com", :admin) + |> Layout.schedule_send("test4@example.com", :admin) + |> Layout.persist(%{current_user: user, my_team: team}) + + assert {:error, :only_one_owner} = + team + |> Layout.init() + |> Layout.schedule_delete(user.email) + |> put(invitation_pending("00-invitation-pending@example.com", role: :owner)) + |> put(invitation_sent("00-invitation-sent@example.com", role: :owner)) + |> Layout.persist(%{current_user: user, my_team: team}) + + assert {:error, :only_one_owner} = + team + |> Layout.init() + |> Layout.update_role(user.email, :viewer) + |> Layout.persist(%{current_user: user, my_team: team}) + + assert {:error, :already_a_member} = + team + |> Layout.init() + |> Layout.schedule_send(user.email, :admin) + |> Layout.persist(%{current_user: user, my_team: team}) + + assert_no_emails_delivered() + end + + test "deletions are made first, so that limits apply accurately", %{user: user, team: team} do + add_member(team, role: :admin, user: new_user(email: "test1@example.com")) + add_member(team, role: :admin, user: new_user(email: "test2@example.com")) + add_member(team, role: :admin, user: new_user(email: "test3@example.com")) + + assert {:ok, 3} = + team + |> Layout.init() + |> Layout.schedule_send("new@example.com", :admin) + |> Layout.schedule_delete("test1@example.com") + |> Layout.schedule_delete("test2@example.com") + |> Layout.persist(%{current_user: user, my_team: team}) + end + + test "multiple ops queue", %{user: user, team: team} do + member1 = add_member(team, role: :admin, user: new_user(email: "test1@example.com")) + member2 = add_member(team, role: :admin, user: new_user(email: "test2@example.com")) + + assert {:ok, 3} = + team + |> Layout.init() + |> Layout.schedule_send("new@example.com", :admin) + |> Layout.schedule_delete("test1@example.com") + |> Layout.update_role("test2@example.com", :viewer) + |> Layout.persist(%{current_user: user, my_team: team}) + + assert {:error, :not_a_member} = Teams.Memberships.team_role(team, member1) + assert {:ok, :viewer} = Teams.Memberships.team_role(team, member2) + assert [%{email: "new@example.com"}] = Teams.Invitations.all(team) + + assert_email_delivered_with( + to: [nil: "new@example.com"], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" + ) + + assert_email_delivered_with( + to: [nil: "test1@example.com"], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) + + assert_no_emails_delivered() + end + + test "deletion of scheduled invitations is no-op", %{user: user, team: team} do + assert {:ok, 0} = + team + |> Layout.init() + |> Layout.schedule_send("new@example.com", :admin) + |> Layout.schedule_delete("new@example.com") + |> Layout.persist(%{current_user: user, my_team: team}) + end + + test "idempotence", %{user: user, team: team} do + add_member(team, role: :admin, user: new_user(email: "test1@example.com")) + add_member(team, role: :admin, user: new_user(email: "test2@example.com")) + + assert {:ok, 3} = + team + |> Layout.init() + |> Layout.schedule_send("new@example.com", :admin) + |> Layout.schedule_delete("test1@example.com") + |> Layout.schedule_send("new@example.com", :admin) + |> Layout.update_role("test2@example.com", :viewer) + |> Layout.schedule_delete("test1@example.com") + |> Layout.update_role("test2@example.com", :viewer) + |> Layout.persist(%{current_user: user, my_team: team}) + + assert_email_delivered_with( + to: [nil: "new@example.com"], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" + ) + + assert_email_delivered_with( + to: [nil: "test1@example.com"], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) + + assert_no_emails_delivered() + end + + test "guests promotion", %{user: user, team: team} do + site = new_site(owner: user) + u2 = new_user() + + add_guest(site, user: u2, role: :viewer) + assert_guest_membership(team, site, u2, :viewer) + + layout = Layout.init(team) + assert Layout.has_guests?(layout) + + layout + |> Layout.update_role(u2.email, :viewer) + |> Layout.persist(%{current_user: user, my_team: team}) + + refute team |> Layout.init() |> Layout.has_guests?() + + assert_non_guest_membership(team, site, u2) + end + end + + def put(layout, entity) do + entry = Entry.new(entity) + Map.put(layout, entry.email, entry) + end +end diff --git a/test/plausible_web/controllers/settings_controller_test.exs b/test/plausible_web/controllers/settings_controller_test.exs index 162b7526bf34..09371bfbd823 100644 --- a/test/plausible_web/controllers/settings_controller_test.exs +++ b/test/plausible_web/controllers/settings_controller_test.exs @@ -1116,14 +1116,23 @@ defmodule PlausibleWeb.SettingsControllerTest do refute html =~ "Team Settings" end - test "renders team settings, when team assigned", %{conn: conn, user: user} do + test "renders team settings, when team assigned and set up", %{conn: conn, user: user} do {:ok, team} = Plausible.Teams.get_or_create(user) + team |> Plausible.Teams.Team.setup_changeset() |> Repo.update!() conn = get(conn, Routes.settings_path(conn, :preferences)) html = html_response(conn, 200) assert html =~ "Team Settings" assert html =~ team.name end + test "does not render team settings, when team not set up", %{conn: conn, user: user} do + {:ok, team} = Plausible.Teams.get_or_create(user) + conn = get(conn, Routes.settings_path(conn, :preferences)) + html = html_response(conn, 200) + assert html =~ "Team Settings" + refute html =~ team.name + end + test "GET /settings/team/general", %{conn: conn, user: user} do {:ok, team} = Plausible.Teams.get_or_create(user) conn = get(conn, Routes.settings_path(conn, :team_general)) diff --git a/test/plausible_web/controllers/site/membership_controller_test.exs b/test/plausible_web/controllers/site/membership_controller_test.exs index 45a73ed6e878..d0bc103a19b5 100644 --- a/test/plausible_web/controllers/site/membership_controller_test.exs +++ b/test/plausible_web/controllers/site/membership_controller_test.exs @@ -20,7 +20,7 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do |> get("/sites/#{site.domain}/memberships/invite") |> html_response(200) - assert html =~ "Invite member to" + assert html =~ "Invite guest to" assert element_exists?(html, ~s/button[type=submit]/) refute element_exists?(html, ~s/button[type=submit][disabled]/) end diff --git a/test/plausible_web/live/team_management_test.exs b/test/plausible_web/live/team_management_test.exs new file mode 100644 index 000000000000..3c983d0d3c66 --- /dev/null +++ b/test/plausible_web/live/team_management_test.exs @@ -0,0 +1,328 @@ +defmodule PlausibleWeb.Live.TeamMangementTest do + use PlausibleWeb.ConnCase, async: false + use Bamboo.Test, shared: true + use Plausible.Teams.Test + + import Phoenix.LiveViewTest + import Plausible.Test.Support.HTML + + def team_general_path(), do: Routes.settings_path(PlausibleWeb.Endpoint, :team_general) + @subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] " + + describe "/settings/team/general" do + setup [:create_user, :log_in, :create_team, :setup_team] + + test "renders team management section", %{conn: conn} do + resp = + conn + |> get(team_general_path()) + |> html_response(200) + |> text() + + assert resp =~ "Add, remove or change your team memberships" + + refute element_exists?(resp, ~s|button[phx-click="save-team-layout"]|) + end + + test "renders existing guests under Guest divider", %{conn: conn, user: user} do + site = new_site(owner: user) + add_guest(site, role: :viewer, user: new_user(name: "Mr Guest", email: "guest@example.com")) + + resp = + conn + |> get(team_general_path()) + |> html_response(200) + + assert element_exists?(resp, "#guests-hr") + + assert find(resp, "#{member_el()}:first-of-type") |> text() =~ "#{user.email}" + assert find(resp, "#{guest_el()}:first-of-type") |> text() =~ "guest@example.com" + end + + test "does not render Guest divider when no guests found", %{conn: conn} do + resp = + conn + |> get(team_general_path()) + |> html_response(200) + + refute element_exists?(resp, "#guests-hr") + refute element_exists?(resp, "#guest-list") + end + end + + describe "live" do + setup [:create_user, :log_in, :create_team, :setup_team] + + test "renders member, immediately delivers invitation", %{conn: conn, user: user, team: team} do + {lv, html} = get_liveview(conn, with_html?: true) + member_row1 = find(html, "#{member_el()}:nth-of-type(1)") |> text() + assert member_row1 =~ "#{user.name}" + assert member_row1 =~ "#{user.email}" + assert member_row1 =~ "You" + + add_invite(lv, "new@example.com", "admin") + + html = render(lv) + + member_row1 = find(html, "#{member_el()}:nth-of-type(1)") |> text() + assert member_row1 =~ "new@example.com" + assert member_row1 =~ "Invited User" + assert member_row1 =~ "Invitation Sent" + + member_row2 = find(html, "#{member_el()}:nth-of-type(2)") |> text() + assert member_row2 =~ "#{user.name}" + assert member_row2 =~ "#{user.email}" + + assert_email_delivered_with( + to: [nil: "new@example.com"], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" + ) + end + + test "allows updating membership role in place", %{conn: conn, team: team} do + member2 = add_member(team, role: :admin) + lv = get_liveview(conn) + + html = render(lv) + + assert text_of_element( + html, + "#{member_el()}:nth-of-type(1) button" + ) == "Owner" + + assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Admin" + + change_role(lv, 2, "viewer") + html = render(lv) + + assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Viewer" + + assert_no_emails_delivered() + + assert_team_membership(member2, team, :viewer) + end + + test "allows updating guest membership so it moves sections", %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + add_guest(site, role: :viewer, user: new_user(name: "Mr Guest", email: "guest@example.com")) + + lv = get_liveview(conn) + + html = render(lv) + + assert length(find(html, member_el())) == 1 + + assert text_of_element(html, "#{guest_el()}:first-of-type button") == "Guest" + + change_role(lv, 1, "viewer", guest_el()) + html = render(lv) + + assert length(find(html, member_el())) == 2 + refute element_exists?(html, "#guest-list") + end + + test "fails to save layout with limits breached", %{conn: conn, team: team} do + lv = get_liveview(conn) + add_invite(lv, "new1@example.com", "admin") + add_invite(lv, "new2@example.com", "admin") + add_invite(lv, "new3@example.com", "admin") + add_invite(lv, "new4@example.com", "admin") + + assert lv |> render() |> text() =~ "Your account is limited to 3 team members" + assert Enum.count(Plausible.Teams.Invitations.all(team)) == 3 + end + + test "fails to accept invitation to already existing e-mail", %{ + conn: conn, + user: user + } do + lv = get_liveview(conn) + add_invite(lv, user.email, "admin") + + assert lv |> render() |> text() =~ + "Error! Make sure the e-mail is valid and is not taken already" + end + + test "allows removing any type of entry", %{ + conn: conn, + user: user, + team: team + } do + member2 = add_member(team, role: :admin) + _invitation = invite_member(team, "sent@example.com", inviter: user, role: :viewer) + + site = new_site(owner: user) + + guest = + add_guest(site, + role: :viewer, + user: new_user(name: "Mr Guest", email: "guest@example.com") + ) + + lv = get_liveview(conn) + add_invite(lv, "pending@example.com", "admin") + + html = render(lv) + + assert html |> find(member_el()) |> Enum.count() == 4 + assert html |> find(guest_el()) |> Enum.count() == 1 + + pending = find(html, "#{member_el()}:nth-of-type(1)") |> text() + sent = find(html, "#{member_el()}:nth-of-type(2)") |> text() + owner = find(html, "#{member_el()}:nth-of-type(3)") |> text() + admin = find(html, "#{member_el()}:nth-of-type(4)") |> text() + + guest_member = find(html, "#{guest_el()}:first-of-type") |> text() + + assert pending =~ "Invitation Pending" + assert sent =~ "Invitation Sent" + assert owner =~ "You" + assert admin =~ "Team Member" + assert guest_member =~ "Guest" + + remove_member(lv, 1) + # next becomes first + remove_member(lv, 1) + # last becomes second + remove_member(lv, 2) + + # remove guest + remove_member(lv, 1, guest_el()) + + html = render(lv) |> text() + + refute html =~ "Invitation Pending" + refute html =~ "Invitation Sent" + refute html =~ "Team Member" + refute html =~ "Guest" + + html = render(lv) + + assert html |> find(member_el()) |> Enum.count() == 1 + refute element_exists?(html, "#guest-list") + + assert_email_delivered_with( + to: [nil: member2.email], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) + + assert_email_delivered_with( + to: [nil: guest.email], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) + + assert_no_emails_delivered() + end + + test "guest->owner promotion", + %{ + conn: conn, + user: user, + team: team + } do + site = new_site(owner: user) + + member2 = + add_guest(site, + role: :viewer, + user: new_user(name: "Mr Guest", email: "guest@example.com") + ) + + lv = get_liveview(conn) + + change_role(lv, 1, "owner", guest_el()) + html = render(lv) + + refute html =~ "Error!" + + assert_team_membership(user, team, :owner) + assert_team_membership(member2, team, :owner) + end + + test "multiple-owners", + %{ + conn: conn, + team: team, + user: user + } do + member2 = add_member(team, role: :admin) + + lv = get_liveview(conn) + + change_role(lv, 2, "owner") + html = render(lv) + + refute html =~ "Error!" + + assert_team_membership(user, team, :owner) + assert_team_membership(member2, team, :owner) + end + end + + describe "to be revisited" do + setup [:create_user, :log_in, :create_team, :setup_team] + + @tag :capture_log + test "billing role is currently not supported by the underlying services", + %{ + conn: conn, + team: team + } do + Process.flag(:trap_exit, true) + _member2 = add_member(team, role: :admin) + + lv = get_liveview(conn) + + assert :unsupported == change_role(lv, 2, "billing") + catch + _, _ -> + :unsupported + end + end + + defp change_role(lv, index, role, main_selector \\ member_el()) do + lv + |> element(~s|#{main_selector}:nth-of-type(#{index}) a[phx-value-role="#{role}"]|) + |> render_click() + end + + defp remove_member(lv, index, main_selector \\ member_el()) do + lv + |> element(~s|#{main_selector}:nth-of-type(#{index}) a[phx-click="remove-member"]|) + |> render_click() + end + + defp add_invite(lv, email, role) do + lv + |> element(~s|#input-role-picker a[phx-value-role="#{role}"]|) + |> render_click() + + lv + |> element("#team-layout-form") + |> render_submit(%{ + "input-email" => email + }) + end + + defp get_liveview(conn, opts \\ []) do + conn = assign(conn, :live_module, PlausibleWeb.Live.TeamManagement) + {:ok, lv, html} = live(conn, team_general_path()) + + if Keyword.get(opts, :with_html?) do + {lv, html} + else + lv + end + end + + defp member_el() do + ~s|#member-list div[data-test-kind="member"]| + end + + defp guest_el() do + ~s|#guest-list div[data-test-kind="guest"]| + end +end diff --git a/test/plausible_web/live/team_setup_sync_test.exs b/test/plausible_web/live/team_setup_sync_test.exs deleted file mode 100644 index de5f47da2882..000000000000 --- a/test/plausible_web/live/team_setup_sync_test.exs +++ /dev/null @@ -1,76 +0,0 @@ -defmodule PlausibleWeb.Live.TeamSetupSyncTest do - use PlausibleWeb.ConnCase, async: false - use Bamboo.Test, shared: true - - use Plausible.Teams.Test - - import Phoenix.LiveViewTest - - alias Plausible.Repo - - @url "/team/setup" - - describe "/team/setup - full integration" do - setup [:create_user, :log_in, :create_team] - - @subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] " - - test "setting up a team successfully creates invitations", %{ - conn: conn, - user: user, - team: team - } do - site = new_site(owner: user) - guest = add_guest(site, role: :viewer) - guest2 = build(:user) - - {:ok, lv, _html} = live(conn, @url) - - type_into_input(lv, "team[name]", "New Team Name") - - type_into_combo(lv, "team-member-candidates", guest.email) - select_combo_option(lv, 1) - - type_into_combo(lv, "team-member-candidates", guest2.email) - select_combo_option(lv, 0) - - lv |> element(~s|button[phx-click="setup-team"]|) |> render_click() - - [i1, i2] = team |> Ecto.assoc(:team_invitations) |> Repo.all() |> Enum.sort_by(& &1.id) - - assert i1.email == guest.email - assert i2.email == guest2.email - - assert_email_delivered_with( - to: [nil: guest.email], - subject: @subject_prefix <> "You've been invited to \"New Team Name\" team" - ) - - assert_email_delivered_with( - to: [nil: guest2.email], - subject: @subject_prefix <> "You've been invited to \"New Team Name\" team" - ) - end - end - - defp type_into_input(lv, id, text) do - lv - |> element("form") - |> render_change(%{id => text}) - end - - defp type_into_combo(lv, id, text) do - lv - |> element("input##{id}") - |> render_change(%{ - "_target" => ["display-#{id}"], - "display-#{id}" => "#{text}" - }) - end - - defp select_combo_option(lv, index) do - lv - |> element(~s/li#dropdown-team-member-candidates-option-#{index} a/) - |> render_click() - end -end diff --git a/test/plausible_web/live/team_setup_test.exs b/test/plausible_web/live/team_setup_test.exs index c230ad76250d..f1726b21b75e 100644 --- a/test/plausible_web/live/team_setup_test.exs +++ b/test/plausible_web/live/team_setup_test.exs @@ -1,15 +1,16 @@ defmodule PlausibleWeb.Live.TeamSetupTest do - use PlausibleWeb.ConnCase, async: true - - alias Plausible.Teams + use PlausibleWeb.ConnCase, async: false use Plausible.Teams.Test + use Bamboo.Test, shared: true import Phoenix.LiveViewTest import Plausible.Test.Support.HTML + alias Plausible.Teams alias Plausible.Repo @url "/team/setup" + @subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] " describe "/team/setup - edge cases" do setup [:create_user, :log_in] @@ -27,231 +28,315 @@ defmodule PlausibleWeb.Live.TeamSetupTest do test "does not redirect to /team/general if dev mode", %{conn: conn, user: user} do {:ok, team} = Teams.get_or_create(user) team |> Teams.Team.setup_changeset() |> Repo.update!() - assert {:ok, _, _} = live(conn, @url <> "?dev=1") + assert {:ok, lv, _} = live(conn, @url <> "?dev=1") + _ = render(lv) end end - describe "/team/setup - functional details" do + describe "/team/setup - main differences from team management" do setup [:create_user, :log_in, :create_team] test "renders form", %{conn: conn} do - {:ok, _, html} = live(conn, @url) - assert element_exists?(html, ~s|input#team_name[name="team[name]"]|) - assert element_exists?(html, ~s|input[name="team-member-candidate"]|) - assert element_exists?(html, ~s|button[phx-click="setup-team"]|) + {:ok, lv, html} = live(conn, @url) + assert element_exists?(html, ~s|input#update-team-form_name[name="team[name]"]|) + assert element_exists?(html, ~s|button[phx-click="save-team-layout"]|) + + _ = render(lv) end test "changing team name, updates team name in db", %{conn: conn, team: team} do {:ok, lv, _html} = live(conn, @url) type_into_input(lv, "team[name]", "New Team Name") assert Repo.reload!(team).name == "New Team Name" + + _ = render(lv) end + end - test "existing guest is suggested from combobox dropdown", %{conn: conn, user: user} do - site = new_site(owner: user) - guest = add_guest(site, role: :viewer) + describe "/team/setup - full integration" do + setup [:create_user, :log_in, :create_team] - {:ok, lv, _html} = live(conn, @url) + test "renders member, enqueues invitation, delivers it", %{conn: conn, user: user, team: team} do + {lv, html} = get_child_lv(conn, with_html?: true) + member_row1 = find(html, "#{member_el()}:nth-of-type(1)") |> text() + assert member_row1 =~ "#{user.name}" + assert member_row1 =~ "#{user.email}" + assert member_row1 =~ "You" - type_into_combo(lv, "team-member-candidates", guest.email) - select_combo_option(lv, 1) + add_invite(lv, "new@example.com", "admin") - [member1_row, member2_row] = - lv - |> render() - |> find(".member") + html = render(lv) + + member_row1 = find(html, "#{member_el()}:nth-of-type(1)") |> text() + assert member_row1 =~ "new@example.com" + assert member_row1 =~ "Invited User" + assert member_row1 =~ "Invitation Pending" + + member_row2 = find(html, "#{member_el()}:nth-of-type(2)") |> text() + assert member_row2 =~ "#{user.name}" + assert member_row2 =~ "#{user.email}" - assert text(member1_row) =~ user.name - assert text(member1_row) =~ "You" - assert text(member1_row) =~ user.email + save_layout(lv) - assert text(member2_row) =~ guest.name - assert text(member2_row) =~ guest.email + assert_redirect(lv, "/settings/team/general") - assert member1_row |> find(".role") |> text() =~ "Owner" - assert member2_row |> find(".role") |> text() =~ "Viewer" + assert_email_delivered_with( + to: [nil: "new@example.com"], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" + ) end - test "team member is added from input", %{conn: conn, user: user} do - new_member_email = build(:user).email + test "allows updating pending invitation role in place", %{conn: conn, team: team} do + lv = get_child_lv(conn) + add_invite(lv, "new@example.com", "admin") - {:ok, lv, _html} = live(conn, @url) + html = render(lv) - type_into_combo(lv, "team-member-candidates", new_member_email) - select_combo_option(lv, 0) + assert text_of_element(html, "#{member_el()}:nth-of-type(1) button") == "Admin" + assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Owner" - [member1_row, member2_row] = - lv - |> render() - |> find(".member") + change_role(lv, 1, "viewer") + html = render(lv) - assert text(member1_row) =~ user.name - assert text(member1_row) =~ "You" - assert text(member1_row) =~ user.email + assert text_of_element(html, "#{member_el()}:nth-of-type(1) button") == "Viewer" - assert text(member2_row) =~ "Invited User" - assert text(member2_row) =~ new_member_email + save_layout(lv) - assert member1_row |> find(".role") |> text() =~ "Owner" - assert member2_row |> find(".role") |> text() =~ "Viewer" + assert_email_delivered_with( + to: [nil: "new@example.com"], + subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" + ) end - test "arbitrary invalid e-mail attempt", %{conn: conn} do - {:ok, lv, _html} = live(conn, @url) - type_into_combo(lv, "team-member-candidates", "invalid") + test "allows updating membership role in place", %{conn: conn, team: team} do + member2 = add_member(team, role: :admin) + {lv, html} = get_child_lv(conn, with_html?: true) + + assert text_of_element(html, "#{member_el()}:nth-of-type(1) button") == "Owner" + assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Admin" - refute lv |> render |> text() =~ "Sorry" + change_role(lv, 2, "viewer") + html = render(lv) - select_combo_option(lv, 0) + assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Viewer" - assert lv |> render() |> text() =~ - "Sorry, e-mail 'invalid' is invalid. Please type the address again." + save_layout(lv) + + assert_no_emails_delivered() + + assert_team_membership(member2, team, :viewer) end - test "owner's own e-mail attempt", %{conn: conn, user: user} do - {:ok, lv, _html} = live(conn, @url) - type_into_combo(lv, "team-member-candidates", user.email) + test "allows updating guest membership so it moves sections", %{ + conn: conn, + user: user + } do + site = new_site(owner: user) + add_guest(site, role: :viewer, user: new_user(name: "Mr Guest", email: "guest@example.com")) - refute lv |> render |> text() =~ "Sorry" + lv = get_child_lv(conn) - select_combo_option(lv, 0) + html = render(lv) - assert lv |> render() |> text() =~ - "Sorry, e-mail '#{user.email}' is invalid. Please type the address again." + assert length(find(html, member_el())) == 1 + + assert text_of_element(html, "#{guest_el()}:first-of-type button") == "Guest" + + change_role(lv, 1, "viewer", guest_el()) + html = render(lv) + + assert length(find(html, member_el())) == 2 + refute element_exists?(html, "#guest-list") end - test "owner's role dropdown consists of inactive options", %{conn: conn} do - {:ok, _lv, html} = live(conn, @url) + test "fails to save layout with limits breached", %{conn: conn} do + lv = get_child_lv(conn) + add_invite(lv, "new1@example.com", "admin") + add_invite(lv, "new2@example.com", "admin") + add_invite(lv, "new3@example.com", "admin") + add_invite(lv, "new4@example.com", "admin") + + refute lv |> render() |> text() =~ "Your account is limited to 3 team members" + + save_layout(lv) - assert html - |> find(".member") - |> Enum.take(1) - |> find(".dropdown-items > *:not([^role=separator])") - |> Enum.all?(fn el -> - text_of_attr(el, "data-ui-state") == "disabled" - end) + assert lv |> render() |> text() =~ "Your account is limited to 3 team members" end - test "candidate's role dropdown allows changing role", %{conn: conn} do - new_member_email = build(:user).email - {:ok, lv, _html} = live(conn, @url) + test "all options are disabled for the sole owner", %{conn: conn} do + lv = get_child_lv(conn) - type_into_combo(lv, "team-member-candidates", new_member_email) - select_combo_option(lv, 0) + options = + lv + |> render() + |> find("#{member_el()} a") - lv - |> element(~s|.member a[phx-click="update-role"][phx-value-role="admin"]|) - |> render_click() + assert Enum.empty?(options) + end - member2_row = lv |> render() |> find(".member:nth-of-type(2) .role") |> text() - assert member2_row =~ "Admin" + test "in case of >1 owner, the one owner limit is still enforced", %{conn: conn, team: team} do + _other_owner = add_member(team, role: :owner) + lv = get_child_lv(conn) - lv - |> element(~s|.member a[phx-click="update-role"][phx-value-role="viewer"]|) - |> render_click() + options = + lv + |> render() + |> find("#{member_el()} a") + + refute Enum.empty?(options) - member2_row = lv |> render() |> find(".member:nth-of-type(2) .role") |> text() - assert member2_row =~ "Viewer" + change_role(lv, 1, "viewer") + + html = lv |> render() + + assert [_ | _] = find(html, "#{member_el()}:nth-of-type(1) a") + assert find(html, "#{member_el()}:nth-of-type(2) a") == [] end - test "member candidate suggestion disappears when selected", %{conn: conn, user: user} do + test "allows removing any type of entry", %{ + conn: conn, + user: user, + team: team + } do + member2 = add_member(team, role: :admin) + _invitation = invite_member(team, "sent@example.com", inviter: user, role: :viewer) + site = new_site(owner: user) - guest = add_guest(site, role: :viewer) - {:ok, lv, _html} = live(conn, @url) + guest = + add_guest(site, + role: :viewer, + user: new_user(name: "Mr Guest", email: "guest@example.com") + ) - type_into_combo(lv, "team-member-candidates", guest.email) + lv = get_child_lv(conn) + add_invite(lv, "pending@example.com", "admin") - assert lv - |> render() - |> find("#dropdown-team-member-candidates") - |> text() =~ guest.email + html = render(lv) - select_combo_option(lv, 1) + assert html |> find(member_el()) |> Enum.count() == 4 + assert html |> find(guest_el()) |> Enum.count() == 1 - _ = render(lv) + pending = find(html, "#{member_el()}:nth-of-type(1)") |> text() + sent = find(html, "#{member_el()}:nth-of-type(2)") |> text() + owner = find(html, "#{member_el()}:nth-of-type(3)") |> text() + admin = find(html, "#{member_el()}:nth-of-type(4)") |> text() - refute lv - |> render() - |> find("#dropdown-team-member-candidates") - |> text() =~ guest.email - end + guest_member = find(html, "#{guest_el()}:first-of-type") |> text() - test "member candidate can be removed", %{conn: conn, user: user} do - site = new_site(owner: user) + assert pending =~ "Invitation Pending" + assert sent =~ "Invitation Sent" + assert owner =~ "You" + assert admin =~ "Team Member" + assert guest_member =~ "Guest" - guest = add_guest(site, role: :viewer) + remove_member(lv, 1) + # next becomes first + remove_member(lv, 1) + # last becomes second + remove_member(lv, 2) - {:ok, lv, _html} = live(conn, @url) + # remove guest + remove_member(lv, 1, guest_el()) - type_into_combo(lv, "team-member-candidates", guest.email) - select_combo_option(lv, 1) + html = render(lv) |> text() - assert lv - |> render() - |> find(".member:nth-of-type(2)") - |> text() =~ guest.email + refute html =~ "Invitation Pending" + refute html =~ "Invitation Sent" + refute html =~ "Team Member" + refute html =~ "Guest" - lv - |> element(~s|.member a[phx-click="remove-member"][phx-value-email="#{guest.email}"]|) - |> render_click() + save_layout(lv) - refute lv - |> render() - |> find(".member") - |> text() =~ guest.email - end - end + assert_email_delivered_with( + to: [nil: guest.email], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) - describe "/team/setup - full integration" do - setup [:create_user, :log_in, :create_team] + assert_email_delivered_with( + to: [nil: member2.email], + subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" + ) - test "setting up team above plan member limits", %{ + assert_no_emails_delivered() + end + + test "respawns membersip enqueued for deletion", %{ conn: conn, - user: user, team: team } do - site = new_site(owner: user) - guest = add_guest(site, role: :viewer) + member2 = add_member(team, role: :editor, user: new_user(email: "another@example.com")) - extra_guests = build_list(3, :user) + lv = get_child_lv(conn) - {:ok, lv, _html} = live(conn, @url) + remove_member(lv, 1) - type_into_combo(lv, "team-member-candidates", guest.email) - select_combo_option(lv, 1) + add_invite(lv, "another@example.com", "viewer") - for g <- extra_guests do - type_into_combo(lv, "team-member-candidates", g.email) - select_combo_option(lv, 0) - end + html = render(lv) - lv |> element(~s|button[phx-click="setup-team"]|) |> render_click() + assert find(html, "#{member_el()}:nth-of-type(1)") |> text() =~ "Team Member" + assert find(html, "#{member_el()}:nth-of-type(2)") |> text() =~ "You" - assert team |> Ecto.assoc(:team_invitations) |> Repo.aggregate(:count) == 0 + save_layout(lv) - assert lv |> render() |> text() =~ "Your account is limited to 3 team members" + assert_no_emails_delivered() + assert_team_membership(member2, team, :viewer) end end defp type_into_input(lv, id, text) do lv - |> element("form") + |> element("form#update-team-form") |> render_change(%{id => text}) end - defp type_into_combo(lv, id, text) do + defp add_invite(lv, email, role) do + lv + |> element(~s|#input-role-picker a[phx-value-role="#{role}"]|) + |> render_click() + lv - |> element("input##{id}") - |> render_change(%{ - "_target" => ["display-#{id}"], - "display-#{id}" => "#{text}" + |> element("#team-layout-form") + |> render_submit(%{ + "input-email" => email }) end - defp select_combo_option(lv, index) do + defp save_layout(lv) do lv - |> element(~s/li#dropdown-team-member-candidates-option-#{index} a/) + |> element("button#save-layout") |> render_click() end + + defp change_role(lv, index, role, main_selector \\ member_el()) do + lv + |> element(~s|#{main_selector}:nth-of-type(#{index}) a[phx-value-role="#{role}"]|) + |> render_click() + end + + defp get_child_lv(conn, opts \\ []) do + {:ok, lv, _} = live(conn, @url) + assert lv = find_live_child(lv, "team-management-setup") + + if Keyword.get(opts, :with_html?) do + {lv, render(lv)} + else + lv + end + end + + defp remove_member(lv, index, main_selector \\ member_el()) do + lv + |> element(~s|#{main_selector}:nth-of-type(#{index}) a[phx-click="remove-member"]|) + |> render_click() + end + + defp member_el() do + ~s|#member-list div[data-test-kind="member"]| + end + + defp guest_el() do + ~s|#guest-list div[data-test-kind="guest"]| + end end diff --git a/test/support/teams/test.ex b/test/support/teams/test.ex index 3ac8c6d553c4..da5358c2a145 100644 --- a/test/support/teams/test.ex +++ b/test/support/teams/test.ex @@ -353,6 +353,21 @@ defmodule Plausible.Teams.Test do ) end + def assert_non_guest_membership(team, site, user) do + assert team_membership = + Repo.get_by(Plausible.Teams.Membership, + user_id: user.id, + team_id: team.id + ) + + assert team_membership.role != :guest + + refute Repo.get_by(Plausible.Teams.GuestMembership, + team_membership_id: team_membership.id, + site_id: site.id + ) + end + def subscription_of(%Plausible.Auth.User{} = user) do user |> team_of() diff --git a/test/support/test_utils.ex b/test/support/test_utils.ex index eacc31f8cb9f..0707b72f1535 100644 --- a/test/support/test_utils.ex +++ b/test/support/test_utils.ex @@ -47,6 +47,15 @@ defmodule Plausible.TestUtils do {:ok, team: team} end + def setup_team(%{team: team}) do + team = + team + |> Plausible.Teams.Team.setup_changeset() + |> Repo.update!() + + {:ok, team: team} + end + def create_legacy_site_import(%{site: site}) do create_site_import(%{site: site, create_legacy_import?: true}) end