From bb40c0dd8729e70b1646ff1659cb8d49eb3b9f2c Mon Sep 17 00:00:00 2001 From: Sanne de Vries Date: Thu, 28 May 2026 14:30:35 +0200 Subject: [PATCH 01/11] 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 e655de73e0c9..34d973f2d22c 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -594,12 +594,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 8527d06172651a73603a920db2223365bb6d6ad7 Mon Sep 17 00:00:00 2001 From: Sanne de Vries Date: Thu, 28 May 2026 15:33:45 +0200 Subject: [PATCH 02/11] 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 From fc3b46a12f5a6e3aa9a88533eccc260bd8dd99c8 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 1 Jun 2026 10:18:29 +0200 Subject: [PATCH 03/11] Do not validate email format on the backend --- lib/plausible_web/live/site_transfer_settings.ex | 3 --- .../live/site_transfer_settings_test.exs | 11 ----------- 2 files changed, 14 deletions(-) diff --git a/lib/plausible_web/live/site_transfer_settings.ex b/lib/plausible_web/live/site_transfer_settings.ex index e3f82111ef3f..530572759017 100644 --- a/lib/plausible_web/live/site_transfer_settings.ex +++ b/lib/plausible_web/live/site_transfer_settings.ex @@ -47,9 +47,6 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do "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 diff --git a/test/plausible_web/live/site_transfer_settings_test.exs b/test/plausible_web/live/site_transfer_settings_test.exs index 095f1cf2fdcf..899224da19b4 100644 --- a/test/plausible_web/live/site_transfer_settings_test.exs +++ b/test/plausible_web/live/site_transfer_settings_test.exs @@ -178,17 +178,6 @@ defmodule PlausibleWeb.Live.SiteTransferSettingsTest do 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 From 89771858e9f1a95315d76335fd230b604dbe197f Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 1 Jun 2026 10:19:32 +0200 Subject: [PATCH 04/11] Use neutral email placeholder consistent with registration form --- lib/plausible_web/live/site_transfer_settings.ex | 2 +- test/plausible_web/live/site_transfer_settings_test.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/plausible_web/live/site_transfer_settings.ex b/lib/plausible_web/live/site_transfer_settings.ex index 530572759017..b4b21b898d46 100644 --- a/lib/plausible_web/live/site_transfer_settings.ex +++ b/lib/plausible_web/live/site_transfer_settings.ex @@ -156,7 +156,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do type="email" field={f[:email]} label="Email address" - placeholder="joe@example.com" + placeholder="example@email.com" mt?={false} /> diff --git a/test/plausible_web/live/site_transfer_settings_test.exs b/test/plausible_web/live/site_transfer_settings_test.exs index 899224da19b4..98a94c485d64 100644 --- a/test/plausible_web/live/site_transfer_settings_test.exs +++ b/test/plausible_web/live/site_transfer_settings_test.exs @@ -92,7 +92,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettingsTest do |> render_change(%{"form" => %{"destination" => "account"}}) assert html =~ "Email address" - assert html =~ "joe@example.com" + assert html =~ "example@email.com" refute html =~ "The site will immediately move to the selected team" assert text_of_attr(html, ~s|button[type=submit]|, "phx-disable-with") == From deb1124ed6d9623e49239d70002fb94fdc64e425 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 1 Jun 2026 10:19:51 +0200 Subject: [PATCH 05/11] Use Ecto.Enum type for destination form field --- .../live/site_transfer_settings.ex | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/lib/plausible_web/live/site_transfer_settings.ex b/lib/plausible_web/live/site_transfer_settings.ex index b4b21b898d46..f9abd4eca483 100644 --- a/lib/plausible_web/live/site_transfer_settings.ex +++ b/lib/plausible_web/live/site_transfer_settings.ex @@ -20,35 +20,32 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do @primary_key false embedded_schema do - field :destination, :string + field :destination, Ecto.Enum, values: [:team, :account] 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" -> + :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" -> + :account -> changeset |> validate_required(:email, message: "Please enter an email address") - _ -> + nil -> changeset end end @@ -67,7 +64,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do |> Enum.map(&{&1.name, &1.identifier}) show_team? = transferable_teams != [] - initial_destination = if show_team?, do: "team", else: "account" + initial_destination = if show_team?, do: :team, else: :account socket = socket @@ -107,14 +104,14 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do type="radio" id="destination-team" name={f[:destination].name} - value="team" - checked={f[:destination].value == "team" and @show_team?} + value={:team} + checked={f[:destination].value == :team and @show_team?} disabled={not @show_team?} label="Team" />

@@ -141,12 +138,12 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do type="radio" id="destination-account" name={f[:destination].name} - value="account" - checked={f[:destination].value == "account"} + value={:account} + checked={f[:destination].value == :account} label="Another Plausible account" />

@@ -187,10 +184,10 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do ) case Ecto.Changeset.apply_action(changeset, :insert) do - {:ok, %Form{destination: "team", team_identifier: identifier}} -> + {:ok, %Form{destination: :team, team_identifier: identifier}} -> do_change_team(socket, identifier, params) - {:ok, %Form{destination: "account", email: email}} -> + {:ok, %Form{destination: :account, email: email}} -> do_transfer_ownership(socket, email, params) {:error, changeset} -> @@ -268,7 +265,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do assign(socket, form: to_form(changeset, as: :form)) end - defp submit_label("team"), do: "Move site" + defp submit_label(:team), do: "Move site" defp submit_label(_), do: "Send transfer request" defp change_team_error_message(:no_plan) do From 8d610ec0b5cd72682ff423d06ba611a465fac082 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 1 Jun 2026 10:34:44 +0200 Subject: [PATCH 06/11] Remove dead code branch --- lib/plausible_web/live/site_transfer_settings.ex | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/plausible_web/live/site_transfer_settings.ex b/lib/plausible_web/live/site_transfer_settings.ex index f9abd4eca483..59d349fa9665 100644 --- a/lib/plausible_web/live/site_transfer_settings.ex +++ b/lib/plausible_web/live/site_transfer_settings.ex @@ -44,9 +44,6 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do :account -> changeset |> validate_required(:email, message: "Please enter an email address") - - nil -> - changeset end end end From 782f467e51c18d264522654385070d7b9e235b84 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 1 Jun 2026 11:37:13 +0200 Subject: [PATCH 07/11] Rely on the transfer service logic to validate destination team --- .../live/site_transfer_settings.ex | 64 +++++++++---------- .../live/site_transfer_settings_test.exs | 26 +++++++- 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/lib/plausible_web/live/site_transfer_settings.ex b/lib/plausible_web/live/site_transfer_settings.ex index 59d349fa9665..219ae23086b9 100644 --- a/lib/plausible_web/live/site_transfer_settings.ex +++ b/lib/plausible_web/live/site_transfer_settings.ex @@ -25,25 +25,20 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do field :email, :string end - def changeset(params, opts \\ []) do + def changeset(params) do %__MODULE__{} |> cast(params, [:destination, :team_identifier, :email]) |> validate_required(:destination) - |> validate_destination_fields(opts) + |> validate_destination_fields() end - defp validate_destination_fields(changeset, opts) do + defp validate_destination_fields(changeset) 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") + validate_required(changeset, :team_identifier, message: "Please select a team") :account -> - changeset - |> validate_required(:email, message: "Please enter an email address") + validate_required(changeset, :email, message: "Please enter an email address") end end end @@ -175,10 +170,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do end def handle_event("save", %{"form" => params}, socket) do - changeset = - Form.changeset(params, - team_identifiers: Enum.map(socket.assigns.transferable_teams, &elem(&1, 1)) - ) + changeset = Form.changeset(params) case Ecto.Changeset.apply_action(changeset, :insert) do {:ok, %Form{destination: :team, team_identifier: identifier}} -> @@ -197,21 +189,29 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do 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)}] - )} + Repo.one(Teams.Users.teams_query(user, roles: [:admin, :owner], identifier: identifier)) + + if destination_team do + 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 + else + {:noreply, + assign_form(socket, params, + action: :insert, + field_errors: [{:team_identifier, "Please select a team"}] + )} end end @@ -242,11 +242,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do end defp assign_form(socket, params, opts \\ []) do - changeset = - params - |> Form.changeset( - team_identifiers: Enum.map(socket.assigns.transferable_teams, &elem(&1, 1)) - ) + changeset = Form.changeset(params) changeset = Enum.reduce(Keyword.get(opts, :field_errors, []), changeset, fn {field, message}, cs -> diff --git a/test/plausible_web/live/site_transfer_settings_test.exs b/test/plausible_web/live/site_transfer_settings_test.exs index 98a94c485d64..d3f87f861547 100644 --- a/test/plausible_web/live/site_transfer_settings_test.exs +++ b/test/plausible_web/live/site_transfer_settings_test.exs @@ -278,6 +278,29 @@ defmodule PlausibleWeb.Live.SiteTransferSettingsTest do assert html =~ "Please select a team" end + + test "rejects a team identifier the user has no transfer permissions for", %{ + conn: conn, + user: user, + site: site + } do + _team2 = join_2nd_team(user) + team3 = join_2nd_team(user, role: :viewer) + + {:ok, lv, _html} = get_liveview(conn, site) + + html = + lv + |> element("#site-transfer-form") + |> render_submit(%{ + "form" => %{ + "destination" => "team", + "team_identifier" => team3.identifier + } + }) + + assert html =~ "Please select a team" + end end defp get_liveview(conn, site) do @@ -286,10 +309,11 @@ defmodule PlausibleWeb.Live.SiteTransferSettingsTest do end defp join_2nd_team(user, opts \\ []) do + role = Keyword.get(opts, :role, :admin) another = new_user() new_site(owner: another) team2 = team_of(another) - add_member(team2, user: user, role: :admin) + add_member(team2, user: user, role: role) if opts[:subscribe?] do subscribe_to_growth_plan(another) From 66db577796c0852384927c320d3e6ca6a54158cc Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 1 Jun 2026 12:02:42 +0200 Subject: [PATCH 08/11] Drop `novalidate` property from the transfer form --- lib/plausible_web/live/site_transfer_settings.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/plausible_web/live/site_transfer_settings.ex b/lib/plausible_web/live/site_transfer_settings.ex index 219ae23086b9..d91e0ec5238d 100644 --- a/lib/plausible_web/live/site_transfer_settings.ex +++ b/lib/plausible_web/live/site_transfer_settings.ex @@ -85,7 +85,6 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do id="site-transfer-form" phx-change="validate" phx-submit="save" - novalidate >

<.label>Destination From 909ec8c3d147e2705848d438911cb2737611c70f Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 1 Jun 2026 15:59:43 +0200 Subject: [PATCH 09/11] Improve naming, exclude my team for options and add my team predicate --- .../live/site_transfer_settings.ex | 26 ++++++++++--------- .../live/site_transfer_settings_test.exs | 7 ++--- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/lib/plausible_web/live/site_transfer_settings.ex b/lib/plausible_web/live/site_transfer_settings.ex index d91e0ec5238d..35601f7a58d7 100644 --- a/lib/plausible_web/live/site_transfer_settings.ex +++ b/lib/plausible_web/live/site_transfer_settings.ex @@ -49,21 +49,23 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do site = Plausible.Sites.get_for_user!(user, domain, roles: [:owner, :admin, :super_admin]) - transferable_teams = + team_options = user |> Teams.Users.teams(roles: [:owner, :admin]) - |> Enum.reject(&(&1.id == site.team_id)) + |> Enum.reject(&(&1.id == site.team_id || not &1.setup_complete)) |> Enum.map(&{&1.name, &1.identifier}) - show_team? = transferable_teams != [] - initial_destination = if show_team?, do: :team, else: :account + show_teams? = team_options != [] + show_my_team? = not is_nil(socket.assigns.my_team) + initial_destination = if show_teams?, do: :team, else: :account socket = socket |> assign( site: site, - transferable_teams: transferable_teams, - show_team?: show_team? + team_options: team_options, + show_teams?: show_teams?, + show_my_team?: show_my_team? ) |> assign_form(%{"destination" => initial_destination}) @@ -90,19 +92,19 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do <.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?} + checked={f[:destination].value == :team and @show_teams?} + disabled={not @show_teams?} label="Team" />

@@ -111,13 +113,13 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do <.input type="select" field={f[:team_identifier]} - options={@transferable_teams} + options={@team_options} prompt="Select a team" mt?={false} />

You aren't a member of any other teams. diff --git a/test/plausible_web/live/site_transfer_settings_test.exs b/test/plausible_web/live/site_transfer_settings_test.exs index d3f87f861547..f711607e8581 100644 --- a/test/plausible_web/live/site_transfer_settings_test.exs +++ b/test/plausible_web/live/site_transfer_settings_test.exs @@ -311,14 +311,15 @@ defmodule PlausibleWeb.Live.SiteTransferSettingsTest do defp join_2nd_team(user, opts \\ []) do role = Keyword.get(opts, :role, :admin) another = new_user() - new_site(owner: another) - team2 = team_of(another) - add_member(team2, user: user, role: role) if opts[:subscribe?] do subscribe_to_growth_plan(another) end + new_site(owner: another) + team2 = another |> team_of() |> Plausible.Teams.complete_setup() + add_member(team2, user: user, role: role) + team2 end end From b8200ce1ac969e11977013e82b736b1c11a83f3e Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 1 Jun 2026 16:49:29 +0200 Subject: [PATCH 10/11] Add My Personal Sites as a choice --- .../live/site_transfer_settings.ex | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/lib/plausible_web/live/site_transfer_settings.ex b/lib/plausible_web/live/site_transfer_settings.ex index 35601f7a58d7..34cc40d31be3 100644 --- a/lib/plausible_web/live/site_transfer_settings.ex +++ b/lib/plausible_web/live/site_transfer_settings.ex @@ -9,7 +9,6 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do use PlausibleWeb, :live_view - alias Plausible.Repo alias Plausible.Teams alias PlausibleWeb.Router.Helpers, as: Routes @@ -20,7 +19,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do @primary_key false embedded_schema do - field :destination, Ecto.Enum, values: [:team, :account] + field :destination, Ecto.Enum, values: [:team, :my_team, :account] field :team_identifier, :string field :email, :string end @@ -39,6 +38,9 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do :account -> validate_required(changeset, :email, message: "Please enter an email address") + + :my_team -> + changeset end end end @@ -49,6 +51,8 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do site = Plausible.Sites.get_for_user!(user, domain, roles: [:owner, :admin, :super_admin]) + teams = Teams.Users.teams(user, roles: [:owner, :admin]) + team_options = user |> Teams.Users.teams(roles: [:owner, :admin]) @@ -56,13 +60,22 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do |> Enum.map(&{&1.name, &1.identifier}) show_teams? = team_options != [] - show_my_team? = not is_nil(socket.assigns.my_team) - initial_destination = if show_teams?, do: :team, else: :account + + show_my_team? = + not is_nil(socket.assigns[:my_team]) and socket.assigns.my_team.id != site.team_id + + initial_destination = + cond do + show_teams? -> :team + show_my_team? -> :my_team + true -> :account + end socket = socket |> assign( site: site, + teams: teams, team_options: team_options, show_teams?: show_teams?, show_my_team?: show_my_team? @@ -126,6 +139,24 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do

+
+
+ <.input + type="radio" + id="destination-my_team" + name={f[:destination].name} + value={:my_team} + checked={f[:destination].value == :my_team} + label="My Personal Sites" + /> + <.input + type="hidden" + field={f[:team_identifier]} + options={@my_team.identifier} + /> +
+
+
<.input type="radio" @@ -175,7 +206,11 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do case Ecto.Changeset.apply_action(changeset, :insert) do {:ok, %Form{destination: :team, team_identifier: identifier}} -> - do_change_team(socket, identifier, params) + team = Enum.find(socket.assigns.teams, &(&1.identifier == identifier)) + do_change_team(socket, team, params) + + {:ok, %Form{destination: :my_team}} -> + do_change_team(socket, socket.assigns[:my_team], params) {:ok, %Form{destination: :account, email: email}} -> do_transfer_ownership(socket, email, params) @@ -185,20 +220,17 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do 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)) - + defp do_change_team(socket, destination_team, params) do if destination_team do + user = socket.assigns.current_user + site = socket.assigns.site + 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))} + |> redirect(to: Routes.site_path(socket, :index, __team: destination_team.identifier))} {:error, reason} -> {:noreply, @@ -260,6 +292,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do end defp submit_label(:team), do: "Move site" + defp submit_label(:my_team), do: "Move site" defp submit_label(_), do: "Send transfer request" defp change_team_error_message(:no_plan) do From bf3271e15ccbf50a8ec1d828b0768b854b401259 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 2 Jun 2026 10:34:57 +0200 Subject: [PATCH 11/11] Refine my team transfer option and improve overall phrasing somewhat --- .../live/site_transfer_settings.ex | 63 +++++++++++++++---- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/lib/plausible_web/live/site_transfer_settings.ex b/lib/plausible_web/live/site_transfer_settings.ex index 34cc40d31be3..9938e5f6790d 100644 --- a/lib/plausible_web/live/site_transfer_settings.ex +++ b/lib/plausible_web/live/site_transfer_settings.ex @@ -22,11 +22,12 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do field :destination, Ecto.Enum, values: [:team, :my_team, :account] field :team_identifier, :string field :email, :string + field :my_team_available, :boolean, default: false end def changeset(params) do %__MODULE__{} - |> cast(params, [:destination, :team_identifier, :email]) + |> cast(params, [:destination, :team_identifier, :my_team_available, :email]) |> validate_required(:destination) |> validate_destination_fields() end @@ -64,6 +65,18 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do show_my_team? = not is_nil(socket.assigns[:my_team]) and socket.assigns.my_team.id != site.team_id + my_team_notice = + cond do + not is_nil(socket.assigns[:my_team]) and socket.assigns.my_team.id == site.team_id -> + "The site is already in My Personal Sites." + + is_nil(socket.assigns[:my_team]) -> + "My Personal Sites does not have an active subscription." + + true -> + nil + end + initial_destination = cond do show_teams? -> :team @@ -78,7 +91,8 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do teams: teams, team_options: team_options, show_teams?: show_teams?, - show_my_team?: show_my_team? + show_my_team?: show_my_team?, + my_team_notice: my_team_notice ) |> assign_form(%{"destination" => initial_destination}) @@ -135,26 +149,34 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do :if={not @show_teams?} class="ml-7 mt-1 text-sm text-gray-500/60 dark:text-gray-400/60 text-pretty" > - You aren't a member of any other teams. + You aren't a member of any other teams or you lack privileges for transfer.

-
-
+
+
<.input type="radio" id="destination-my_team" name={f[:destination].name} value={:my_team} checked={f[:destination].value == :my_team} + disabled={not @show_my_team?} label="My Personal Sites" /> <.input + :if={@show_my_team?} type="hidden" - field={f[:team_identifier]} - options={@my_team.identifier} + field={f[:my_team_available]} + value={true} />
+

+ {@my_team_notice} +

@@ -222,9 +244,14 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do defp do_change_team(socket, destination_team, params) do if destination_team do + my_team? = + not is_nil(socket.assigns[:my_team]) and socket.assigns.my_team.id == destination_team.id + user = socket.assigns.current_user site = socket.assigns.site + error_field = if(my_team?, do: :my_team_available, else: :team_identifier) + case Teams.Sites.Transfer.change_team(site, user, destination_team) do :ok -> {:noreply, @@ -236,7 +263,7 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do {:noreply, assign_form(socket, params, action: :insert, - field_errors: [{:team_identifier, change_team_error_message(reason)}] + field_errors: [{error_field, change_team_error_message(reason, my_team?)}] )} end else @@ -295,15 +322,27 @@ defmodule PlausibleWeb.Live.SiteTransferSettings do defp submit_label(:my_team), do: "Move site" defp submit_label(_), do: "Send transfer request" - defp change_team_error_message(:no_plan) do + defp change_team_error_message(:no_plan, false = _my_team?) 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 + defp change_team_error_message(:no_plan, true = _my_team?) do + "You don't have a subscription. Please start a subscription first and then try moving the site again." + end + + defp change_team_error_message({:over_plan_limits, _}, false = _my_team?) 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" + defp change_team_error_message({:over_plan_limits, _}, true = _my_team?) do + "This site's usage exceeds your subscription limits. Upgrade your subscription to continue." + end + + defp change_team_error_message(_, false = _my_team?) do + "Sorry, this team cannot be used." + end + + defp change_team_error_message(_, true = _my_team?) do + "Sorry, My Personal Sites cannot be used." end end