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..e3f82111ef3f
--- /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 and have 48 hours to accept the transfer. 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..095f1cf2fdcf
--- /dev/null
+++ b/test/plausible_web/live/site_transfer_settings_test.exs
@@ -0,0 +1,311 @@
+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