diff --git a/lib/slax/accounts.ex b/lib/slax/accounts.ex
new file mode 100644
index 0000000..013597b
--- /dev/null
+++ b/lib/slax/accounts.ex
@@ -0,0 +1,353 @@
+defmodule Slax.Accounts do
+ @moduledoc """
+ The Accounts context.
+ """
+
+ import Ecto.Query, warn: false
+ alias Slax.Repo
+
+ alias Slax.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/slax/accounts/user.ex b/lib/slax/accounts/user.ex
new file mode 100644
index 0000000..e3c259f
--- /dev/null
+++ b/lib/slax/accounts/user.ex
@@ -0,0 +1,161 @@
+defmodule Slax.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, Slax.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?(%Slax.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/slax/accounts/user_notifier.ex b/lib/slax/accounts/user_notifier.ex
new file mode 100644
index 0000000..950d60d
--- /dev/null
+++ b/lib/slax/accounts/user_notifier.ex
@@ -0,0 +1,79 @@
+defmodule Slax.Accounts.UserNotifier do
+ import Swoosh.Email
+
+ alias Slax.Mailer
+
+ # Delivers the email using the application mailer.
+ defp deliver(recipient, subject, body) do
+ email =
+ new()
+ |> to(recipient)
+ |> from({"Slax", "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/slax/accounts/user_token.ex b/lib/slax/accounts/user_token.ex
new file mode 100644
index 0000000..a6b1524
--- /dev/null
+++ b/lib/slax/accounts/user_token.ex
@@ -0,0 +1,179 @@
+defmodule Slax.Accounts.UserToken do
+ use Ecto.Schema
+ import Ecto.Query
+ alias Slax.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, Slax.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/slax_web/controllers/user_session_controller.ex b/lib/slax_web/controllers/user_session_controller.ex
new file mode 100644
index 0000000..37bf65c
--- /dev/null
+++ b/lib/slax_web/controllers/user_session_controller.ex
@@ -0,0 +1,42 @@
+defmodule SlaxWeb.UserSessionController do
+ use SlaxWeb, :controller
+
+ alias Slax.Accounts
+ alias SlaxWeb.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/slax_web/live/chat_room_live.ex b/lib/slax_web/live/chat_room_live.ex
index 43146dc..5e42d14 100644
--- a/lib/slax_web/live/chat_room_live.ex
+++ b/lib/slax_web/live/chat_room_live.ex
@@ -43,6 +43,47 @@ defmodule SlaxWeb.ChatRoomLive do
<% end %>
+
"""
diff --git a/lib/slax_web/live/user_confirmation_instructions_live.ex b/lib/slax_web/live/user_confirmation_instructions_live.ex
new file mode 100644
index 0000000..85bd2e3
--- /dev/null
+++ b/lib/slax_web/live/user_confirmation_instructions_live.ex
@@ -0,0 +1,51 @@
+defmodule SlaxWeb.UserConfirmationInstructionsLive do
+ use SlaxWeb, :live_view
+
+ alias Slax.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/slax_web/live/user_confirmation_live.ex b/lib/slax_web/live/user_confirmation_live.ex
new file mode 100644
index 0000000..2a3ece9
--- /dev/null
+++ b/lib/slax_web/live/user_confirmation_live.ex
@@ -0,0 +1,58 @@
+defmodule SlaxWeb.UserConfirmationLive do
+ use SlaxWeb, :live_view
+
+ alias Slax.Accounts
+
+ def render(%{live_action: :edit} = assigns) do
+ ~H"""
+
+ <.link href={~p"/users/register"}>Register
+ | <.link href={~p"/users/log_in"}>Log in
+
+
+ """
+ end
+
+ def mount(%{"token" => token}, _session, socket) do
+ form = to_form(%{"token" => token}, as: "user")
+ {:ok, assign(socket, form: form), temporary_assigns: [form: nil]}
+ end
+
+ # Do not log in the user after confirmation to avoid a
+ # leaked token giving the user access to the account.
+ def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do
+ case Accounts.confirm_user(token) do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "User confirmed successfully.")
+ |> redirect(to: ~p"/")}
+
+ :error ->
+ # If there is a current user and the account was already confirmed,
+ # then odds are that the confirmation link was already visited, either
+ # by some automation or by the user themselves, so we redirect without
+ # a warning message.
+ case socket.assigns do
+ %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
+ {:noreply, redirect(socket, to: ~p"/")}
+
+ %{} ->
+ {:noreply,
+ socket
+ |> put_flash(:error, "User confirmation link is invalid or it has expired.")
+ |> redirect(to: ~p"/")}
+ end
+ end
+ end
+end
diff --git a/lib/slax_web/live/user_forgot_password_live.ex b/lib/slax_web/live/user_forgot_password_live.ex
new file mode 100644
index 0000000..a6ddaba
--- /dev/null
+++ b/lib/slax_web/live/user_forgot_password_live.ex
@@ -0,0 +1,50 @@
+defmodule SlaxWeb.UserForgotPasswordLive do
+ use SlaxWeb, :live_view
+
+ alias Slax.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/slax_web/live/user_login_live.ex b/lib/slax_web/live/user_login_live.ex
new file mode 100644
index 0000000..7122a2d
--- /dev/null
+++ b/lib/slax_web/live/user_login_live.ex
@@ -0,0 +1,43 @@
+defmodule SlaxWeb.UserLoginLive do
+ use SlaxWeb, :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/slax_web/live/user_registration_live.ex b/lib/slax_web/live/user_registration_live.ex
new file mode 100644
index 0000000..2fc93b2
--- /dev/null
+++ b/lib/slax_web/live/user_registration_live.ex
@@ -0,0 +1,87 @@
+defmodule SlaxWeb.UserRegistrationLive do
+ use SlaxWeb, :live_view
+
+ alias Slax.Accounts
+ alias Slax.Accounts.User
+
+ def render(assigns) do
+ ~H"""
+