From 0ecace20223e6934ff88672a3dae6f2e0fe2498e Mon Sep 17 00:00:00 2001
From: Roman Cattaneo <>
Date: Sun, 22 Jun 2025 10:12:10 +0200
Subject: [PATCH 01/31] mix gen auth and first round of html templating
---
config/test.exs | 3 +
lib/cklist/accounts.ex | 353 ++++++++++++
lib/cklist/accounts/user.ex | 161 ++++++
lib/cklist/accounts/user_notifier.ex | 79 +++
lib/cklist/accounts/user_token.ex | 179 ++++++
.../components/layouts/app.html.heex | 46 +-
lib/cklist_web/controllers/page_controller.ex | 4 +
lib/cklist_web/controllers/page_html.ex | 14 +
.../controllers/page_html/about.html.heex | 6 +
.../controllers/page_html/home.html.heex | 79 ++-
.../controllers/user_session_controller.ex | 42 ++
.../user_confirmation_instructions_live.ex | 51 ++
lib/cklist_web/live/user_confirmation_live.ex | 58 ++
.../live/user_forgot_password_live.ex | 50 ++
lib/cklist_web/live/user_login_live.ex | 43 ++
lib/cklist_web/live/user_registration_live.ex | 87 +++
.../live/user_reset_password_live.ex | 89 +++
lib/cklist_web/live/user_settings_live.ex | 167 ++++++
lib/cklist_web/router.ex | 42 ++
lib/cklist_web/user_auth.ex | 229 ++++++++
mix.exs | 1 +
mix.lock | 3 +
...0250607090156_create_users_auth_tables.exs | 29 +
test/cklist/accounts_test.exs | 508 ++++++++++++++++++
.../user_session_controller_test.exs | 113 ++++
...er_confirmation_instructions_live_test.exs | 67 +++
.../live/user_confirmation_live_test.exs | 89 +++
.../live/user_forgot_password_live_test.exs | 63 +++
test/cklist_web/live/user_login_live_test.exs | 87 +++
.../live/user_registration_live_test.exs | 87 +++
.../live/user_reset_password_live_test.exs | 118 ++++
.../live/user_settings_live_test.exs | 210 ++++++++
test/cklist_web/user_auth_test.exs | 272 ++++++++++
test/support/conn_case.ex | 26 +
test/support/fixtures/accounts_fixtures.ex | 31 ++
35 files changed, 3450 insertions(+), 36 deletions(-)
create mode 100644 lib/cklist/accounts.ex
create mode 100644 lib/cklist/accounts/user.ex
create mode 100644 lib/cklist/accounts/user_notifier.ex
create mode 100644 lib/cklist/accounts/user_token.ex
create mode 100644 lib/cklist_web/controllers/page_html/about.html.heex
create mode 100644 lib/cklist_web/controllers/user_session_controller.ex
create mode 100644 lib/cklist_web/live/user_confirmation_instructions_live.ex
create mode 100644 lib/cklist_web/live/user_confirmation_live.ex
create mode 100644 lib/cklist_web/live/user_forgot_password_live.ex
create mode 100644 lib/cklist_web/live/user_login_live.ex
create mode 100644 lib/cklist_web/live/user_registration_live.ex
create mode 100644 lib/cklist_web/live/user_reset_password_live.ex
create mode 100644 lib/cklist_web/live/user_settings_live.ex
create mode 100644 lib/cklist_web/user_auth.ex
create mode 100644 priv/repo/migrations/20250607090156_create_users_auth_tables.exs
create mode 100644 test/cklist/accounts_test.exs
create mode 100644 test/cklist_web/controllers/user_session_controller_test.exs
create mode 100644 test/cklist_web/live/user_confirmation_instructions_live_test.exs
create mode 100644 test/cklist_web/live/user_confirmation_live_test.exs
create mode 100644 test/cklist_web/live/user_forgot_password_live_test.exs
create mode 100644 test/cklist_web/live/user_login_live_test.exs
create mode 100644 test/cklist_web/live/user_registration_live_test.exs
create mode 100644 test/cklist_web/live/user_reset_password_live_test.exs
create mode 100644 test/cklist_web/live/user_settings_live_test.exs
create mode 100644 test/cklist_web/user_auth_test.exs
create mode 100644 test/support/fixtures/accounts_fixtures.ex
diff --git a/config/test.exs b/config/test.exs
index ad3e16f..ef72d2a 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,5 +1,8 @@
import Config
+# Only in tests, remove the complexity from the password hashing algorithm
+config :bcrypt_elixir, :log_rounds, 1
+
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
diff --git a/lib/cklist/accounts.ex b/lib/cklist/accounts.ex
new file mode 100644
index 0000000..d71dad7
--- /dev/null
+++ b/lib/cklist/accounts.ex
@@ -0,0 +1,353 @@
+defmodule Cklist.Accounts do
+ @moduledoc """
+ The Accounts context.
+ """
+
+ import Ecto.Query, warn: false
+ alias Cklist.Repo
+
+ alias Cklist.Accounts.{User, UserToken, UserNotifier}
+
+ ## Database getters
+
+ @doc """
+ Gets a user by email.
+
+ ## Examples
+
+ iex> get_user_by_email("foo@example.com")
+ %User{}
+
+ iex> get_user_by_email("unknown@example.com")
+ nil
+
+ """
+ def get_user_by_email(email) when is_binary(email) do
+ Repo.get_by(User, email: email)
+ end
+
+ @doc """
+ Gets a user by email and password.
+
+ ## Examples
+
+ iex> get_user_by_email_and_password("foo@example.com", "correct_password")
+ %User{}
+
+ iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
+ nil
+
+ """
+ def get_user_by_email_and_password(email, password)
+ when is_binary(email) and is_binary(password) do
+ user = Repo.get_by(User, email: email)
+ if User.valid_password?(user, password), do: user
+ end
+
+ @doc """
+ Gets a single user.
+
+ Raises `Ecto.NoResultsError` if the User does not exist.
+
+ ## Examples
+
+ iex> get_user!(123)
+ %User{}
+
+ iex> get_user!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_user!(id), do: Repo.get!(User, id)
+
+ ## User registration
+
+ @doc """
+ Registers a user.
+
+ ## Examples
+
+ iex> register_user(%{field: value})
+ {:ok, %User{}}
+
+ iex> register_user(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def register_user(attrs) do
+ %User{}
+ |> User.registration_changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking user changes.
+
+ ## Examples
+
+ iex> change_user_registration(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_registration(%User{} = user, attrs \\ %{}) do
+ User.registration_changeset(user, attrs, hash_password: false, validate_email: false)
+ end
+
+ ## Settings
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for changing the user email.
+
+ ## Examples
+
+ iex> change_user_email(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_email(user, attrs \\ %{}) do
+ User.email_changeset(user, attrs, validate_email: false)
+ end
+
+ @doc """
+ Emulates that the email will change without actually changing
+ it in the database.
+
+ ## Examples
+
+ iex> apply_user_email(user, "valid password", %{email: ...})
+ {:ok, %User{}}
+
+ iex> apply_user_email(user, "invalid password", %{email: ...})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def apply_user_email(user, password, attrs) do
+ user
+ |> User.email_changeset(attrs)
+ |> User.validate_current_password(password)
+ |> Ecto.Changeset.apply_action(:update)
+ end
+
+ @doc """
+ Updates the user email using the given token.
+
+ If the token matches, the user email is updated and the token is deleted.
+ The confirmed_at date is also updated to the current time.
+ """
+ def update_user_email(user, token) do
+ context = "change:#{user.email}"
+
+ with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
+ %UserToken{sent_to: email} <- Repo.one(query),
+ {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
+ :ok
+ else
+ _ -> :error
+ end
+ end
+
+ defp user_email_multi(user, email, context) do
+ changeset =
+ user
+ |> User.email_changeset(%{email: email})
+ |> User.confirm_changeset()
+
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, changeset)
+ |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context]))
+ end
+
+ @doc ~S"""
+ Delivers the update email instructions to the given user.
+
+ ## Examples
+
+ iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1}"))
+ {:ok, %{to: ..., body: ...}}
+
+ """
+ def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
+ when is_function(update_email_url_fun, 1) do
+ {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
+
+ Repo.insert!(user_token)
+ UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for changing the user password.
+
+ ## Examples
+
+ iex> change_user_password(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_password(user, attrs \\ %{}) do
+ User.password_changeset(user, attrs, hash_password: false)
+ end
+
+ @doc """
+ Updates the user password.
+
+ ## Examples
+
+ iex> update_user_password(user, "valid password", %{password: ...})
+ {:ok, %User{}}
+
+ iex> update_user_password(user, "invalid password", %{password: ...})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_user_password(user, password, attrs) do
+ changeset =
+ user
+ |> User.password_changeset(attrs)
+ |> User.validate_current_password(password)
+
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, changeset)
+ |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: user}} -> {:ok, user}
+ {:error, :user, changeset, _} -> {:error, changeset}
+ end
+ end
+
+ ## Session
+
+ @doc """
+ Generates a session token.
+ """
+ def generate_user_session_token(user) do
+ {token, user_token} = UserToken.build_session_token(user)
+ Repo.insert!(user_token)
+ token
+ end
+
+ @doc """
+ Gets the user with the given signed token.
+ """
+ def get_user_by_session_token(token) do
+ {:ok, query} = UserToken.verify_session_token_query(token)
+ Repo.one(query)
+ end
+
+ @doc """
+ Deletes the signed token with the given context.
+ """
+ def delete_user_session_token(token) do
+ Repo.delete_all(UserToken.by_token_and_context_query(token, "session"))
+ :ok
+ end
+
+ ## Confirmation
+
+ @doc ~S"""
+ Delivers the confirmation email instructions to the given user.
+
+ ## Examples
+
+ iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}"))
+ {:ok, %{to: ..., body: ...}}
+
+ iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}"))
+ {:error, :already_confirmed}
+
+ """
+ def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
+ when is_function(confirmation_url_fun, 1) do
+ if user.confirmed_at do
+ {:error, :already_confirmed}
+ else
+ {encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
+ Repo.insert!(user_token)
+ UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
+ end
+ end
+
+ @doc """
+ Confirms a user by the given token.
+
+ If the token matches, the user account is marked as confirmed
+ and the token is deleted.
+ """
+ def confirm_user(token) do
+ with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
+ %User{} = user <- Repo.one(query),
+ {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do
+ {:ok, user}
+ else
+ _ -> :error
+ end
+ end
+
+ defp confirm_user_multi(user) do
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, User.confirm_changeset(user))
+ |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"]))
+ end
+
+ ## Reset password
+
+ @doc ~S"""
+ Delivers the reset password email to the given user.
+
+ ## Examples
+
+ iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}"))
+ {:ok, %{to: ..., body: ...}}
+
+ """
+ def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
+ when is_function(reset_password_url_fun, 1) do
+ {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
+ Repo.insert!(user_token)
+ UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
+ end
+
+ @doc """
+ Gets the user by reset password token.
+
+ ## Examples
+
+ iex> get_user_by_reset_password_token("validtoken")
+ %User{}
+
+ iex> get_user_by_reset_password_token("invalidtoken")
+ nil
+
+ """
+ def get_user_by_reset_password_token(token) do
+ with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
+ %User{} = user <- Repo.one(query) do
+ user
+ else
+ _ -> nil
+ end
+ end
+
+ @doc """
+ Resets the user password.
+
+ ## Examples
+
+ iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
+ {:ok, %User{}}
+
+ iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def reset_user_password(user, attrs) do
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
+ |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: user}} -> {:ok, user}
+ {:error, :user, changeset, _} -> {:error, changeset}
+ end
+ end
+end
diff --git a/lib/cklist/accounts/user.ex b/lib/cklist/accounts/user.ex
new file mode 100644
index 0000000..3d3ca4a
--- /dev/null
+++ b/lib/cklist/accounts/user.ex
@@ -0,0 +1,161 @@
+defmodule Cklist.Accounts.User do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "users" do
+ field :email, :string
+ field :password, :string, virtual: true, redact: true
+ field :hashed_password, :string, redact: true
+ field :current_password, :string, virtual: true, redact: true
+ field :confirmed_at, :utc_datetime
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc """
+ A user changeset for registration.
+
+ It is important to validate the length of both email and password.
+ Otherwise databases may truncate the email without warnings, which
+ could lead to unpredictable or insecure behaviour. Long passwords may
+ also be very expensive to hash for certain algorithms.
+
+ ## Options
+
+ * `:hash_password` - Hashes the password so it can be stored securely
+ in the database and ensures the password field is cleared to prevent
+ leaks in the logs. If password hashing is not needed and clearing the
+ password field is not desired (like when using this changeset for
+ validations on a LiveView form), this option can be set to `false`.
+ Defaults to `true`.
+
+ * `:validate_email` - Validates the uniqueness of the email, in case
+ you don't want to validate the uniqueness of the email (like when
+ using this changeset for validations on a LiveView form before
+ submitting the form), this option can be set to `false`.
+ Defaults to `true`.
+ """
+ def registration_changeset(user, attrs, opts \\ []) do
+ user
+ |> cast(attrs, [:email, :password])
+ |> validate_email(opts)
+ |> validate_password(opts)
+ end
+
+ defp validate_email(changeset, opts) do
+ changeset
+ |> validate_required([:email])
+ |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
+ |> validate_length(:email, max: 160)
+ |> maybe_validate_unique_email(opts)
+ end
+
+ defp validate_password(changeset, opts) do
+ changeset
+ |> validate_required([:password])
+ |> validate_length(:password, min: 12, max: 72)
+ # Examples of additional password validation:
+ # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
+ # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
+ # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
+ |> maybe_hash_password(opts)
+ end
+
+ defp maybe_hash_password(changeset, opts) do
+ hash_password? = Keyword.get(opts, :hash_password, true)
+ password = get_change(changeset, :password)
+
+ if hash_password? && password && changeset.valid? do
+ changeset
+ # If using Bcrypt, then further validate it is at most 72 bytes long
+ |> validate_length(:password, max: 72, count: :bytes)
+ # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
+ # would keep the database transaction open longer and hurt performance.
+ |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
+ |> delete_change(:password)
+ else
+ changeset
+ end
+ end
+
+ defp maybe_validate_unique_email(changeset, opts) do
+ if Keyword.get(opts, :validate_email, true) do
+ changeset
+ |> unsafe_validate_unique(:email, Cklist.Repo)
+ |> unique_constraint(:email)
+ else
+ changeset
+ end
+ end
+
+ @doc """
+ A user changeset for changing the email.
+
+ It requires the email to change otherwise an error is added.
+ """
+ def email_changeset(user, attrs, opts \\ []) do
+ user
+ |> cast(attrs, [:email])
+ |> validate_email(opts)
+ |> case do
+ %{changes: %{email: _}} = changeset -> changeset
+ %{} = changeset -> add_error(changeset, :email, "did not change")
+ end
+ end
+
+ @doc """
+ A user changeset for changing the password.
+
+ ## Options
+
+ * `:hash_password` - Hashes the password so it can be stored securely
+ in the database and ensures the password field is cleared to prevent
+ leaks in the logs. If password hashing is not needed and clearing the
+ password field is not desired (like when using this changeset for
+ validations on a LiveView form), this option can be set to `false`.
+ Defaults to `true`.
+ """
+ def password_changeset(user, attrs, opts \\ []) do
+ user
+ |> cast(attrs, [:password])
+ |> validate_confirmation(:password, message: "does not match password")
+ |> validate_password(opts)
+ end
+
+ @doc """
+ Confirms the account by setting `confirmed_at`.
+ """
+ def confirm_changeset(user) do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+ change(user, confirmed_at: now)
+ end
+
+ @doc """
+ Verifies the password.
+
+ If there is no user or the user doesn't have a password, we call
+ `Bcrypt.no_user_verify/0` to avoid timing attacks.
+ """
+ def valid_password?(%Cklist.Accounts.User{hashed_password: hashed_password}, password)
+ when is_binary(hashed_password) and byte_size(password) > 0 do
+ Bcrypt.verify_pass(password, hashed_password)
+ end
+
+ def valid_password?(_, _) do
+ Bcrypt.no_user_verify()
+ false
+ end
+
+ @doc """
+ Validates the current password otherwise adds an error to the changeset.
+ """
+ def validate_current_password(changeset, password) do
+ changeset = cast(changeset, %{current_password: password}, [:current_password])
+
+ if valid_password?(changeset.data, password) do
+ changeset
+ else
+ add_error(changeset, :current_password, "is not valid")
+ end
+ end
+end
diff --git a/lib/cklist/accounts/user_notifier.ex b/lib/cklist/accounts/user_notifier.ex
new file mode 100644
index 0000000..3582b10
--- /dev/null
+++ b/lib/cklist/accounts/user_notifier.ex
@@ -0,0 +1,79 @@
+defmodule Cklist.Accounts.UserNotifier do
+ import Swoosh.Email
+
+ alias Cklist.Mailer
+
+ # Delivers the email using the application mailer.
+ defp deliver(recipient, subject, body) do
+ email =
+ new()
+ |> to(recipient)
+ |> from({"Cklist", "contact@example.com"})
+ |> subject(subject)
+ |> text_body(body)
+
+ with {:ok, _metadata} <- Mailer.deliver(email) do
+ {:ok, email}
+ end
+ end
+
+ @doc """
+ Deliver instructions to confirm account.
+ """
+ def deliver_confirmation_instructions(user, url) do
+ deliver(user.email, "Confirmation instructions", """
+
+ ==============================
+
+ Hi #{user.email},
+
+ You can confirm your account by visiting the URL below:
+
+ #{url}
+
+ If you didn't create an account with us, please ignore this.
+
+ ==============================
+ """)
+ end
+
+ @doc """
+ Deliver instructions to reset a user password.
+ """
+ def deliver_reset_password_instructions(user, url) do
+ deliver(user.email, "Reset password instructions", """
+
+ ==============================
+
+ Hi #{user.email},
+
+ You can reset your password by visiting the URL below:
+
+ #{url}
+
+ If you didn't request this change, please ignore this.
+
+ ==============================
+ """)
+ end
+
+ @doc """
+ Deliver instructions to update a user email.
+ """
+ def deliver_update_email_instructions(user, url) do
+ deliver(user.email, "Update email instructions", """
+
+ ==============================
+
+ Hi #{user.email},
+
+ You can change your email by visiting the URL below:
+
+ #{url}
+
+ If you didn't request this change, please ignore this.
+
+ ==============================
+ """)
+ end
+end
diff --git a/lib/cklist/accounts/user_token.ex b/lib/cklist/accounts/user_token.ex
new file mode 100644
index 0000000..b8e5a46
--- /dev/null
+++ b/lib/cklist/accounts/user_token.ex
@@ -0,0 +1,179 @@
+defmodule Cklist.Accounts.UserToken do
+ use Ecto.Schema
+ import Ecto.Query
+ alias Cklist.Accounts.UserToken
+
+ @hash_algorithm :sha256
+ @rand_size 32
+
+ # It is very important to keep the reset password token expiry short,
+ # since someone with access to the email may take over the account.
+ @reset_password_validity_in_days 1
+ @confirm_validity_in_days 7
+ @change_email_validity_in_days 7
+ @session_validity_in_days 60
+
+ schema "users_tokens" do
+ field :token, :binary
+ field :context, :string
+ field :sent_to, :string
+ belongs_to :user, Cklist.Accounts.User
+
+ timestamps(type: :utc_datetime, updated_at: false)
+ end
+
+ @doc """
+ Generates a token that will be stored in a signed place,
+ such as session or cookie. As they are signed, those
+ tokens do not need to be hashed.
+
+ The reason why we store session tokens in the database, even
+ though Phoenix already provides a session cookie, is because
+ Phoenix' default session cookies are not persisted, they are
+ simply signed and potentially encrypted. This means they are
+ valid indefinitely, unless you change the signing/encryption
+ salt.
+
+ Therefore, storing them allows individual user
+ sessions to be expired. The token system can also be extended
+ to store additional data, such as the device used for logging in.
+ You could then use this information to display all valid sessions
+ and devices in the UI and allow users to explicitly expire any
+ session they deem invalid.
+ """
+ def build_session_token(user) do
+ token = :crypto.strong_rand_bytes(@rand_size)
+ {token, %UserToken{token: token, context: "session", user_id: user.id}}
+ end
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ The token is valid if it matches the value in the database and it has
+ not expired (after @session_validity_in_days).
+ """
+ def verify_session_token_query(token) do
+ query =
+ from token in by_token_and_context_query(token, "session"),
+ join: user in assoc(token, :user),
+ where: token.inserted_at > ago(@session_validity_in_days, "day"),
+ select: user
+
+ {:ok, query}
+ end
+
+ @doc """
+ Builds a token and its hash to be delivered to the user's email.
+
+ The non-hashed token is sent to the user email while the
+ hashed part is stored in the database. The original token cannot be reconstructed,
+ which means anyone with read-only access to the database cannot directly use
+ the token in the application to gain access. Furthermore, if the user changes
+ their email in the system, the tokens sent to the previous email are no longer
+ valid.
+
+ Users can easily adapt the existing code to provide other types of delivery methods,
+ for example, by phone numbers.
+ """
+ def build_email_token(user, context) do
+ build_hashed_token(user, context, user.email)
+ end
+
+ defp build_hashed_token(user, context, sent_to) do
+ token = :crypto.strong_rand_bytes(@rand_size)
+ hashed_token = :crypto.hash(@hash_algorithm, token)
+
+ {Base.url_encode64(token, padding: false),
+ %UserToken{
+ token: hashed_token,
+ context: context,
+ sent_to: sent_to,
+ user_id: user.id
+ }}
+ end
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ The given token is valid if it matches its hashed counterpart in the
+ database and the user email has not changed. This function also checks
+ if the token is being used within a certain period, depending on the
+ context. The default contexts supported by this function are either
+ "confirm", for account confirmation emails, and "reset_password",
+ for resetting the password. For verifying requests to change the email,
+ see `verify_change_email_token_query/2`.
+ """
+ def verify_email_token_query(token, context) do
+ case Base.url_decode64(token, padding: false) do
+ {:ok, decoded_token} ->
+ hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
+ days = days_for_context(context)
+
+ query =
+ from token in by_token_and_context_query(hashed_token, context),
+ join: user in assoc(token, :user),
+ where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
+ select: user
+
+ {:ok, query}
+
+ :error ->
+ :error
+ end
+ end
+
+ defp days_for_context("confirm"), do: @confirm_validity_in_days
+ defp days_for_context("reset_password"), do: @reset_password_validity_in_days
+
+ @doc """
+ Checks if the token is valid and returns its underlying lookup query.
+
+ The query returns the user found by the token, if any.
+
+ This is used to validate requests to change the user
+ email. It is different from `verify_email_token_query/2` precisely because
+ `verify_email_token_query/2` validates the email has not changed, which is
+ the starting point by this function.
+
+ The given token is valid if it matches its hashed counterpart in the
+ database and if it has not expired (after @change_email_validity_in_days).
+ The context must always start with "change:".
+ """
+ def verify_change_email_token_query(token, "change:" <> _ = context) do
+ case Base.url_decode64(token, padding: false) do
+ {:ok, decoded_token} ->
+ hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
+
+ query =
+ from token in by_token_and_context_query(hashed_token, context),
+ where: token.inserted_at > ago(@change_email_validity_in_days, "day")
+
+ {:ok, query}
+
+ :error ->
+ :error
+ end
+ end
+
+ @doc """
+ Returns the token struct for the given token value and context.
+ """
+ def by_token_and_context_query(token, context) do
+ from UserToken, where: [token: ^token, context: ^context]
+ end
+
+ @doc """
+ Gets all tokens for the given user for the given contexts.
+ """
+ def by_user_and_contexts_query(user, :all) do
+ from t in UserToken, where: t.user_id == ^user.id
+ end
+
+ def by_user_and_contexts_query(user, [_ | _] = contexts) do
+ from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts
+ end
+end
diff --git a/lib/cklist_web/components/layouts/app.html.heex b/lib/cklist_web/components/layouts/app.html.heex
index 3b3b607..0b14481 100644
--- a/lib/cklist_web/components/layouts/app.html.heex
+++ b/lib/cklist_web/components/layouts/app.html.heex
@@ -1,32 +1,42 @@
+
+ <.awesome
+ title="free and open-source"
+ desc="We believe in the power of open-source software." />
+ <.awesome
+ title="sequential and non-sequential lists"
+ desc="Do you need to make sure one step follows the other? We got you covered. Is order not important? That works too!"
+ />
+ <.awesome
+ title="collaborative lists"
+ desc="Share lists with others and work together."
+ />
+ <.awesome
+ title="traces"
+ desc="Full accountability: see who ticked off what at which time." />
+
+
+
+ <.link href={~p"/users/register"}>
+ <.button class="w-full" >Sign up now
+
+
+
+
+
+
+
+
diff --git a/lib/cklist_web/controllers/user_session_controller.ex b/lib/cklist_web/controllers/user_session_controller.ex
new file mode 100644
index 0000000..f20a471
--- /dev/null
+++ b/lib/cklist_web/controllers/user_session_controller.ex
@@ -0,0 +1,42 @@
+defmodule CklistWeb.UserSessionController do
+ use CklistWeb, :controller
+
+ alias Cklist.Accounts
+ alias CklistWeb.UserAuth
+
+ def create(conn, %{"_action" => "registered"} = params) do
+ create(conn, params, "Account created successfully!")
+ end
+
+ def create(conn, %{"_action" => "password_updated"} = params) do
+ conn
+ |> put_session(:user_return_to, ~p"/users/settings")
+ |> create(params, "Password updated successfully!")
+ end
+
+ def create(conn, params) do
+ create(conn, params, "Welcome back!")
+ end
+
+ defp create(conn, %{"user" => user_params}, info) do
+ %{"email" => email, "password" => password} = user_params
+
+ if user = Accounts.get_user_by_email_and_password(email, password) do
+ conn
+ |> put_flash(:info, info)
+ |> UserAuth.log_in_user(user, user_params)
+ else
+ # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
+ conn
+ |> put_flash(:error, "Invalid email or password")
+ |> put_flash(:email, String.slice(email, 0, 160))
+ |> redirect(to: ~p"/users/log_in")
+ end
+ end
+
+ def delete(conn, _params) do
+ conn
+ |> put_flash(:info, "Logged out successfully.")
+ |> UserAuth.log_out_user()
+ end
+end
diff --git a/lib/cklist_web/live/user_confirmation_instructions_live.ex b/lib/cklist_web/live/user_confirmation_instructions_live.ex
new file mode 100644
index 0000000..ceceab7
--- /dev/null
+++ b/lib/cklist_web/live/user_confirmation_instructions_live.ex
@@ -0,0 +1,51 @@
+defmodule CklistWeb.UserConfirmationInstructionsLive do
+ use CklistWeb, :live_view
+
+ alias Cklist.Accounts
+
+ def render(assigns) do
+ ~H"""
+
+ <.header class="text-center">
+ No confirmation instructions received?
+ <:subtitle>We'll send a new confirmation link to your inbox
+
+
+ <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions">
+ <.input field={@form[:email]} type="email" placeholder="Email" required />
+ <:actions>
+ <.button phx-disable-with="Sending..." class="w-full">
+ Resend confirmation instructions
+
+
+
+
+
+ <.link href={~p"/users/register"}>Register
+ | <.link href={~p"/users/log_in"}>Log in
+
+
+ """
+ end
+
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, form: to_form(%{}, as: "user"))}
+ end
+
+ def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do
+ if user = Accounts.get_user_by_email(email) do
+ Accounts.deliver_user_confirmation_instructions(
+ user,
+ &url(~p"/users/confirm/#{&1}")
+ )
+ end
+
+ info =
+ "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
+
+ {:noreply,
+ socket
+ |> put_flash(:info, info)
+ |> redirect(to: ~p"/")}
+ end
+end
diff --git a/lib/cklist_web/live/user_confirmation_live.ex b/lib/cklist_web/live/user_confirmation_live.ex
new file mode 100644
index 0000000..b4fe025
--- /dev/null
+++ b/lib/cklist_web/live/user_confirmation_live.ex
@@ -0,0 +1,58 @@
+defmodule CklistWeb.UserConfirmationLive do
+ use CklistWeb, :live_view
+
+ alias Cklist.Accounts
+
+ def render(%{live_action: :edit} = assigns) do
+ ~H"""
+
+ <.link href={~p"/users/register"}>Register
+ | <.link href={~p"/users/log_in"}>Log in
+
+
+ """
+ end
+
+ def mount(%{"token" => token}, _session, socket) do
+ form = to_form(%{"token" => token}, as: "user")
+ {:ok, assign(socket, form: form), temporary_assigns: [form: nil]}
+ end
+
+ # Do not log in the user after confirmation to avoid a
+ # leaked token giving the user access to the account.
+ def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do
+ case Accounts.confirm_user(token) do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "User confirmed successfully.")
+ |> redirect(to: ~p"/")}
+
+ :error ->
+ # If there is a current user and the account was already confirmed,
+ # then odds are that the confirmation link was already visited, either
+ # by some automation or by the user themselves, so we redirect without
+ # a warning message.
+ case socket.assigns do
+ %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
+ {:noreply, redirect(socket, to: ~p"/")}
+
+ %{} ->
+ {:noreply,
+ socket
+ |> put_flash(:error, "User confirmation link is invalid or it has expired.")
+ |> redirect(to: ~p"/")}
+ end
+ end
+ end
+end
diff --git a/lib/cklist_web/live/user_forgot_password_live.ex b/lib/cklist_web/live/user_forgot_password_live.ex
new file mode 100644
index 0000000..dde1ef5
--- /dev/null
+++ b/lib/cklist_web/live/user_forgot_password_live.ex
@@ -0,0 +1,50 @@
+defmodule CklistWeb.UserForgotPasswordLive do
+ use CklistWeb, :live_view
+
+ alias Cklist.Accounts
+
+ def render(assigns) do
+ ~H"""
+
+ <.header class="text-center">
+ Forgot your password?
+ <:subtitle>We'll send a password reset link to your inbox
+
+
+ <.simple_form for={@form} id="reset_password_form" phx-submit="send_email">
+ <.input field={@form[:email]} type="email" placeholder="Email" required />
+ <:actions>
+ <.button phx-disable-with="Sending..." class="w-full">
+ Send password reset instructions
+
+
+
+
+ <.link href={~p"/users/register"}>Register
+ | <.link href={~p"/users/log_in"}>Log in
+
+
+ """
+ end
+
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, form: to_form(%{}, as: "user"))}
+ end
+
+ def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
+ if user = Accounts.get_user_by_email(email) do
+ Accounts.deliver_user_reset_password_instructions(
+ user,
+ &url(~p"/users/reset_password/#{&1}")
+ )
+ end
+
+ info =
+ "If your email is in our system, you will receive instructions to reset your password shortly."
+
+ {:noreply,
+ socket
+ |> put_flash(:info, info)
+ |> redirect(to: ~p"/")}
+ end
+end
diff --git a/lib/cklist_web/live/user_login_live.ex b/lib/cklist_web/live/user_login_live.ex
new file mode 100644
index 0000000..a390f13
--- /dev/null
+++ b/lib/cklist_web/live/user_login_live.ex
@@ -0,0 +1,43 @@
+defmodule CklistWeb.UserLoginLive do
+ use CklistWeb, :live_view
+
+ def render(assigns) do
+ ~H"""
+
+ <.header class="text-center">
+ Log in to account
+ <:subtitle>
+ Don't have an account?
+ <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
+ Sign up
+
+ for an account now.
+
+
+
+ <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">
+ <.input field={@form[:email]} type="email" label="Email" required />
+ <.input field={@form[:password]} type="password" label="Password" required />
+
+ <:actions>
+ <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
+ <.link href={~p"/users/reset_password"} class="text-sm font-semibold">
+ Forgot your password?
+
+
+ <:actions>
+ <.button phx-disable-with="Logging in..." class="w-full">
+ Log in →
+
+
+
+
+ """
+ end
+
+ def mount(_params, _session, socket) do
+ email = Phoenix.Flash.get(socket.assigns.flash, :email)
+ form = to_form(%{"email" => email}, as: "user")
+ {:ok, assign(socket, form: form), temporary_assigns: [form: form]}
+ end
+end
diff --git a/lib/cklist_web/live/user_registration_live.ex b/lib/cklist_web/live/user_registration_live.ex
new file mode 100644
index 0000000..b161a6a
--- /dev/null
+++ b/lib/cklist_web/live/user_registration_live.ex
@@ -0,0 +1,87 @@
+defmodule CklistWeb.UserRegistrationLive do
+ use CklistWeb, :live_view
+
+ alias Cklist.Accounts
+ alias Cklist.Accounts.User
+
+ def render(assigns) do
+ ~H"""
+
+ <.awesome
+ title="free and open-source"
+ desc="We believe in the power of open-source software."
+ />
+ <.awesome
+ title="sequential and non-sequential lists"
+ desc="Do you need to make sure one step follows the other? We got you covered. Is order not important? That works too!"
+ />
+ <.awesome
+ title="collaborative lists"
+ desc="Share lists with others and work together."
+ />
+ <.awesome
+ title="traces"
+ desc="Full accountability: see who ticked off what at which time."
+ />
-
-
- Here's why we think cklists are awesome
-
-
- <.awesome
- title="free and open-source"
- desc="We believe in the power of open-source software." />
- <.awesome
- title="sequential and non-sequential lists"
- desc="Do you need to make sure one step follows the other? We got you covered. Is order not important? That works too!"
- />
- <.awesome
- title="collaborative lists"
- desc="Share lists with others and work together."
- />
- <.awesome
- title="traces"
- desc="Full accountability: see who ticked off what at which time." />
-
-
- <.link href={~p"/users/register"}>
- <.button class="w-full" >Sign up now
-
+
+ <.link href={~p"/users/register"}>
+ <.button class="w-full">Sign up now
+
+
- <.link href={~p"/users/log_in"}>
- <.button>Login
-
+ <%= if !@current_user do %>
+ <.link href={~p"/users/log_in"}>
+ <.button>Login
+
+ <% end %>
@@ -24,7 +26,7 @@
- Here's why we think cklists are awesome
+ Here's why we think cklists will be awesome
<.awesome
@@ -46,9 +48,11 @@
- <.link href={~p"/users/register"}>
- <.button class="w-full">Sign up now
-
+ <%= if !@current_user do %>
+ <.link href={~p"/users/register"}>
+ <.button class="w-full">Sign up now
+
+ <% end %>
@@ -56,7 +60,17 @@
diff --git a/test/cklist_web/controllers/user_session_controller_test.exs b/test/cklist_web/controllers/user_session_controller_test.exs
index 65b7b63..dccfaff 100644
--- a/test/cklist_web/controllers/user_session_controller_test.exs
+++ b/test/cklist_web/controllers/user_session_controller_test.exs
@@ -20,7 +20,6 @@ defmodule CklistWeb.UserSessionControllerTest do
# Now do a logged in request and assert on the menu
conn = get(conn, ~p"/")
response = html_response(conn, 200)
- assert response =~ user.email
assert response =~ ~p"/users/settings"
assert response =~ ~p"/users/log_out"
end
diff --git a/test/cklist_web/live/user_registration_live_test.exs b/test/cklist_web/live/user_registration_live_test.exs
index 5921601..9f0143c 100644
--- a/test/cklist_web/live/user_registration_live_test.exs
+++ b/test/cklist_web/live/user_registration_live_test.exs
@@ -50,7 +50,6 @@ defmodule CklistWeb.UserRegistrationLiveTest do
# Now do a logged in request and assert on the menu
conn = get(conn, "/")
response = html_response(conn, 200)
- assert response =~ email
assert response =~ "Settings"
assert response =~ "Log out"
end
From fd037413d8b761248b8e99510a2f7a01190e1eeb Mon Sep 17 00:00:00 2001
From: Roman Cattaneo <>
Date: Fri, 4 Jul 2025 22:32:44 +0200
Subject: [PATCH 04/31] Added FAQ (since we aren't functional yet)
---
.../controllers/page_html/about.html.heex | 22 +++++++++++++++++--
.../controllers/page_html/home.html.heex | 12 +++++-----
2 files changed, 26 insertions(+), 8 deletions(-)
diff --git a/lib/cklist_web/controllers/page_html/about.html.heex b/lib/cklist_web/controllers/page_html/about.html.heex
index 42e3b6b..e1762d4 100644
--- a/lib/cklist_web/controllers/page_html/about.html.heex
+++ b/lib/cklist_web/controllers/page_html/about.html.heex
@@ -2,5 +2,23 @@
About
-
This is cklist version {Application.spec(:cklist, :vsn)}.
-
Built with phoenix version {Application.spec(:phoenix, :vsn)}.
+
This is cklist version {Application.spec(:cklist, :vsn)}, built with phoenix {Application.spec(:phoenix, :vsn)}.
+
+<.header class="my-4">FAQ
+
+
+
Are cklists working yet?
+
+ No, <.link href={~p"/"}>cklist.org is not yet functional. We are building cklists in the open. This is a pre-release. Util we reach version 1.0, it won't be feature complete.
+
+
+
Can I sing-up before?
+
+ Yes, you can sign-up and log in/out at any time you like. More features will be available once we add them.
+
+
+
Any timeline for new features?
+
+ No, currently not. We are building <.link href={~p"/"}>cklist.org in our free time. You might find more information on GitHub.
+
<.awesome
- title="free and open-source"
+ title="Free and open-source"
desc="We believe in the power of open-source software."
/>
<.awesome
- title="sequential and non-sequential lists"
- desc="Do you need to make sure one step follows the other? We got you covered. Is order not important? That works too!"
+ title="Sequential and non-sequential lists"
+ desc="You need to make sure one step follows another? We've got you covered! Order is not important? That works too!"
/>
<.awesome
- title="collaborative lists"
+ title="Collaborative lists"
desc="Share lists with others and work together."
/>
<.awesome
- title="traces"
- desc="Full accountability: see who ticked off what at which time."
+ title="Full accountability"
+ desc="See who ticked off what at which time."
/>
+ """
+ end
end
diff --git a/lib/cklist_web/controllers/page_html/about.html.heex b/lib/cklist_web/controllers/page_html/about.html.heex
index e1762d4..c0a71de 100644
--- a/lib/cklist_web/controllers/page_html/about.html.heex
+++ b/lib/cklist_web/controllers/page_html/about.html.heex
@@ -2,23 +2,30 @@
About
-
This is cklist version {Application.spec(:cklist, :vsn)}, built with phoenix {Application.spec(:phoenix, :vsn)}.
+
+ This is cklist version {Application.spec(:cklist, :vsn)}, built with phoenix {Application.spec(
+ :phoenix,
+ :vsn
+ )}.
+
-<.header class="my-4">FAQ
-
-
-
Are cklists working yet?
-
- No, <.link href={~p"/"}>cklist.org is not yet functional. We are building cklists in the open. This is a pre-release. Util we reach version 1.0, it won't be feature complete.
-
-
-
Can I sing-up before?
-
- Yes, you can sign-up and log in/out at any time you like. More features will be available once we add them.
-
-
-
Any timeline for new features?
-
- No, currently not. We are building <.link href={~p"/"}>cklist.org in our free time. You might find more information on GitHub.
-
-
+<.faq items={[
+ %{
+ question: "Are cklists working yet?",
+ answer:
+ ~H"No, <.link href={~p'/'}>cklist.org
+is not yet functional. We are building cklists in the open. This is a pre-release. Util we reach version 1.0, it won't be feature complete."
+ },
+ %{
+ question: "Can I sing-up now?",
+ answer:
+ ~H"Yes, you can sign-up and log in/out at any time you like. More features will be available once we add them."
+ },
+ %{
+ question: "Any timeline for new features?",
+ answer:
+ ~H'No, currently not. We are building <.link href={~p"/"}>cklist.org
+in our free time. You might find more information on GitHub.'
+ }
+]}>
+
diff --git a/lib/cklist_web/controllers/page_html/home.html.heex b/lib/cklist_web/controllers/page_html/home.html.heex
index 476fcdf..48f1491 100644
--- a/lib/cklist_web/controllers/page_html/home.html.heex
+++ b/lib/cklist_web/controllers/page_html/home.html.heex
@@ -41,10 +41,7 @@
title="Collaborative lists"
desc="Share lists with others and work together."
/>
- <.awesome
- title="Full accountability"
- desc="See who ticked off what at which time."
- />
+ <.awesome title="Full accountability" desc="See who ticked off what at which time." />
diff --git a/lib/cklist_web/controllers/page_controller.ex b/lib/cklist_web/controllers/page_controller.ex
index f13ccfb..0bc459a 100644
--- a/lib/cklist_web/controllers/page_controller.ex
+++ b/lib/cklist_web/controllers/page_controller.ex
@@ -10,4 +10,8 @@ defmodule CklistWeb.PageController do
def about(conn, _params) do
render(conn, :about)
end
+
+ def privacy(conn, _params) do
+ render(conn, :privacy)
+ end
end
diff --git a/lib/cklist_web/controllers/page_html/about.html.heex b/lib/cklist_web/controllers/page_html/about.html.heex
index c0a71de..5bb160b 100644
--- a/lib/cklist_web/controllers/page_html/about.html.heex
+++ b/lib/cklist_web/controllers/page_html/about.html.heex
@@ -1,6 +1,4 @@
-<.header class="mb-12">
- About
-
+<.header class="mb-12">About
This is cklist version {Application.spec(:cklist, :vsn)}, built with phoenix {Application.spec(
diff --git a/lib/cklist_web/controllers/page_html/home.html.heex b/lib/cklist_web/controllers/page_html/home.html.heex
index 48f1491..b508422 100644
--- a/lib/cklist_web/controllers/page_html/home.html.heex
+++ b/lib/cklist_web/controllers/page_html/home.html.heex
@@ -61,6 +61,7 @@
+ We value your privacy. We'd like to be explicit what data we collect and how we might use that data. To this end, let's distinguish "site visitors" and "registered users".
+
+
+<.header class="my-4">Site visitors
+
+
+ Site visitors are people visiting the website <.link href={~p"/"}>cklist.org
+ without logging in. The following data is stored:
+
+
+
+
+ Your browser sends a request to the server where the website is hosted. Theses requests are logged on the server and automatically deleted after one year. This data is useful for troubleshooting issues and could be used for statistical purposes.
+
+
+ The website installs a session cookie in your browsers storage. It is used to tell apart site visitors from logged-in users. You can instruct your browser to reject cookies.
+
+
+
+<.header class="my-4">Registered users
+
+
+ Registered users are visiting the website <.link href={~p"/"}>cklist.org, which are logged in. The following data is stored:
+
+
+
+
+ Your browser sends a request to the server where the website is hosted. Theses requests are logged on the server and automatically deleted after one year. This data is useful for troubleshooting issues and could be used for statistical purposes.
+
+
+ The website installs a session cookie in your browsers storage. It is used to tell apart site visitors from logged-in users. If you instruct your browser to reject cookies, you won't be able to log in and out.
+
+
+ During registration you provide an email address. This address (in combination with a password) is used to identify your account and we might send you emails e.g. for resetting your password.
+
+
diff --git a/lib/cklist_web/router.ex b/lib/cklist_web/router.ex
index 5b34b39..dbe81f6 100644
--- a/lib/cklist_web/router.ex
+++ b/lib/cklist_web/router.ex
@@ -22,6 +22,7 @@ defmodule CklistWeb.Router do
get "/", PageController, :home
get "/about", PageController, :about
+ get "/privacy", PageController, :privacy
end
# Other scopes may use custom stacks.
diff --git a/test/cklist_web/controllers/page_controller_test.exs b/test/cklist_web/controllers/page_controller_test.exs
index 280dd3b..8e8f47b 100644
--- a/test/cklist_web/controllers/page_controller_test.exs
+++ b/test/cklist_web/controllers/page_controller_test.exs
@@ -6,7 +6,7 @@ defmodule CklistWeb.PageControllerTest do
assert html_response(conn, 200) =~ "Check that list"
end
- describe "about.html" do
+ describe "about page" do
test "contains current app version", %{conn: conn} do
conn = get(conn, ~p"/about")
assert html_response(conn, 200) =~ "cklist version #{Application.spec(:cklist, :vsn)}"
@@ -22,4 +22,9 @@ defmodule CklistWeb.PageControllerTest do
assert html_response(conn, 200) =~ "FAQ"
end
end
+
+ test "privacy policy", %{conn: conn} do
+ conn = get(conn, ~p"/privacy")
+ assert html_response(conn, 200) =~ "We value your privacy."
+ end
end
From 7fbd1d56a6cac3d44a02b279b9af1d1537b3a431 Mon Sep 17 00:00:00 2001
From: Roman Cattaneo <>
Date: Sun, 17 Aug 2025 13:36:09 +0200
Subject: [PATCH 28/31] [feature] allow users to delete their account
- missing better docs
- missing tests
---
lib/cklist/accounts.ex | 26 ++++++++++
lib/cklist/accounts/user.ex | 4 ++
.../controllers/user_session_controller.ex | 11 ++++-
lib/cklist_web/live/user_settings_live.ex | 49 +++++++++++++++++++
4 files changed, 88 insertions(+), 2 deletions(-)
diff --git a/lib/cklist/accounts.ex b/lib/cklist/accounts.ex
index d71dad7..cc16886 100644
--- a/lib/cklist/accounts.ex
+++ b/lib/cklist/accounts.ex
@@ -215,6 +215,32 @@ defmodule Cklist.Accounts do
end
end
+ @doc """
+ Returns an `%Ecto.Changeset{}` for deleting the user.
+
+ ## Examples
+
+ iex> change_user_delete()
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_delete(user, attrs \\ %{}) do
+ User.delete_changeset(user, attrs)
+ end
+
+ def delete_user(user, password) do
+ changeset =
+ user
+ |> User.validate_current_password(password)
+
+ if changeset.valid? do
+ Repo.delete!(user)
+ :ok
+ else
+ {:error, changeset}
+ end
+ end
+
## Session
@doc """
diff --git a/lib/cklist/accounts/user.ex b/lib/cklist/accounts/user.ex
index 3d3ca4a..a2f1ebb 100644
--- a/lib/cklist/accounts/user.ex
+++ b/lib/cklist/accounts/user.ex
@@ -130,6 +130,10 @@ defmodule Cklist.Accounts.User do
change(user, confirmed_at: now)
end
+ def delete_changeset(user, attrs) do
+ user |> cast(attrs, [])
+ end
+
@doc """
Verifies the password.
diff --git a/lib/cklist_web/controllers/user_session_controller.ex b/lib/cklist_web/controllers/user_session_controller.ex
index f20a471..a8e0d18 100644
--- a/lib/cklist_web/controllers/user_session_controller.ex
+++ b/lib/cklist_web/controllers/user_session_controller.ex
@@ -34,9 +34,16 @@ defmodule CklistWeb.UserSessionController do
end
end
- def delete(conn, _params) do
+ def delete(conn, params) do
conn
- |> put_flash(:info, "Logged out successfully.")
+ |> put_flash(:info, delete_message(params))
|> UserAuth.log_out_user()
end
+
+ defp delete_message(params) do
+ case params["_action"] do
+ "user_deleted" -> "User successfully deleted."
+ _ -> "Logged out successfully."
+ end
+ end
end
diff --git a/lib/cklist_web/live/user_settings_live.ex b/lib/cklist_web/live/user_settings_live.ex
index 61e8ef7..c6ce2eb 100644
--- a/lib/cklist_web/live/user_settings_live.ex
+++ b/lib/cklist_web/live/user_settings_live.ex
@@ -69,6 +69,33 @@ defmodule CklistWeb.UserSettingsLive do
+
+
Danger zone
+
+ Deleting your account is a permanent action that cannot be undone. This is the point of no return. You have been warned.
+