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 @@ +
+
-
+
- + -

- v{Application.spec(:phoenix, :vsn)} -

-
+ +
<.flash_group flash={@flash} /> {@inner_content}
+ + + +
diff --git a/lib/cklist_web/controllers/page_controller.ex b/lib/cklist_web/controllers/page_controller.ex index 74ea1fa..f13ccfb 100644 --- a/lib/cklist_web/controllers/page_controller.ex +++ b/lib/cklist_web/controllers/page_controller.ex @@ -6,4 +6,8 @@ defmodule CklistWeb.PageController do # so skip the default app layout. render(conn, :home, layout: false) end + + def about(conn, _params) do + render(conn, :about) + end end diff --git a/lib/cklist_web/controllers/page_html.ex b/lib/cklist_web/controllers/page_html.ex index 7357ffa..32841fc 100644 --- a/lib/cklist_web/controllers/page_html.ex +++ b/lib/cklist_web/controllers/page_html.ex @@ -7,4 +7,18 @@ defmodule CklistWeb.PageHTML do use CklistWeb, :html embed_templates "page_html/*" + + attr :title, :string, required: true + attr :desc, :string, default: nil + def awesome(assigns) do + ~H""" +
+ +
+

{@title}

+

{@desc}

+
+
+ """ + end end diff --git a/lib/cklist_web/controllers/page_html/about.html.heex b/lib/cklist_web/controllers/page_html/about.html.heex new file mode 100644 index 0000000..35048da --- /dev/null +++ b/lib/cklist_web/controllers/page_html/about.html.heex @@ -0,0 +1,6 @@ +<.header class="mb-12"> + About + + +

This is cklist version {Application.spec(:cklist, :vsn)}.

+

Built with phoenix version {Application.spec(:phoenix, :vsn)}.

diff --git a/lib/cklist_web/controllers/page_html/home.html.heex b/lib/cklist_web/controllers/page_html/home.html.heex index 739b476..1c0712f 100644 --- a/lib/cklist_web/controllers/page_html/home.html.heex +++ b/lib/cklist_web/controllers/page_html/home.html.heex @@ -1,20 +1,63 @@ -<.flash_group flash={@flash} /> - -
-
- -

- cklist - - v{Application.spec(:cklist, :vsn)} - -

-

- Check that list! -

-

- Awesome checklists are coming up here. Stay tuned. -

+ +
+ +
+ <.flash_group flash={@flash} /> + +
+
+ <.link href={~p"/users/log_in"}> + <.button>Login + +
+ +
+
+
+ +
+

+ cklist.org +

+

+ Check that list! +

+
+
+

+ 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"/about"}>About

+

<.link href="https://github.com/romanc/cklist/">GitHub

+
+
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""" +
+ <.header class="text-center">Confirm Account + + <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account"> + + <:actions> + <.button phx-disable-with="Confirming..." class="w-full">Confirm my account + + + +

+ <.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""" +
+ <.header class="text-center"> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> + Log in + + to your account now. + + + + <.simple_form + for={@form} + id="registration_form" + phx-submit="save" + phx-change="validate" + phx-trigger-action={@trigger_submit} + action={~p"/users/log_in?_action=registered"} + method="post" + > + <.error :if={@check_errors}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:email]} type="email" label="Email" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.button phx-disable-with="Creating account..." class="w-full">Create an account + + +
+ """ + end + + def mount(_params, _session, socket) do + changeset = Accounts.change_user_registration(%User{}) + + socket = + socket + |> assign(trigger_submit: false, check_errors: false) + |> assign_form(changeset) + + {:ok, socket, temporary_assigns: [form: nil]} + end + + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_user_confirmation_instructions( + user, + &url(~p"/users/confirm/#{&1}") + ) + + changeset = Accounts.change_user_registration(user) + {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_registration(%User{}, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + form = to_form(changeset, as: "user") + + if changeset.valid? do + assign(socket, form: form, check_errors: false) + else + assign(socket, form: form) + end + end +end diff --git a/lib/cklist_web/live/user_reset_password_live.ex b/lib/cklist_web/live/user_reset_password_live.ex new file mode 100644 index 0000000..b02ac21 --- /dev/null +++ b/lib/cklist_web/live/user_reset_password_live.ex @@ -0,0 +1,89 @@ +defmodule CklistWeb.UserResetPasswordLive do + use CklistWeb, :live_view + + alias Cklist.Accounts + + def render(assigns) do + ~H""" +
+ <.header class="text-center">Reset Password + + <.simple_form + for={@form} + id="reset_password_form" + phx-submit="reset_password" + phx-change="validate" + > + <.error :if={@form.errors != []}> + Oops, something went wrong! Please check the errors below. + + + <.input field={@form[:password]} type="password" label="New password" required /> + <.input + field={@form[:password_confirmation]} + type="password" + label="Confirm new password" + required + /> + <:actions> + <.button phx-disable-with="Resetting..." class="w-full">Reset Password + + + +

+ <.link href={~p"/users/register"}>Register + | <.link href={~p"/users/log_in"}>Log in +

+
+ """ + end + + def mount(params, _session, socket) do + socket = assign_user_and_token(socket, params) + + form_source = + case socket.assigns do + %{user: user} -> + Accounts.change_user_password(user) + + _ -> + %{} + end + + {:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]} + end + + # Do not log in the user after reset password to avoid a + # leaked token giving the user access to the account. + def handle_event("reset_password", %{"user" => user_params}, socket) do + case Accounts.reset_user_password(socket.assigns.user, user_params) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Password reset successfully.") + |> redirect(to: ~p"/users/log_in")} + + {:error, changeset} -> + {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_password(socket.assigns.user, user_params) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_user_and_token(socket, %{"token" => token}) do + if user = Accounts.get_user_by_reset_password_token(token) do + assign(socket, user: user, token: token) + else + socket + |> put_flash(:error, "Reset password link is invalid or it has expired.") + |> redirect(to: ~p"/") + end + end + + defp assign_form(socket, %{} = source) do + assign(socket, :form, to_form(source, as: "user")) + end +end diff --git a/lib/cklist_web/live/user_settings_live.ex b/lib/cklist_web/live/user_settings_live.ex new file mode 100644 index 0000000..61e8ef7 --- /dev/null +++ b/lib/cklist_web/live/user_settings_live.ex @@ -0,0 +1,167 @@ +defmodule CklistWeb.UserSettingsLive do + use CklistWeb, :live_view + + alias Cklist.Accounts + + def render(assigns) do + ~H""" + <.header class="text-center"> + Account Settings + <:subtitle>Manage your account email address and password settings + + +
+
+ <.simple_form + for={@email_form} + id="email_form" + phx-submit="update_email" + phx-change="validate_email" + > + <.input field={@email_form[:email]} type="email" label="Email" required /> + <.input + field={@email_form[:current_password]} + name="current_password" + id="current_password_for_email" + type="password" + label="Current password" + value={@email_form_current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Email + + +
+
+ <.simple_form + for={@password_form} + id="password_form" + action={~p"/users/log_in?_action=password_updated"} + method="post" + phx-change="validate_password" + phx-submit="update_password" + phx-trigger-action={@trigger_submit} + > + + <.input field={@password_form[:password]} type="password" label="New password" required /> + <.input + field={@password_form[:password_confirmation]} + type="password" + label="Confirm new password" + /> + <.input + field={@password_form[:current_password]} + name="current_password" + type="password" + label="Current password" + id="current_password_for_password" + value={@current_password} + required + /> + <:actions> + <.button phx-disable-with="Changing...">Change Password + + +
+
+ """ + end + + def mount(%{"token" => token}, _session, socket) do + socket = + case Accounts.update_user_email(socket.assigns.current_user, token) do + :ok -> + put_flash(socket, :info, "Email changed successfully.") + + :error -> + put_flash(socket, :error, "Email change link is invalid or it has expired.") + end + + {:ok, push_navigate(socket, to: ~p"/users/settings")} + end + + def mount(_params, _session, socket) do + user = socket.assigns.current_user + email_changeset = Accounts.change_user_email(user) + password_changeset = Accounts.change_user_password(user) + + socket = + socket + |> assign(:current_password, nil) + |> assign(:email_form_current_password, nil) + |> assign(:current_email, user.email) + |> assign(:email_form, to_form(email_changeset)) + |> assign(:password_form, to_form(password_changeset)) + |> assign(:trigger_submit, false) + + {:ok, socket} + end + + def handle_event("validate_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + email_form = + socket.assigns.current_user + |> Accounts.change_user_email(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, email_form: email_form, email_form_current_password: password)} + end + + def handle_event("update_email", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.apply_user_email(user, password, user_params) do + {:ok, applied_user} -> + Accounts.deliver_user_update_email_instructions( + applied_user, + user.email, + &url(~p"/users/settings/confirm_email/#{&1}") + ) + + info = "A link to confirm your email change has been sent to the new address." + {:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)} + + {:error, changeset} -> + {:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))} + end + end + + def handle_event("validate_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + + password_form = + socket.assigns.current_user + |> Accounts.change_user_password(user_params) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, password_form: password_form, current_password: password)} + end + + def handle_event("update_password", params, socket) do + %{"current_password" => password, "user" => user_params} = params + user = socket.assigns.current_user + + case Accounts.update_user_password(user, password, user_params) do + {:ok, user} -> + password_form = + user + |> Accounts.change_user_password(user_params) + |> to_form() + + {:noreply, assign(socket, trigger_submit: true, password_form: password_form)} + + {:error, changeset} -> + {:noreply, assign(socket, password_form: to_form(changeset))} + end + end +end diff --git a/lib/cklist_web/router.ex b/lib/cklist_web/router.ex index dec61eb..5b34b39 100644 --- a/lib/cklist_web/router.ex +++ b/lib/cklist_web/router.ex @@ -1,6 +1,8 @@ defmodule CklistWeb.Router do use CklistWeb, :router + import CklistWeb.UserAuth + pipeline :browser do plug :accepts, ["html"] plug :fetch_session @@ -8,6 +10,7 @@ defmodule CklistWeb.Router do plug :put_root_layout, html: {CklistWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_current_user end pipeline :api do @@ -18,6 +21,7 @@ defmodule CklistWeb.Router do pipe_through :browser get "/", PageController, :home + get "/about", PageController, :about end # Other scopes may use custom stacks. @@ -41,4 +45,42 @@ defmodule CklistWeb.Router do forward "/mailbox", Plug.Swoosh.MailboxPreview end end + + ## Authentication routes + + scope "/", CklistWeb do + pipe_through [:browser, :redirect_if_user_is_authenticated] + + live_session :redirect_if_user_is_authenticated, + on_mount: [{CklistWeb.UserAuth, :redirect_if_user_is_authenticated}] do + live "/users/register", UserRegistrationLive, :new + live "/users/log_in", UserLoginLive, :new + live "/users/reset_password", UserForgotPasswordLive, :new + live "/users/reset_password/:token", UserResetPasswordLive, :edit + end + + post "/users/log_in", UserSessionController, :create + end + + scope "/", CklistWeb do + pipe_through [:browser, :require_authenticated_user] + + live_session :require_authenticated_user, + on_mount: [{CklistWeb.UserAuth, :ensure_authenticated}] do + live "/users/settings", UserSettingsLive, :edit + live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email + end + end + + scope "/", CklistWeb do + pipe_through [:browser] + + delete "/users/log_out", UserSessionController, :delete + + live_session :current_user, + on_mount: [{CklistWeb.UserAuth, :mount_current_user}] do + live "/users/confirm/:token", UserConfirmationLive, :edit + live "/users/confirm", UserConfirmationInstructionsLive, :new + end + end end diff --git a/lib/cklist_web/user_auth.ex b/lib/cklist_web/user_auth.ex new file mode 100644 index 0000000..8510bf4 --- /dev/null +++ b/lib/cklist_web/user_auth.ex @@ -0,0 +1,229 @@ +defmodule CklistWeb.UserAuth do + use CklistWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias Cklist.Accounts + + # Make the remember me cookie valid for 60 days. + # If you want bump or reduce this value, also change + # the token expiry itself in UserToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_cklist_web_user_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @doc """ + Logs the user in. + + It renews the session ID and clears the whole session + to avoid fixation attacks. See the renew_session + function to customize this behaviour. + + It also sets a `:live_socket_id` key in the session, + so LiveView sessions are identified and automatically + disconnected on log out. The line can be safely removed + if you are not using LiveView. + """ + def log_in_user(conn, user, params \\ %{}) do + token = Accounts.generate_user_session_token(user) + user_return_to = get_session(conn, :user_return_to) + + conn + |> renew_session() + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do + put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) + end + + defp maybe_write_remember_me_cookie(conn, _token, _params) do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn) do + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn) do + delete_csrf_token() + + conn + |> configure_session(renew: true) + |> clear_session() + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + CklistWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: ~p"/") + end + + @doc """ + Authenticates the user by looking into the session + and remember me token. + """ + def fetch_current_user(conn, _opts) do + {user_token, conn} = ensure_user_token(conn) + user = user_token && Accounts.get_user_by_session_token(user_token) + assign(conn, :current_user, user) + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, put_token_in_session(conn, token)} + else + {nil, conn} + end + end + end + + @doc """ + Handles mounting and authenticating the current_user in LiveViews. + + ## `on_mount` arguments + + * `:mount_current_user` - Assigns current_user + to socket assigns based on user_token, or nil if + there's no user_token or no matching user. + + * `:ensure_authenticated` - Authenticates the user from the session, + and assigns the current_user to socket assigns based + on user_token. + Redirects to login page if there's no logged user. + + * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. + Redirects to signed_in_path if there's a logged user. + + ## Examples + + Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate + the current_user: + + defmodule CklistWeb.PageLive do + use CklistWeb, :live_view + + on_mount {CklistWeb.UserAuth, :mount_current_user} + ... + end + + Or use the `live_session` of your router to invoke the on_mount callback: + + live_session :authenticated, on_mount: [{CklistWeb.UserAuth, :ensure_authenticated}] do + live "/profile", ProfileLive, :index + end + """ + def on_mount(:mount_current_user, _params, session, socket) do + {:cont, mount_current_user(socket, session)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") + + {:halt, socket} + end + end + + def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do + socket = mount_current_user(socket, session) + + if socket.assigns.current_user do + {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} + else + {:cont, socket} + end + end + + defp mount_current_user(socket, session) do + Phoenix.Component.assign_new(socket, :current_user, fn -> + if user_token = session["user_token"] do + Accounts.get_user_by_session_token(user_token) + end + end) + end + + @doc """ + Used for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns[:current_user] do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the user to be authenticated. + + If you want to enforce the user email is confirmed before + they use the application at all, here would be a good place. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns[:current_user] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log_in") + |> halt() + end + end + + defp put_token_in_session(conn, token) do + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: ~p"/" +end diff --git a/mix.exs b/mix.exs index d7202d7..6c732cc 100644 --- a/mix.exs +++ b/mix.exs @@ -32,6 +32,7 @@ defmodule Cklist.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:bcrypt_elixir, "~> 3.0"}, {:phoenix, "~> 1.7.21"}, {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.10"}, diff --git a/mix.lock b/mix.lock index 6f12fdb..4c1edec 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,14 @@ %{ "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, + "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, diff --git a/priv/repo/migrations/20250607090156_create_users_auth_tables.exs b/priv/repo/migrations/20250607090156_create_users_auth_tables.exs new file mode 100644 index 0000000..fed4fb2 --- /dev/null +++ b/priv/repo/migrations/20250607090156_create_users_auth_tables.exs @@ -0,0 +1,29 @@ +defmodule Cklist.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + create table(:users) do + add :email, :citext, null: false + add :hashed_password, :string, null: false + add :confirmed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:email]) + + create table(:users_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/test/cklist/accounts_test.exs b/test/cklist/accounts_test.exs new file mode 100644 index 0000000..95b8ca4 --- /dev/null +++ b/test/cklist/accounts_test.exs @@ -0,0 +1,508 @@ +defmodule Cklist.AccountsTest do + use Cklist.DataCase + + alias Cklist.Accounts + + import Cklist.AccountsFixtures + alias Cklist.Accounts.{User, UserToken} + + describe "get_user_by_email/1" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email("unknown@example.com") + end + + test "returns the user if the email exists" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user_by_email(user.email) + end + end + + describe "get_user_by_email_and_password/2" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the user if the password is not valid" do + user = user_fixture() + refute Accounts.get_user_by_email_and_password(user.email, "invalid") + end + + test "returns the user if the email and password are valid" do + %{id: id} = user = user_fixture() + + assert %User{id: ^id} = + Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end + end + + describe "get_user!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!(-1) + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "register_user/1" do + test "requires email and password to be set" do + {:error, changeset} = Accounts.register_user(%{}) + + assert %{ + password: ["can't be blank"], + email: ["can't be blank"] + } = errors_on(changeset) + end + + test "validates email and password when given" do + {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"}) + + assert %{ + email: ["must have the @ sign and no spaces"], + password: ["should be at least 12 character(s)"] + } = errors_on(changeset) + end + + test "validates maximum values for email and password for security" do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates email uniqueness" do + %{email: email} = user_fixture() + {:error, changeset} = Accounts.register_user(%{email: email}) + assert "has already been taken" in errors_on(changeset).email + + # Now try with the upper cased email too, to check that email case is ignored. + {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers users with a hashed password" do + email = unique_user_email() + {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) + assert user.email == email + assert is_binary(user.hashed_password) + assert is_nil(user.confirmed_at) + assert is_nil(user.password) + end + end + + describe "change_user_registration/2" do + test "returns a changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{}) + assert changeset.required == [:password, :email] + end + + test "allows fields to be set" do + email = unique_user_email() + password = valid_user_password() + + changeset = + Accounts.change_user_registration( + %User{}, + valid_user_attributes(email: email, password: password) + ) + + assert changeset.valid? + assert get_change(changeset, :email) == email + assert get_change(changeset, :password) == password + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "change_user_email/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "apply_user_email/3" do + setup do + %{user: user_fixture()} + end + + test "requires email to change", %{user: user} do + {:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{}) + assert %{email: ["did not change"]} = errors_on(changeset) + end + + test "validates email", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"}) + + assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) + end + + test "validates maximum value for email for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.apply_user_email(user, valid_user_password(), %{email: too_long}) + + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness", %{user: user} do + %{email: email} = user_fixture() + password = valid_user_password() + + {:error, changeset} = Accounts.apply_user_email(user, password, %{email: email}) + + assert "has already been taken" in errors_on(changeset).email + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "applies the email without persisting it", %{user: user} do + email = unique_user_email() + {:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email}) + assert user.email == email + assert Accounts.get_user!(user.id).email != email + end + end + + describe "deliver_user_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert Accounts.update_user_email(user, token) == :ok + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + assert changed_user.confirmed_at + assert changed_user.confirmed_at != user.confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.update_user_email(user, token) == :error + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "change_user_password/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_password(%User{}, %{ + "password" => "new valid password" + }) + + assert changeset.valid? + assert get_change(changeset, :password) == "new valid password" + assert is_nil(get_change(changeset, :hashed_password)) + end + end + + describe "update_user_password/3" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_user_password(user, valid_user_password(), %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "validates current password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, "invalid", %{password: valid_user_password()}) + + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "updates the password", %{user: user} do + {:ok, user} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + + {:ok, _} = + Accounts.update_user_password(user, valid_user_password(), %{ + password: "new valid password" + }) + + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "delete_user_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_user_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_user_confirmation_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "confirm" + end + end + + describe "confirm_user/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "confirms the email with a valid token", %{user: user, token: token} do + assert {:ok, confirmed_user} = Accounts.confirm_user(token) + assert confirmed_user.confirmed_at + assert confirmed_user.confirmed_at != user.confirmed_at + assert Repo.get!(User, user.id).confirmed_at + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm with invalid token", %{user: user} do + assert Accounts.confirm_user("oops") == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not confirm email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + assert Accounts.confirm_user(token) == :error + refute Repo.get!(User, user.id).confirmed_at + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "deliver_user_reset_password_instructions/2" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "reset_password" + end + end + + describe "get_user_by_reset_password_token/1" do + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{user: user, token: token} + end + + test "returns the user with valid token", %{user: %{id: id}, token: token} do + assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: id) + end + + test "does not return the user with invalid token", %{user: user} do + refute Accounts.get_user_by_reset_password_token("oops") + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not return the user if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_reset_password_token(token) + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "reset_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.reset_user_password(user, %{ + password: "not valid", + password_confirmation: "another" + }) + + assert %{ + password: ["should be at least 12 character(s)"], + password_confirmation: ["does not match password"] + } = errors_on(changeset) + end + + test "validates maximum values for password for security", %{user: user} do + too_long = String.duplicate("db", 100) + {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long}) + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{user: user} do + {:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"}) + assert is_nil(updated_user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"}) + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "inspect/2 for the User module" do + test "does not include password" do + refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" + end + end +end diff --git a/test/cklist_web/controllers/user_session_controller_test.exs b/test/cklist_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..65b7b63 --- /dev/null +++ b/test/cklist_web/controllers/user_session_controller_test.exs @@ -0,0 +1,113 @@ +defmodule CklistWeb.UserSessionControllerTest do + use CklistWeb.ConnCase, async: true + + import Cklist.AccountsFixtures + + setup do + %{user: user_fixture()} + end + + describe "POST /users/log_in" do + test "logs the user in", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + + # 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 + + test "logs the user in with remember me", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_cklist_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log_in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "login following registration", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "registered", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" + end + + test "login following password update", %{conn: conn, user: user} do + conn = + conn + |> post(~p"/users/log_in", %{ + "_action" => "password_updated", + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully" + end + + test "redirects to login page with invalid credentials", %{conn: conn} do + conn = + post(conn, ~p"/users/log_in", %{ + "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + assert redirected_to(conn) == ~p"/users/log_in" + end + end + + describe "DELETE /users/log_out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log_out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/cklist_web/live/user_confirmation_instructions_live_test.exs b/test/cklist_web/live/user_confirmation_instructions_live_test.exs new file mode 100644 index 0000000..ae27c11 --- /dev/null +++ b/test/cklist_web/live/user_confirmation_instructions_live_test.exs @@ -0,0 +1,67 @@ +defmodule CklistWeb.UserConfirmationInstructionsLiveTest do + use CklistWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Cklist.AccountsFixtures + + alias Cklist.Accounts + alias Cklist.Repo + + setup do + %{user: user_fixture()} + end + + describe "Resend confirmation" do + test "renders the resend confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm") + assert html =~ "Resend confirmation instructions" + end + + test "sends a new confirmation token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" + end + + test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do + Repo.update!(Accounts.User.confirm_changeset(user)) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + refute Repo.get_by(Accounts.UserToken, user_id: user.id) + end + + test "does not send confirmation token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm") + + {:ok, conn} = + lv + |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "If your email is in our system" + + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/cklist_web/live/user_confirmation_live_test.exs b/test/cklist_web/live/user_confirmation_live_test.exs new file mode 100644 index 0000000..14cd40f --- /dev/null +++ b/test/cklist_web/live/user_confirmation_live_test.exs @@ -0,0 +1,89 @@ +defmodule CklistWeb.UserConfirmationLiveTest do + use CklistWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Cklist.AccountsFixtures + + alias Cklist.Accounts + alias Cklist.Repo + + setup do + %{user: user_fixture()} + end + + describe "Confirm user" do + test "renders confirmation page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token") + assert html =~ "Confirm Account" + end + + test "confirms the given token once", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_confirmation_instructions(user, url) + end) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "User confirmed successfully" + + assert Accounts.get_user!(user.id).confirmed_at + refute get_session(conn, :user_token) + assert Repo.all(Accounts.UserToken) == [] + + # when not logged in + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + # when logged in + conn = + build_conn() + |> log_in_user(user) + + {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") + + result = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, "/") + + assert {:ok, conn} = result + refute Phoenix.Flash.get(conn.assigns.flash, :error) + end + + test "does not confirm email with invalid token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token") + + {:ok, conn} = + lv + |> form("#confirmation_form") + |> render_submit() + |> follow_redirect(conn, ~p"/") + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "User confirmation link is invalid or it has expired" + + refute Accounts.get_user!(user.id).confirmed_at + end + end +end diff --git a/test/cklist_web/live/user_forgot_password_live_test.exs b/test/cklist_web/live/user_forgot_password_live_test.exs new file mode 100644 index 0000000..4a09e97 --- /dev/null +++ b/test/cklist_web/live/user_forgot_password_live_test.exs @@ -0,0 +1,63 @@ +defmodule CklistWeb.UserForgotPasswordLiveTest do + use CklistWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Cklist.AccountsFixtures + + alias Cklist.Accounts + alias Cklist.Repo + + describe "Forgot password page" do + test "renders email page", %{conn: conn} do + {:ok, lv, html} = live(conn, ~p"/users/reset_password") + + assert html =~ "Forgot your password?" + assert has_element?(lv, ~s|a[href="#{~p"/users/register"}"]|, "Register") + assert has_element?(lv, ~s|a[href="#{~p"/users/log_in"}"]|, "Log in") + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/reset_password") + |> follow_redirect(conn, ~p"/") + + assert {:ok, _conn} = result + end + end + + describe "Reset link" do + setup do + %{user: user_fixture()} + end + + test "sends a new reset password token", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => user.email}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + + assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == + "reset_password" + end + + test "does not send reset password token if email is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password") + + {:ok, conn} = + lv + |> form("#reset_password_form", user: %{"email" => "unknown@example.com"}) + |> render_submit() + |> follow_redirect(conn, "/") + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" + assert Repo.all(Accounts.UserToken) == [] + end + end +end diff --git a/test/cklist_web/live/user_login_live_test.exs b/test/cklist_web/live/user_login_live_test.exs new file mode 100644 index 0000000..9c41734 --- /dev/null +++ b/test/cklist_web/live/user_login_live_test.exs @@ -0,0 +1,87 @@ +defmodule CklistWeb.UserLoginLiveTest do + use CklistWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Cklist.AccountsFixtures + + describe "Log in page" do + test "renders log in page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/log_in") + + assert html =~ "Log in" + assert html =~ "Register" + assert html =~ "Forgot your password?" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/log_in") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + end + + describe "user login" do + test "redirects if user login with valid credentials", %{conn: conn} do + password = "123456789abcd" + user = user_fixture(%{password: password}) + + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true}) + + conn = submit_form(form, conn) + + assert redirected_to(conn) == ~p"/" + end + + test "redirects to login page with a flash error if there are no valid credentials", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + form = + form(lv, "#login_form", + user: %{email: "test@email.com", password: "123456", remember_me: true} + ) + + conn = submit_form(form, conn) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + + assert redirected_to(conn) == "/users/log_in" + end + end + + describe "login navigation" do + test "redirects to registration page when the Register button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Sign up")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert login_html =~ "Register" + end + + test "redirects to forgot password page when the Forgot Password button is clicked", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log_in") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Forgot your password?")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/reset_password") + + assert conn.resp_body =~ "Forgot your password?" + end + end +end diff --git a/test/cklist_web/live/user_registration_live_test.exs b/test/cklist_web/live/user_registration_live_test.exs new file mode 100644 index 0000000..5921601 --- /dev/null +++ b/test/cklist_web/live/user_registration_live_test.exs @@ -0,0 +1,87 @@ +defmodule CklistWeb.UserRegistrationLiveTest do + use CklistWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Cklist.AccountsFixtures + + describe "Registration page" do + test "renders registration page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/register") + + assert html =~ "Register" + assert html =~ "Log in" + end + + test "redirects if already logged in", %{conn: conn} do + result = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/register") + |> follow_redirect(conn, "/") + + assert {:ok, _conn} = result + end + + test "renders errors for invalid data", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + result = + lv + |> element("#registration_form") + |> render_change(user: %{"email" => "with spaces", "password" => "too short"}) + + assert result =~ "Register" + assert result =~ "must have the @ sign and no spaces" + assert result =~ "should be at least 12 character" + end + end + + describe "register user" do + test "creates account and logs the user in", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + email = unique_user_email() + form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) + render_submit(form) + conn = follow_trigger_action(form, conn) + + assert redirected_to(conn) == ~p"/" + + # 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 + + test "renders errors for duplicated email", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + user = user_fixture(%{email: "test@email.com"}) + + result = + lv + |> form("#registration_form", + user: %{"email" => user.email, "password" => "valid_password"} + ) + |> render_submit() + + assert result =~ "has already been taken" + end + end + + describe "registration navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + {:ok, _login_live, login_html} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert login_html =~ "Log in" + end + end +end diff --git a/test/cklist_web/live/user_reset_password_live_test.exs b/test/cklist_web/live/user_reset_password_live_test.exs new file mode 100644 index 0000000..370741f --- /dev/null +++ b/test/cklist_web/live/user_reset_password_live_test.exs @@ -0,0 +1,118 @@ +defmodule CklistWeb.UserResetPasswordLiveTest do + use CklistWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Cklist.AccountsFixtures + + alias Cklist.Accounts + + setup do + user = user_fixture() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_reset_password_instructions(user, url) + end) + + %{token: token, user: user} + end + + describe "Reset password page" do + test "renders reset password with valid token", %{conn: conn, token: token} do + {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}") + + assert html =~ "Reset Password" + end + + test "does not render reset password with invalid token", %{conn: conn} do + {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid") + + assert to == %{ + flash: %{"error" => "Reset password link is invalid or it has expired."}, + to: ~p"/" + } + end + + test "renders errors for invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> element("#reset_password_form") + |> render_change( + user: %{"password" => "secret12", "password_confirmation" => "secret123456"} + ) + + assert result =~ "should be at least 12 character" + assert result =~ "does not match password" + end + end + + describe "Reset Password" do + test "resets password once", %{conn: conn, token: token, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> form("#reset_password_form", + user: %{ + "password" => "new valid password", + "password_confirmation" => "new valid password" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/users/log_in") + + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "does not reset password on invalid data", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + result = + lv + |> form("#reset_password_form", + user: %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + ) + |> render_submit() + + assert result =~ "Reset Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + end + + describe "Reset password navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Log in")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/log_in") + + assert conn.resp_body =~ "Log in" + end + + test "redirects to registration page when the Register button is clicked", %{ + conn: conn, + token: token + } do + {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") + + {:ok, conn} = + lv + |> element(~s|main a:fl-contains("Register")|) + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert conn.resp_body =~ "Register" + end + end +end diff --git a/test/cklist_web/live/user_settings_live_test.exs b/test/cklist_web/live/user_settings_live_test.exs new file mode 100644 index 0000000..dd75961 --- /dev/null +++ b/test/cklist_web/live/user_settings_live_test.exs @@ -0,0 +1,210 @@ +defmodule CklistWeb.UserSettingsLiveTest do + use CklistWeb.ConnCase, async: true + + alias Cklist.Accounts + import Phoenix.LiveViewTest + import Cklist.AccountsFixtures + + describe "Settings page" do + test "renders settings page", %{conn: conn} do + {:ok, _lv, html} = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/settings") + + assert html =~ "Change Email" + assert html =~ "Change Password" + end + + test "redirects if user is not logged in", %{conn: conn} do + assert {:error, redirect} = live(conn, ~p"/users/settings") + + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => "You must log in to access this page."} = flash + end + end + + describe "update email form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user email", %{conn: conn, password: password, user: user} do + new_email = unique_user_email() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => password, + "user" => %{"email" => new_email} + }) + |> render_submit() + + assert result =~ "A link to confirm your email" + assert Accounts.get_user_by_email(user.email) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#email_form") + |> render_change(%{ + "action" => "update_email", + "current_password" => "invalid", + "user" => %{"email" => "with spaces"} + }) + + assert result =~ "Change Email" + assert result =~ "must have the @ sign and no spaces" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "current_password" => "invalid", + "user" => %{"email" => user.email} + }) + |> render_submit() + + assert result =~ "Change Email" + assert result =~ "did not change" + assert result =~ "is not valid" + end + end + + describe "update password form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "updates the user password", %{conn: conn, user: user, password: password} do + new_password = valid_user_password() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + form = + form(lv, "#password_form", %{ + "current_password" => password, + "user" => %{ + "email" => user.email, + "password" => new_password, + "password_confirmation" => new_password + } + }) + + render_submit(form) + + new_password_conn = follow_trigger_action(form, conn) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, new_password) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#password_form") + |> render_change(%{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#password_form", %{ + "current_password" => "invalid", + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + |> render_submit() + + assert result =~ "Change Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + assert result =~ "is not valid" + end + end + + describe "confirm email" do + setup %{conn: conn} do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{conn: log_in_user(conn, user), token: token, email: email, user: user} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"info" => message} = flash + assert message == "Email changed successfully." + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + # use confirm token again + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log_in" + assert %{"error" => message} = flash + assert message == "You must log in to access this page." + end + end +end diff --git a/test/cklist_web/user_auth_test.exs b/test/cklist_web/user_auth_test.exs new file mode 100644 index 0000000..1bf8b93 --- /dev/null +++ b/test/cklist_web/user_auth_test.exs @@ -0,0 +1,272 @@ +defmodule CklistWeb.UserAuthTest do + use CklistWeb.ConnCase, async: true + + alias Phoenix.LiveView + alias Cklist.Accounts + alias CklistWeb.UserAuth + import Cklist.AccountsFixtures + + @remember_me_cookie "_cklist_web_user_remember_me" + + setup %{conn: conn} do + conn = + conn + |> Map.replace!(:secret_key_base, CklistWeb.Endpoint.config(:secret_key_base)) + |> init_test_session(%{}) + + %{user: user_fixture(), conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == ~p"/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == 5_184_000 + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "users_sessions:abcdef-token" + CklistWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> UserAuth.log_out_user() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + end + end + + describe "fetch_current_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) + assert conn.assigns.current_user.id == user.id + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_user([]) + + assert conn.assigns.current_user.id == user.id + assert get_session(conn, :user_token) == user_token + + assert get_session(conn, :live_socket_id) == + "users_sessions:#{Base.url_encode64(user_token)}" + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_user + end + end + + describe "on_mount :mount_current_user" do + test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + + test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :ensure_authenticated" do + test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_user.id == user.id + end + + test "redirects to login page if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + socket = %LiveView.Socket{ + endpoint: CklistWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + + test "redirects to login page if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + socket = %LiveView.Socket{ + endpoint: CklistWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_user == nil + end + end + + describe "on_mount :redirect_if_user_is_authenticated" do + test "redirects if there is an authenticated user ", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + assert {:halt, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + + test "doesn't redirect if there is no authenticated user", %{conn: conn} do + session = conn |> get_session() + + assert {:cont, _updated_socket} = + UserAuth.on_mount( + :redirect_if_user_is_authenticated, + %{}, + session, + %LiveView.Socket{} + ) + end + end + + describe "redirect_if_user_is_authenticated/2" do + test "redirects if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) + assert conn.halted + assert redirected_to(conn) == ~p"/" + end + + test "does not redirect if user is not authenticated", %{conn: conn} do + conn = UserAuth.redirect_if_user_is_authenticated(conn, []) + refute conn.halted + refute conn.status + end + end + + describe "require_authenticated_user/2" do + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + + assert redirected_to(conn) == ~p"/users/log_in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) + refute conn.halted + refute conn.status + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 2a9d5ba..402b22b 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,4 +35,30 @@ defmodule CklistWeb.ConnCase do Cklist.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn}) do + user = Cklist.AccountsFixtures.user_fixture() + %{conn: log_in_user(conn, user), user: user} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user) do + token = Cklist.Accounts.generate_user_session_token(user) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + end end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..e4a5d2d --- /dev/null +++ b/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,31 @@ +defmodule Cklist.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Cklist.Accounts` context. + """ + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email(), + password: valid_user_password() + }) + end + + def user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> Cklist.Accounts.register_user() + + user + end + + def extract_user_token(fun) do + {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]") + [_, token | _] = String.split(captured_email.text_body, "[TOKEN]") + token + end +end From 4a99e0ef9ef3fca66936a45c6b9811ce5491b0ce Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sun, 22 Jun 2025 10:16:12 +0200 Subject: [PATCH 02/31] fix formatting --- .../components/layouts/app.html.heex | 70 ++++++------ lib/cklist_web/controllers/page_html.ex | 1 + .../controllers/page_html/about.html.heex | 2 +- .../controllers/page_html/home.html.heex | 105 +++++++++--------- 4 files changed, 88 insertions(+), 90 deletions(-) diff --git a/lib/cklist_web/components/layouts/app.html.heex b/lib/cklist_web/components/layouts/app.html.heex index 0b14481..c6e1e64 100644 --- a/lib/cklist_web/components/layouts/app.html.heex +++ b/lib/cklist_web/components/layouts/app.html.heex @@ -1,42 +1,40 @@
- -
-
-
- - - -
-
- <%= if @current_user do %> - - Settings - - - Log out - - <% else %> - - Register +
+
+
+ + - - Log in - - <% end %> +
+
+ <%= if @current_user do %> + + Settings + + + Log out + + <% else %> + + Register + + + Log in + + <% end %> +
-
-
- -
-
- <.flash_group flash={@flash} /> - {@inner_content} -
-
+ -
-

<.link href={~p"/about"}>About

-

<.link href="https://github.com/romanc/cklist/">GitHub

-
+
+
+ <.flash_group flash={@flash} /> + {@inner_content} +
+
+
+

<.link href={~p"/about"}>About

+

<.link href="https://github.com/romanc/cklist/">GitHub

+
diff --git a/lib/cklist_web/controllers/page_html.ex b/lib/cklist_web/controllers/page_html.ex index 32841fc..c3d2fa6 100644 --- a/lib/cklist_web/controllers/page_html.ex +++ b/lib/cklist_web/controllers/page_html.ex @@ -10,6 +10,7 @@ defmodule CklistWeb.PageHTML do attr :title, :string, required: true attr :desc, :string, default: nil + def awesome(assigns) do ~H"""
diff --git a/lib/cklist_web/controllers/page_html/about.html.heex b/lib/cklist_web/controllers/page_html/about.html.heex index 35048da..42e3b6b 100644 --- a/lib/cklist_web/controllers/page_html/about.html.heex +++ b/lib/cklist_web/controllers/page_html/about.html.heex @@ -1,5 +1,5 @@ <.header class="mb-12"> - About + About

This is cklist version {Application.spec(:cklist, :vsn)}.

diff --git a/lib/cklist_web/controllers/page_html/home.html.heex b/lib/cklist_web/controllers/page_html/home.html.heex index 1c0712f..2c39fd6 100644 --- a/lib/cklist_web/controllers/page_html/home.html.heex +++ b/lib/cklist_web/controllers/page_html/home.html.heex @@ -1,63 +1,62 @@ -
- -
- <.flash_group flash={@flash} /> - -
-
- <.link href={~p"/users/log_in"}> - <.button>Login - +
+ <.flash_group flash={@flash} /> + +
+
+ <.link href={~p"/users/log_in"}> + <.button>Login + +
-
-
-
- -
-

- cklist.org -

-

- Check that list! -

+
+
+
+ +
+

+ cklist.org +

+

+ Check that list! +

+
+
+

+ 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." + />
-
-

- 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"/about"}>About

-

<.link href="https://github.com/romanc/cklist/">GitHub

-
+
+
+

<.link href={~p"/about"}>About

+

<.link href="https://github.com/romanc/cklist/">GitHub

+
From 61819df8145ccb6dbb0ecaf2f122c9b37c9b215c Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Fri, 4 Jul 2025 22:00:52 +0200 Subject: [PATCH 03/31] Add login/logout logic, fix log out link, fix tests --- .../components/layouts/app.html.heex | 4 +-- .../controllers/page_html/home.html.heex | 32 +++++++++++++------ .../user_session_controller_test.exs | 1 - .../live/user_registration_live_test.exs | 1 - 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/lib/cklist_web/components/layouts/app.html.heex b/lib/cklist_web/components/layouts/app.html.heex index c6e1e64..5514d3f 100644 --- a/lib/cklist_web/components/layouts/app.html.heex +++ b/lib/cklist_web/components/layouts/app.html.heex @@ -11,9 +11,9 @@ Settings - + <.link href={~p"/users/log_out"} method="delete" class="hover:text-zinc-700"> Log out - + <% else %> Register diff --git a/lib/cklist_web/controllers/page_html/home.html.heex b/lib/cklist_web/controllers/page_html/home.html.heex index 2c39fd6..2791506 100644 --- a/lib/cklist_web/controllers/page_html/home.html.heex +++ b/lib/cklist_web/controllers/page_html/home.html.heex @@ -5,9 +5,11 @@
- <.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. +
+
diff --git a/lib/cklist_web/controllers/page_html/home.html.heex b/lib/cklist_web/controllers/page_html/home.html.heex index 2791506..476fcdf 100644 --- a/lib/cklist_web/controllers/page_html/home.html.heex +++ b/lib/cklist_web/controllers/page_html/home.html.heex @@ -30,20 +30,20 @@

<.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." />
From 728c00f28908e71e47ab8b7a48f092e031ecbe7d Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Fri, 4 Jul 2025 23:09:12 +0200 Subject: [PATCH 05/31] Add FAQ as component --- lib/cklist_web/controllers/page_html.ex | 13 ++++++ .../controllers/page_html/about.html.heex | 45 +++++++++++-------- .../controllers/page_html/home.html.heex | 13 +++--- 3 files changed, 45 insertions(+), 26 deletions(-) diff --git a/lib/cklist_web/controllers/page_html.ex b/lib/cklist_web/controllers/page_html.ex index c3d2fa6..7559d18 100644 --- a/lib/cklist_web/controllers/page_html.ex +++ b/lib/cklist_web/controllers/page_html.ex @@ -22,4 +22,17 @@ defmodule CklistWeb.PageHTML do """ end + + def faq(assigns) do + ~H""" +
+ <.header class="my-4">FAQ + +
+
{item.question}
+
{item.answer}
+
+
+ """ + 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." />
@@ -66,10 +63,12 @@

<.link href="https://github.com/romanc/cklist/">GitHub

<%= if @current_user do %> -
-

<.link href={~p"/users/log_out"} method="delete">Log out

+
+

+ <.link href={~p"/users/log_out"} method="delete">Log out +

<.link href={~p"/users/settings"}>Settings

-
+
<% end %> From 428156a011f9a236a1a12192da83bfae01ffa08b Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Tue, 8 Jul 2025 20:06:02 +0200 Subject: [PATCH 06/31] Configure production mailserver --- config/runtime.exs | 12 +++++++++++- lib/cklist/accounts/user_notifier.ex | 2 +- mix.exs | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index 4a21a50..a0d8379 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -107,7 +107,17 @@ if config_env() == :prod do # adapter: Swoosh.Adapters.Mailgun, # api_key: System.get_env("MAILGUN_API_KEY"), # domain: System.get_env("MAILGUN_DOMAIN") - # + + config :cklist, Cklist.Mailer, + adapter: Bamboo.SMTPAdapter, + server: System.get_env("SMTP_SERVER"), + port: System.get_env("SMTP_PORT"), + username: System.get_env("SMTP_USERNAME"), + password: System.get_env("SMTP_PASSWORD"), + tls: :always, + ssl: false, + retries: 1 + # For this example you need include a HTTP client required by Swoosh API client. # Swoosh supports Hackney and Finch out of the box: # diff --git a/lib/cklist/accounts/user_notifier.ex b/lib/cklist/accounts/user_notifier.ex index 3582b10..956f7a5 100644 --- a/lib/cklist/accounts/user_notifier.ex +++ b/lib/cklist/accounts/user_notifier.ex @@ -8,7 +8,7 @@ defmodule Cklist.Accounts.UserNotifier do email = new() |> to(recipient) - |> from({"Cklist", "contact@example.com"}) + |> from({"Cklist", "no-reply@cklist.org"}) |> subject(subject) |> text_body(body) diff --git a/mix.exs b/mix.exs index 6c732cc..f02a0e5 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Cklist.MixProject do def project do [ app: :cklist, - version: "0.0.12", + version: "0.1.0", elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From d9318c0df7e39c25fb9b63a6d6271854c0d97e20 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Mon, 21 Jul 2025 00:38:19 +0200 Subject: [PATCH 07/31] WIP docs on rc deployment --- .github/workflows/release_deploy.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release_deploy.yaml b/.github/workflows/release_deploy.yaml index 829f210..a5a218d 100644 --- a/.github/workflows/release_deploy.yaml +++ b/.github/workflows/release_deploy.yaml @@ -12,12 +12,12 @@ jobs: deploy: runs-on: ubuntu-24.04 env: - SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }} MIX_ENV: prod # Dummy DATABASE_URL used when building a release. Otherwise, runtime.exs # is complaining. But actually, we have no need to connect to the database # while building a release. DATABASE_URL: "ecto://USER:PASS@HOST/DATABASE" + SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }} steps: - uses: actions/checkout@v5 @@ -73,6 +73,9 @@ jobs: - name: Activate new release if: github.event_name == 'push' + # TODO + # Double check migration. It looks like "DATABASE_URL" is not defined as envvar for the migration script + # See GHA deploy logs run: | ssh -o StrictHostKeyChecking=no -p ${{ secrets.DEPLOY_PORT }} ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "cd ${{ secrets.DEPLOY_PATH_RELEASES }}; rm current; ln -s ${{ env.release }}/ current; ${{ secrets.DEPLOY_RESTART_SERVER }}" env: From b826e4df16b9e63b9a56ce977f1cf9d16ec9e31d Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sun, 27 Jul 2025 18:41:37 +0200 Subject: [PATCH 08/31] Add bamboo and bamboo_smtp dependencies --- .github/workflows/release_deploy.yaml | 3 --- mix.exs | 4 +++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release_deploy.yaml b/.github/workflows/release_deploy.yaml index a5a218d..fd4fe95 100644 --- a/.github/workflows/release_deploy.yaml +++ b/.github/workflows/release_deploy.yaml @@ -73,9 +73,6 @@ jobs: - name: Activate new release if: github.event_name == 'push' - # TODO - # Double check migration. It looks like "DATABASE_URL" is not defined as envvar for the migration script - # See GHA deploy logs run: | ssh -o StrictHostKeyChecking=no -p ${{ secrets.DEPLOY_PORT }} ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "cd ${{ secrets.DEPLOY_PATH_RELEASES }}; rm current; ln -s ${{ env.release }}/ current; ${{ secrets.DEPLOY_RESTART_SERVER }}" env: diff --git a/mix.exs b/mix.exs index f02a0e5..46761df 100644 --- a/mix.exs +++ b/mix.exs @@ -58,7 +58,9 @@ defmodule Cklist.MixProject do {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.5"} + {:bandit, "~> 1.5"}, + {:bamboo, "~> 2.5.0"}, + {:bamboo_smtp, "~> 4.2.2"} ] end From 71773dea7debfc8fa1e65f08ae73621788c346f5 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sun, 27 Jul 2025 18:59:25 +0200 Subject: [PATCH 09/31] Switching to swoosh smtp/mail adapter --- config/runtime.exs | 4 ++-- mix.exs | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index a0d8379..cc12d50 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -109,8 +109,8 @@ if config_env() == :prod do # domain: System.get_env("MAILGUN_DOMAIN") config :cklist, Cklist.Mailer, - adapter: Bamboo.SMTPAdapter, - server: System.get_env("SMTP_SERVER"), + adapter: Swoosh.Adapters.SMTP, + relay: System.get_env("SMTP_SERVER"), port: System.get_env("SMTP_PORT"), username: System.get_env("SMTP_USERNAME"), password: System.get_env("SMTP_PASSWORD"), diff --git a/mix.exs b/mix.exs index 46761df..f02a0e5 100644 --- a/mix.exs +++ b/mix.exs @@ -58,9 +58,7 @@ defmodule Cklist.MixProject do {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.5"}, - {:bamboo, "~> 2.5.0"}, - {:bamboo_smtp, "~> 4.2.2"} + {:bandit, "~> 1.5"} ] end From 9167064d226f5f586969a1eecaaab1698b11a3e4 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sun, 27 Jul 2025 19:15:42 +0200 Subject: [PATCH 10/31] Add gen_smtp dependency (missing?) --- mix.exs | 1 + mix.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/mix.exs b/mix.exs index f02a0e5..dc0eaff 100644 --- a/mix.exs +++ b/mix.exs @@ -52,6 +52,7 @@ defmodule Cklist.MixProject do compile: false, depth: 1}, {:swoosh, "~> 1.5"}, + {:gen_smtp, "~> 1.3"}, {:finch, "~> 0.13"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index 4c1edec..16510ea 100644 --- a/mix.lock +++ b/mix.lock @@ -14,6 +14,7 @@ "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, + "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, @@ -33,6 +34,7 @@ "plug": {:hex, :plug, "1.18.0", "d78df36c41f7e798f2edf1f33e1727eae438e9dd5d809a9997c463a108244042", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "819f9e176d51e44dc38132e132fe0accaf6767eab7f0303431e404da8476cfa2"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "swoosh": {:hex, :swoosh, "1.19.1", "77e839b27fc7af0704788e5854934c77d4dea7b437270c924a717513d598b8a4", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eab57462d41a3330e82cb93a9d7640f5c79a85951f3457db25c1eb28fda193a6"}, "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, From a62e2bb46e971a202bffaa8a58f191478fa1a1a8 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sun, 27 Jul 2025 19:45:03 +0200 Subject: [PATCH 11/31] let's try more tls options --- config/runtime.exs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/config/runtime.exs b/config/runtime.exs index cc12d50..694ea85 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -116,7 +116,16 @@ if config_env() == :prod do password: System.get_env("SMTP_PASSWORD"), tls: :always, ssl: false, - retries: 1 + auth: :always, + no_mx_lookups: false, + retries: 2, + tls_options: [ + versions: [:"tlsv1.2", :"tlsv1.3"], + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + server_name_indication: ~c"#{System.get_env("SMTP_SERVER")}", + depth: 99 + ] # For this example you need include a HTTP client required by Swoosh API client. # Swoosh supports Hackney and Finch out of the box: From e9994a3cc4b0fa2ae552169ed823ab47fa77a2d6 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sun, 27 Jul 2025 20:07:06 +0200 Subject: [PATCH 12/31] switch to mua mail adapter --- config/runtime.exs | 18 ++++-------------- mix.exs | 4 +++- mix.lock | 2 ++ 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index 694ea85..c75a505 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -109,22 +109,12 @@ if config_env() == :prod do # domain: System.get_env("MAILGUN_DOMAIN") config :cklist, Cklist.Mailer, - adapter: Swoosh.Adapters.SMTP, + adapter: Swoosh.Adapters.Mua, relay: System.get_env("SMTP_SERVER"), port: System.get_env("SMTP_PORT"), - username: System.get_env("SMTP_USERNAME"), - password: System.get_env("SMTP_PASSWORD"), - tls: :always, - ssl: false, - auth: :always, - no_mx_lookups: false, - retries: 2, - tls_options: [ - versions: [:"tlsv1.2", :"tlsv1.3"], - verify: :verify_peer, - cacerts: :public_key.cacerts_get(), - server_name_indication: ~c"#{System.get_env("SMTP_SERVER")}", - depth: 99 + auth: [ + username: System.get_env("SMTP_USERNAME"), + password: System.get_env("SMTP_PASSWORD"), ] # For this example you need include a HTTP client required by Swoosh API client. diff --git a/mix.exs b/mix.exs index dc0eaff..a58e341 100644 --- a/mix.exs +++ b/mix.exs @@ -59,7 +59,9 @@ defmodule Cklist.MixProject do {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.5"} + {:bandit, "~> 1.5"}, + {:mua, "~> 0.2.4"}, + {:mail, "~> 0.5.1"} ] end diff --git a/mix.lock b/mix.lock index 16510ea..e25c6e6 100644 --- a/mix.lock +++ b/mix.lock @@ -19,8 +19,10 @@ "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mail": {:hex, :mail, "0.5.1", "6383a61620aea24675c96e34b9019dede1bfc9a37ee10ce5a5cafcc7c5e48743", [:mix], [], "hexpm", "595144340b74f23d651ea2b4a72a896819940478d7425dfa302ea3b5c9041ec9"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mua": {:hex, :mua, "0.2.4", "a9172ab0a1ac8732cf2699d739ceac3febcb9b4ffc540260ad2e32c0b6632af9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "e7e4dacd5ad65f13e3542772e74a159c00bd2d5579e729e9bb72d2c73a266fb7"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, From b4a3b7e9bc7fb8a56e9b48ab8f385592330493e3 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sun, 27 Jul 2025 21:41:34 +0200 Subject: [PATCH 13/31] Revert "switch to mua mail adapter" This reverts commit 690fe6078f02cbc44e10b943ebe235c7bf4a3277. --- config/runtime.exs | 18 ++++++++++++++---- mix.exs | 4 +--- mix.lock | 2 -- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index c75a505..694ea85 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -109,12 +109,22 @@ if config_env() == :prod do # domain: System.get_env("MAILGUN_DOMAIN") config :cklist, Cklist.Mailer, - adapter: Swoosh.Adapters.Mua, + adapter: Swoosh.Adapters.SMTP, relay: System.get_env("SMTP_SERVER"), port: System.get_env("SMTP_PORT"), - auth: [ - username: System.get_env("SMTP_USERNAME"), - password: System.get_env("SMTP_PASSWORD"), + username: System.get_env("SMTP_USERNAME"), + password: System.get_env("SMTP_PASSWORD"), + tls: :always, + ssl: false, + auth: :always, + no_mx_lookups: false, + retries: 2, + tls_options: [ + versions: [:"tlsv1.2", :"tlsv1.3"], + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + server_name_indication: ~c"#{System.get_env("SMTP_SERVER")}", + depth: 99 ] # For this example you need include a HTTP client required by Swoosh API client. diff --git a/mix.exs b/mix.exs index a58e341..dc0eaff 100644 --- a/mix.exs +++ b/mix.exs @@ -59,9 +59,7 @@ defmodule Cklist.MixProject do {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.5"}, - {:mua, "~> 0.2.4"}, - {:mail, "~> 0.5.1"} + {:bandit, "~> 1.5"} ] end diff --git a/mix.lock b/mix.lock index e25c6e6..16510ea 100644 --- a/mix.lock +++ b/mix.lock @@ -19,10 +19,8 @@ "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "mail": {:hex, :mail, "0.5.1", "6383a61620aea24675c96e34b9019dede1bfc9a37ee10ce5a5cafcc7c5e48743", [:mix], [], "hexpm", "595144340b74f23d651ea2b4a72a896819940478d7425dfa302ea3b5c9041ec9"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, - "mua": {:hex, :mua, "0.2.4", "a9172ab0a1ac8732cf2699d739ceac3febcb9b4ffc540260ad2e32c0b6632af9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "e7e4dacd5ad65f13e3542772e74a159c00bd2d5579e729e9bb72d2c73a266fb7"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, From fc3b56b078872d5e01768e87ed2bad04c5985091 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sun, 27 Jul 2025 22:02:11 +0200 Subject: [PATCH 14/31] Skip certificate validation --- config/runtime.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/runtime.exs b/config/runtime.exs index 694ea85..fa1aac4 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -121,7 +121,7 @@ if config_env() == :prod do retries: 2, tls_options: [ versions: [:"tlsv1.2", :"tlsv1.3"], - verify: :verify_peer, + verify: :verify_none, cacerts: :public_key.cacerts_get(), server_name_indication: ~c"#{System.get_env("SMTP_SERVER")}", depth: 99 From 1cdb6d7fa9a29e57e3b2d4003ea510f1fa573ffd Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sun, 27 Jul 2025 23:21:50 +0200 Subject: [PATCH 15/31] this seems to work locally --- config/runtime.exs | 2 +- mix.exs | 3 ++- mix.lock | 8 ++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index fa1aac4..694ea85 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -121,7 +121,7 @@ if config_env() == :prod do retries: 2, tls_options: [ versions: [:"tlsv1.2", :"tlsv1.3"], - verify: :verify_none, + verify: :verify_peer, cacerts: :public_key.cacerts_get(), server_name_indication: ~c"#{System.get_env("SMTP_SERVER")}", depth: 99 diff --git a/mix.exs b/mix.exs index dc0eaff..c46a3d0 100644 --- a/mix.exs +++ b/mix.exs @@ -59,7 +59,8 @@ defmodule Cklist.MixProject do {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.5"} + {:bandit, "~> 1.5"}, + {:hackney, "~> 1.25"} ] end diff --git a/mix.lock b/mix.lock index 16510ea..1cb3918 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,7 @@ "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, @@ -16,13 +17,18 @@ "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, @@ -35,12 +41,14 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "swoosh": {:hex, :swoosh, "1.19.1", "77e839b27fc7af0704788e5854934c77d4dea7b437270c924a717513d598b8a4", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eab57462d41a3330e82cb93a9d7640f5c79a85951f3457db25c1eb28fda193a6"}, "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, } From 6640d31c86bd0a062d4c669100f8d36f53d5c000 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Mon, 28 Jul 2025 00:03:11 +0200 Subject: [PATCH 16/31] try not validating --- config/runtime.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/runtime.exs b/config/runtime.exs index 694ea85..fa1aac4 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -121,7 +121,7 @@ if config_env() == :prod do retries: 2, tls_options: [ versions: [:"tlsv1.2", :"tlsv1.3"], - verify: :verify_peer, + verify: :verify_none, cacerts: :public_key.cacerts_get(), server_name_indication: ~c"#{System.get_env("SMTP_SERVER")}", depth: 99 From 0052cdd9f1145d2d62b0b10e116726c7a4e3f47c Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Mon, 28 Jul 2025 00:19:16 +0200 Subject: [PATCH 17/31] let's try the mua mailer that one also seems to work locally --- config/runtime.exs | 18 ++++-------------- mix.exs | 4 +++- mix.lock | 2 ++ 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index fa1aac4..c75a505 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -109,22 +109,12 @@ if config_env() == :prod do # domain: System.get_env("MAILGUN_DOMAIN") config :cklist, Cklist.Mailer, - adapter: Swoosh.Adapters.SMTP, + adapter: Swoosh.Adapters.Mua, relay: System.get_env("SMTP_SERVER"), port: System.get_env("SMTP_PORT"), - username: System.get_env("SMTP_USERNAME"), - password: System.get_env("SMTP_PASSWORD"), - tls: :always, - ssl: false, - auth: :always, - no_mx_lookups: false, - retries: 2, - tls_options: [ - versions: [:"tlsv1.2", :"tlsv1.3"], - verify: :verify_none, - cacerts: :public_key.cacerts_get(), - server_name_indication: ~c"#{System.get_env("SMTP_SERVER")}", - depth: 99 + auth: [ + username: System.get_env("SMTP_USERNAME"), + password: System.get_env("SMTP_PASSWORD"), ] # For this example you need include a HTTP client required by Swoosh API client. diff --git a/mix.exs b/mix.exs index c46a3d0..7e7f45a 100644 --- a/mix.exs +++ b/mix.exs @@ -60,7 +60,9 @@ defmodule Cklist.MixProject do {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, {:bandit, "~> 1.5"}, - {:hackney, "~> 1.25"} + {:hackney, "~> 1.25"}, + {:mua, "~> 0.2.0"}, + {:mail, "~> 0.3.0"}, ] end diff --git a/mix.lock b/mix.lock index 1cb3918..640317f 100644 --- a/mix.lock +++ b/mix.lock @@ -22,10 +22,12 @@ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mua": {:hex, :mua, "0.2.4", "a9172ab0a1ac8732cf2699d739ceac3febcb9b4ffc540260ad2e32c0b6632af9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "e7e4dacd5ad65f13e3542772e74a159c00bd2d5579e729e9bb72d2c73a266fb7"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, From 8ef54cc93b9699a526a9330cd180876f88793a1c Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Mon, 28 Jul 2025 00:25:08 +0200 Subject: [PATCH 18/31] port is an int --- config/runtime.exs | 4 ++-- mix.exs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index c75a505..e66b710 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -111,10 +111,10 @@ if config_env() == :prod do config :cklist, Cklist.Mailer, adapter: Swoosh.Adapters.Mua, relay: System.get_env("SMTP_SERVER"), - port: System.get_env("SMTP_PORT"), + port: String.to_integer(System.get_env("SMTP_PORT")), auth: [ username: System.get_env("SMTP_USERNAME"), - password: System.get_env("SMTP_PASSWORD"), + password: System.get_env("SMTP_PASSWORD") ] # For this example you need include a HTTP client required by Swoosh API client. diff --git a/mix.exs b/mix.exs index 7e7f45a..37bfef8 100644 --- a/mix.exs +++ b/mix.exs @@ -62,7 +62,7 @@ defmodule Cklist.MixProject do {:bandit, "~> 1.5"}, {:hackney, "~> 1.25"}, {:mua, "~> 0.2.0"}, - {:mail, "~> 0.3.0"}, + {:mail, "~> 0.3.0"} ] end From 38ca091c8d4dc739839fb866ac92a5063490518f Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Mon, 28 Jul 2025 00:31:57 +0200 Subject: [PATCH 19/31] add default port for smtp --- config/runtime.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/runtime.exs b/config/runtime.exs index e66b710..8110781 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -111,7 +111,7 @@ if config_env() == :prod do config :cklist, Cklist.Mailer, adapter: Swoosh.Adapters.Mua, relay: System.get_env("SMTP_SERVER"), - port: String.to_integer(System.get_env("SMTP_PORT")), + port: String.to_integer(System.get_env("SMTP_PORT") || "25"), auth: [ username: System.get_env("SMTP_USERNAME"), password: System.get_env("SMTP_PASSWORD") From 30441167dd9b2466ed36d02bfd6e08ebaa6dbd81 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Mon, 28 Jul 2025 00:53:43 +0200 Subject: [PATCH 20/31] one last test for today: send emails directly --- config/runtime.exs | 15 +++++++-------- lib/cklist/accounts/user_notifier.ex | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index 8110781..be32d28 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -108,14 +108,13 @@ if config_env() == :prod do # api_key: System.get_env("MAILGUN_API_KEY"), # domain: System.get_env("MAILGUN_DOMAIN") - config :cklist, Cklist.Mailer, - adapter: Swoosh.Adapters.Mua, - relay: System.get_env("SMTP_SERVER"), - port: String.to_integer(System.get_env("SMTP_PORT") || "25"), - auth: [ - username: System.get_env("SMTP_USERNAME"), - password: System.get_env("SMTP_PASSWORD") - ] + config :cklist, Cklist.Mailer, adapter: Swoosh.Adapters.Mua + # relay: System.get_env("SMTP_SERVER"), + # port: String.to_integer(System.get_env("SMTP_PORT") || "25"), + # auth: [ + # username: System.get_env("SMTP_USERNAME"), + # password: System.get_env("SMTP_PASSWORD") + # ] # For this example you need include a HTTP client required by Swoosh API client. # Swoosh supports Hackney and Finch out of the box: diff --git a/lib/cklist/accounts/user_notifier.ex b/lib/cklist/accounts/user_notifier.ex index 956f7a5..ae8c84c 100644 --- a/lib/cklist/accounts/user_notifier.ex +++ b/lib/cklist/accounts/user_notifier.ex @@ -8,7 +8,7 @@ defmodule Cklist.Accounts.UserNotifier do email = new() |> to(recipient) - |> from({"Cklist", "no-reply@cklist.org"}) + |> from({"Cklist", "no-reply@mail.cklist.org"}) |> subject(subject) |> text_body(body) From e7e7bd1de720a6e270a6494bc033a0d832a58f23 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Tue, 29 Jul 2025 23:15:35 +0200 Subject: [PATCH 21/31] Test sending via ssl (and without mx_lookup) --- config/runtime.exs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/config/runtime.exs b/config/runtime.exs index be32d28..c4d1304 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -108,7 +108,27 @@ if config_env() == :prod do # api_key: System.get_env("MAILGUN_API_KEY"), # domain: System.get_env("MAILGUN_DOMAIN") - config :cklist, Cklist.Mailer, adapter: Swoosh.Adapters.Mua + config :cklist, Cklist.Mailer, + adapter: Swoosh.Adapters.SMTP, + relay: System.get_env("SMTP_SERVER"), + port: String.to_integer(System.get_env("SMTP_PORT") || "25"), + username: System.get_env("SMTP_USERNAME"), + password: System.get_env("SMTP_PASSWORD"), + ssl: true, + tls: :never, + auth: :always, + retries: 2, + no_mx_lookups: true, + sockopts: [ + versions: [:"tlsv1.3"], + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + depth: 5, + customize_hostname_check: [ + match_fun: :public_key.pkix_verify_hostname_match_fun(:https) + ] + ] + # relay: System.get_env("SMTP_SERVER"), # port: String.to_integer(System.get_env("SMTP_PORT") || "25"), # auth: [ From 32802523310f9046e5934d33e03afa434e617bc9 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Wed, 30 Jul 2025 00:41:33 +0200 Subject: [PATCH 22/31] separate relay name and host name --- config/runtime.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/runtime.exs b/config/runtime.exs index c4d1304..c85f562 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -110,7 +110,8 @@ if config_env() == :prod do config :cklist, Cklist.Mailer, adapter: Swoosh.Adapters.SMTP, - relay: System.get_env("SMTP_SERVER"), + relay: System.get_env("SMTP_RELAY"), + hostname: System.get_env("SMTP_HOSTNAME"), port: String.to_integer(System.get_env("SMTP_PORT") || "25"), username: System.get_env("SMTP_USERNAME"), password: System.get_env("SMTP_PASSWORD"), From 4c1b6a4041d01d6bbf07aeae7a132b17596cd9c7 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Wed, 30 Jul 2025 01:17:41 +0200 Subject: [PATCH 23/31] clean up dependency and document mail adapter config --- config/runtime.exs | 25 +++++++++---------------- mix.exs | 6 ++---- mix.lock | 2 -- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index c85f562..de9d474 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -110,19 +110,19 @@ if config_env() == :prod do config :cklist, Cklist.Mailer, adapter: Swoosh.Adapters.SMTP, - relay: System.get_env("SMTP_RELAY"), - hostname: System.get_env("SMTP_HOSTNAME"), - port: String.to_integer(System.get_env("SMTP_PORT") || "25"), - username: System.get_env("SMTP_USERNAME"), - password: System.get_env("SMTP_PASSWORD"), + relay: System.get_env("SMTP_RELAY"), # relay name as in the certificate, e.g. mail.example.org + hostname: System.get_env("SMTP_HOSTNAME"), # hostname that ssl connects to + port: System.get_env("SMTP_PORT"), # port that ssl connects to + username: System.get_env("SMTP_USERNAME"), # mailuser name + password: System.get_env("SMTP_PASSWORD"), # mailuser password ssl: true, - tls: :never, + tls: :never, # Used for STARTTLS config. We use SSL/TLS, so we don't need this. auth: :always, retries: 2, - no_mx_lookups: true, + no_mx_lookups: true, # don't look up mx entries. We already specify everything correctly. sockopts: [ - versions: [:"tlsv1.3"], - verify: :verify_peer, + versions: [:"tlsv1.3"], # force new tls version + verify: :verify_peer, # verify ssl certificates cacerts: :public_key.cacerts_get(), depth: 5, customize_hostname_check: [ @@ -130,13 +130,6 @@ if config_env() == :prod do ] ] - # relay: System.get_env("SMTP_SERVER"), - # port: String.to_integer(System.get_env("SMTP_PORT") || "25"), - # auth: [ - # username: System.get_env("SMTP_USERNAME"), - # password: System.get_env("SMTP_PASSWORD") - # ] - # For this example you need include a HTTP client required by Swoosh API client. # Swoosh supports Hackney and Finch out of the box: # diff --git a/mix.exs b/mix.exs index 37bfef8..fbb491d 100644 --- a/mix.exs +++ b/mix.exs @@ -53,16 +53,14 @@ defmodule Cklist.MixProject do depth: 1}, {:swoosh, "~> 1.5"}, {:gen_smtp, "~> 1.3"}, + {:hackney, "~> 1.25"}, {:finch, "~> 0.13"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.5"}, - {:hackney, "~> 1.25"}, - {:mua, "~> 0.2.0"}, - {:mail, "~> 0.3.0"} + {:bandit, "~> 1.5"} ] end diff --git a/mix.lock b/mix.lock index 640317f..1cb3918 100644 --- a/mix.lock +++ b/mix.lock @@ -22,12 +22,10 @@ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, - "mua": {:hex, :mua, "0.2.4", "a9172ab0a1ac8732cf2699d739ceac3febcb9b4ffc540260ad2e32c0b6632af9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "e7e4dacd5ad65f13e3542772e74a159c00bd2d5579e729e9bb72d2c73a266fb7"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, From ab6c599fd7a541d059f0535d1b5e3018124bc9bf Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Wed, 30 Jul 2025 01:24:34 +0200 Subject: [PATCH 24/31] fix formatting --- config/runtime.exs | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index de9d474..9c05a33 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -110,19 +110,28 @@ if config_env() == :prod do config :cklist, Cklist.Mailer, adapter: Swoosh.Adapters.SMTP, - relay: System.get_env("SMTP_RELAY"), # relay name as in the certificate, e.g. mail.example.org - hostname: System.get_env("SMTP_HOSTNAME"), # hostname that ssl connects to - port: System.get_env("SMTP_PORT"), # port that ssl connects to - username: System.get_env("SMTP_USERNAME"), # mailuser name - password: System.get_env("SMTP_PASSWORD"), # mailuser password + # relay name as in the certificate, e.g. mail.example.org + relay: System.get_env("SMTP_RELAY"), + # hostname that ssl connects to + hostname: System.get_env("SMTP_HOSTNAME"), + # port that ssl connects to + port: System.get_env("SMTP_PORT"), + # mailuser name + username: System.get_env("SMTP_USERNAME"), + # mailuser password + password: System.get_env("SMTP_PASSWORD"), ssl: true, - tls: :never, # Used for STARTTLS config. We use SSL/TLS, so we don't need this. + # Used for STARTTLS config. We use SSL/TLS, so we don't need this. + tls: :never, auth: :always, retries: 2, - no_mx_lookups: true, # don't look up mx entries. We already specify everything correctly. + # don't look up mx entries. We already specify everything correctly. + no_mx_lookups: true, sockopts: [ - versions: [:"tlsv1.3"], # force new tls version - verify: :verify_peer, # verify ssl certificates + # force new tls version + versions: [:"tlsv1.3"], + # verify ssl certificates + verify: :verify_peer, cacerts: :public_key.cacerts_get(), depth: 5, customize_hostname_check: [ From 5fd6d4151398dd566c600b158af56a0238e5bbbe Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sun, 10 Aug 2025 18:06:15 +0200 Subject: [PATCH 25/31] minimize diff --- .github/workflows/release_deploy.yaml | 2 +- config/runtime.exs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release_deploy.yaml b/.github/workflows/release_deploy.yaml index fd4fe95..829f210 100644 --- a/.github/workflows/release_deploy.yaml +++ b/.github/workflows/release_deploy.yaml @@ -12,12 +12,12 @@ jobs: deploy: runs-on: ubuntu-24.04 env: + SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }} MIX_ENV: prod # Dummy DATABASE_URL used when building a release. Otherwise, runtime.exs # is complaining. But actually, we have no need to connect to the database # while building a release. DATABASE_URL: "ecto://USER:PASS@HOST/DATABASE" - SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }} steps: - uses: actions/checkout@v5 diff --git a/config/runtime.exs b/config/runtime.exs index 9c05a33..7f355bb 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -107,6 +107,7 @@ if config_env() == :prod do # adapter: Swoosh.Adapters.Mailgun, # api_key: System.get_env("MAILGUN_API_KEY"), # domain: System.get_env("MAILGUN_DOMAIN") + # config :cklist, Cklist.Mailer, adapter: Swoosh.Adapters.SMTP, From dcda0e99cc8e5bfded31c7fb5f14a624e596c12a Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sat, 16 Aug 2025 12:26:07 +0200 Subject: [PATCH 26/31] Simple tests for about page --- .../controllers/page_controller_test.exs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/cklist_web/controllers/page_controller_test.exs b/test/cklist_web/controllers/page_controller_test.exs index 3b05657..280dd3b 100644 --- a/test/cklist_web/controllers/page_controller_test.exs +++ b/test/cklist_web/controllers/page_controller_test.exs @@ -5,4 +5,21 @@ defmodule CklistWeb.PageControllerTest do conn = get(conn, ~p"/") assert html_response(conn, 200) =~ "Check that list" end + + describe "about.html" 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)}" + end + + test "contains phx version", %{conn: conn} do + conn = get(conn, ~p"/about") + assert html_response(conn, 200) =~ "phoenix #{Application.spec(:phoenix, :vsn)}" + end + + test "renders faq", %{conn: conn} do + conn = get(conn, ~p"/about") + assert html_response(conn, 200) =~ "FAQ" + end + end end From 60e5ab1dcc7faa0dd52de0cb71124bc775e37cc0 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sat, 16 Aug 2025 13:03:13 +0200 Subject: [PATCH 27/31] Simple privacy policy --- .../components/layouts/app.html.heex | 1 + lib/cklist_web/controllers/page_controller.ex | 4 ++ .../controllers/page_html/about.html.heex | 4 +- .../controllers/page_html/home.html.heex | 1 + .../controllers/page_html/privacy.html.heex | 39 +++++++++++++++++++ lib/cklist_web/router.ex | 1 + .../controllers/page_controller_test.exs | 7 +++- 7 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 lib/cklist_web/controllers/page_html/privacy.html.heex diff --git a/lib/cklist_web/components/layouts/app.html.heex b/lib/cklist_web/components/layouts/app.html.heex index 5514d3f..a690eba 100644 --- a/lib/cklist_web/components/layouts/app.html.heex +++ b/lib/cklist_web/components/layouts/app.html.heex @@ -36,5 +36,6 @@ 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 @@

<.link href={~p"/about"}>About

<.link href="https://github.com/romanc/cklist/">GitHub

+

<.link href={~p"/privacy"}>Privacy Policy

<%= if @current_user do %>
diff --git a/lib/cklist_web/controllers/page_html/privacy.html.heex b/lib/cklist_web/controllers/page_html/privacy.html.heex new file mode 100644 index 0000000..8b0b514 --- /dev/null +++ b/lib/cklist_web/controllers/page_html/privacy.html.heex @@ -0,0 +1,39 @@ +<.header class="mb-12">Privacy Policy + +

+ 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: +

+ + + +<.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: +

+ + 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. +

+ <.simple_form + for={@delete_form} + id="delete_form" + action={~p"/users/log_out?_action=user_deleted"} + method="delete" + phx-submit="delete_user" + phx-trigger-action={@trigger_submit_delete} + > + <.input + field={@delete_form[:current_password]} + name="current_password" + id="current_password_for_delete" + type="password" + label="Current password" + value={@delete_form_current_password} + required + /> + <:actions> + <.button phx-disable-with="Deleting...">Delete Account + + +
""" end @@ -90,15 +117,19 @@ defmodule CklistWeb.UserSettingsLive do user = socket.assigns.current_user email_changeset = Accounts.change_user_email(user) password_changeset = Accounts.change_user_password(user) + delete_changeset = Accounts.change_user_delete(user) socket = socket |> assign(:current_password, nil) |> assign(:email_form_current_password, nil) + |> assign(:delete_form_current_password, nil) |> assign(:current_email, user.email) |> assign(:email_form, to_form(email_changeset)) |> assign(:password_form, to_form(password_changeset)) + |> assign(:delete_form, to_form(delete_changeset)) |> assign(:trigger_submit, false) + |> assign(:trigger_submit_delete, false) {:ok, socket} end @@ -164,4 +195,22 @@ defmodule CklistWeb.UserSettingsLive do {:noreply, assign(socket, password_form: to_form(changeset))} end end + + def handle_event("delete_user", params, socket) do + %{"current_password" => password} = params + user = socket.assigns.current_user + + case Accounts.delete_user(user, password) do + :ok -> + {:noreply, assign(socket, trigger_submit_delete: true)} + + {:error, changeset} -> + socket = + socket + |> assign(:delete_form, to_form(Map.put(changeset, :action, :insert))) + |> assign(:delete_form_current_password, password) + + {:noreply, socket} + end + end end From 1f82591dd7722fa254b5a867fa3c631bf02fa971 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Fri, 26 Sep 2025 21:23:43 +0200 Subject: [PATCH 29/31] WIP: automatic tests for user account deletion --- test/cklist/accounts_test.exs | 28 +++++++++++++++ .../user_session_controller_test.exs | 5 +++ .../live/user_settings_live_test.exs | 36 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/test/cklist/accounts_test.exs b/test/cklist/accounts_test.exs index 95b8ca4..2821bfc 100644 --- a/test/cklist/accounts_test.exs +++ b/test/cklist/accounts_test.exs @@ -505,4 +505,32 @@ defmodule Cklist.AccountsTest do refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" end end + + describe "change_user_delete/2" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = Accounts.change_user_delete(%User{}) + end + end + + describe "delete_user/2" do + test "requires valid password" do + user = user_fixture() + {:error, changeset} = Accounts.delete_user(user, "invalid_password") + assert %{current_password: ["is not valid"]} = errors_on(changeset) + end + + test "deletes user from database" do + %{id: id} = user = user_fixture() + # assert user is in the DB before attempting to delete + assert %User{id: ^id} = Accounts.get_user!(user.id) + + # call to delete user from DB + assert :ok = Accounts.delete_user(user, valid_user_password()) + + # asserts user is not in the DB anymore + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!(user.id) + end + end + end end diff --git a/test/cklist_web/controllers/user_session_controller_test.exs b/test/cklist_web/controllers/user_session_controller_test.exs index dccfaff..d462f79 100644 --- a/test/cklist_web/controllers/user_session_controller_test.exs +++ b/test/cklist_web/controllers/user_session_controller_test.exs @@ -108,5 +108,10 @@ defmodule CklistWeb.UserSessionControllerTest do refute get_session(conn, :user_token) assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" end + + test "has a different flash info on account deletion", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log_out", %{_action: "user_deleted"}) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User successfully deleted." + end end end diff --git a/test/cklist_web/live/user_settings_live_test.exs b/test/cklist_web/live/user_settings_live_test.exs index dd75961..f640d4a 100644 --- a/test/cklist_web/live/user_settings_live_test.exs +++ b/test/cklist_web/live/user_settings_live_test.exs @@ -14,6 +14,8 @@ defmodule CklistWeb.UserSettingsLiveTest do assert html =~ "Change Email" assert html =~ "Change Password" + assert html =~ "Danger zone" + assert html =~ "Delete Account" end test "redirects if user is not logged in", %{conn: conn} do @@ -207,4 +209,38 @@ defmodule CklistWeb.UserSettingsLiveTest do assert message == "You must log in to access this page." end end + + describe "delete user form" do + setup %{conn: conn} do + password = valid_user_password() + user = user_fixture(%{password: password}) + %{conn: log_in_user(conn, user), user: user, password: password} + end + + test "deletes user and redirects to home", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + form = + form(lv, "#delete_form", %{ + "current_password" => valid_user_password() + }) + + result = render_submit(form) + + inspect(result) + + user_delete_conn = follow_trigger_action(form, conn) + + assert redirected_to(user_delete_conn) == ~p"/" + + refute get_session(user_delete_conn, :user_token) + + assert Phoenix.Flash.get(user_delete_conn.assigns.flash, :info) =~ + "User deleted successfully" + + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!(user.id) + end + end + end end From 8d3b48c44d717860470583c8980f1abfc6f6671f Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sat, 27 Sep 2025 00:03:12 +0200 Subject: [PATCH 30/31] fix form to make test pass --- lib/cklist_web/live/user_settings_live.ex | 2 +- lib/cklist_web/router.ex | 1 + .../live/user_settings_live_test.exs | 19 ++++++++++--------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/lib/cklist_web/live/user_settings_live.ex b/lib/cklist_web/live/user_settings_live.ex index c6ce2eb..04de0cc 100644 --- a/lib/cklist_web/live/user_settings_live.ex +++ b/lib/cklist_web/live/user_settings_live.ex @@ -78,7 +78,7 @@ defmodule CklistWeb.UserSettingsLive do for={@delete_form} id="delete_form" action={~p"/users/log_out?_action=user_deleted"} - method="delete" + method="post" phx-submit="delete_user" phx-trigger-action={@trigger_submit_delete} > diff --git a/lib/cklist_web/router.ex b/lib/cklist_web/router.ex index dbe81f6..e82d447 100644 --- a/lib/cklist_web/router.ex +++ b/lib/cklist_web/router.ex @@ -76,6 +76,7 @@ defmodule CklistWeb.Router do scope "/", CklistWeb do pipe_through [:browser] + post "/users/log_out", UserSessionController, :delete delete "/users/log_out", UserSessionController, :delete live_session :current_user, diff --git a/test/cklist_web/live/user_settings_live_test.exs b/test/cklist_web/live/user_settings_live_test.exs index f640d4a..d17ce97 100644 --- a/test/cklist_web/live/user_settings_live_test.exs +++ b/test/cklist_web/live/user_settings_live_test.exs @@ -221,22 +221,23 @@ defmodule CklistWeb.UserSettingsLiveTest do {:ok, lv, _html} = live(conn, ~p"/users/settings") form = - form(lv, "#delete_form", %{ - "current_password" => valid_user_password() - }) - - result = render_submit(form) + form(lv, "#delete_form", %{"current_password" => valid_user_password()}) - inspect(result) + rendered = render_submit(form) + assert rendered =~ ~r/phx-trigger-action/ user_delete_conn = follow_trigger_action(form, conn) + # NOTE: html forms can only have "GET" and "POST" as methods. It is tempting + # to use method="delete" but this just doesn't exist in the standard. + assert user_delete_conn.method == "POST" + # make sure we get redirected home, ... assert redirected_to(user_delete_conn) == ~p"/" - + # ... have no session anymore, refute get_session(user_delete_conn, :user_token) - + # ... and show the expected flash card assert Phoenix.Flash.get(user_delete_conn.assigns.flash, :info) =~ - "User deleted successfully" + "User successfully deleted" assert_raise Ecto.NoResultsError, fn -> Accounts.get_user!(user.id) From f0f569159bbedd33e74e7175cab6b646b0af1134 Mon Sep 17 00:00:00 2001 From: Roman Cattaneo <> Date: Sat, 27 Sep 2025 00:19:48 +0200 Subject: [PATCH 31/31] fix typo in faq --- lib/cklist_web/controllers/page_html/about.html.heex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cklist_web/controllers/page_html/about.html.heex b/lib/cklist_web/controllers/page_html/about.html.heex index 5bb160b..e4da337 100644 --- a/lib/cklist_web/controllers/page_html/about.html.heex +++ b/lib/cklist_web/controllers/page_html/about.html.heex @@ -12,7 +12,7 @@ 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." +is not yet functional. We are building cklists in the open. This is a pre-release. Until we reach version 1.0, it won't be feature complete." }, %{ question: "Can I sing-up now?",