diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index ea6803110682..e1c9513fb210 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -14,11 +14,12 @@ import topbar from 'topbar' import Alpine from 'alpinejs' import CopySnippet from './copy-snippet' +import MemberRows from './member-rows' let csrfToken = document.querySelector("meta[name='csrf-token']") let websocketUrl = document.querySelector("meta[name='websocket-url']") if (csrfToken && websocketUrl) { - let Hooks = { Modal, Dropdown, CopySnippet } + let Hooks = { Modal, Dropdown, CopySnippet, MemberRows } Hooks.VerificationLifecycle = { mounted() { diff --git a/assets/js/liveview/member-rows.js b/assets/js/liveview/member-rows.js new file mode 100644 index 000000000000..c2845599f022 --- /dev/null +++ b/assets/js/liveview/member-rows.js @@ -0,0 +1,178 @@ +// Instantly adds/removes rows, and selects a role, in the "create team" +// form's member list - entirely client-side, no server round trip. Row and +// role state is plain form data (an email input and a hidden role input per +// row), read once when the form is submitted. +// +// Expects a `template[data-row-template]` (the row blueprint, with the +// literal placeholder `__ROW_ID__` standing in for the row id in its +// attributes), a `[data-row-list]` container to append/remove rows from, a +// `[data-add-row]` button, `[data-remove-row]` buttons, role pickers built +// from a `details[data-role-picker]` (listbox-button pattern: a `` +// trigger, a `[role=listbox]` container, and `[data-role-item]` +// `[role=option]` buttons) containing a `[data-role-label]` span and a +// `[data-role-value]` hidden input, and a `data-max-rows` attribute capping +// how many rows can exist (computed server-side from the plan's team member +// limit) - the add button shows a not-allowed cursor once that cap is hit. + +const ROW_ID_PLACEHOLDER = '__ROW_ID__' + +const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1) + +export default { + mounted() { + this.template = this.el.querySelector('template[data-row-template]') + this.list = this.el.querySelector('[data-row-list]') + this.maxRows = parseInt(this.el.dataset.maxRows, 10) + this.addButton = this.el.querySelector('[data-add-row]') + + this.addButton.addEventListener('click', () => this.addRow()) + + this.list.addEventListener('click', (e) => { + const removeButton = e.target.closest('[data-remove-row]') + if (removeButton) return this.removeRow(removeButton) + + const roleItem = e.target.closest('[data-role-item]') + if (roleItem) return this.selectRole(roleItem) + }) + + // Native
only closes on a second click on - close it + // on an outside click too, like any other dropdown. + this.handleOutsideClick = (e) => { + this.list + .querySelectorAll('[data-role-picker][open]') + .forEach((details) => { + if (!details.contains(e.target)) this.closeRolePicker(details) + }) + } + document.addEventListener('click', this.handleOutsideClick) + + this.list + .querySelectorAll('[data-role-picker]') + .forEach((details) => this.wireRolePicker(details)) + + this.updateAddButtonState() + }, + + destroyed() { + document.removeEventListener('click', this.handleOutsideClick) + }, + + addRow() { + if (this.list.children.length >= this.maxRows) return + + const rowId = + window.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}` + + const html = this.template.innerHTML.replaceAll(ROW_ID_PLACEHOLDER, rowId) + const wrapper = document.createElement('div') + wrapper.innerHTML = html + const row = wrapper.firstElementChild + + this.list.appendChild(row) + this.wireRolePicker(row.querySelector('[data-role-picker]')) + this.updateAddButtonState() + }, + + updateAddButtonState() { + this.addButton.classList.toggle( + 'cursor-not-allowed', + this.list.children.length >= this.maxRows + ) + }, + + removeRow(button) { + button.closest('[data-row]').remove() + this.updateAddButtonState() + }, + + selectRole(item) { + const row = item.closest('[data-row]') + const details = row.querySelector('[data-role-picker]') + const items = [...details.querySelectorAll('[data-role-item]')] + const role = item.dataset.roleItem + + row.querySelector('[data-role-value]').value = role + row.querySelector('[data-role-label]').textContent = capitalize(role) + items.forEach((i) => i.setAttribute('aria-selected', i === item)) + + this.closeRolePicker(details) + details.querySelector('summary').focus() + }, + + // Wires up the WAI-ARIA listbox-button keyboard pattern for a role picker: + // arrow keys move a roving tabindex between options (opening the listbox on + // first use if needed), Home/End jump to the ends, Escape closes and + // returns focus to the trigger, and Tab closes the listbox on its way out. + wireRolePicker(details) { + const summary = details.querySelector('summary') + const items = [...details.querySelectorAll('[data-role-item]')] + + details.addEventListener('toggle', () => { + summary.setAttribute('aria-expanded', details.open) + this.setRovingIndex(items, 0) + }) + + details.addEventListener('keydown', (e) => { + const currentIndex = items.indexOf(document.activeElement) + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + details.open = true + this.focusItem( + items, + currentIndex === -1 ? 0 : (currentIndex + 1) % items.length + ) + break + + case 'ArrowUp': + e.preventDefault() + details.open = true + this.focusItem( + items, + currentIndex === -1 + ? items.length - 1 + : (currentIndex - 1 + items.length) % items.length + ) + break + + case 'Home': + if (!details.open) return + e.preventDefault() + this.focusItem(items, 0) + break + + case 'End': + if (!details.open) return + e.preventDefault() + this.focusItem(items, items.length - 1) + break + + case 'Escape': + if (!details.open) return + this.closeRolePicker(details) + summary.focus() + break + + case 'Tab': + this.closeRolePicker(details) + break + } + }) + }, + + focusItem(items, index) { + this.setRovingIndex(items, index) + items[index].focus() + }, + + setRovingIndex(items, index) { + items.forEach((item, i) => + item.setAttribute('tabindex', i === index ? '0' : '-1') + ) + }, + + closeRolePicker(details) { + details.removeAttribute('open') + } +} diff --git a/e2e/tests/dashboard/team-setup.spec.ts b/e2e/tests/dashboard/team-setup.spec.ts index 47a0926da53f..d7f1da58cb57 100644 --- a/e2e/tests/dashboard/team-setup.spec.ts +++ b/e2e/tests/dashboard/team-setup.spec.ts @@ -11,20 +11,16 @@ test('submitting team name via Enter key does not crash', async ({ await expectLiveViewConnected(page) - await expect(page.getByRole('button', { name: 'Create Team' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Create team' })).toBeVisible() const nameInput = page.locator('input[name="team[name]"]') await nameInput.clear() await nameInput.fill('My New Team') + // Enter submits the whole form directly (single phx-submit handler) await nameInput.press('Enter') - await expect(nameInput).toHaveValue('My New Team') - - // the form had no phx-submit handler and plain HTTP POST fallback was made - await page.getByRole('button', { name: 'Create Team' }).click() - await expect(page).toHaveURL(/\/settings\/team\/general/) await expectLiveViewConnected(page) @@ -34,7 +30,7 @@ test('submitting team name via Enter key does not crash', async ({ await expect(nameInput2).toHaveValue('My New Team') }) -test('create team is blocked while the name is rejected', async ({ +test('create team is blocked when the name is rejected on submit', async ({ page, request }) => { @@ -43,26 +39,24 @@ test('create team is blocked while the name is rejected', async ({ await expectLiveViewConnected(page) - const createTeam = page.getByRole('button', { name: 'Create Team' }) + const createTeam = page.getByRole('button', { name: 'Create team' }) const nameInput = page.locator('input[name="team[name]"]') await expect(createTeam).toBeEnabled() await nameInput.fill('My personal sites') + await createTeam.click() - await expect(page.locator('#update-team-form')).toContainText('is reserved') - await expect(createTeam).toBeDisabled() + await expect(page.getByText('is reserved')).toBeVisible() + await expect(page).toHaveURL(/\/team\/setup/) await test.step('recovers once the name is fixed', async () => { await nameInput.fill('Fixed Team Name') + await createTeam.click() - await expect(createTeam).toBeEnabled() + await expect(page).toHaveURL(/\/settings\/team\/general/) }) - await createTeam.click() - - await expect(page).toHaveURL(/\/settings\/team\/general/) - await expectLiveViewConnected(page) await expect(page.locator('input[name="team[name]"]')).toHaveValue( @@ -95,37 +89,34 @@ test('creating a team when the user name is long', async ({ await expectLiveViewConnected(page) // the page mounts instead of crashing on the over-long suggested name - await expect(page.getByRole('button', { name: 'Create Team' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Create team' })).toBeVisible() const nameInput = page.locator('input[name="team[name]"]') + const createTeam = page.getByRole('button', { name: 'Create team' }) await expect(nameInput).toHaveValue(expectedTeamName) - await test.step('a name over the limit is rejected', async () => { + await test.step('a name over the limit is rejected on submit', async () => { await nameInput.fill('b'.repeat(51)) + await createTeam.click() - await expect(page.locator('#update-team-form')).toContainText( - 'should be at most 50 character(s)' - ) await expect( - page.getByRole('button', { name: 'Create Team' }) - ).toBeDisabled() + page.getByText('should be at most 50 character(s)') + ).toBeVisible() + await expect(page).toHaveURL(/\/team\/setup/) }) - await test.step('a name carrying a URL scheme is rejected', async () => { + await test.step('a name carrying a URL scheme is rejected on submit', async () => { await nameInput.fill('Cheap meds at https://spam.example.com') + await createTeam.click() - await expect(page.locator('#update-team-form')).toContainText( - 'cannot contain a URL' - ) - await expect( - page.getByRole('button', { name: 'Create Team' }) - ).toBeDisabled() + await expect(page.getByText('cannot contain a URL')).toBeVisible() + await expect(page).toHaveURL(/\/team\/setup/) }) await nameInput.fill('Chosen Team Name') - await page.getByRole('button', { name: 'Create Team' }).click() + await createTeam.click() await expect(page).toHaveURL(/\/settings\/team\/general/) diff --git a/lib/plausible/teams/billing.ex b/lib/plausible/teams/billing.ex index db3ced242997..a49854bd4066 100644 --- a/lib/plausible/teams/billing.ex +++ b/lib/plausible/teams/billing.ex @@ -233,6 +233,7 @@ defmodule Plausible.Teams.Billing do Teams.owned_sites_count(team) end + @spec team_member_limit(Teams.Team.t() | nil) :: non_neg_integer() | :unlimited on_ee do @team_member_limit_for_trials 10 @@ -256,7 +257,16 @@ defmodule Plausible.Teams.Billing do team_member_limit(team) == 0 end else - def team_member_limit(_team), do: :unlimited + def team_member_limit(_team) do + # The `else` branch is not reachable. + # This a workaround for Elixir 1.18+ compiler + # being too smart. + if :erlang.phash2(1, 1) == 0 do + :unlimited + else + 0 + end + end def solo?(_team), do: always(false) end diff --git a/lib/plausible/teams/team.ex b/lib/plausible/teams/team.ex index 86e08967c8b4..47342f67408a 100644 --- a/lib/plausible/teams/team.ex +++ b/lib/plausible/teams/team.ex @@ -147,11 +147,21 @@ defmodule Plausible.Teams.Team do end def name_changeset(team, attrs \\ %{}) do - team - |> cast(attrs, [:name]) - |> validate_required(:name) - |> validate_name() - |> validate_exclusion(:name, [Plausible.Teams.default_name()]) + changeset = + team + |> cast(attrs, [:name]) + |> validate_name() + |> validate_required(:name) + + # validate_exclusion/3 only runs its check when the field actually changed + # relative to the struct's current value, which would let a freshly + # auto-created team (whose name already equals the reserved default) keep + # that name simply by resubmitting it unchanged. Check unconditionally. + if get_field(changeset, :name) == Plausible.Teams.default_name() do + add_error(changeset, :name, "is reserved") + else + changeset + end end def setup_changeset(team, now \\ NaiveDateTime.utc_now(:second)) do diff --git a/lib/plausible_web/live/components/team.ex b/lib/plausible_web/live/components/team.ex index 06d2a14218e9..3a2215c8980e 100644 --- a/lib/plausible_web/live/components/team.ex +++ b/lib/plausible_web/live/components/team.ex @@ -8,6 +8,20 @@ defmodule PlausibleWeb.Live.Components.Team do alias Plausible.Auth.User + @role_descriptions [ + owner: "Manage the team without restrictions", + admin: "Manage all team settings", + editor: "Create and view new sites", + billing: "Manage subscription", + viewer: "View all sites under your team" + ] + + def role_descriptions, do: @role_descriptions + + @roles_cast_map Enum.into(@role_descriptions, %{}, fn {role, _} -> {to_string(role), role} end) + + def role_to_atom(role), do: Map.fetch!(@roles_cast_map, role) + attr(:user, User, required: true) attr(:label, :string, default: nil) attr(:role, :atom, default: nil) @@ -59,62 +73,19 @@ defmodule PlausibleWeb.Live.Components.Team do <:menu class="dropdown-items max-w-60"> <.role_item + :for={{role, description} <- role_descriptions()} user={@user} - id={"option-#{:erlang.phash2(@user.email)}-owner"} - phx-value-email={@user.email} - phx-value-name={@user.name} - role={:owner} - disabled={@disabled or @role == :owner} - dispatch_animation?={@role == :guest} - > - Manage the team without restrictions - - <.role_item - user={@user} - id={"option-#{:erlang.phash2(@user.email)}-admin"} + id={"option-#{:erlang.phash2(@user.email)}-#{role}"} phx-value-email={@user.email} phx-value-name={@user.name} - role={:admin} - disabled={@disabled or @role == :admin} + role={role} + disabled={@disabled or @role == role} dispatch_animation?={@role == :guest} + data-confirm={ + if @me? and role in [:editor, :billing, :viewer], do: lower_role_warning() + } > - Manage all team settings - - <.role_item - user={@user} - id={"option-#{:erlang.phash2(@user.email)}-editor"} - phx-value-email={@user.email} - phx-value-name={@user.name} - role={:editor} - disabled={@disabled or @role == :editor} - dispatch_animation?={@role == :guest} - data-confirm={if @me?, do: lower_role_warning()} - > - Create and view new sites - - <.role_item - user={@user} - id={"option-#{:erlang.phash2(@user.email)}-billing"} - phx-value-email={@user.email} - phx-value-name={@user.name} - role={:billing} - disabled={@disabled or @role == :billing} - dispatch_animation?={@role == :guest} - data-confirm={if @me?, do: lower_role_warning()} - > - Manage subscription - - <.role_item - user={@user} - id={"option-#{:erlang.phash2(@user.email)}-viewer"} - phx-value-email={@user.email} - phx-value-name={@user.name} - role={:viewer} - disabled={@disabled or @role == :viewer} - dispatch_animation?={@role == :guest} - data-confirm={if @me?, do: lower_role_warning()} - > - View all sites under your team + {description} <.dropdown_divider /> @@ -145,6 +116,32 @@ defmodule PlausibleWeb.Live.Components.Team do """ end + attr(:id, :string, required: true) + attr(:role, :atom, required: true) + attr(:my_role, :atom, required: true) + attr(:rest, :global) + + def role_picker(assigns) do + ~H""" + <.dropdown id={@id}> + <:button class="role w-[100px] inline-flex items-center justify-between font-medium rounded-md px-3 py-2 text-sm border border-gray-300 dark:border-gray-750 rounded-md text-gray-800 dark:text-gray-100 dark:bg-gray-750 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate shadow-xs hover:shadow-sm transition-all duration-150 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"> + {@role |> Atom.to_string() |> String.capitalize()} + + + <:menu class="dropdown-items max-w-60"> + <.role_item + :for={{role, description} <- role_descriptions()} + role={role} + disabled={role_change_disabled?(@my_role, role)} + {@rest} + > + {description} + + + + """ + end + attr(:role, :atom, required: true) attr(:disabled, :boolean, default: false) attr(:dispatch_animation?, :boolean, default: false) @@ -191,6 +188,9 @@ defmodule PlausibleWeb.Live.Components.Team do """ end + defp role_change_disabled?(my_role, :owner), do: my_role != :owner + defp role_change_disabled?(my_role, _role), do: my_role not in [:owner, :admin] + defp lower_role_warning() do "You're about to lower your own role. Some team management features will no longer be accessible to you, and you'll need to ask a team owner to restore your access. Do you want to continue?" end diff --git a/lib/plausible_web/live/team_management.ex b/lib/plausible_web/live/team_management.ex index be42a2a79650..196d6501930d 100644 --- a/lib/plausible_web/live/team_management.ex +++ b/lib/plausible_web/live/team_management.ex @@ -10,42 +10,8 @@ defmodule PlausibleWeb.Live.TeamManagement do alias Plausible.Teams.Management.Layout - def mount(_params, session, socket) do - mode = - if session["mode"] == "team-setup" do - :team_setup - else - :team_management - end - - socket = - if mode == :team_setup do - setup_team_name(socket) - else - socket - end - - {:ok, socket |> assign(mode: mode) |> reset()} - end - - # In team setup the name is part of this form, so that creating the team can - # only ever happen with a name this LiveView has accepted. - defp setup_team_name( - %{assigns: %{current_user: current_user, current_team: current_team}} = socket - ) do - suggested_name = Teams.Team.suggested_name(current_user.name) - - current_team = - current_team - |> Teams.Team.name_changeset(%{name: suggested_name}) - |> Plausible.Repo.update!() - - assign(socket, - current_team: current_team, - suggested_name: suggested_name, - team_name_form: to_form(Teams.Team.name_changeset(current_team, %{})), - locked?: Plausible.Teams.Billing.solo?(current_team) - ) + def mount(_params, _session, socket) do + {:ok, reset(socket)} end defp reset(%{assigns: %{current_user: current_user, current_team: current_team}} = socket) do @@ -66,32 +32,6 @@ defmodule PlausibleWeb.Live.TeamManagement do def render(assigns) do ~H""" - <.form - :let={f} - :if={@mode == :team_setup} - for={@team_name_form} - method="post" - phx-change="update-team" - phx-submit="update-team" - phx-blur="update-team" - id="update-team-form" - class="mt-4 mb-8" - > - <.input - type="text" - placeholder={@suggested_name} - autofocus={not @locked?} - field={f[:name]} - label="Name" - width="w-full" - phx-debounce="500" - /> - - - <.label :if={@mode == :team_setup} class="mb-2"> - Team members - - <.flash_messages flash={@flash} /> - <.dropdown id="input-role-picker"> - <:button class="role inline-flex items-center gap-x-2 font-medium rounded-md px-3 py-2 text-sm border border-gray-300 dark:border-gray-750 rounded-md text-gray-800 dark:text-gray-100 dark:bg-gray-750 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate shadow-xs hover:shadow-sm transition-all duration-150 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"> - {@input_role |> Atom.to_string() |> String.capitalize()} - - - <:menu class="dropdown-items max-w-60"> - <.role_item role={:owner} disabled={@my_role != :owner} phx-click="switch-role"> - Manage the team without restrictions - - <.role_item - role={:admin} - disabled={@my_role not in [:owner, :admin]} - phx-click="switch-role" - > - Manage all team settings - - <.role_item - role={:editor} - disabled={@my_role not in [:owner, :admin]} - phx-click="switch-role" - > - Create and view new sites - - <.role_item - role={:billing} - disabled={@my_role not in [:owner, :admin]} - phx-click="switch-role" - > - Manage subscription - - <.role_item - role={:viewer} - disabled={@my_role not in [:owner, :admin]} - phx-click="switch-role" - > - View all sites under your team - - - + <.role_picker + id="input-role-picker" + role={@input_role} + my_role={@my_role} + phx-click="switch-role" + /> <.button id="invite-member" @@ -205,30 +112,16 @@ defmodule PlausibleWeb.Live.TeamManagement do disabled={@my_role not in [:owner, :admin]} /> - - <.button - :if={@mode == :team_setup} - id="save-layout" - type="submit" - phx-click="save-team-layout" - disabled={not @team_name_form.source.valid?} - class="mt-8 w-full" - > - Create Team - """ end - @roles Plausible.Teams.Membership.roles() -- [:guest] - @roles_cast_map Enum.into(@roles, %{}, fn role -> {to_string(role), role} end) - def handle_event("form-changed", params, socket) do {:noreply, assign(socket, input_email: params["input-email"])} end def handle_event("switch-role", %{"role" => role}, socket) do - socket = assign(socket, input_role: Map.fetch!(@roles_cast_map, role)) + socket = assign(socket, input_role: role_to_atom(role)) {:noreply, socket} end @@ -275,33 +168,6 @@ defmodule PlausibleWeb.Live.TeamManagement do {:noreply, socket} end - def handle_event("update-team", %{"team" => %{"name" => name}}, socket) do - changeset = Teams.Team.name_changeset(socket.assigns.current_team, %{name: name}) - - socket = - case Plausible.Repo.update(changeset) do - {:ok, team} -> - assign(socket, team_name_form: to_form(changeset), current_team: team) - - {:error, changeset} -> - assign(socket, team_name_form: to_form(changeset)) - end - - {:noreply, socket} - end - - def handle_event( - "save-team-layout", - _params, - socket - ) do - if team_name_accepted?(socket) do - {:noreply, save_team_layout(socket)} - else - {:noreply, put_live_flash(socket, :error, "Please fix the team name first")} - end - end - def handle_event("remove-member", %{"email" => email}, %{assigns: %{layout: layout}} = socket) do socket = case Layout.verify_removable(layout, email) do @@ -325,7 +191,7 @@ defmodule PlausibleWeb.Live.TeamManagement do %{assigns: %{layout: layout}} = socket ) do socket = - update_layout(socket, Layout.update_role(layout, email, Map.fetch!(@roles_cast_map, role))) + update_layout(socket, Layout.update_role(layout, email, role_to_atom(role))) |> push_event("js-exec", %{ to: "#member-row-#{:erlang.phash2(email)}", attr: "data-role-changed" @@ -334,28 +200,14 @@ defmodule PlausibleWeb.Live.TeamManagement do {:noreply, socket} end - defp team_name_accepted?(%{assigns: %{mode: :team_setup}} = socket) do - socket.assigns.team_name_form.source.valid? - end - - defp team_name_accepted?(_socket), do: true - defp valid_email?(email) do String.contains?(email, "@") and String.contains?(email, ".") end defp update_layout(socket, layout) do - socket = - assign(socket, - layout: layout, - team_layout_changed?: true - ) - - if socket.assigns.mode == :team_management do - save_team_layout(socket) - else - socket - end + socket + |> assign(layout: layout, team_layout_changed?: true) + |> save_team_layout() end defp save_team_layout( @@ -368,15 +220,8 @@ defmodule PlausibleWeb.Live.TeamManagement do current_team: Plausible.Repo.reload!(current_team) }) - case {result, socket.assigns.mode} do - {{:ok, _}, :team_setup} -> - socket - |> put_flash(:success, "Your team is now created") - |> redirect( - to: Routes.settings_path(socket, :team_general, __team: current_team.identifier) - ) - - {{:ok, _}, :team_management} -> + case result do + {:ok, _} -> case Teams.Memberships.team_role(current_team, current_user) do {:ok, role} when role in [:viewer, :billing, :editor] -> redirect(socket, @@ -390,28 +235,28 @@ defmodule PlausibleWeb.Live.TeamManagement do redirect(socket, to: Routes.site_path(socket, :index, __team: "none")) end - {{:error, :permission_denied}, _} -> + {:error, :permission_denied} -> socket |> put_live_flash( :error, "Permission denied" ) - {{:error, :only_one_owner}, _} -> + {:error, :only_one_owner} -> socket |> put_live_flash( :error, "The team has to have at least one owner" ) - {{:error, :disabled_2fa}, _} -> + {:error, :disabled_2fa} -> socket |> put_live_flash( :error, "User must have 2FA enabled to become an owner" ) - {{:error, {:over_limit, limit}}, _} -> + {:error, {:over_limit, limit}} -> socket |> put_live_flash( :error, diff --git a/lib/plausible_web/live/team_setup.ex b/lib/plausible_web/live/team_setup.ex index fe33db208e1c..8a42d5080890 100644 --- a/lib/plausible_web/live/team_setup.ex +++ b/lib/plausible_web/live/team_setup.ex @@ -5,7 +5,9 @@ defmodule PlausibleWeb.Live.TeamSetup do use PlausibleWeb, :live_view + alias Plausible.Repo alias Plausible.Teams + alias Plausible.Teams.Management.Layout alias PlausibleWeb.Router.Helpers, as: Routes def mount(_params, _session, socket) do @@ -13,11 +15,19 @@ defmodule PlausibleWeb.Live.TeamSetup do case socket.assigns.current_team do %Teams.Team{setup_complete: true} -> socket - |> put_flash(:success, "Your team is now created") + |> put_flash(:success, "Your team has already been created") |> redirect(to: Routes.settings_path(socket, :team_general)) - %Teams.Team{} -> - socket + %Teams.Team{} = team -> + suggested_name = Teams.Team.suggested_name(socket.assigns.current_user.name) + name_changeset = Teams.Team.name_changeset(team, %{name: suggested_name}) + + assign(socket, + current_team: team, + team_name_form: to_form(name_changeset), + locked?: Plausible.Teams.Billing.solo?(team), + max_new_members: max_new_members(team) + ) _ -> socket @@ -29,8 +39,6 @@ defmodule PlausibleWeb.Live.TeamSetup do end def render(assigns) do - assigns = assign(assigns, :locked?, Plausible.Teams.Billing.solo?(assigns.current_team)) - ~H""" <.focus_box padding?={false}> <:title> @@ -43,7 +51,7 @@ defmodule PlausibleWeb.Live.TeamSetup do <:subtitle>

- Name your team, add team members and assign roles. When ready, click "Create Team" to send invitations + Name your team and optionally invite members by email. When ready, click "Create team"

@@ -53,16 +61,233 @@ defmodule PlausibleWeb.Live.TeamSetup do current_team={@current_team} locked?={@locked?} > - {live_render(@socket, PlausibleWeb.Live.TeamManagement, - id: "team-management-setup", - container: {:div, id: "team-setup"}, - session: %{ - "mode" => "team-setup" - } - )} + <.flash_messages flash={@flash} /> + + <.form :let={f} for={@team_name_form} id="create-team-form" phx-submit="create-team"> + <.input + type="text" + placeholder={"#{@current_user.name}'s team"} + autofocus={not @locked?} + field={f[:name]} + label="Name" + width="w-full" + required + /> + +
+
+ <.label> + Team members + + + +
+ +
+ <.member_row row={%{id: "1", email: "", role: :viewer}} /> +
+ + +
+ + <.button id="create-team-submit" type="submit" class="mt-8 w-full"> + Create team + + """ end + + attr(:row, :map, required: true) + + defp member_row(assigns) do + ~H""" +
+
+ <.input + type="email" + name={"rows[#{@row.id}][email]"} + value={@row.email} + placeholder="Enter e-mail" + mt?={false} + /> +
+ +
+ + +
+ +
+
+ + + + +
+ """ + end + + def handle_event("create-team", %{"team" => %{"name" => name}} = params, socket) do + changeset = Teams.Team.name_changeset(socket.assigns.current_team, %{name: name}) + + case Repo.update(changeset) do + {:ok, team} -> + create_team( + assign(socket, current_team: team), + Map.get(params, "rows", %{}) + ) + + {:error, changeset} -> + {:noreply, assign(socket, team_name_form: to_form(changeset))} + end + end + + defp create_team(socket, rows_params) do + entries = + rows_params + |> Map.values() + |> Enum.map(fn row_params -> + email = row_params |> Map.get("email", "") |> String.trim() + + role = + PlausibleWeb.Live.Components.Team.role_to_atom(Map.get(row_params, "role", "viewer")) + + %{email: email, role: role} + end) + |> Enum.filter(&(&1.email != "")) + + emails = Enum.map(entries, & &1.email) + + cond do + Enum.any?(emails, &(not valid_email?(&1))) -> + {:noreply, put_live_flash(socket, :error, "Make sure all e-mails are valid")} + + Enum.uniq(emails) != emails -> + {:noreply, put_live_flash(socket, :error, "Make sure e-mails are unique")} + + socket.assigns.current_user.email in emails -> + {:noreply, put_live_flash(socket, :error, "You cannot invite yourself")} + + true -> + layout = + Enum.reduce(entries, %{}, fn %{email: email, role: role}, layout -> + Layout.schedule_send(layout, email, role) + end) + + persist_layout(socket, layout) + end + end + + defp persist_layout(socket, layout) do + case Layout.persist(layout, %{ + current_user: socket.assigns.current_user, + current_team: socket.assigns.current_team + }) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:success, "Your team is now created") + |> redirect( + to: + Routes.settings_path(socket, :team_general, + __team: socket.assigns.current_team.identifier + ) + )} + + {:error, {:over_limit, limit}} -> + {:noreply, + put_live_flash( + socket, + :error, + "Your account is limited to #{limit} team members. You can upgrade your plan to increase this limit" + )} + + {:error, other} -> + # Team setup only ever sends brand-new invitations (never updates or + # removes existing memberships), so none of Layout.persist/2's other + # known error reasons (permission checks, last-owner protection, 2FA + # enforcement) are reachable from here. Anything else is unexpected. + Sentry.capture_message("Unexpected error while creating team via team setup", + extra: %{error: inspect(other), team_id: socket.assigns.current_team.id} + ) + + {:noreply, put_live_flash(socket, :error, "Something went wrong. Please try again")} + end + end + + defp valid_email?(email) do + String.contains?(email, "@") and String.contains?(email, ".") + end + + @max_rows_when_unlimited 20 + + # Defines the upper bound for how many email+role input rows the UI + # should render at most, in order to prevent the user getting a + # "team member limit exceeded" error upon submit without any prior + # indication about it. + + # Since team setup is the very first creation of a team, we can safely + # assume that there are no existing memberships/invitations, and that + # max rows is equivalent to the team member limit. + + # There's one exception though: guest memberships/invitations (accounts + # invited to individual sites of a team with `setup_complete: false`) + # *can* exist, but we're deliberately ignoring those here. While such + # teams might hit the team member limit error upon submit, we still + # shouldn't prevent them from adding as many rows as the team member + # limit because inviting an email that's already a guest won't count + # "double" towards the team member limit. + defp max_new_members(team) do + case Teams.Billing.team_member_limit(team) do + :unlimited -> @max_rows_when_unlimited + limit -> limit + end + end end diff --git a/test/plausible_web/live/team_setup_test.exs b/test/plausible_web/live/team_setup_test.exs index 9089500d46f8..b48614de8142 100644 --- a/test/plausible_web/live/team_setup_test.exs +++ b/test/plausible_web/live/team_setup_test.exs @@ -29,166 +29,107 @@ defmodule PlausibleWeb.Live.TeamSetupTest do test "shortens a long user name to fit the limit", %{conn: conn} do user = new_user(name: String.duplicate("a", 55)) {:ok, conn: conn} = log_in(%{user: user, conn: conn}) - {:ok, team} = Teams.get_or_create(user) + {:ok, _team} = Teams.get_or_create(user) - {_lv, html} = get_child_lv(conn, with_html?: true) + {:ok, _lv, html} = live(conn, @url) expected = String.duplicate("a", 43) <> "'s team" - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == + assert text_of_attr(html, ~s|input#create-team-form_name[name="team[name]"]|, "value") == expected - - assert Repo.reload!(team).name == expected end test "falls back to a generic name when the user name carries a URL scheme", %{conn: conn} do user = new_user(name: "Cheap meds https://spam.example.com") {:ok, conn: conn} = log_in(%{user: user, conn: conn}) - {:ok, team} = Teams.get_or_create(user) + {:ok, _team} = Teams.get_or_create(user) - {_lv, html} = get_child_lv(conn, with_html?: true) + {:ok, _lv, html} = live(conn, @url) - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == + assert text_of_attr(html, ~s|input#create-team-form_name[name="team[name]"]|, "value") == "My team" - - assert Repo.reload!(team).name == "My team" end test "falls back to a generic name when shortening overflows the column", %{conn: conn} do user = new_user(name: String.duplicate("๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", 36)) {:ok, conn: conn} = log_in(%{user: user, conn: conn}) - {:ok, team} = Teams.get_or_create(user) + {:ok, _team} = Teams.get_or_create(user) - {_lv, html} = get_child_lv(conn, with_html?: true) + {:ok, _lv, html} = live(conn, @url) - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == + assert text_of_attr(html, ~s|input#create-team-form_name[name="team[name]"]|, "value") == "My team" - - assert Repo.reload!(team).name == "My team" end end - describe "/team/setup - main differences from team management" do + describe "/team/setup - team name" do setup [:create_user, :log_in, :create_team] - test "renames the team on first render", %{conn: conn, team: team} do - assert team.name == "My personal sites" - {:ok, _lv, html} = live(conn, @url) - - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == - "Jane Smith's team" - - assert Repo.reload!(team).name == "Jane Smith's team" - end - - test "renames even if team already has non-default name", %{conn: conn, team: team} do + test "suggests a default name without persisting it", %{conn: conn, team: team} do assert team.name == "My personal sites" - Repo.update!(Teams.Team.name_changeset(team, %{name: "Foo"})) {:ok, _lv, html} = live(conn, @url) - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == + assert text_of_attr(html, ~s|input#create-team-form_name[name="team[name]"]|, "value") == "Jane Smith's team" - assert Repo.reload!(team).name == "Jane Smith's team" + assert Repo.reload!(team).name == "My personal sites" end test "renders form", %{conn: conn} do - {: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 - lv = get_child_lv(conn) - type_into_input(lv, "team[name]", "New Team Name") - assert Repo.reload!(team).name == "New Team Name" - - _ = render(lv) + {:ok, _lv, html} = live(conn, @url) + assert element_exists?(html, ~s|input#create-team-form_name[name="team[name]"]|) + assert element_exists?(html, "#create-team-submit") + assert elem_count(html, row_el()) == 1 end - test "setting team name to 'My personal sites' is reserved", %{ - conn: conn, - team: team, - user: user - } do - {lv, html} = get_child_lv(conn, with_html?: true) - - assert text_of_attr(html, ~s|input#update-team-form_name[name="team[name]"]|, "value") == - "#{user.name}'s team" + test "marks the name input as required so the browser blocks a blank submit", %{conn: conn} do + {:ok, _lv, html} = live(conn, @url) - type_into_input(lv, "team[name]", "Team Name 1") - _ = render(lv) - type_into_input(lv, "team[name]", "My personal sites") - _ = render(lv) - assert Repo.reload!(team).name == "Team Name 1" + assert element_exists?(html, ~s|input#create-team-form_name[name="team[name]"][required]|) end - test "reserved name is rejected on the very first edit", %{ - conn: conn, - team: team, - user: user - } do - lv = get_child_lv(conn) - - type_into_input(lv, "team[name]", "My personal sites") + test "rejects a blank name on submit", %{conn: conn, team: team} do + {:ok, lv, _html} = live(conn, @url) - assert render(lv) =~ "is reserved" - assert Repo.reload!(team).name == "#{user.name}'s team" + assert finish_setup(lv, name: "") =~ "blank" + refute Repo.reload!(team).setup_complete end test "setting team name containing a URL is rejected", %{conn: conn, team: team} do - lv = get_child_lv(conn) - - type_into_input(lv, "team[name]", "Team Name 1") - _ = render(lv) + {:ok, lv, _html} = live(conn, @url) - type_into_input(lv, "team[name]", "Cheap meds at https://spam.example.com") + assert finish_setup(lv, name: "Cheap meds at https://spam.example.com") =~ + "cannot contain a URL" - assert render(lv) =~ "cannot contain a URL" - assert element_exists?(render(lv), "button#save-layout[disabled]") - assert Repo.reload!(team).name == "Team Name 1" + refute Repo.reload!(team).setup_complete + assert Repo.reload!(team).name == "My personal sites" end test "setting team name longer than the limit is rejected", %{conn: conn, team: team} do - lv = get_child_lv(conn) + {:ok, lv, _html} = live(conn, @url) - type_into_input(lv, "team[name]", "Team Name 1") - _ = render(lv) + assert finish_setup(lv, name: String.duplicate("a", 51)) =~ + "should be at most 50 character(s)" - type_into_input(lv, "team[name]", String.duplicate("a", 51)) - - assert render(lv) =~ "should be at most 50 character(s)" - assert element_exists?(render(lv), "button#save-layout[disabled]") - assert Repo.reload!(team).name == "Team Name 1" + refute Repo.reload!(team).setup_complete + assert Repo.reload!(team).name == "My personal sites" end - test "creating the team is blocked while the name is rejected", %{conn: conn, team: team} do - lv = get_child_lv(conn) - - refute element_exists?(render(lv), "button#save-layout[disabled]") - - type_into_input(lv, "team[name]", "My personal sites") - - assert render(lv) =~ "is reserved" - assert element_exists?(render(lv), "button#save-layout[disabled]") - - # the server refuses as well, not just the disabled button - assert render_click(lv, "save-team-layout", %{}) =~ "Please fix the team name first" + test "rejects the reserved default team name on submit", %{conn: conn, team: team} do + {:ok, lv, _html} = live(conn, @url) + assert finish_setup(lv, name: "My personal sites") =~ "is reserved" refute Repo.reload!(team).setup_complete + assert Repo.reload!(team).name == "My personal sites" end - test "creating the team goes through once the name is accepted", %{conn: conn, team: team} do - lv = get_child_lv(conn) - - type_into_input(lv, "team[name]", "My personal sites") - _ = render(lv) - type_into_input(lv, "team[name]", "Fixed Team Name") - _ = render(lv) + test "creating the team goes through once a valid name is submitted", %{ + conn: conn, + team: team + } do + {:ok, lv, _html} = live(conn, @url) - save_layout(lv) + finish_setup(lv, name: "Fixed Team Name") assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) @@ -212,317 +153,150 @@ defmodule PlausibleWeb.Live.TeamSetupTest do end end - describe "/team/setup - full integration" do + # Adding/removing rows and picking a role all happen entirely client-side + # (see assets/js/liveview/member-rows.js) - the server only ever sees the + # final "rows" form data once, on submit. These tests exercise that submit + # handling directly with the payload a real form submission would produce, + # since ExUnit's LiveViewTest can't drive the client-side JS itself. + describe "/team/setup - adding members" do setup [:create_user, :log_in, :create_team] - 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" - - 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 pending" - - member_row2 = find(html, "#{member_el()}:nth-of-type(2)") |> text() - assert member_row2 =~ "#{user.name}" - assert member_row2 =~ "#{user.email}" - - save_layout(lv) - - assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) - - team = Repo.reload!(team) - - assert_email_delivered_with( - to: [nil: "new@example.com"], - subject: @subject_prefix <> "You've been invited to \"#{team.name}\" team" - ) + test "starts out with a single empty row defaulting to viewer", %{conn: conn} do + {:ok, _lv, html} = live(conn, @url) + assert elem_count(html, row_el()) == 1 + assert text_of_attr(html, ~s|#member-rows input[type="hidden"]|, "value") == "viewer" end - 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") - - html = render(lv) - - 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" - - change_role(lv, 1, "viewer") - html = render(lv) + test "creating the team sends out an invitation for a filled row with the given role", %{ + conn: conn, + team: team + } do + {:ok, lv, _html} = live(conn, @url) - assert text_of_element(html, "#{member_el()}:nth-of-type(1) button") == "Viewer" + finish_setup(lv, rows: %{"1" => %{"email" => "new@example.com", "role" => "admin"}}) - save_layout(lv) + assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) team = Repo.reload!(team) + assert team.setup_complete 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, 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" - - change_role(lv, 2, "viewer") - html = render(lv) - - assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Viewer" - - save_layout(lv) - - assert_no_emails_delivered() - - assert_team_membership(member2, team, :viewer) + assert [invitation] = Teams.Invitations.pending_team_invitations_for(team) + assert invitation.email == "new@example.com" + assert invitation.role == :admin end - test "allows updating guest membership so it moves sections and sends out promotion e-mail", - %{ - conn: conn, - user: user, - team: team - } do - site = new_site(owner: user) - add_guest(site, role: :viewer, user: new_user(name: "Mr Guest", email: "guest@example.com")) - - lv = get_child_lv(conn) - - type_into_input(lv, "team[name]", "A-Team!") - - assert Repo.reload!(team).name == "A-Team!" + test "blank rows are ignored on submit", %{conn: conn, team: team} do + {:ok, lv, _html} = live(conn, @url) - html = render(lv) - - assert elem_count(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 elem_count(html, member_el()) == 2 - refute element_exists?(html, "#guest-list") - - save_layout(lv) - - assert_email_delivered_with( - to: [nil: "guest@example.com"], - subject: @subject_prefix <> "Welcome to \"A-Team!\" team" + finish_setup(lv, + rows: %{ + "1" => %{"email" => "", "role" => "viewer"}, + "2" => %{"email" => "second@example.com", "role" => "viewer"} + } ) - end - @tag :ee_only - test "fails to save layout with limits breached", %{conn: conn, team: team} do - insert(:growth_subscription, team: team) - - lv = get_child_lv(conn) - html = render(lv) - refute attr_defined?(html, ~s|#team-layout-form input[name="input-email"]|, "readonly") - refute attr_defined?(html, ~s|#invite-member|, "disabled") - - 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") - - html = render(lv) - - assert attr_defined?(html, ~s|#team-layout-form input[name="input-email"]|, "readonly") - assert attr_defined?(html, ~s|#invite-member|, "disabled") + assert_redirect(lv, "/settings/team/general?__team=" <> team.identifier) - assert text_of_element(html, ~s/[data-test="limit-exceeded-notice"]/) =~ - "This account is limited to 3 members" + team = Repo.reload!(team) + assert [invitation] = Teams.Invitations.pending_team_invitations_for(team) + assert invitation.email == "second@example.com" end - test "all options are disabled for the sole owner", %{conn: conn} do - lv = get_child_lv(conn) + test "rejects invalid e-mails", %{conn: conn, team: team} do + {:ok, lv, _html} = live(conn, @url) - options = - lv - |> render() - |> find("#{member_el()} a") + assert finish_setup(lv, rows: %{"1" => %{"email" => "not-an-email", "role" => "viewer"}}) =~ + "Make sure all e-mails are valid" - assert Enum.empty?(options) + refute Repo.reload!(team).setup_complete end - 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) + test "rejects duplicate e-mails across rows", %{conn: conn, team: team} do + {:ok, lv, _html} = live(conn, @url) - options = - lv - |> render() - |> find("#{member_el()} a") + rows = %{ + "1" => %{"email" => "dup@example.com", "role" => "viewer"}, + "2" => %{"email" => "dup@example.com", "role" => "admin"} + } - refute Enum.empty?(options) + assert finish_setup(lv, rows: rows) =~ "Make sure e-mails are unique" + refute Repo.reload!(team).setup_complete + end - change_role(lv, 1, "viewer") + test "rejects inviting yourself", %{conn: conn, team: team, user: user} do + {:ok, lv, _html} = live(conn, @url) - html = lv |> render() + assert finish_setup(lv, rows: %{"1" => %{"email" => user.email, "role" => "admin"}}) =~ + "You cannot invite yourself" - assert element_exists?(html, "#{member_el()}:nth-of-type(1) a") - refute element_exists?(html, "#{member_el()}:nth-of-type(2) a") + refute Repo.reload!(team).setup_complete end - test "allows removing any type of entry", %{ + @tag :ee_only + test "fails to create the team when the plan's member limit is breached", %{ 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_child_lv(conn) - add_invite(lv, "pending@example.com", "admin") - - html = render(lv) - - assert elem_count(html, member_el()) == 4 - assert elem_count(html, guest_el()) == 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 != "" - 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 text_of_element(render(lv), "#member-list") =~ "Team member" - refute html =~ "Guest" - - save_layout(lv) - - team = Repo.reload!(team) - - assert_email_delivered_with( - to: [nil: guest.email], - subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" - ) + insert(:growth_subscription, team: team) + {:ok, lv, _html} = live(conn, @url) - assert_email_delivered_with( - to: [nil: member2.email], - subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked" - ) + rows = + for n <- 1..4, into: %{} do + {to_string(n), %{"email" => "new#{n}@example.com", "role" => "viewer"}} + end + assert finish_setup(lv, rows: rows) =~ "Your account is limited to 3 team members" + refute Repo.reload!(team).setup_complete assert_no_emails_delivered() end + end - test "respawns membersip enqueued for deletion", %{ - conn: conn, - team: team - } do - member2 = add_member(team, role: :editor, user: new_user(email: "another@example.com")) - - lv = get_child_lv(conn) - - remove_member(lv, 1) - - add_invite(lv, "another@example.com", "viewer") - - html = render(lv) - - assert find(html, "#{member_el()}:nth-of-type(2)") |> text() =~ "You" + # The client-side row cap itself (assets/js/liveview/member-rows.js) can't + # be exercised here since ExUnit's LiveViewTest can't drive that JS - these + # tests cover the server-computed limit it's seeded from via `data-max-rows`. + describe "/team/setup - member row limit" do + setup [:create_user, :log_in, :create_team] - save_layout(lv) + @tag :ee_only + test "defaults to the trial team member limit", %{conn: conn} do + {:ok, _lv, html} = live(conn, @url) - assert_no_emails_delivered() - assert_team_membership(member2, team, :viewer) + assert text_of_attr(html, "#member-rows-container", "data-max-rows") == "10" end - end - defp type_into_input(lv, id, text) do - lv - |> element("form#update-team-form") - |> render_change(%{id => text}) - end - - defp add_invite(lv, email, role) do - lv - |> element(~s|#input-role-picker a[phx-value-role="#{role}"]|) - |> render_click() + @tag :ee_only + test "is not reduced by an existing guest member", %{conn: conn, team: team} do + site = new_site(team: team) + add_guest(site, role: :viewer, user: new_user()) - lv - |> element("#team-layout-form") - |> render_submit(%{ - "input-email" => email - }) - end + {:ok, _lv, html} = live(conn, @url) - defp save_layout(lv) do - lv - |> element("button#save-layout") - |> render_click() - end + assert text_of_attr(html, "#member-rows-container", "data-max-rows") == "10" + 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 + @tag :ee_only + test "is capped at 20 for an unlimited plan", %{conn: conn, user: user} do + subscribe_to_enterprise_plan(user, team_member_limit: :unlimited) - defp get_child_lv(conn, opts \\ []) do - {:ok, lv, _} = live(conn, @url) - assert lv = find_live_child(lv, "team-management-setup") + {:ok, _lv, html} = live(conn, @url) - if Keyword.get(opts, :with_html?) do - {lv, render(lv)} - else - lv + assert text_of_attr(html, "#member-rows-container", "data-max-rows") == "20" 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 row_el(), do: ~s|#member-rows > div| - defp member_el() do - ~s|#member-list div[data-test-kind="member"]| - end + defp finish_setup(lv, opts) do + name = Keyword.get(opts, :name, "Jane Smith's team") + rows = Keyword.get(opts, :rows, %{"1" => %{"email" => "", "role" => "viewer"}}) - defp guest_el() do - ~s|#guest-list div[data-test-kind="guest"]| + lv + |> element("#create-team-form") + |> render_submit(%{"team" => %{"name" => name}, "rows" => rows}) end end