Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement team deletion and refactor user deletion #5196

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 52 additions & 32 deletions lib/plausible/auth/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -70,52 +70,72 @@ defmodule Plausible.Auth do
end
end

@spec delete_user(Auth.User.t()) ::
{:ok, :deleted} | {:error, :is_only_team_owner | :active_subscription}
def delete_user(user) do
Repo.transaction(fn ->
case Teams.get_by_owner(user) do
{:ok, %{setup_complete: false} = team} ->
for site <- Teams.owned_sites(team) do
Plausible.Site.Removal.run(site)
end
case Teams.get_by_owner(user) do
{:ok, %{setup_complete: false} = team} ->
delete_team_and_user(team, user)

Repo.delete_all(from s in Plausible.Billing.Subscription, where: s.team_id == ^team.id)
{:ok, team} ->
with :ok <- check_can_leave_team(team) do
delete_user!(user)
{:ok, :deleted}
end

Repo.delete_all(
from ep in Plausible.Billing.EnterprisePlan, where: ep.team_id == ^team.id
)
{:error, :multiple_teams} ->
teams = Teams.Users.owned_teams(user)

Plausible.Segments.user_removed(user)
Repo.delete!(team)
Repo.delete!(user)
with :ok <- check_can_leave_teams(teams) do
personal_team = Enum.find(teams, & &1.setup_complete)
delete_team_and_user(personal_team, user)
end

{:ok, team} ->
check_can_leave_team!(team)
Repo.delete!(user)
{:error, :no_team} ->
delete_user!(user)
{:ok, :deleted}
end
end

{:error, :multiple_teams} ->
check_can_leave_teams!(user)
Repo.delete!(user)
defp delete_team_and_user(nil, user) do
delete_user!(user)
{:ok, :deleted}
end

{:error, :no_team} ->
Repo.delete!(user)
end
defp delete_team_and_user(team, user) do
Repo.transaction(fn ->
case Teams.delete(team) do
{:ok, :deleted} ->
delete_user!(user)
:deleted

:deleted
{:error, error} ->
Repo.rollback(error)
end
end)
end

defp check_can_leave_teams!(user) do
user
|> Teams.Users.owned_teams()
|> Enum.reject(&(&1.setup_complete == false))
|> Enum.map(fn team ->
check_can_leave_team!(team)
defp delete_user!(user) do
Plausible.Segments.user_removed(user)
Repo.delete!(user)
end

defp check_can_leave_teams(teams) do
teams
|> Enum.filter(& &1.setup_complete)
|> Enum.reduce_while(:ok, fn team, :ok ->
case check_can_leave_team(team) do
:ok -> {:cont, :ok}
{:error, error} -> {:halt, {:error, error}}
end
end)
end

defp check_can_leave_team!(team) do
if Teams.Memberships.owners_count(team) <= 1 do
Repo.rollback(:is_only_team_owner)
defp check_can_leave_team(team) do
if Teams.Memberships.owners_count(team) > 1 do
:ok
else
{:error, :is_only_team_owner}
end
end

Expand Down
24 changes: 24 additions & 0 deletions lib/plausible/teams.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule Plausible.Teams do

alias __MODULE__
alias Plausible.Auth
alias Plausible.Billing
alias Plausible.Repo
use Plausible

Expand Down Expand Up @@ -185,6 +186,29 @@ defmodule Plausible.Teams do
end
end

@spec delete(Teams.Team.t()) :: {:ok, :deleted} | {:error, :active_subscription}
def delete(team) do
team = Teams.with_subscription(team)

if Billing.Subscription.Status.active?(team.subscription) do
{:error, :active_subscription}
else
Repo.transaction(fn ->
for site <- Teams.owned_sites(team) do
Plausible.Site.Removal.run(site)
end

Repo.delete_all(from s in Billing.Subscription, where: s.team_id == ^team.id)

Repo.delete_all(from ep in Billing.EnterprisePlan, where: ep.team_id == ^team.id)

Repo.delete!(team)

:deleted
end)
end
end

@spec get_by_owner(Auth.User.t()) ::
{:ok, Teams.Team.t()} | {:error, :no_team | :multiple_teams}
def get_by_owner(user) do
Expand Down
8 changes: 8 additions & 0 deletions lib/plausible_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,14 @@ defmodule PlausibleWeb.AuthController do
{:ok, :deleted} ->
logout(conn, params)

{:error, :active_subscription} ->
conn
|> put_flash(
:error,
"You have an active subscription which must be canceled first."
)
|> redirect(to: Routes.settings_path(conn, :danger_zone))

{:error, :is_only_team_owner} ->
conn
|> put_flash(
Expand Down
26 changes: 26 additions & 0 deletions lib/plausible_web/controllers/settings_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ defmodule PlausibleWeb.SettingsController do
plug Plausible.Plugs.AuthorizeTeamAccess,
[:owner, :admin, :billing] when action in [:invoices]

plug Plausible.Plugs.AuthorizeTeamAccess,
[:owner] when action in [:team_danger_zone, :delete_team]

def index(conn, _params) do
redirect(conn, to: Routes.settings_path(conn, :preferences))
end
Expand Down Expand Up @@ -132,6 +135,29 @@ defmodule PlausibleWeb.SettingsController do
render(conn, :danger_zone, layout: {PlausibleWeb.LayoutView, :settings})
end

def team_danger_zone(conn, _params) do
render(conn, :team_danger_zone, layout: {PlausibleWeb.LayoutView, :settings})
end

def delete_team(conn, _params) do
team = conn.assigns.current_team

case Plausible.Teams.delete(team) do
{:ok, :deleted} ->
conn
|> put_flash(:success, ~s|Team "#{Plausible.Teams.name(team)}" deleted|)
|> redirect(to: Routes.site_path(conn, :index, __team: "none"))

{:error, :active_subscription} ->
conn
|> put_flash(
:error,
"Team has an active subscription. You must cancel it first."
)
|> redirect(to: Routes.settings_path(conn, :team_danger_zone))
end
end

# Preferences actions

def update_name(conn, %{"user" => params}) do
Expand Down
2 changes: 2 additions & 0 deletions lib/plausible_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ defmodule PlausibleWeb.Router do
post "/team/invitations/:invitation_id/accept", InvitationController, :accept_invitation
post "/team/invitations/:invitation_id/reject", InvitationController, :reject_invitation
delete "/team/invitations/:invitation_id", InvitationController, :remove_team_invitation
get "/team/delete", SettingsController, :team_danger_zone
delete "/team/delete", SettingsController, :delete_team
end

scope "/", PlausibleWeb do
Expand Down
25 changes: 25 additions & 0 deletions lib/plausible_web/templates/settings/team_danger_zone.html.heex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<.notice title="Danger Zone" theme={:red}>
Destructive actions below can result in irrecoverable data loss. Be careful.
</.notice>

<.settings_tiles>
<.tile docs="delete-team">
<:title>Delete Team</:title>
<:subtitle>Deleting the team removes all associated sites and collected stats</:subtitle>

<%= if Plausible.Billing.Subscription.Status.active?(@current_team && @current_team.subscription) do %>
<.notice theme={:gray} title="Cannot delete the team at this time">
The team cannot be deleted because it has an active subscription. Please cancel the subscription first.
</.notice>
<% else %>
<.button_link
data-confirm="Deleting the team will also delete all the associated sites and data. This action cannot be reversed. Are you sure?"
href={Routes.settings_path(@conn, :delete_team)}
method="delete"
theme="danger"
>
Delete "{Plausible.Teams.name(@current_team)}"
</.button_link>
<% end %>
</.tile>
</.settings_tiles>
3 changes: 3 additions & 0 deletions lib/plausible_web/views/layout_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ defmodule PlausibleWeb.LayoutView do
%{key: "Subscription", value: "billing/subscription", icon: :circle_stack},
if(current_team_role in [:owner, :admin, :billing],
do: %{key: "Invoices", value: "billing/invoices", icon: :banknotes}
),
if(current_team_role == :owner,
do: %{key: "Danger Zone", value: "team/delete", icon: :exclamation_triangle}
)
]
|> Enum.reject(&is_nil/1)
Expand Down
70 changes: 70 additions & 0 deletions test/plausible/teams_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ defmodule Plausible.TeamsTest do
use Plausible
use Plausible.Teams.Test

alias Plausible.Billing.Subscription
alias Plausible.Teams
alias Plausible.Repo

require Plausible.Billing.Subscription.Status

describe "name/1" do
test "returns default name when there's no team" do
assert Teams.name(nil) == "My Personal Sites"
Expand Down Expand Up @@ -323,4 +326,71 @@ defmodule Plausible.TeamsTest do
assert Teams.accept_traffic_until(team_of(user)) == ~D[2135-01-01]
end
end

describe "delete/1" do
test "deletes a team" do
user = new_user()
subscribe_to_growth_plan(user, status: Subscription.Status.deleted())
subscribe_to_enterprise_plan(user, site_limit: 1, subscription?: false)
team = team_of(user)
team = Teams.complete_setup(team)

another_user = new_user()
another_site = new_site(owner: another_user)
another_team = team_of(another_user)
add_member(another_team, user: user, role: :owner)

site1 = new_site(team: team)
site2 = new_site(team: team)

viewer_member = new_user()
add_member(team, user: viewer_member, role: :viewer)
owner_member = new_user()
add_member(team, user: owner_member, role: :owner)

guest_member = new_user()
add_guest(site1, user: guest_member, role: :editor)

team_invitee = new_user()
invite_member(team, team_invitee, inviter: user, role: :admin)
guest_invitee = new_user()
invite_guest(site2, guest_invitee, inviter: user, role: :viewer)

assert {:ok, :deleted} = Teams.delete(team)

refute Repo.reload(team)

assert Repo.reload(another_user)
assert Repo.reload(another_team)
assert Repo.reload(another_site)

refute Repo.reload(site1)
refute Repo.reload(site2)

assert Repo.reload(viewer_member)
refute_team_member(viewer_member, team)

assert Repo.reload(owner_member)
refute_team_member(owner_member, team)

assert Repo.reload(guest_member)
refute_team_member(guest_member, team)

assert Repo.reload(team_invitee)
refute_team_invitation(team, team_invitee.email)

assert Repo.reload(guest_invitee)
refute_team_invitation(team, guest_invitee.email)
end

test "does not delete a team with active subscription" do
user = new_user()
subscribe_to_growth_plan(user, status: Subscription.Status.active())
team = team_of(user)

assert {:error, :active_subscription} = Teams.delete(team)

assert Repo.reload(team)
end
end
end
17 changes: 16 additions & 1 deletion test/plausible_web/controllers/auth_controller_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,6 @@ defmodule PlausibleWeb.AuthControllerTest do

insert(:google_auth, site: site, user: user)
subscribe_to_growth_plan(user, status: Subscription.Status.deleted())
subscribe_to_growth_plan(user, status: Subscription.Status.active())
subscribe_to_enterprise_plan(user, site_limit: 1, subscription?: false)

{:ok, team} = Plausible.Teams.get_or_create(user)
Expand All @@ -601,6 +600,22 @@ defmodule PlausibleWeb.AuthControllerTest do
refute Repo.get(Plausible.Teams.Team, team.id)
end

test "refuses to delete when a personal team has an active subscription", %{
conn: conn,
user: user
} do
subscribe_to_growth_plan(user, status: Subscription.Status.active())

conn = delete(conn, "/me")

assert redirected_to(conn, 302) == Routes.settings_path(conn, :danger_zone)

assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"You have an active subscription which must be canceled first"

assert Repo.reload(user)
end

test "deletes sites that the user owns", %{conn: conn, user: user, site: owner_site} do
viewer_site = new_site()
add_guest(viewer_site, user: user, role: :viewer)
Expand Down
Loading
Loading