Team Settings
{@my_team.name}
diff --git a/lib/plausible_web/templates/settings/team_general.html.heex b/lib/plausible_web/templates/settings/team_general.html.heex
index bbd038d66b1b..eb5e4c742697 100644
--- a/lib/plausible_web/templates/settings/team_general.html.heex
+++ b/lib/plausible_web/templates/settings/team_general.html.heex
@@ -19,4 +19,16 @@
+ <.tile>
+ <:title>
+
Team Members
+
+ <:subtitle>
+ Add, remove or change your team memberships
+
+ {live_render(@conn, PlausibleWeb.Live.TeamManagement,
+ id: "team-setup",
+ session: %{"mode" => "team-management"}
+ )}
+
diff --git a/lib/plausible_web/templates/site/membership/invite_member_form.html.heex b/lib/plausible_web/templates/site/membership/invite_member_form.html.heex
index 34a2ca02f8a6..359d6046c988 100644
--- a/lib/plausible_web/templates/site/membership/invite_member_form.html.heex
+++ b/lib/plausible_web/templates/site/membership/invite_member_form.html.heex
@@ -1,6 +1,6 @@
<.focus_box>
<:title>
- Invite member to {@site.domain}
+ Invite guest to {@site.domain}
<:subtitle>
diff --git a/lib/plausible_web/templates/site/settings_people.html.heex b/lib/plausible_web/templates/site/settings_people.html.heex
index d5c540942ac4..3d77b62a5d2a 100644
--- a/lib/plausible_web/templates/site/settings_people.html.heex
+++ b/lib/plausible_web/templates/site/settings_people.html.heex
@@ -12,7 +12,7 @@
mt?={false}
href={Routes.membership_path(@conn, :invite_member_form, @site.domain)}
>
- Invite new member
+ Invite New Guest
@@ -50,7 +50,7 @@
- <.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