From f9aabf96fca682d650ee8f503e411e5253536295 Mon Sep 17 00:00:00 2001 From: Sanne de Vries Date: Thu, 28 May 2026 14:30:35 +0200 Subject: [PATCH 1/2] Consolidate and improve site transfer UX - Combine "Transfer site" and "Change team" into a single tile with a destination radio (Team / Another Plausible account) - Convert site transfer tile to `LiveView` - Show validation and server errors inline next to the relevant field - Always render the Team option, greyed out when the user has no other team - Add disabled cursor styling to the shared radio input - Add documentation links to all tiles in `Danger Zone` --- .../controllers/site/membership_controller.ex | 119 ------- .../controllers/site_controller.ex | 1 + lib/plausible_web/live/components/form.ex | 4 +- .../live/site_transfer_settings.ex | 288 ++++++++++++++++ lib/plausible_web/router.ex | 6 - .../membership/change_team_form.html.heex | 24 -- .../transfer_ownership_form.html.heex | 36 -- .../site/settings_danger_zone.html.heex | 30 +- .../site/membership_controller_test.exs | 89 +---- .../controllers/site_controller_test.exs | 121 +------ .../live/site_transfer_settings_test.exs | 310 ++++++++++++++++++ 11 files changed, 613 insertions(+), 415 deletions(-) create mode 100644 lib/plausible_web/live/site_transfer_settings.ex delete mode 100644 lib/plausible_web/templates/site/membership/change_team_form.html.heex delete mode 100644 lib/plausible_web/templates/site/membership/transfer_ownership_form.html.heex create mode 100644 test/plausible_web/live/site_transfer_settings_test.exs diff --git a/lib/plausible_web/controllers/site/membership_controller.ex b/lib/plausible_web/controllers/site/membership_controller.ex index c10a7e41ffe4..8607be6a6870 100644 --- a/lib/plausible_web/controllers/site/membership_controller.ex +++ b/lib/plausible_web/controllers/site/membership_controller.ex @@ -93,125 +93,6 @@ defmodule PlausibleWeb.Site.MembershipController do end end - def transfer_ownership_form(conn, _params) do - site_domain = conn.assigns.site.domain - - site = - Plausible.Sites.get_for_user!(conn.assigns.current_user, site_domain) - - render( - conn, - "transfer_ownership_form.html", - site: site, - skip_plausible_tracking: true - ) - end - - def transfer_ownership(conn, %{"email" => email}) do - site_domain = conn.assigns.site.domain - - site = - Plausible.Sites.get_for_user!(conn.assigns.current_user, site_domain) - - case Teams.Invitations.InviteToSite.invite( - site, - conn.assigns.current_user, - email, - :owner - ) do - {:ok, _invitation} -> - conn - |> put_flash(:success, "Site transfer request has been sent to #{email}") - |> redirect(to: Routes.site_path(conn, :settings_people, site.domain)) - - {:error, changeset} -> - errors = Plausible.ChangesetHelpers.traverse_errors(changeset) - - message = - case errors do - %{invitation: ["already sent" | _]} -> "Invitation has already been sent" - _other -> "Site transfer request to #{email} has failed" - end - - conn - |> put_flash(:ttl, :timer.seconds(5)) - |> put_flash(:error_title, "Transfer error") - |> put_flash(:error, message) - |> redirect(to: Routes.site_path(conn, :settings_people, site.domain)) - end - end - - def change_team_form(conn, _params) do - site_domain = conn.assigns.site.domain - user = conn.assigns.current_user - - site = - Plausible.Sites.get_for_user!(user, site_domain) - - render_change_team_form(conn, user, site) - end - - defp render_change_team_form(conn, user, site, opts \\ []) do - transferable_teams = - user - |> Plausible.Teams.Users.teams(roles: [:owner, :admin]) - |> Enum.reject(&(&1.id == site.team_id)) - |> Enum.map(&{&1.name, &1.identifier}) - - render( - conn, - "change_team_form.html", - site: site, - skip_plausible_tracking: true, - transferable_teams: transferable_teams, - error: opts[:error] - ) - end - - def change_team(conn, %{"team_identifier" => identifier}) do - site_domain = conn.assigns.site.domain - user = conn.assigns.current_user - - site = - Plausible.Sites.get_for_user!(user, site_domain) - - destination_team = - Repo.one!(Teams.Users.teams_query(user, roles: [:admin, :owner], identifier: identifier)) - - case Teams.Sites.Transfer.change_team( - site, - conn.assigns.current_user, - destination_team - ) do - :ok -> - conn - |> put_flash(:success, "Site team was changed") - |> redirect(to: Routes.site_path(conn, :index, __team: identifier)) - - {:error, :no_plan} -> - conn - |> render_change_team_form(conn.assigns.current_user, site, - error: - "This team doesn't have a subscription. Please start a subscription for " <> - "the team first and then try moving the site again" - ) - - {:error, {:over_plan_limits, _}} -> - conn - |> render_change_team_form(conn.assigns.current_user, site, - error: - "This site's usage is over the limits of the team's subscription. " <> - "Please upgrade the team to an appropriate subscription and then try moving the site again" - ) - - {:error, _} -> - conn - |> render_change_team_form(conn.assigns.current_user, site, - error: "Sorry, this team cannot be used" - ) - end - end - @doc """ Updates the role of a user. The user being updated could be the same or different from the user taking the action. When updating the role, it's important to enforce permissions: diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex index a9a560bd4de2..7413111898eb 100644 --- a/lib/plausible_web/controllers/site_controller.ex +++ b/lib/plausible_web/controllers/site_controller.ex @@ -192,6 +192,7 @@ defmodule PlausibleWeb.SiteController do conn |> render("settings_danger_zone.html", site: site, + connect_live_socket: true, dogfood_page_path: "/:dashboard/settings/danger-zone", layout: {PlausibleWeb.LayoutView, "site_settings.html"} ) diff --git a/lib/plausible_web/live/components/form.ex b/lib/plausible_web/live/components/form.ex index c49689be154f..39b92bd18506 100644 --- a/lib/plausible_web/live/components/form.ex +++ b/lib/plausible_web/live/components/form.ex @@ -136,7 +136,7 @@ defmodule PlausibleWeb.Live.Components.Form do id={@id} name={@name} checked={assigns[:checked]} - class="block dark:bg-gray-900 size-4.5 mt-px cursor-pointer text-indigo-600 border-gray-400 dark:border-gray-600 checked:border-indigo-600 dark:checked:border-white" + class="block dark:bg-gray-900 size-4.5 mt-px cursor-pointer text-indigo-600 border-gray-400 dark:border-gray-600 checked:border-indigo-600 dark:checked:border-white disabled:cursor-not-allowed" {@rest} /> <.label :if={@label} class="flex flex-col flex-inline" for={@id}> @@ -416,7 +416,7 @@ defmodule PlausibleWeb.Live.Components.Form do def error(assigns) do ~H""" -

+

{render_slot(@inner_block)}

""" diff --git a/lib/plausible_web/live/site_transfer_settings.ex b/lib/plausible_web/live/site_transfer_settings.ex new file mode 100644 index 000000000000..fb26308d1978 --- /dev/null +++ b/lib/plausible_web/live/site_transfer_settings.ex @@ -0,0 +1,288 @@ +defmodule PlausibleWeb.Live.SiteTransferSettings do + @moduledoc """ + LiveView for the "Transfer site" tile in the site Danger Zone. + + Lets the site owner pick a destination (another team they own/admin or + another Plausible account), validates the form interactively, and + dispatches to the appropriate transfer action. + """ + + use PlausibleWeb, :live_view + + alias Plausible.Repo + alias Plausible.Teams + alias PlausibleWeb.Router.Helpers, as: Routes + + defmodule Form do + @moduledoc false + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + embedded_schema do + field :destination, :string + field :team_identifier, :string + field :email, :string + end + + @valid_destinations ~w(team account) + + def changeset(params, opts \\ []) do + %__MODULE__{} + |> cast(params, [:destination, :team_identifier, :email]) + |> validate_required(:destination) + |> validate_inclusion(:destination, @valid_destinations) + |> validate_destination_fields(opts) + end + + defp validate_destination_fields(changeset, opts) do + case get_field(changeset, :destination) do + "team" -> + allowed = Keyword.get(opts, :team_identifiers, []) + + changeset + |> validate_required(:team_identifier, message: "Please select a team") + |> validate_inclusion(:team_identifier, allowed, message: "Please select a team") + + "account" -> + changeset + |> validate_required(:email, message: "Please enter an email address") + |> validate_format(:email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, + message: "Please enter a valid email address" + ) + + _ -> + changeset + end + end + end + + def mount(_params, %{"domain" => domain}, socket) do + user = socket.assigns.current_user + + site = + Plausible.Sites.get_for_user!(user, domain, roles: [:owner, :admin, :super_admin]) + + transferable_teams = + user + |> Teams.Users.teams(roles: [:owner, :admin]) + |> Enum.reject(&(&1.id == site.team_id)) + |> Enum.map(&{&1.name, &1.identifier}) + + show_team? = transferable_teams != [] + initial_destination = if show_team?, do: "team", else: "account" + + socket = + socket + |> assign( + site: site, + transferable_teams: transferable_teams, + show_team?: show_team? + ) + |> assign_form(%{"destination" => initial_destination}) + + {:ok, socket} + end + + def render(assigns) do + ~H""" +
+ <.flash_messages flash={@flash} /> + + <.tile docs="transfer-ownership"> + <:title>Transfer site + <:subtitle>Move this site to another team or Plausible account. + + <.form + :let={f} + for={@form} + id="site-transfer-form" + phx-change="validate" + phx-submit="save" + novalidate + > +
+ <.label>Destination + +
+
+ <.input + type="radio" + id="destination-team" + name={f[:destination].name} + value="team" + checked={f[:destination].value == "team" and @show_team?} + disabled={not @show_team?} + label="Team" + /> +
+
+

+ The site will immediately move to the selected team. Billing does not transfer. +

+ <.input + type="select" + field={f[:team_identifier]} + options={@transferable_teams} + prompt="Select a team" + mt?={false} + /> +
+

+ You aren't a member of any other teams. +

+
+ +
+ <.input + type="radio" + id="destination-account" + name={f[:destination].name} + value="account" + checked={f[:destination].value == "account"} + label="Another Plausible account" + /> +
+

+ The recipient will receive an email to accept the transfer within 48 hours. You'll keep Guest Editor access by default. +

+ <.input + type="email" + field={f[:email]} + label="Email address" + placeholder="joe@example.com" + mt?={false} + /> +
+
+
+ + <.button + type="submit" + theme="danger" + phx-disable-with="Transferring..." + > + {submit_label(f[:destination].value)} + + + +
+ """ + end + + def handle_event("validate", %{"form" => params}, socket) do + {:noreply, assign_form(socket, params)} + end + + def handle_event("save", %{"form" => params}, socket) do + changeset = + Form.changeset(params, + team_identifiers: Enum.map(socket.assigns.transferable_teams, &elem(&1, 1)) + ) + + case Ecto.Changeset.apply_action(changeset, :insert) do + {:ok, %Form{destination: "team", team_identifier: identifier}} -> + do_change_team(socket, identifier, params) + + {:ok, %Form{destination: "account", email: email}} -> + do_transfer_ownership(socket, email, params) + + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset, as: :form))} + end + end + + defp do_change_team(socket, identifier, params) do + user = socket.assigns.current_user + site = socket.assigns.site + + destination_team = + Repo.one!(Teams.Users.teams_query(user, roles: [:admin, :owner], identifier: identifier)) + + case Teams.Sites.Transfer.change_team(site, user, destination_team) do + :ok -> + {:noreply, + socket + |> put_flash(:success, "Site team was changed") + |> redirect(to: Routes.site_path(socket, :index, __team: identifier))} + + {:error, reason} -> + {:noreply, + assign_form(socket, params, + action: :insert, + field_errors: [{:team_identifier, change_team_error_message(reason)}] + )} + end + end + + defp do_transfer_ownership(socket, email, params) do + user = socket.assigns.current_user + site = socket.assigns.site + + case Teams.Invitations.InviteToSite.invite(site, user, email, :owner) do + {:ok, _invitation} -> + {:noreply, + socket + |> put_flash(:success, "Site transfer request has been sent to #{email}") + |> redirect(to: Routes.site_path(socket, :settings_people, site.domain))} + + {:error, %Ecto.Changeset{} = changeset} -> + message = + case Plausible.ChangesetHelpers.traverse_errors(changeset) do + %{invitation: ["already sent" | _]} -> "Invitation has already been sent" + _ -> "Site transfer request to #{email} has failed" + end + + {:noreply, + assign_form(socket, params, + action: :insert, + field_errors: [{:email, message}] + )} + end + end + + defp assign_form(socket, params, opts \\ []) do + changeset = + params + |> Form.changeset( + team_identifiers: Enum.map(socket.assigns.transferable_teams, &elem(&1, 1)) + ) + + changeset = + Enum.reduce(Keyword.get(opts, :field_errors, []), changeset, fn {field, message}, cs -> + Ecto.Changeset.add_error(cs, field, message) + end) + + changeset = + case opts[:action] do + nil -> changeset + action -> Map.put(changeset, :action, action) + end + + assign(socket, form: to_form(changeset, as: :form)) + end + + defp submit_label("team"), do: "Move site" + defp submit_label(_), do: "Send transfer request" + + defp change_team_error_message(:no_plan) do + "This team doesn't have a subscription. Please start a subscription for the team first and then try moving the site again." + end + + defp change_team_error_message({:over_plan_limits, _}) do + "This site's usage exceeds the destination team's subscription limits. Upgrade the team's subscription to continue." + end + + defp change_team_error_message(_) do + "Sorry, this team cannot be used" + end +end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index be2c099abeca..6bd5eb411981 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -593,12 +593,6 @@ defmodule PlausibleWeb.Router do delete "/sites/:domain/invitations/:invitation_id", InvitationController, :remove_invitation - get "/sites/:domain/transfer-ownership", Site.MembershipController, :transfer_ownership_form - post "/sites/:domain/transfer-ownership", Site.MembershipController, :transfer_ownership - - get "/sites/:domain/change-team", Site.MembershipController, :change_team_form - post "/sites/:domain/change-team", Site.MembershipController, :change_team - put "/sites/:domain/memberships/u/:id/role/:new_role", Site.MembershipController, :update_role_by_user diff --git a/lib/plausible_web/templates/site/membership/change_team_form.html.heex b/lib/plausible_web/templates/site/membership/change_team_form.html.heex deleted file mode 100644 index f30682096998..000000000000 --- a/lib/plausible_web/templates/site/membership/change_team_form.html.heex +++ /dev/null @@ -1,24 +0,0 @@ -<.focus_box> - <:title> - Change the team of {@site.domain} - - <:subtitle> - Choose the team you'd like to move the site to. The new team must have a sufficient subscription plan. - - <.form :let={f} for={@conn} action={Routes.membership_path(@conn, :change_team, @site.domain)}> -
- <.input - type="select" - options={@transferable_teams} - field={f[:team_identifier]} - label="Destination Team" - required="true" - /> - <%= if @conn.assigns[:error] do %> -
{@conn.assigns[:error]}
- <% end %> -
- - <.button type="submit" class="w-full" mt?={false}>Change team - - diff --git a/lib/plausible_web/templates/site/membership/transfer_ownership_form.html.heex b/lib/plausible_web/templates/site/membership/transfer_ownership_form.html.heex deleted file mode 100644 index 489777e641e3..000000000000 --- a/lib/plausible_web/templates/site/membership/transfer_ownership_form.html.heex +++ /dev/null @@ -1,36 +0,0 @@ -<.focus_box> - <:title> - Transfer ownership of {@site.domain} - - <:subtitle> - Enter the email address of the new owner. We will contact them over email to - offer them the ownership of {@site.domain}. If they don't respond in 48 - hours, the request will expire automatically.

- Do note that a subscription plan is not transferred alongside the site. If - they accept the transfer request, the new owner will need to have an active - subscription. Your access will be downgraded to guest editor - and any other - member roles will stay the same. - - <.form - :let={f} - for={@conn} - action={Routes.membership_path(@conn, :transfer_ownership, @site.domain)} - > - <%= if @conn.assigns[:error] do %> -
{@conn.assigns[:error]}
- <% end %> - -
- <.input - type="email" - field={f[:email]} - label="Email address" - placeholder="joe@example.com" - required="true" - /> -
- - <.button type="submit" class="w-full" mt?={false}>Request transfer - - diff --git a/lib/plausible_web/templates/site/settings_danger_zone.html.heex b/lib/plausible_web/templates/site/settings_danger_zone.html.heex index 3861992735b8..ed0bbd96c4a7 100644 --- a/lib/plausible_web/templates/site/settings_danger_zone.html.heex +++ b/lib/plausible_web/templates/site/settings_danger_zone.html.heex @@ -3,30 +3,12 @@ <.settings_tiles> - <.tile> - <:title>Transfer site ownership - <:subtitle>Transfer ownership of the site to a different account. - <.button_link - href={Routes.membership_path(@conn, :transfer_ownership_form, @site.domain)} - theme="danger" - mt?={false} - > - Transfer {@site.domain} ownership - - - - <.tile :if={Enum.count(Plausible.Teams.Users.teams(@current_user, roles: [:owner, :admin])) > 1}> - <:title>Change Teams - <:subtitle>Move the site to another team that you are a member of - <.button_link - href={Routes.membership_path(@conn, :change_team_form, @site.domain)} - theme="danger" - > - Change {@site.domain} team - - + {live_render(@conn, PlausibleWeb.Live.SiteTransferSettings, + id: "site-transfer-settings", + session: %{"domain" => @site.domain} + )} - <.tile> + <.tile docs="reset-site-data"> <:title>Reset stats <:subtitle>Reset all stats but keep the site configuration intact. <.button_link @@ -40,7 +22,7 @@ - <.tile> + <.tile docs="delete-site-data"> <:title>Delete site <:subtitle>Permanently delete all stats and site settings. <.button_link diff --git a/test/plausible_web/controllers/site/membership_controller_test.exs b/test/plausible_web/controllers/site/membership_controller_test.exs index a5c0138d27c7..cda8f96496b3 100644 --- a/test/plausible_web/controllers/site/membership_controller_test.exs +++ b/test/plausible_web/controllers/site/membership_controller_test.exs @@ -90,7 +90,9 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do new_owner = new_user() - post(conn, "/sites/#{site.domain}/transfer-ownership", %{email: new_owner.email}) + {:ok, _invitation} = + Plausible.Teams.Invitations.InviteToSite.invite(site, user, new_owner.email, :owner) + assert_site_transfer(site, new_owner.email) conn = @@ -201,91 +203,6 @@ defmodule PlausibleWeb.Site.MembershipControllerTest do end end - describe "GET /sites/:domain/transfer-ownership" do - test "shows ownership transfer form", %{conn: conn, user: user} do - site = new_site(owner: user) - - conn = get(conn, "/sites/#{site.domain}/transfer-ownership") - - assert html_response(conn, 200) =~ "Transfer ownership of" - end - end - - describe "POST /sites/:domain/transfer-ownership" do - test "creates invitation with :owner role", %{conn: conn, user: user} do - site = new_site(owner: user) - - conn = - post(conn, "/sites/#{site.domain}/transfer-ownership", %{email: "john.doe@example.com"}) - - assert_site_transfer(site, "john.doe@example.com") - - assert redirected_to(conn) == "/#{URI.encode_www_form(site.domain)}/settings/people" - end - - test "sends ownership transfer email for new user", %{conn: conn, user: user} do - site = new_site(owner: user) - - post(conn, "/sites/#{site.domain}/transfer-ownership", %{email: "john.doe@example.com"}) - - assert_email_delivered_with( - to: [nil: "john.doe@example.com"], - subject: @subject_prefix <> "Request to transfer ownership of #{site.domain}" - ) - end - - test "sends invitation email for existing user", %{conn: conn, user: user} do - existing_user = insert(:user) - site = new_site(owner: user) - - post(conn, "/sites/#{site.domain}/transfer-ownership", %{email: existing_user.email}) - - assert_email_delivered_with( - to: [nil: existing_user.email], - subject: @subject_prefix <> "Request to transfer ownership of #{site.domain}" - ) - end - - test "fails to transfer ownership to a foreign domain", %{conn: conn, user: user} do - new_site(owner: user) - foreign_site = new_site() - - conn = - post(conn, "/sites/#{foreign_site.domain}/transfer-ownership", %{ - email: "john.doe@example.com" - }) - - assert conn.status == 404 - end - - test "fails to transfer ownership to invited user with proper error message", ctx do - %{conn: conn, user: user} = ctx - site = new_site(owner: user) - invited = "john.doe@example.com" - - # invite a user but don't join - - conn = - post(conn, "/sites/#{site.domain}/memberships/invite", %{ - email: invited, - role: "editor" - }) - - conn = get(recycle(conn), redirected_to(conn, 302)) - - assert html_response(conn, 200) =~ - "#{invited} has been invited to #{site.domain} as an editor" - - # transferring ownership to that domain now fails - - conn = post(conn, "/sites/#{site.domain}/transfer-ownership", %{email: invited}) - conn = get(recycle(conn), redirected_to(conn, 302)) - html = html_response(conn, 200) - assert html =~ "Transfer error" - assert html =~ "Invitation has already been sent" - end - end - describe "PUT /sites/memberships/:id/role/:new_role" do test "updates a site member's role by user id", %{conn: conn, user: user} do site = new_site(owner: user) diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs index e603dceb6249..bb76d271a853 100644 --- a/test/plausible_web/controllers/site_controller_test.exs +++ b/test/plausible_web/controllers/site_controller_test.exs @@ -1887,130 +1887,15 @@ defmodule PlausibleWeb.SiteControllerTest do end end - describe "change team" do + describe "settings danger zone" do setup [:create_user, :log_in, :create_site] - test "no change team section appears when <1 team", %{conn: conn, site: site} do + test "renders the transfer tile", %{conn: conn, site: site} do conn = get(conn, Routes.site_path(conn, :settings_danger_zone, site.domain)) html = html_response(conn, 200) assert html =~ "Danger zone" + assert html =~ "Transfer site" assert html =~ "Delete #{site.domain}" - refute html =~ "Change #{site.domain} team" - end - - test "change team section appears when >1 team", %{user: user, conn: conn, site: site} do - join_2nd_team(user) - - conn = get(conn, Routes.site_path(conn, :settings_danger_zone, site.domain)) - html = html_response(conn, 200) - assert html =~ "Danger zone" - assert html =~ "Delete #{site.domain}" - assert html =~ "Change #{site.domain} team" - end - - test "change team form renders", %{user: user, conn: conn, site: site} do - join_2nd_team(user) - - conn = get(conn, Routes.membership_path(conn, :change_team_form, site.domain)) - html = html_response(conn, 200) - assert html =~ "Change the team of #{site.domain}" - - assert element_exists?( - html, - ~s|form[action="#{Routes.membership_path(conn, :change_team, site.domain)}"]| - ) - - assert element_exists?(html, ~s|button[type=submit]|) - end - - @tag :ee_only - test "change team form error: destination team has no subscription", %{ - user: user, - conn: conn, - site: site - } do - team2 = join_2nd_team(user) - - conn = - post( - conn, - Routes.membership_path(conn, :change_team, site.domain, - team_identifier: team2.identifier - ) - ) - - html = html_response(conn, 200) - assert text(html) =~ "This team doesn't have a subscription" - end - - @tag :ee_only - test "change team form error: subscription insufficient", %{ - user: user, - conn: conn, - site: site - } do - team2 = join_2nd_team(user, subscribe?: true) - - generate_usage_for(site, 11_000, NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -5)) - generate_usage_for(site, 11_000, NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -35)) - - conn = - post( - conn, - Routes.membership_path(conn, :change_team, site.domain, - team_identifier: team2.identifier - ) - ) - - html = html_response(conn, 200) - - assert text(html) =~ "This site's usage is over the limits of the team's subscription" - end - - test "change team form error: unknown team identifier", %{ - conn: conn, - site: site - } do - assert_raise Ecto.NoResultsError, fn -> - post( - conn, - Routes.membership_path(conn, :change_team, site.domain, - team_identifier: Ecto.UUID.generate() - ) - ) - end - end - - test "successfully changes team", %{ - user: user, - conn: conn, - site: site - } do - team2 = join_2nd_team(user, subscribe?: true) - - conn = - post( - conn, - Routes.membership_path(conn, :change_team, site.domain, - team_identifier: team2.identifier - ) - ) - - assert redirected_to(conn) == "/sites?__team=#{team2.identifier}" - assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "Site team was changed" - end - - defp join_2nd_team(user, opts \\ []) do - another = new_user() - new_site(owner: another) - team2 = team_of(another) - add_member(team2, user: user, role: :admin) - - if opts[:subscribe?] do - subscribe_to_growth_plan(another) - end - - team2 end end end diff --git a/test/plausible_web/live/site_transfer_settings_test.exs b/test/plausible_web/live/site_transfer_settings_test.exs new file mode 100644 index 000000000000..4ef576ff8a2a --- /dev/null +++ b/test/plausible_web/live/site_transfer_settings_test.exs @@ -0,0 +1,310 @@ +defmodule PlausibleWeb.Live.SiteTransferSettingsTest do + use PlausibleWeb.ConnCase, async: false + use Plausible.Repo + use Bamboo.Test, shared: :true + + import Phoenix.LiveViewTest + + @subject_prefix if ee?(), do: "[Plausible Analytics] ", else: "[Plausible CE] " + + setup [:create_user, :log_in, :create_site] + + describe "mount" do + test "renders the Team radio as disabled when user has no other team", %{ + conn: conn, + site: site + } do + {:ok, _lv, html} = get_liveview(conn, site) + + assert html =~ "Transfer site" + assert html =~ "Move this site to another team or Plausible account" + assert html =~ "Another Plausible account" + + assert element_exists?(html, ~s|input[name="form[destination]"][value="account"]|) + + assert element_exists?( + html, + ~s|input[name="form[destination]"][value="team"][disabled]| + ) + + assert text_of_element(html, "#site-transfer-form") =~ + "You aren't a member of any other teams" + end + + test "renders both destinations enabled when the user has another team", %{ + conn: conn, + user: user, + site: site + } do + _team2 = join_2nd_team(user) + + {:ok, _lv, html} = get_liveview(conn, site) + + assert element_exists?(html, ~s|input[name="form[destination]"][value="team"]|) + assert element_exists?(html, ~s|input[name="form[destination]"][value="account"]|) + + refute element_exists?( + html, + ~s|input[name="form[destination]"][value="team"][disabled]| + ) + + assert html =~ "The site will immediately move to the selected team" + refute html =~ "joe@example.com" + refute text_of_element(html, "#site-transfer-form") =~ + "You aren't a member of any other teams" + end + + test "Team destination is preselected when available", %{ + conn: conn, + user: user, + site: site + } do + _team2 = join_2nd_team(user) + + {:ok, _lv, html} = get_liveview(conn, site) + + assert element_exists?( + html, + ~s|input[name="form[destination]"][value="team"][checked]| + ) + + refute element_exists?( + html, + ~s|input[name="form[destination]"][value="account"][checked]| + ) + end + end + + describe "switching destination" do + test "changing to Account hides team picker and shows email input", %{ + conn: conn, + user: user, + site: site + } do + _team2 = join_2nd_team(user) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_change(%{"form" => %{"destination" => "account"}}) + + assert html =~ "Email address" + assert html =~ "joe@example.com" + refute html =~ "The site will immediately move to the selected team" + + assert text_of_attr(html, ~s|button[type=submit]|, "phx-disable-with") == + "Transferring..." + + assert text_of_element(html, ~s|button[type=submit]|) =~ "Send transfer request" + end + + test "changing to Team hides email and shows team picker", %{ + conn: conn, + user: user, + site: site + } do + _team2 = join_2nd_team(user) + + {:ok, lv, _html} = get_liveview(conn, site) + + lv + |> element("#site-transfer-form") + |> render_change(%{"form" => %{"destination" => "account"}}) + + html = + lv + |> element("#site-transfer-form") + |> render_change(%{"form" => %{"destination" => "team"}}) + + assert html =~ "Select a team" + refute html =~ "Email address" + assert text_of_element(html, ~s|button[type=submit]|) =~ "Move site" + end + end + + describe "submitting (account destination)" do + test "creates a site transfer and redirects to settings/people", %{ + conn: conn, + site: site + } do + {:ok, lv, _html} = get_liveview(conn, site) + + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "account", "email" => "john.doe@example.com"} + }) + + assert_redirect(lv, "/#{URI.encode_www_form(site.domain)}/settings/people") + + assert_email_delivered_with( + to: [nil: "john.doe@example.com"], + subject: @subject_prefix <> "Request to transfer ownership of #{site.domain}" + ) + end + + test "renders an inline error when the user has already been invited", %{ + conn: conn, + user: user, + site: site + } do + invited = "john.doe@example.com" + + {:ok, _invitation} = + Plausible.Teams.Invitations.InviteToSite.invite(site, user, invited, :editor) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "account", "email" => invited} + }) + + assert html =~ "Invitation has already been sent" + end + + test "validates that an email is required", %{conn: conn, site: site} do + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{"form" => %{"destination" => "account", "email" => ""}}) + + assert html =~ "Please enter an email address" + end + + test "validates that the email is well-formed", %{conn: conn, site: site} do + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{"form" => %{"destination" => "account", "email" => "not-an-email"}}) + + assert html =~ "Please enter a valid email address" + end + end + + describe "submitting (team destination)" do + test "successfully changes the site's team and redirects to sites listing", %{ + conn: conn, + user: user, + site: site + } do + team2 = join_2nd_team(user, subscribe?: true) + + {:ok, lv, _html} = get_liveview(conn, site) + + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "team", "team_identifier" => team2.identifier} + }) + + assert_redirect(lv, "/sites?__team=#{team2.identifier}") + + assert Plausible.Repo.reload!(site).team_id == team2.id + end + + @tag :ee_only + test "renders an inline error when the destination team has no subscription", %{ + conn: conn, + user: user, + site: site + } do + team2 = join_2nd_team(user) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "team", "team_identifier" => team2.identifier} + }) + + assert text_of_element(html, "#site-transfer-form") =~ + "This team doesn't have a subscription" + end + + @tag :ee_only + test "renders an inline error when usage exceeds destination team's limits", %{ + conn: conn, + user: user, + site: site + } do + team2 = join_2nd_team(user, subscribe?: true) + + generate_usage_for(site, 11_000, NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -5)) + generate_usage_for(site, 11_000, NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -35)) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "team", "team_identifier" => team2.identifier} + }) + + assert text_of_element(html, "#site-transfer-form") =~ + "This site's usage exceeds the destination team's subscription limits" + end + + test "validates that a team must be selected", %{conn: conn, user: user, site: site} do + _team2 = join_2nd_team(user) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{"destination" => "team", "team_identifier" => ""} + }) + + assert html =~ "Please select a team" + end + + test "rejects an unknown team identifier", %{conn: conn, user: user, site: site} do + _team2 = join_2nd_team(user) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{ + "destination" => "team", + "team_identifier" => Ecto.UUID.generate() + } + }) + + assert html =~ "Please select a team" + end + end + + defp get_liveview(conn, site) do + conn = assign(conn, :live_module, PlausibleWeb.Live.SiteTransferSettings) + live(conn, "/#{URI.encode_www_form(site.domain)}/settings/danger-zone") + end + + defp join_2nd_team(user, opts \\ []) do + another = new_user() + new_site(owner: another) + team2 = team_of(another) + add_member(team2, user: user, role: :admin) + + if opts[:subscribe?] do + subscribe_to_growth_plan(another) + end + + team2 + end +end From 943a7c55225288ee6d693b7e7c585f4bf61aaaf9 Mon Sep 17 00:00:00 2001 From: Sanne de Vries Date: Thu, 28 May 2026 15:33:45 +0200 Subject: [PATCH 2/2] Update copy --- lib/plausible_web/live/site_transfer_settings.ex | 2 +- test/plausible_web/live/site_transfer_settings_test.exs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/plausible_web/live/site_transfer_settings.ex b/lib/plausible_web/live/site_transfer_settings.ex index fb26308d1978..e3f82111ef3f 100644 --- a/lib/plausible_web/live/site_transfer_settings.ex +++ b/lib/plausible_web/live/site_transfer_settings.ex @@ -153,7 +153,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do class="ml-7 mt-1 flex flex-col gap-y-2" >

- The recipient will receive an email to accept the transfer within 48 hours. You'll keep Guest Editor access by default. + The recipient will receive an email and have 48 hours to accept the transfer. You'll keep Guest Editor access by default.

<.input type="email" diff --git a/test/plausible_web/live/site_transfer_settings_test.exs b/test/plausible_web/live/site_transfer_settings_test.exs index 4ef576ff8a2a..095f1cf2fdcf 100644 --- a/test/plausible_web/live/site_transfer_settings_test.exs +++ b/test/plausible_web/live/site_transfer_settings_test.exs @@ -1,7 +1,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettingsTest do use PlausibleWeb.ConnCase, async: false use Plausible.Repo - use Bamboo.Test, shared: :true + use Bamboo.Test, shared: true import Phoenix.LiveViewTest @@ -50,6 +50,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettingsTest do assert html =~ "The site will immediately move to the selected team" refute html =~ "joe@example.com" + refute text_of_element(html, "#site-transfer-form") =~ "You aren't a member of any other teams" end