diff --git a/app/config/config.exs b/app/config/config.exs
index 81ba333..4fd0283 100644
--- a/app/config/config.exs
+++ b/app/config/config.exs
@@ -41,7 +41,7 @@ config :esbuild,
# Configure tailwind (the version is required)
config :tailwind,
- version: "3.2.7",
+ version: "3.3.2",
default: [
args: ~w(
--config=tailwind.config.js
diff --git a/app/config/prod.exs b/app/config/prod.exs
index 4fa69d9..5593bd0 100644
--- a/app/config/prod.exs
+++ b/app/config/prod.exs
@@ -12,6 +12,9 @@ config :prymn, PrymnWeb.Endpoint,
# Configures Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Prymn.Finch
+# Disable Swoosh Local Memory Storage
+config :swoosh, local: false
+
# Do not print debug messages in production
config :logger, level: :info
diff --git a/app/config/test.exs b/app/config/test.exs
index 6749396..2529f14 100644
--- a/app/config/test.exs
+++ b/app/config/test.exs
@@ -1,5 +1,8 @@
import Config
+# Only in tests, remove the complexity from the password hashing algorithm
+config :argon2_elixir, t_cost: 1, m_cost: 8
+
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
diff --git a/app/lib/prymn/accounts.ex b/app/lib/prymn/accounts.ex
new file mode 100644
index 0000000..edbc37c
--- /dev/null
+++ b/app/lib/prymn/accounts.ex
@@ -0,0 +1,356 @@
+defmodule Prymn.Accounts do
+ @moduledoc """
+ The Accounts context.
+ """
+
+ import Ecto.Query, warn: false
+ alias Prymn.Repo
+
+ alias Prymn.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
+
+ """
+ @spec get_user_by_email(String.t()) :: User.t() | 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
+
+ """
+ @spec get_user_by_email_and_password(String.t(), String.t()) :: User.t() | 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)
+
+ """
+ @spec get_user!(integer()) :: User.t()
+ 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.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.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.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.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.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/app/lib/prymn/accounts/user.ex b/app/lib/prymn/accounts/user.ex
new file mode 100644
index 0000000..bb13972
--- /dev/null
+++ b/app/lib/prymn/accounts/user.ex
@@ -0,0 +1,163 @@
+defmodule Prymn.Accounts.User do
+ import Ecto.Changeset
+
+ use Ecto.Schema
+
+ @type t :: %__MODULE__{
+ id: term,
+ email: String.t(),
+ password: String.t(),
+ hashed_password: String.t(),
+ confirmed_at: NaiveDateTime.t()
+ }
+
+ schema "users" do
+ field :email, :string
+ field :password, :string, virtual: true, redact: true
+ field :hashed_password, :string, redact: true
+ field :confirmed_at, :naive_datetime
+
+ timestamps()
+ 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: "Email 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)
+ |> 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
+ # 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, Argon2.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, Prymn.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, "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: "Passwords do not match")
+ |> validate_password(opts)
+ end
+
+ @doc """
+ Confirms the account by setting `confirmed_at`.
+ """
+ def confirm_changeset(user) do
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.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
+ `Argon2.no_user_verify/0` to avoid timing attacks.
+ """
+ def valid_password?(%Prymn.Accounts.User{hashed_password: hashed_password}, password)
+ when is_binary(hashed_password) and byte_size(password) > 0 do
+ Argon2.verify_pass(password, hashed_password)
+ end
+
+ def valid_password?(_, _) do
+ Argon2.no_user_verify()
+ false
+ end
+
+ @doc """
+ Validates the current password otherwise adds an error to the changeset.
+ """
+ def validate_current_password(changeset, password) do
+ if valid_password?(changeset.data, password) do
+ changeset
+ else
+ add_error(changeset, :current_password, "Password is not valid")
+ end
+ end
+end
diff --git a/app/lib/prymn/accounts/user_notifier.ex b/app/lib/prymn/accounts/user_notifier.ex
new file mode 100644
index 0000000..4651b0f
--- /dev/null
+++ b/app/lib/prymn/accounts/user_notifier.ex
@@ -0,0 +1,79 @@
+defmodule Prymn.Accounts.UserNotifier do
+ import Swoosh.Email
+
+ alias Prymn.Mailer
+
+ # Delivers the email using the application mailer.
+ defp deliver(recipient, subject, body) do
+ email =
+ new()
+ |> to(recipient)
+ |> from({"Prymn", "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/app/lib/prymn/accounts/user_token.ex b/app/lib/prymn/accounts/user_token.ex
new file mode 100644
index 0000000..f2e8c2f
--- /dev/null
+++ b/app/lib/prymn/accounts/user_token.ex
@@ -0,0 +1,179 @@
+defmodule Prymn.Accounts.UserToken do
+ use Ecto.Schema
+ import Ecto.Query
+ alias Prymn.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, Prymn.Accounts.User
+
+ timestamps(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 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 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 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 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 user_and_contexts_query(user, :all) do
+ from t in UserToken, where: t.user_id == ^user.id
+ end
+
+ def 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/app/lib/prymn_web/components/layouts/app.html.heex b/app/lib/prymn_web/components/layouts/app.html.heex
index e23bfc8..025e311 100644
--- a/app/lib/prymn_web/components/layouts/app.html.heex
+++ b/app/lib/prymn_web/components/layouts/app.html.heex
@@ -21,6 +21,12 @@
>
Get Started →
+ <.link href={~p"/users/settings"} class="hover:text-zinc-700">
+ <%= @current_user.email %>
+
+ <.link href={~p"/auth/log_out"} method="delete" class="hover:text-zinc-700">
+ Log out
+
diff --git a/app/lib/prymn_web/components/layouts/auth.html.heex b/app/lib/prymn_web/components/layouts/auth.html.heex
new file mode 100644
index 0000000..01bee1f
--- /dev/null
+++ b/app/lib/prymn_web/components/layouts/auth.html.heex
@@ -0,0 +1,6 @@
+
+ <.flash_group flash={@flash} />
+
+ <%= @inner_content %>
+
+
diff --git a/app/lib/prymn_web/controllers/page_controller.ex b/app/lib/prymn_web/controllers/page_controller.ex
index da40c30..49200bd 100644
--- a/app/lib/prymn_web/controllers/page_controller.ex
+++ b/app/lib/prymn_web/controllers/page_controller.ex
@@ -2,9 +2,7 @@ defmodule PrymnWeb.PageController do
use PrymnWeb, :controller
def home(conn, _params) do
- # The home page is often custom made,
- # so skip the default app layout.
- render(conn, :home, layout: false)
+ render(conn, :home)
end
def install(conn, _params) do
diff --git a/app/lib/prymn_web/controllers/page_html/home.html.heex b/app/lib/prymn_web/controllers/page_html/home.html.heex
index b541c29..2a2a822 100644
--- a/app/lib/prymn_web/controllers/page_html/home.html.heex
+++ b/app/lib/prymn_web/controllers/page_html/home.html.heex
@@ -1,43 +1,3 @@
-<.flash_group flash={@flash} />
-
diff --git a/app/lib/prymn_web/controllers/user_session_controller.ex b/app/lib/prymn_web/controllers/user_session_controller.ex
new file mode 100644
index 0000000..5e26c13
--- /dev/null
+++ b/app/lib/prymn_web/controllers/user_session_controller.ex
@@ -0,0 +1,40 @@
+defmodule PrymnWeb.UserSessionController do
+ use PrymnWeb, :controller
+
+ alias Prymn.Accounts
+ alias PrymnWeb.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
+ conn
+ |> put_flash("login_error", "Invalid email or password")
+ |> redirect(to: ~p"/auth/log_in?#{[email: String.slice(email, 0, 160)]}")
+ end
+ end
+
+ def delete(conn, _params) do
+ conn
+ |> put_flash(:info, "Logged out successfully.")
+ |> UserAuth.log_out_user()
+ end
+end
diff --git a/app/lib/prymn_web/live/server_live/new_server.ex b/app/lib/prymn_web/live/server_live/new_server.ex
index 5e8949b..3cf9d7b 100644
--- a/app/lib/prymn_web/live/server_live/new_server.ex
+++ b/app/lib/prymn_web/live/server_live/new_server.ex
@@ -15,7 +15,13 @@ defmodule PrymnWeb.ServerLive.NewServer do
<.error :if={assigns[:error]}>There were some errors.
- <.simple_form for={@form} phx-change="validate" phx-submit="connect" phx-target={@myself}>
+ <.simple_form
+ id="server-form"
+ for={@form}
+ phx-change="validate"
+ phx-submit="connect"
+ phx-target={@myself}
+ >
<.input field={@form[:name]} label="Server Name" phx-debounce={1000} />
<.input
field={@form[:provider]}
diff --git a/app/lib/prymn_web/live/user_confirmation_live.ex b/app/lib/prymn_web/live/user_confirmation_live.ex
new file mode 100644
index 0000000..88a580e
--- /dev/null
+++ b/app/lib/prymn_web/live/user_confirmation_live.ex
@@ -0,0 +1,97 @@
+defmodule PrymnWeb.UserConfirmationLive do
+ use PrymnWeb, :live_view
+
+ alias Prymn.Accounts
+
+ @impl true
+ def render(%{live_action: :edit} = assigns) do
+ ~H"""
+
+ <.header class="text-center">Email Confirm
+
+ <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
+ <.input field={@form[:token]} type="hidden" />
+ <:actions>
+ <.button phx-disable-with="Confirming..." class="w-full">Confirm my email
+
+
+
+ """
+ end
+
+ @impl true
+ def render(%{live_action: :new} = assigns) do
+ ~H"""
+
+ <.header class="text-center">
+ No confirmation instructions received?
+ <:subtitle>We'll send a new email confirmation link to your inbox.
+
+
+ <.simple_form for={@form} id="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"/auth/register"} class="px-3">Register
+ <.link href={~p"/auth/log_in"} class="px-3">Log in
+
+
+ """
+ end
+
+ @impl true
+ def mount(params, _session, socket) do
+ {:ok, assign(socket, form: to_form(params, as: "user")), temporary_assigns: [form: nil]}
+ end
+
+ @impl true
+ def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do
+ case Accounts.confirm_user(token) do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> put_flash(:info, "Email 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, "Email confirmation link is invalid or it has expired.")
+ |> redirect(to: ~p"/")}
+ end
+ end
+ end
+
+ @impl true
+ 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"/auth/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/app/lib/prymn_web/live/user_forgot_password_live.ex b/app/lib/prymn_web/live/user_forgot_password_live.ex
new file mode 100644
index 0000000..9d0d575
--- /dev/null
+++ b/app/lib/prymn_web/live/user_forgot_password_live.ex
@@ -0,0 +1,57 @@
+defmodule PrymnWeb.UserForgotPasswordLive do
+ use PrymnWeb, :live_view
+
+ alias Prymn.Accounts
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header class="text-center">
+ Forgot your password?
+ <:subtitle>
+ Enter your email that your account was registered with and we will
+ 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"/auth/register"} class="px-3">Register
+ <.link href={~p"/auth/log_in"} class="px-3">Log in
+
+
+ """
+ end
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, form: to_form(%{}, as: "user"))}
+ end
+
+ @impl true
+ 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"/auth/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/app/lib/prymn_web/live/user_login_live.ex b/app/lib/prymn_web/live/user_login_live.ex
new file mode 100644
index 0000000..26a3d93
--- /dev/null
+++ b/app/lib/prymn_web/live/user_login_live.ex
@@ -0,0 +1,54 @@
+defmodule PrymnWeb.UserLoginLive do
+ use PrymnWeb, :live_view
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header class="text-center">
+ Sign in to account
+ <:subtitle>
+ Don't have an account?
+ <.link navigate={~p"/auth/register"} class="text-brand font-semibold hover:underline">
+ Sign up
+
+ for an account now.
+
+
+
+ <.simple_form for={@form} id="login_form" action={~p"/auth/log_in"} phx-update="ignore">
+ <.error :if={@error}>
+ Could not sign in: <%= @error %>
+
+
+ <.input
+ field={@form[:email]}
+ type="email"
+ label="Email"
+ placeholder="hello@example.com"
+ 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"/auth/reset_password"} class="text-sm font-semibold">
+ Forgot your password?
+
+
+ <:actions>
+ <.button phx-disable-with="Signing in..." class="w-full">Sign in
+
+
+
+ """
+ end
+
+ @impl true
+ def mount(params, _session, socket) do
+ form = to_form(%{"email" => params["email"] || nil}, as: "user")
+ error = Phoenix.Flash.get(socket.assigns.flash, "login_error")
+
+ {:ok, assign(socket, form: form, error: error), temporary_assigns: [form: form]}
+ end
+end
diff --git a/app/lib/prymn_web/live/user_registration_live.ex b/app/lib/prymn_web/live/user_registration_live.ex
new file mode 100644
index 0000000..f7c569e
--- /dev/null
+++ b/app/lib/prymn_web/live/user_registration_live.ex
@@ -0,0 +1,85 @@
+defmodule PrymnWeb.UserRegistrationLive do
+ use PrymnWeb, :live_view
+
+ alias Prymn.Accounts
+ alias Prymn.Accounts.User
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.header class="text-center">
+ Register for an account
+ <:subtitle>
+ Already registered?
+ <.link navigate={~p"/auth/log_in"} class="text-brand font-semibold hover:underline">
+ Sign 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"/auth/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" phx-debounce required />
+ <.input field={@form[:password]} type="password" label="Password" phx-debounce required />
+
+ <:actions>
+ <.button phx-disable-with="Creating account..." class="w-full">Create an account
+
+
+
+ """
+ end
+
+ @impl true
+ 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
+
+ @impl true
+ 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"/auth/confirm/#{&1}")
+ )
+
+ changeset = Accounts.change_user_registration(user)
+ {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, socket |> assign_form(changeset) |> assign(check_errors: true)}
+ end
+ end
+
+ @impl true
+ 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
+ assign(socket, form: to_form(changeset, as: "user"))
+ end
+end
diff --git a/app/lib/prymn_web/live/user_reset_password_live.ex b/app/lib/prymn_web/live/user_reset_password_live.ex
new file mode 100644
index 0000000..efb971e
--- /dev/null
+++ b/app/lib/prymn_web/live/user_reset_password_live.ex
@@ -0,0 +1,85 @@
+defmodule PrymnWeb.UserResetPasswordLive do
+ use PrymnWeb, :live_view
+
+ alias Prymn.Accounts
+
+ @impl true
+ 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={@check_errors}>
+ Oops, something went wrong! Please check the errors below.
+
+
+ <.input field={@form[:password]} type="password" label="New password" phx-debounce required />
+ <.input
+ field={@form[:password_confirmation]}
+ type="password"
+ label="Confirm new password"
+ phx-debounce
+ required
+ />
+ <:actions>
+ <.button phx-disable-with="Resetting..." class="w-full">Reset Password
+
+
+
+
+ <.link href={~p"/auth/register"} class="px-3">Register
+ <.link href={~p"/auth/log_in"} class="px-3">Log in
+
+
+ """
+ end
+
+ @impl true
+ def mount(%{"token" => token}, _session, socket) do
+ if user = Accounts.get_user_by_reset_password_token(token) do
+ changeset = Accounts.change_user_password(user)
+
+ socket =
+ socket
+ |> assign(user: user, check_errors: false)
+ |> assign_form(changeset)
+
+ {:ok, socket, temporary_assigns: [form: nil]}
+ else
+ {:ok,
+ socket
+ |> put_flash(:error, "Reset password link is invalid or it has expired.")
+ |> redirect(to: ~p"/")}
+ end
+ end
+
+ @impl true
+ 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. Please sign in.")
+ |> redirect(to: ~p"/auth/log_in")}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, socket |> assign_form(changeset) |> assign(check_errors: true)}
+ end
+ end
+
+ @impl true
+ 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_form(socket, %Ecto.Changeset{} = changeset) do
+ assign(socket, form: to_form(changeset, as: "user"))
+ end
+end
diff --git a/app/lib/prymn_web/live/user_settings_live.ex b/app/lib/prymn_web/live/user_settings_live.ex
new file mode 100644
index 0000000..1906e89
--- /dev/null
+++ b/app/lib/prymn_web/live/user_settings_live.ex
@@ -0,0 +1,174 @@
+defmodule PrymnWeb.UserSettingsLive do
+ alias Prymn.Accounts
+
+ use PrymnWeb, :live_view
+
+ @impl true
+ 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"/auth/log_in?_action=password_updated"}
+ method="post"
+ phx-change="validate_password"
+ phx-submit="update_password"
+ phx-trigger-action={@trigger_submit}
+ >
+ <.input
+ field={@password_form[:email]}
+ type="hidden"
+ id="hidden_user_email"
+ value={@current_email}
+ />
+ <.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
+
+ @impl true
+ 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
+
+ @impl true
+ 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
+
+ @impl true
+ 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
+
+ @impl true
+ 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
+
+ @impl true
+ 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
+
+ @impl true
+ 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/app/lib/prymn_web/router.ex b/app/lib/prymn_web/router.ex
index 6fc2565..518acb9 100644
--- a/app/lib/prymn_web/router.ex
+++ b/app/lib/prymn_web/router.ex
@@ -1,4 +1,6 @@
defmodule PrymnWeb.Router do
+ import PrymnWeb.UserAuth
+
use PrymnWeb, :router
pipeline :browser do
@@ -8,6 +10,7 @@ defmodule PrymnWeb.Router do
plug :put_root_layout, html: {PrymnWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
+ plug :fetch_current_user
end
pipeline :api do
@@ -15,15 +18,53 @@ defmodule PrymnWeb.Router do
end
scope "/", PrymnWeb do
- pipe_through :browser
+ pipe_through [:browser, :require_authenticated_user]
get "/", PageController, :home
+
+ live_session :require_authenticated_user,
+ on_mount: [{PrymnWeb.UserAuth, :ensure_authenticated}] do
+ live "/users/settings", UserSettingsLive
+ live "/users/settings/confirm_email/:token", UserSettingsLive
+
+ live "/servers", ServerLive.Index, :index
+ live "/servers/new", ServerLive.Index, :new
+ live "/servers/:id", ServerLive.Show
+ end
+ end
+
+ scope "/", PrymnWeb do
+ pipe_through [:browser]
+
get "/install", PageController, :install
+ end
- live "/servers", ServerLive.Index, :index
- live "/servers/new", ServerLive.Index, :new
+ scope "/auth", PrymnWeb do
+ pipe_through [:browser, :redirect_if_user_is_authenticated]
- live "/servers/:id", ServerLive.Show
+ live_session :redirect_if_user_is_authenticated,
+ on_mount: [{PrymnWeb.UserAuth, :redirect_if_user_is_authenticated}],
+ layout: {PrymnWeb.Layouts, :auth} do
+ live "/register", UserRegistrationLive
+ live "/log_in", UserLoginLive
+ live "/reset_password", UserForgotPasswordLive
+ live "/reset_password/:token", UserResetPasswordLive
+ end
+
+ post "/log_in", UserSessionController, :create
+ end
+
+ scope "/auth", PrymnWeb do
+ pipe_through [:browser]
+
+ live_session :current_user,
+ on_mount: [{PrymnWeb.UserAuth, :mount_current_user}],
+ layout: {PrymnWeb.Layouts, :auth} do
+ live "/confirm", UserConfirmationLive, :new
+ live "/confirm/:token", UserConfirmationLive, :edit
+ end
+
+ delete "/log_out", UserSessionController, :delete
end
scope "/api/v1", PrymnWeb do
diff --git a/app/lib/prymn_web/telemetry.ex b/app/lib/prymn_web/telemetry.ex
index 51a8851..bbc9a3d 100644
--- a/app/lib/prymn_web/telemetry.ex
+++ b/app/lib/prymn_web/telemetry.ex
@@ -43,7 +43,7 @@ defmodule PrymnWeb.Telemetry do
summary("phoenix.socket_connected.duration",
unit: {:native, :millisecond}
),
- summary("phoenix.channel_join.duration",
+ summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_handled_in.duration",
diff --git a/app/lib/prymn_web/user_auth.ex b/app/lib/prymn_web/user_auth.ex
new file mode 100644
index 0000000..a5075ea
--- /dev/null
+++ b/app/lib/prymn_web/user_auth.ex
@@ -0,0 +1,221 @@
+defmodule PrymnWeb.UserAuth do
+ use PrymnWeb, :verified_routes
+
+ import Plug.Conn
+ import Phoenix.Controller
+
+ alias Prymn.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 "_prymn_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
+ 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
+ PrymnWeb.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 PrymnWeb.PageLive do
+ use PrymnWeb, :live_view
+
+ on_mount {PrymnWeb.UserAuth, :mount_current_user}
+ ...
+ end
+
+ Or use the `live_session` of your router to invoke the on_mount callback:
+
+ live_session :authenticated, on_mount: [{PrymnWeb.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
+ {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/auth/log_in")}
+ 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
+ |> maybe_store_return_to()
+ |> redirect(to: ~p"/auth/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/app/mix.exs b/app/mix.exs
index 4f8b9d6..32d1fbb 100644
--- a/app/mix.exs
+++ b/app/mix.exs
@@ -33,7 +33,8 @@ defmodule Prymn.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
- {:phoenix, "~> 1.7.6"},
+ {:argon2_elixir, "~> 3.0"},
+ {:phoenix, "~> 1.7.7"},
{:phoenix_ecto, "~> 4.4"},
{:ecto_sql, "~> 3.10"},
{:postgrex, ">= 0.0.0"},
diff --git a/app/mix.lock b/app/mix.lock
index 7155d8c..1eede72 100644
--- a/app/mix.lock
+++ b/app/mix.lock
@@ -1,5 +1,7 @@
%{
+ "argon2_elixir": {:hex, :argon2_elixir, "3.1.0", "4135e0a1b4ff800d42c85aa663e068efa3cb356297189b5b65caa992db8ec8cf", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "c08feae0ee0292165d1b945003363c7cd8523d002e0483c627dfca930291dd73"},
"castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"},
+ "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"},
@@ -8,6 +10,7 @@
"dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"},
"ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 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", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"},
"ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 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", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"},
+ "elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"esbuild": {:hex, :esbuild, "0.7.1", "fa0947e8c3c3c2f86c9bf7e791a0a385007ccd42b86885e8e893bdb6631f5169", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "66661cdf70b1378ee4dc16573fcee67750b59761b2605a0207c267ab9d19f13c"},
"expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"},
@@ -29,16 +32,16 @@
"phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.0", "0b3158b5b198aa444473c91d23d79f52fb077e807ffad80dacf88ce078fa8df2", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "87785a54474fed91a67a1227a741097eb1a42c2e49d3c0d098b588af65cd410d"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
- "phoenix_live_view": {:hex, :phoenix_live_view, "0.19.4", "dd9ffe3ca0683bdef4f340bcdd2c35a6ee0d581a2696033fc25f52e742618bdc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, 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]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fd2c666d227476d63af7b8c20e6e61d16f07eb49f924cf4198fca7668156f15b"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "0.19.5", "6e730595e8e9b8c5da230a814e557768828fd8dfeeb90377d2d8dbb52d4ec00a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, 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]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b2eaa0dd3cfb9bd7fb949b88217df9f25aed915e986a28ad5c8a0d054e7ca9d3"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
- "phoenix_template": {:hex, :phoenix_template, "1.0.2", "a3dd349493d7c0b8f58da8175f805963a5b809ffc7d8c1b8dd46ba5b199ef58f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "ab78ebc964685b9eeba102344049eb32d69e582c497d5a0ae6f25909db00c67b"},
+ "phoenix_template": {:hex, :phoenix_template, "1.0.3", "32de561eefcefa951aead30a1f94f1b5f0379bc9e340bb5c667f65f1edfa4326", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "16f4b6588a4152f3cc057b9d0c0ba7e82ee23afa65543da535313ad8d25d8e2c"},
"plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"},
"plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"},
"plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"},
"postgrex": {:hex, :postgrex, "0.17.2", "a3ec9e3239d9b33f1e5841565c4eb200055c52cc0757a22b63ca2d529bbe764c", [: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", "80a918a9e9531d39f7bd70621422f3ebc93c01618c645f2d91306f50041ed90c"},
"protobuf": {:hex, :protobuf, "0.12.0", "58c0dfea5f929b96b5aa54ec02b7130688f09d2de5ddc521d696eec2a015b223", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "75fa6cbf262062073dd51be44dd0ab940500e18386a6c4e87d5819a58964dc45"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
- "swoosh": {:hex, :swoosh, "1.11.3", "49caa2653205bfa0a567b5404afb5c39e932a9678d2e43cc78271670721397c8", [:mix], [{: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]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e6f3381f9e95d88effa31446177d9cb7c3f6d617c355ab806ddddceda35208d7"},
+ "swoosh": {:hex, :swoosh, "1.11.4", "9b353f998cba3c5e101a0669559c2fb2757b5d9eb7db058bf08687d82e93e416", [:mix], [{: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]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d3390914022a456ae1604bfcb3431bd12509b2afe8c70296bae6c9dca4903d0f"},
"tailwind": {:hex, :tailwind, "0.2.1", "83d8eadbe71a8e8f67861fe7f8d51658ecfb258387123afe4d9dc194eddc36b0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e8a13f6107c95f73e58ed1b4221744e1eb5a093cd1da244432067e19c8c9a277"},
"tailwind_formatter": {:hex, :tailwind_formatter, "0.3.6", "f3b02687a79a99106f2cee604d36561091ab5b9c9d16a97ae5901d91b3357047", [:mix], [{:phoenix_live_view, ">= 0.17.6", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "3a0d75dad1700f9fa9394185c4ce0eb0eff2b1a0eb9aef66b4b382eae657bded"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
diff --git a/app/priv/repo/migrations/20230727164710_create_users_auth_tables.exs b/app/priv/repo/migrations/20230727164710_create_users_auth_tables.exs
new file mode 100644
index 0000000..3b8f98d
--- /dev/null
+++ b/app/priv/repo/migrations/20230727164710_create_users_auth_tables.exs
@@ -0,0 +1,27 @@
+defmodule Prymn.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, :naive_datetime
+ timestamps()
+ 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(updated_at: false)
+ end
+
+ create index(:users_tokens, [:user_id])
+ create unique_index(:users_tokens, [:context, :token])
+ end
+end
diff --git a/app/test/prymn/accounts_test.exs b/app/test/prymn/accounts_test.exs
new file mode 100644
index 0000000..4248c03
--- /dev/null
+++ b/app/test/prymn/accounts_test.exs
@@ -0,0 +1,508 @@
+defmodule Prymn.AccountsTest do
+ use Prymn.DataCase
+
+ alias Prymn.Accounts
+
+ import Prymn.AccountsFixtures
+ alias Prymn.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: ["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: ["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: ["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: ["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: ["Passwords do not match"]
+ } = 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: ["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: ["Passwords do not match"]
+ } = 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/app/test/prymn/servers_test.exs b/app/test/prymn/servers_test.exs
index 7ff5053..3208469 100644
--- a/app/test/prymn/servers_test.exs
+++ b/app/test/prymn/servers_test.exs
@@ -21,10 +21,14 @@ defmodule Prymn.ServersTest do
end
test "create_server/1 with valid data creates a server" do
- valid_attrs = %{name: "some name"}
+ valid_attrs = %{name: "some name", public_ip: "127.0.0.1", provider: :Custom}
assert {:ok, %Server{} = server} = Servers.create_server(valid_attrs)
assert server.name == "some name"
+ assert server.public_ip == "127.0.0.1"
+ assert server.status == :unregistered
+ assert server.provider == :Custom
+ assert server.registration_token != nil
end
test "create_server/1 with invalid data returns error changeset" do
diff --git a/app/test/prymn_web/controllers/page_controller_test.exs b/app/test/prymn_web/controllers/page_controller_test.exs
index db2159c..c556308 100644
--- a/app/test/prymn_web/controllers/page_controller_test.exs
+++ b/app/test/prymn_web/controllers/page_controller_test.exs
@@ -1,8 +1,20 @@
defmodule PrymnWeb.PageControllerTest do
+ alias Prymn.Accounts
use PrymnWeb.ConnCase
- test "GET /", %{conn: conn} do
+ import Prymn.AccountsFixtures
+
+ test "redirect unauthenticated user", %{conn: conn} do
conn = get(conn, ~p"/")
- assert html_response(conn, 200) =~ "Welcome"
+ assert html_response(conn, 302) =~ "redirected"
+ end
+
+ describe "authenticated" do
+ setup :register_and_log_in_user
+
+ test "shows page", %{conn: conn} do
+ conn = get(conn, ~p"/")
+ assert html_response(conn, 200)
+ end
end
end
diff --git a/app/test/prymn_web/controllers/user_session_controller_test.exs b/app/test/prymn_web/controllers/user_session_controller_test.exs
new file mode 100644
index 0000000..f9a977d
--- /dev/null
+++ b/app/test/prymn_web/controllers/user_session_controller_test.exs
@@ -0,0 +1,114 @@
+defmodule PrymnWeb.UserSessionControllerTest do
+ use PrymnWeb.ConnCase, async: true
+
+ import Prymn.AccountsFixtures
+
+ setup do
+ %{user: user_fixture()}
+ end
+
+ describe "POST /auth/log_in" do
+ test "logs the user in", %{conn: conn, user: user} do
+ conn =
+ post(conn, ~p"/auth/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"/auth/log_out"
+ end
+
+ test "logs the user in with remember me", %{conn: conn, user: user} do
+ conn =
+ post(conn, ~p"/auth/log_in", %{
+ "user" => %{
+ "email" => user.email,
+ "password" => valid_user_password(),
+ "remember_me" => "true"
+ }
+ })
+
+ assert conn.resp_cookies["_prymn_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"/auth/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"/auth/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"/auth/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
+ email = "invalid@email.com"
+
+ conn =
+ post(conn, ~p"/auth/log_in", %{
+ "user" => %{"email" => email, "password" => "invalid_password"}
+ })
+
+ assert redirected_to(conn) == ~p"/auth/log_in?#{[email: email]}"
+ end
+ end
+
+ describe "DELETE /auth/log_out" do
+ test "logs the user out", %{conn: conn, user: user} do
+ conn = conn |> log_in_user(user) |> delete(~p"/auth/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"/auth/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/app/test/prymn_web/live/server_live_test.exs b/app/test/prymn_web/live/server_live_test.exs
index f468176..7637577 100644
--- a/app/test/prymn_web/live/server_live_test.exs
+++ b/app/test/prymn_web/live/server_live_test.exs
@@ -4,8 +4,7 @@ defmodule PrymnWeb.ServerLiveTest do
import Phoenix.LiveViewTest
import Prymn.ServersFixtures
- @create_attrs %{name: "some name"}
- @update_attrs %{name: "some updated name"}
+ @create_attrs %{name: "some name", provider: :Custom}
@invalid_attrs %{name: nil}
defp create_server(_) do
@@ -14,12 +13,12 @@ defmodule PrymnWeb.ServerLiveTest do
end
describe "Index" do
- setup [:create_server]
+ setup [:create_server, :register_and_log_in_user]
test "lists all servers", %{conn: conn, server: server} do
{:ok, _index_live, html} = live(conn, ~p"/servers")
- assert html =~ "Listing Servers"
+ assert html =~ "Your servers"
assert html =~ server.name
end
@@ -42,20 +41,13 @@ defmodule PrymnWeb.ServerLiveTest do
assert_patch(index_live, ~p"/servers")
html = render(index_live)
- assert html =~ "Server created successfully"
+ assert html =~ "Success!"
assert html =~ "some name"
end
-
- test "deletes server in listing", %{conn: conn, server: server} do
- {:ok, index_live, _html} = live(conn, ~p"/servers")
-
- assert index_live |> element("#servers-#{server.id} a", "Delete") |> render_click()
- refute has_element?(index_live, "#servers-#{server.id}")
- end
end
describe "Show" do
- setup [:create_server]
+ setup [:create_server, :register_and_log_in_user]
test "displays server", %{conn: conn, server: server} do
{:ok, _show_live, html} = live(conn, ~p"/servers/#{server}")
@@ -63,24 +55,4 @@ defmodule PrymnWeb.ServerLiveTest do
assert html =~ "Server some name"
end
end
-
- describe "Edit" do
- setup [:create_server]
-
- test "updates server", %{conn: conn, server: server} do
- {:ok, edit_live, _html} = live(conn, ~p"/servers/#{server}/edit")
-
- assert edit_live
- |> form("#server-form", server: @invalid_attrs)
- |> render_change() =~
- "can't be blank"
-
- assert edit_live
- |> form("#server-form", server: @update_attrs)
- |> render_submit()
-
- html = render(edit_live)
- assert html =~ "some updated name"
- end
- end
end
diff --git a/app/test/prymn_web/live/user_confirmation_live_test.exs b/app/test/prymn_web/live/user_confirmation_live_test.exs
new file mode 100644
index 0000000..01b961d
--- /dev/null
+++ b/app/test/prymn_web/live/user_confirmation_live_test.exs
@@ -0,0 +1,137 @@
+defmodule PrymnWeb.UserConfirmationLiveTest do
+ use PrymnWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+ import Prymn.AccountsFixtures
+
+ alias Prymn.Accounts
+ alias Prymn.Repo
+
+ setup do
+ %{user: user_fixture()}
+ end
+
+ describe "Confirm user" do
+ 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"/auth/confirm/#{token}")
+
+ result =
+ lv
+ |> form("#confirmation_form")
+ |> render_submit()
+ |> follow_redirect(conn, "/")
+
+ assert {:ok, conn} = result
+
+ assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
+ "Email 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"/auth/confirm/#{token}")
+
+ result =
+ lv
+ |> form("#confirmation_form")
+ |> render_submit()
+ |> follow_redirect(conn, "/")
+
+ assert {:ok, conn} = result
+
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
+ "Email confirmation link is invalid or it has expired"
+
+ # when logged in
+ {:ok, lv, _html} =
+ build_conn()
+ |> log_in_user(user)
+ |> live(~p"/auth/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"/auth/confirm/invalid-token")
+
+ {:ok, conn} =
+ lv
+ |> form("#confirmation_form")
+ |> render_submit()
+ |> follow_redirect(conn, ~p"/")
+
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
+ "Email confirmation link is invalid or it has expired"
+
+ refute Accounts.get_user!(user.id).confirmed_at
+ end
+ end
+
+ describe "Resend confirmation" do
+ test "renders the resend confirmation page", %{conn: conn} do
+ {:ok, _lv, html} = live(conn, ~p"/auth/confirm")
+ assert html =~ "Resend confirmation instructions"
+ end
+
+ test "sends a new confirmation token", %{conn: conn, user: user} do
+ {:ok, lv, _html} = live(conn, ~p"/auth/confirm")
+
+ {:ok, conn} =
+ lv
+ |> form("#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"/auth/confirm")
+
+ {:ok, conn} =
+ lv
+ |> form("#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"/auth/confirm")
+
+ {:ok, conn} =
+ lv
+ |> form("#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/app/test/prymn_web/live/user_forgot_password_live_test.exs b/app/test/prymn_web/live/user_forgot_password_live_test.exs
new file mode 100644
index 0000000..be9f901
--- /dev/null
+++ b/app/test/prymn_web/live/user_forgot_password_live_test.exs
@@ -0,0 +1,55 @@
+defmodule PrymnWeb.UserForgotPasswordLiveTest do
+ use PrymnWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+ import Prymn.AccountsFixtures
+
+ alias Prymn.Accounts
+ alias Prymn.Repo
+
+ describe "Forgot password page" do
+ test "redirects if already logged in", %{conn: conn} do
+ result =
+ conn
+ |> log_in_user(user_fixture())
+ |> live(~p"/auth/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"/auth/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"/auth/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/app/test/prymn_web/live/user_login_live_test.exs b/app/test/prymn_web/live/user_login_live_test.exs
new file mode 100644
index 0000000..551ce92
--- /dev/null
+++ b/app/test/prymn_web/live/user_login_live_test.exs
@@ -0,0 +1,47 @@
+defmodule PrymnWeb.UserLoginLiveTest do
+ use PrymnWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+ import Prymn.AccountsFixtures
+
+ describe "Log in page" do
+ test "redirects if already logged in", %{conn: conn} do
+ result =
+ conn
+ |> log_in_user(user_fixture())
+ |> live(~p"/auth/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"/auth/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 if there are no valid credentials", %{conn: conn} do
+ {:ok, lv, _html} = live(conn, ~p"/auth/log_in")
+
+ email = "test@email.com"
+
+ form =
+ form(lv, "#login_form", user: %{email: email, password: "123456", remember_me: true})
+
+ conn = submit_form(form, conn)
+
+ assert redirected_to(conn) == ~p"/auth/log_in?#{[email: email]}"
+ end
+ end
+end
diff --git a/app/test/prymn_web/live/user_registration_live_test.exs b/app/test/prymn_web/live/user_registration_live_test.exs
new file mode 100644
index 0000000..5157f2f
--- /dev/null
+++ b/app/test/prymn_web/live/user_registration_live_test.exs
@@ -0,0 +1,65 @@
+defmodule PrymnWeb.UserRegistrationLiveTest do
+ use PrymnWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+ import Prymn.AccountsFixtures
+
+ describe "Registration page" do
+ test "redirects if already logged in", %{conn: conn} do
+ result =
+ conn
+ |> log_in_user(user_fixture())
+ |> live(~p"/auth/register")
+ |> follow_redirect(conn, "/")
+
+ assert {:ok, _conn} = result
+ end
+
+ test "renders errors for invalid data", %{conn: conn} do
+ {:ok, lv, _html} = live(conn, ~p"/auth/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"/auth/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 =~ "Welcome"
+ end
+
+ test "renders errors for duplicated email", %{conn: conn} do
+ {:ok, lv, _html} = live(conn, ~p"/auth/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
+end
diff --git a/app/test/prymn_web/live/user_reset_password_live_test.exs b/app/test/prymn_web/live/user_reset_password_live_test.exs
new file mode 100644
index 0000000..2672c82
--- /dev/null
+++ b/app/test/prymn_web/live/user_reset_password_live_test.exs
@@ -0,0 +1,83 @@
+defmodule PrymnWeb.UserResetPasswordLiveTest do
+ use PrymnWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+ import Prymn.AccountsFixtures
+
+ alias Prymn.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 "does not render reset password with invalid token", %{conn: conn} do
+ {:error, {:redirect, to}} = live(conn, ~p"/auth/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"/auth/reset_password/#{token}")
+
+ result =
+ lv
+ |> element("#reset_password_form")
+ |> render_change(
+ user: %{"password" => "secret12", "confirmation_password" => "secret123456"}
+ )
+
+ assert result =~ "should be at least 12 character"
+ assert result =~ "Passwords do not match"
+ end
+ end
+
+ describe "Reset Password" do
+ test "resets password once", %{conn: conn, token: token, user: user} do
+ {:ok, lv, _html} = live(conn, ~p"/auth/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"/auth/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"/auth/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 =~ "Passwords do not match"
+ end
+ end
+end
diff --git a/app/test/prymn_web/live/user_settings_live_test.exs b/app/test/prymn_web/live/user_settings_live_test.exs
new file mode 100644
index 0000000..a1337e6
--- /dev/null
+++ b/app/test/prymn_web/live/user_settings_live_test.exs
@@ -0,0 +1,188 @@
+defmodule PrymnWeb.UserSettingsLiveTest do
+ use PrymnWeb.ConnCase
+
+ alias Prymn.Accounts
+ import Phoenix.LiveViewTest
+ import Prymn.AccountsFixtures
+
+ 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 =~ "Passwords do not match"
+ 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 =~ "Passwords do not match"
+ 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}} = redirect
+ assert path == ~p"/auth/log_in"
+ end
+ end
+end
diff --git a/app/test/prymn_web/user_auth_test.exs b/app/test/prymn_web/user_auth_test.exs
new file mode 100644
index 0000000..f803173
--- /dev/null
+++ b/app/test/prymn_web/user_auth_test.exs
@@ -0,0 +1,269 @@
+defmodule PrymnWeb.UserAuthTest do
+ use PrymnWeb.ConnCase, async: true
+
+ alias Phoenix.LiveView
+ alias Prymn.Accounts
+ alias PrymnWeb.UserAuth
+ import Prymn.AccountsFixtures
+
+ @remember_me_cookie "_prymn_web_user_remember_me"
+
+ setup %{conn: conn} do
+ conn =
+ conn
+ |> Map.replace!(:secret_key_base, PrymnWeb.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"
+ PrymnWeb.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: PrymnWeb.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: PrymnWeb.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"/auth/log_in"
+ 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/app/test/support/conn_case.ex b/app/test/support/conn_case.ex
index 8b1b3bc..856f5a0 100644
--- a/app/test/support/conn_case.ex
+++ b/app/test/support/conn_case.ex
@@ -35,4 +35,30 @@ defmodule PrymnWeb.ConnCase do
Prymn.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 = Prymn.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 = Prymn.Accounts.generate_user_session_token(user)
+
+ conn
+ |> Phoenix.ConnTest.init_test_session(%{})
+ |> Plug.Conn.put_session(:user_token, token)
+ end
end
diff --git a/app/test/support/fixtures/accounts_fixtures.ex b/app/test/support/fixtures/accounts_fixtures.ex
new file mode 100644
index 0000000..2f16579
--- /dev/null
+++ b/app/test/support/fixtures/accounts_fixtures.ex
@@ -0,0 +1,31 @@
+defmodule Prymn.AccountsFixtures do
+ @moduledoc """
+ This module defines test helpers for creating
+ entities via the `Prymn.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()
+ |> Prymn.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
diff --git a/app/test/support/fixtures/servers_fixtures.ex b/app/test/support/fixtures/servers_fixtures.ex
index 8ea8614..9692d76 100644
--- a/app/test/support/fixtures/servers_fixtures.ex
+++ b/app/test/support/fixtures/servers_fixtures.ex
@@ -12,8 +12,8 @@ defmodule Prymn.ServersFixtures do
attrs
|> Enum.into(%{
name: "some name",
- ipv4: "192.168.1.1",
- ipv6: "[1234:5678::1]"
+ public_ip: "127.0.0.1",
+ provider: "Custom"
})
|> Prymn.Servers.create_server()