Skip to content

Commit

Permalink
Implement account deletion from the user settings page
Browse files Browse the repository at this point in the history
  • Loading branch information
ku1ik committed May 16, 2024
1 parent 0b4b6ab commit f282c30
Show file tree
Hide file tree
Showing 13 changed files with 185 additions and 15 deletions.
11 changes: 11 additions & 0 deletions assets/css/_user_edit.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,15 @@
opacity: 0.7;
}
}

.danger-zone {
margin-top: 2em;
}
}

.c-user.a-delete {
h1 {
margin-top: 2rem;
margin-bottom: 2rem;
}
}
7 changes: 7 additions & 0 deletions lib/asciinema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ defmodule Asciinema do
end
end

def send_account_deletion_email(user, routes) do
url = Accounts.generate_deletion_url(user, routes)
Emails.send_email(:account_deletion, user.email, url)

:ok
end

defdelegate verify_login_token(token), to: Accounts

def merge_accounts(src_user, dst_user) do
Expand Down
20 changes: 18 additions & 2 deletions lib/asciinema/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule Asciinema.Accounts do
alias Asciinema.Accounts.{User, ApiToken}
alias Asciinema.{Fonts, Repo, Themes}
alias Ecto.Changeset
alias Phoenix.Token

@valid_email_re ~r/^[A-Z0-9._%+-]+@([A-Z0-9-]+\.)+[A-Z]{2,}$/i
@valid_username_re ~r/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/
Expand Down Expand Up @@ -158,8 +159,6 @@ defmodule Asciinema.Accounts do
end
end

alias Phoenix.Token

def signup_token(email) do
Token.sign(config(:secret), "signup", email)
end
Expand Down Expand Up @@ -210,6 +209,23 @@ defmodule Asciinema.Accounts do
end
end

def generate_deletion_url(user, routes) do
user
|> generate_deletion_token()
|> routes.account_deletion_url()
end

def generate_deletion_token(%User{id: user_id}) do
Token.sign(config(:secret), "acct-delete", user_id)
end

def verify_deletion_token(token) do
case Token.verify(config(:secret), "acct-delete", token, max_age: 3600) do
{:ok, user_id} -> {:ok, user_id}
{:error, _} -> {:error, :token_invalid}
end
end

def get_user_with_api_token(token, tmp_username \\ nil) do
case get_api_token(token) do
{:ok, %ApiToken{user: user}} ->
Expand Down
5 changes: 5 additions & 0 deletions lib/asciinema/emails.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ defmodule Asciinema.Emails do
job.args["to"]
|> Email.login_email(job.args["url"])
|> deliver()

"account_deletion" ->
job.args["to"]
|> Email.account_deletion_email(job.args["url"])
|> deliver()
end

:ok
Expand Down
28 changes: 16 additions & 12 deletions lib/asciinema/emails/email.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,36 @@ defmodule Asciinema.Emails.Email do
import Bamboo.Email

def signup_email(email_address, signup_url) do
hostname = instance_hostname()

base_email()
|> to(email_address)
|> subject("Welcome to #{hostname}")
|> render("signup.text", signup_url: signup_url, hostname: hostname)
|> render("signup.html", signup_url: signup_url, hostname: hostname)
|> subject("Welcome to #{instance_hostname()}")
|> render("signup.text", signup_url: signup_url)
|> render("signup.html", signup_url: signup_url)
|> fix_text_body()
end

def login_email(email_address, login_url) do
hostname = instance_hostname()
base_email()
|> to(email_address)
|> subject("Login to #{instance_hostname()}")
|> render("login.text", login_url: login_url)
|> render("login.html", login_url: login_url)
|> fix_text_body()
end

def account_deletion_email(email_address, confirmation_url) do
base_email()
|> to(email_address)
|> subject("Login to #{hostname}")
|> render("login.text", login_url: login_url, hostname: hostname)
|> render("login.html", login_url: login_url, hostname: hostname)
|> subject("Account deletion")
|> render("account_deletion.text", confirmation_url: confirmation_url)
|> render("account_deletion.html", confirmation_url: confirmation_url)
|> fix_text_body()
end

def test_email(email_address) do
hostname = instance_hostname()

base_email()
|> to(email_address)
|> subject("Test email from #{hostname}")
|> subject("Test email from #{instance_hostname()}")
|> text_body("It works!")
end

Expand All @@ -39,6 +42,7 @@ defmodule Asciinema.Emails.Email do
|> put_header("Date", Timex.format!(Timex.now(), "{RFC1123}"))
|> put_header("Reply-To", reply_to_address())
|> put_html_layout({AsciinemaWeb.LayoutView, "email.html"})
|> assign(:hostname, instance_hostname())
end

defp from_address do
Expand Down
4 changes: 4 additions & 0 deletions lib/asciinema_web.ex
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,8 @@ defmodule AsciinemaWeb do
def login_url(token) do
Routes.session_url(Endpoint, :new, t: token)
end

def account_deletion_url(token) do
Routes.user_deletion_url(Endpoint, :delete, t: token)
end
end
17 changes: 17 additions & 0 deletions lib/asciinema_web/controllers/user/delete.html.heex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Are you sure?</h1>

<p>This will permanently delete your account and all associated recordings.</p>

<.form for={@conn} method="delete" action={~p"/user"}>
<.input name="confirmed" type="hidden" value="1" />
<.input name="token" type="hidden" value={@token} />
<.button type="submit" class="btn btn-danger" data-confirm="There's no going back!">
Yes, delete my account
</.button>
</.form>
</div>
</div>
</div>
22 changes: 22 additions & 0 deletions lib/asciinema_web/controllers/user/edit.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -255,4 +255,26 @@
</form>
</div>
</div>

<div class="row danger-zone">
<div class="col-md-12 col-lg-9">
<form>
<legend>Delete account</legend>
</form>

<p>
If you ever decide to delete your <%= @conn.host %> account you can do so by clicking that big red button below.
</p>

<p>
<.link href={~p"/user"} class="btn btn-danger" method="delete">
Delete my account
</.link>
</p>

<p>
NOTE: You'll get a confirmation email sent to <%= @changeset.data.email %>. Open the link from the email to confirm account deletion.
</p>
</div>
</div>
</div>
32 changes: 32 additions & 0 deletions lib/asciinema_web/controllers/user_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,36 @@ defmodule AsciinemaWeb.UserController do
api_tokens: api_tokens
)
end

def delete(conn, %{"token" => token, "confirmed" => _}) do
with {:ok, user_id} <- Accounts.verify_deletion_token(token),
user when not is_nil(user) <- Accounts.get_user(user_id) do
:ok = Asciinema.delete_user!(user)

conn
|> Auth.log_out()
|> put_flash(:info, "Account deleted")
|> redirect(to: ~p"/")
else
_ ->
conn
|> put_flash(:error, "Invalid account deletion token")
|> redirect(to: ~p"/")
end
end

def delete(conn, %{"t" => token}) do
render(conn, :delete, token: token)
end

def delete(conn, _params) do
user = conn.assigns.current_user
address = user.email

:ok = Asciinema.send_account_deletion_email(user, AsciinemaWeb)

conn
|> put_flash(:info, "Account removal initiated - check your inbox (#{address})")
|> redirect(to: profile_path(conn))
end
end
3 changes: 2 additions & 1 deletion lib/asciinema_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,11 @@ defmodule AsciinemaWeb.Router do
resources "/login", LoginController, only: [:new, :create], singleton: true
get "/login/sent", LoginController, :sent, as: :login

resources "/user", UserController, as: :user, only: [:edit, :update], singleton: true
resources "/user", UserController, as: :user, only: [:edit, :update, :delete], singleton: true
resources "/users", UserController, as: :users, only: [:new, :create]
get "/u/:id", UserController, :show
get "/~:username", UserController, :show
get "/user/delete", UserController, :delete, as: :user_deletion

resources "/username", UsernameController, only: [:new, :create], singleton: true
get "/username/skip", UsernameController, :skip, as: :username
Expand Down
10 changes: 10 additions & 0 deletions lib/asciinema_web/templates/email/account_deletion.html.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<p>It seems you have requested deletion of your <%= @hostname %> account.</p>

<p>If you wish to proceed, open the following link in your browser:</p>

<p><a href="<%= @confirmation_url %>"><%= @confirmation_url %></a></p>

<p>
<br>
If you did not initiate this request, just ignore this email.
</p>
8 changes: 8 additions & 0 deletions lib/asciinema_web/templates/email/account_deletion.text.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
It seems you have requested deletion of your <%= @hostname %> account.

If you wish to proceed, open the following link in your browser:

<%= @confirmation_url %>


If you did not initiate this request, just ignore this email.
33 changes: 33 additions & 0 deletions test/controllers/user_controller_test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
defmodule Asciinema.UserControllerTest do
use AsciinemaWeb.ConnCase
use Oban.Testing, repo: Asciinema.Repo
import Asciinema.Factory
alias Asciinema.Accounts

Expand Down Expand Up @@ -129,4 +130,36 @@ defmodule Asciinema.UserControllerTest do
assert html_response(conn, 200) =~ "at least 2"
end
end

describe "account deletion" do
test "phase 1", %{conn: conn} do
user = insert(:user)
conn = log_in(conn, user)

conn = delete(conn, ~p"/user")

assert response(conn, 302)
assert flash(conn, :info) =~ ~r/initiated/i
assert_enqueued(worker: Asciinema.Emails.Job, args: %{"type" => "account_deletion"})
end

test "phase 2", %{conn: conn} do
user = insert(:user)
token = Accounts.generate_deletion_token(user)

conn = get(conn, ~p"/user/delete", %{t: token})

assert html_response(conn, 200) =~ ~r/Yes, delete/
end

test "phase 3", %{conn: conn} do
user = insert(:user)
token = Accounts.generate_deletion_token(user)

conn = delete(conn, ~p"/user", %{token: token, confirmed: "1"})

assert response(conn, 302)
assert flash(conn, :info) =~ ~r/deleted/i
end
end
end

0 comments on commit f282c30

Please sign in to comment.