Add auth
This commit is contained in:
		
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -35,3 +35,7 @@ tradex-*.tar | |||||||
| npm-debug.log | npm-debug.log | ||||||
| /assets/node_modules/ | /assets/node_modules/ | ||||||
|  |  | ||||||
|  | # Ignore language server output | ||||||
|  | .lexical/ | ||||||
|  | .elixirls/ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,8 @@ | |||||||
| import Config | import Config | ||||||
|  |  | ||||||
|  | # Only in tests, remove the complexity from the password hashing algorithm | ||||||
|  | config :bcrypt_elixir, :log_rounds, 1 | ||||||
|  |  | ||||||
| # Configure your database | # Configure your database | ||||||
| # | # | ||||||
| # The MIX_TEST_PARTITION environment variable can be used | # The MIX_TEST_PARTITION environment variable can be used | ||||||
|   | |||||||
							
								
								
									
										353
									
								
								lib/tradex/accounts.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								lib/tradex/accounts.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,353 @@ | |||||||
|  | defmodule Tradex.Accounts do | ||||||
|  |   @moduledoc """ | ||||||
|  |   The Accounts context. | ||||||
|  |   """ | ||||||
|  |  | ||||||
|  |   import Ecto.Query, warn: false | ||||||
|  |   alias Tradex.Repo | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts.{User, UserToken, UserNotifier} | ||||||
|  |  | ||||||
|  |   ## Database getters | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Gets a user by email. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> get_user_by_email("foo@example.com") | ||||||
|  |       %User{} | ||||||
|  |  | ||||||
|  |       iex> get_user_by_email("unknown@example.com") | ||||||
|  |       nil | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def get_user_by_email(email) when is_binary(email) do | ||||||
|  |     Repo.get_by(User, email: email) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Gets a user by email and password. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> get_user_by_email_and_password("foo@example.com", "correct_password") | ||||||
|  |       %User{} | ||||||
|  |  | ||||||
|  |       iex> get_user_by_email_and_password("foo@example.com", "invalid_password") | ||||||
|  |       nil | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def get_user_by_email_and_password(email, password) | ||||||
|  |       when is_binary(email) and is_binary(password) do | ||||||
|  |     user = Repo.get_by(User, email: email) | ||||||
|  |     if User.valid_password?(user, password), do: user | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Gets a single user. | ||||||
|  |  | ||||||
|  |   Raises `Ecto.NoResultsError` if the User does not exist. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> get_user!(123) | ||||||
|  |       %User{} | ||||||
|  |  | ||||||
|  |       iex> get_user!(456) | ||||||
|  |       ** (Ecto.NoResultsError) | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def get_user!(id), do: Repo.get!(User, id) | ||||||
|  |  | ||||||
|  |   ## User registration | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Registers a user. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> register_user(%{field: value}) | ||||||
|  |       {:ok, %User{}} | ||||||
|  |  | ||||||
|  |       iex> register_user(%{field: bad_value}) | ||||||
|  |       {:error, %Ecto.Changeset{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def register_user(attrs) do | ||||||
|  |     %User{} | ||||||
|  |     |> User.registration_changeset(attrs) | ||||||
|  |     |> Repo.insert() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns an `%Ecto.Changeset{}` for tracking user changes. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> change_user_registration(user) | ||||||
|  |       %Ecto.Changeset{data: %User{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def change_user_registration(%User{} = user, attrs \\ %{}) do | ||||||
|  |     User.registration_changeset(user, attrs, hash_password: false, validate_email: false) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   ## Settings | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns an `%Ecto.Changeset{}` for changing the user email. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> change_user_email(user) | ||||||
|  |       %Ecto.Changeset{data: %User{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def change_user_email(user, attrs \\ %{}) do | ||||||
|  |     User.email_changeset(user, attrs, validate_email: false) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Emulates that the email will change without actually changing | ||||||
|  |   it in the database. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> apply_user_email(user, "valid password", %{email: ...}) | ||||||
|  |       {:ok, %User{}} | ||||||
|  |  | ||||||
|  |       iex> apply_user_email(user, "invalid password", %{email: ...}) | ||||||
|  |       {:error, %Ecto.Changeset{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def apply_user_email(user, password, attrs) do | ||||||
|  |     user | ||||||
|  |     |> User.email_changeset(attrs) | ||||||
|  |     |> User.validate_current_password(password) | ||||||
|  |     |> Ecto.Changeset.apply_action(:update) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Updates the user email using the given token. | ||||||
|  |  | ||||||
|  |   If the token matches, the user email is updated and the token is deleted. | ||||||
|  |   The confirmed_at date is also updated to the current time. | ||||||
|  |   """ | ||||||
|  |   def update_user_email(user, token) do | ||||||
|  |     context = "change:#{user.email}" | ||||||
|  |  | ||||||
|  |     with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), | ||||||
|  |          %UserToken{sent_to: email} <- Repo.one(query), | ||||||
|  |          {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do | ||||||
|  |       :ok | ||||||
|  |     else | ||||||
|  |       _ -> :error | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp user_email_multi(user, email, context) do | ||||||
|  |     changeset = | ||||||
|  |       user | ||||||
|  |       |> User.email_changeset(%{email: email}) | ||||||
|  |       |> User.confirm_changeset() | ||||||
|  |  | ||||||
|  |     Ecto.Multi.new() | ||||||
|  |     |> Ecto.Multi.update(:user, changeset) | ||||||
|  |     |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context])) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc ~S""" | ||||||
|  |   Delivers the update email instructions to the given user. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1}")) | ||||||
|  |       {:ok, %{to: ..., body: ...}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) | ||||||
|  |       when is_function(update_email_url_fun, 1) do | ||||||
|  |     {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") | ||||||
|  |  | ||||||
|  |     Repo.insert!(user_token) | ||||||
|  |     UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns an `%Ecto.Changeset{}` for changing the user password. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> change_user_password(user) | ||||||
|  |       %Ecto.Changeset{data: %User{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def change_user_password(user, attrs \\ %{}) do | ||||||
|  |     User.password_changeset(user, attrs, hash_password: false) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Updates the user password. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> update_user_password(user, "valid password", %{password: ...}) | ||||||
|  |       {:ok, %User{}} | ||||||
|  |  | ||||||
|  |       iex> update_user_password(user, "invalid password", %{password: ...}) | ||||||
|  |       {:error, %Ecto.Changeset{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def update_user_password(user, password, attrs) do | ||||||
|  |     changeset = | ||||||
|  |       user | ||||||
|  |       |> User.password_changeset(attrs) | ||||||
|  |       |> User.validate_current_password(password) | ||||||
|  |  | ||||||
|  |     Ecto.Multi.new() | ||||||
|  |     |> Ecto.Multi.update(:user, changeset) | ||||||
|  |     |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) | ||||||
|  |     |> Repo.transaction() | ||||||
|  |     |> case do | ||||||
|  |       {:ok, %{user: user}} -> {:ok, user} | ||||||
|  |       {:error, :user, changeset, _} -> {:error, changeset} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   ## Session | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Generates a session token. | ||||||
|  |   """ | ||||||
|  |   def generate_user_session_token(user) do | ||||||
|  |     {token, user_token} = UserToken.build_session_token(user) | ||||||
|  |     Repo.insert!(user_token) | ||||||
|  |     token | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Gets the user with the given signed token. | ||||||
|  |   """ | ||||||
|  |   def get_user_by_session_token(token) do | ||||||
|  |     {:ok, query} = UserToken.verify_session_token_query(token) | ||||||
|  |     Repo.one(query) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Deletes the signed token with the given context. | ||||||
|  |   """ | ||||||
|  |   def delete_user_session_token(token) do | ||||||
|  |     Repo.delete_all(UserToken.by_token_and_context_query(token, "session")) | ||||||
|  |     :ok | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   ## Confirmation | ||||||
|  |  | ||||||
|  |   @doc ~S""" | ||||||
|  |   Delivers the confirmation email instructions to the given user. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}")) | ||||||
|  |       {:ok, %{to: ..., body: ...}} | ||||||
|  |  | ||||||
|  |       iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}")) | ||||||
|  |       {:error, :already_confirmed} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) | ||||||
|  |       when is_function(confirmation_url_fun, 1) do | ||||||
|  |     if user.confirmed_at do | ||||||
|  |       {:error, :already_confirmed} | ||||||
|  |     else | ||||||
|  |       {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") | ||||||
|  |       Repo.insert!(user_token) | ||||||
|  |       UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Confirms a user by the given token. | ||||||
|  |  | ||||||
|  |   If the token matches, the user account is marked as confirmed | ||||||
|  |   and the token is deleted. | ||||||
|  |   """ | ||||||
|  |   def confirm_user(token) do | ||||||
|  |     with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), | ||||||
|  |          %User{} = user <- Repo.one(query), | ||||||
|  |          {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do | ||||||
|  |       {:ok, user} | ||||||
|  |     else | ||||||
|  |       _ -> :error | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp confirm_user_multi(user) do | ||||||
|  |     Ecto.Multi.new() | ||||||
|  |     |> Ecto.Multi.update(:user, User.confirm_changeset(user)) | ||||||
|  |     |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"])) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   ## Reset password | ||||||
|  |  | ||||||
|  |   @doc ~S""" | ||||||
|  |   Delivers the reset password email to the given user. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}")) | ||||||
|  |       {:ok, %{to: ..., body: ...}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) | ||||||
|  |       when is_function(reset_password_url_fun, 1) do | ||||||
|  |     {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") | ||||||
|  |     Repo.insert!(user_token) | ||||||
|  |     UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Gets the user by reset password token. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> get_user_by_reset_password_token("validtoken") | ||||||
|  |       %User{} | ||||||
|  |  | ||||||
|  |       iex> get_user_by_reset_password_token("invalidtoken") | ||||||
|  |       nil | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def get_user_by_reset_password_token(token) do | ||||||
|  |     with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), | ||||||
|  |          %User{} = user <- Repo.one(query) do | ||||||
|  |       user | ||||||
|  |     else | ||||||
|  |       _ -> nil | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Resets the user password. | ||||||
|  |  | ||||||
|  |   ## Examples | ||||||
|  |  | ||||||
|  |       iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) | ||||||
|  |       {:ok, %User{}} | ||||||
|  |  | ||||||
|  |       iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) | ||||||
|  |       {:error, %Ecto.Changeset{}} | ||||||
|  |  | ||||||
|  |   """ | ||||||
|  |   def reset_user_password(user, attrs) do | ||||||
|  |     Ecto.Multi.new() | ||||||
|  |     |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) | ||||||
|  |     |> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all)) | ||||||
|  |     |> Repo.transaction() | ||||||
|  |     |> case do | ||||||
|  |       {:ok, %{user: user}} -> {:ok, user} | ||||||
|  |       {:error, :user, changeset, _} -> {:error, changeset} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										161
									
								
								lib/tradex/accounts/user.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								lib/tradex/accounts/user.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,161 @@ | |||||||
|  | defmodule Tradex.Accounts.User do | ||||||
|  |   use Tradex.Schema | ||||||
|  |   import Ecto.Changeset | ||||||
|  |  | ||||||
|  |   schema "users" do | ||||||
|  |     field :email, :string | ||||||
|  |     field :password, :string, virtual: true, redact: true | ||||||
|  |     field :hashed_password, :string, redact: true | ||||||
|  |     field :current_password, :string, virtual: true, redact: true | ||||||
|  |     field :confirmed_at, :utc_datetime | ||||||
|  |  | ||||||
|  |     timestamps(type: :utc_datetime) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   A user changeset for registration. | ||||||
|  |  | ||||||
|  |   It is important to validate the length of both email and password. | ||||||
|  |   Otherwise databases may truncate the email without warnings, which | ||||||
|  |   could lead to unpredictable or insecure behaviour. Long passwords may | ||||||
|  |   also be very expensive to hash for certain algorithms. | ||||||
|  |  | ||||||
|  |   ## Options | ||||||
|  |  | ||||||
|  |     * `:hash_password` - Hashes the password so it can be stored securely | ||||||
|  |       in the database and ensures the password field is cleared to prevent | ||||||
|  |       leaks in the logs. If password hashing is not needed and clearing the | ||||||
|  |       password field is not desired (like when using this changeset for | ||||||
|  |       validations on a LiveView form), this option can be set to `false`. | ||||||
|  |       Defaults to `true`. | ||||||
|  |  | ||||||
|  |     * `:validate_email` - Validates the uniqueness of the email, in case | ||||||
|  |       you don't want to validate the uniqueness of the email (like when | ||||||
|  |       using this changeset for validations on a LiveView form before | ||||||
|  |       submitting the form), this option can be set to `false`. | ||||||
|  |       Defaults to `true`. | ||||||
|  |   """ | ||||||
|  |   def registration_changeset(user, attrs, opts \\ []) do | ||||||
|  |     user | ||||||
|  |     |> cast(attrs, [:email, :password]) | ||||||
|  |     |> validate_email(opts) | ||||||
|  |     |> validate_password(opts) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp validate_email(changeset, opts) do | ||||||
|  |     changeset | ||||||
|  |     |> validate_required([:email]) | ||||||
|  |     |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") | ||||||
|  |     |> validate_length(:email, max: 160) | ||||||
|  |     |> maybe_validate_unique_email(opts) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp validate_password(changeset, opts) do | ||||||
|  |     changeset | ||||||
|  |     |> validate_required([:password]) | ||||||
|  |     |> validate_length(:password, min: 12, max: 72) | ||||||
|  |     # Examples of additional password validation: | ||||||
|  |     # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") | ||||||
|  |     # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") | ||||||
|  |     # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") | ||||||
|  |     |> maybe_hash_password(opts) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp maybe_hash_password(changeset, opts) do | ||||||
|  |     hash_password? = Keyword.get(opts, :hash_password, true) | ||||||
|  |     password = get_change(changeset, :password) | ||||||
|  |  | ||||||
|  |     if hash_password? && password && changeset.valid? do | ||||||
|  |       changeset | ||||||
|  |       # If using Bcrypt, then further validate it is at most 72 bytes long | ||||||
|  |       |> validate_length(:password, max: 72, count: :bytes) | ||||||
|  |       # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that | ||||||
|  |       # would keep the database transaction open longer and hurt performance. | ||||||
|  |       |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) | ||||||
|  |       |> delete_change(:password) | ||||||
|  |     else | ||||||
|  |       changeset | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp maybe_validate_unique_email(changeset, opts) do | ||||||
|  |     if Keyword.get(opts, :validate_email, true) do | ||||||
|  |       changeset | ||||||
|  |       |> unsafe_validate_unique(:email, Tradex.Repo) | ||||||
|  |       |> unique_constraint(:email) | ||||||
|  |     else | ||||||
|  |       changeset | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   A user changeset for changing the email. | ||||||
|  |  | ||||||
|  |   It requires the email to change otherwise an error is added. | ||||||
|  |   """ | ||||||
|  |   def email_changeset(user, attrs, opts \\ []) do | ||||||
|  |     user | ||||||
|  |     |> cast(attrs, [:email]) | ||||||
|  |     |> validate_email(opts) | ||||||
|  |     |> case do | ||||||
|  |       %{changes: %{email: _}} = changeset -> changeset | ||||||
|  |       %{} = changeset -> add_error(changeset, :email, "did not change") | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   A user changeset for changing the password. | ||||||
|  |  | ||||||
|  |   ## Options | ||||||
|  |  | ||||||
|  |     * `:hash_password` - Hashes the password so it can be stored securely | ||||||
|  |       in the database and ensures the password field is cleared to prevent | ||||||
|  |       leaks in the logs. If password hashing is not needed and clearing the | ||||||
|  |       password field is not desired (like when using this changeset for | ||||||
|  |       validations on a LiveView form), this option can be set to `false`. | ||||||
|  |       Defaults to `true`. | ||||||
|  |   """ | ||||||
|  |   def password_changeset(user, attrs, opts \\ []) do | ||||||
|  |     user | ||||||
|  |     |> cast(attrs, [:password]) | ||||||
|  |     |> validate_confirmation(:password, message: "does not match password") | ||||||
|  |     |> validate_password(opts) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Confirms the account by setting `confirmed_at`. | ||||||
|  |   """ | ||||||
|  |   def confirm_changeset(user) do | ||||||
|  |     now = DateTime.utc_now() |> DateTime.truncate(:second) | ||||||
|  |     change(user, confirmed_at: now) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Verifies the password. | ||||||
|  |  | ||||||
|  |   If there is no user or the user doesn't have a password, we call | ||||||
|  |   `Bcrypt.no_user_verify/0` to avoid timing attacks. | ||||||
|  |   """ | ||||||
|  |   def valid_password?(%Tradex.Accounts.User{hashed_password: hashed_password}, password) | ||||||
|  |       when is_binary(hashed_password) and byte_size(password) > 0 do | ||||||
|  |     Bcrypt.verify_pass(password, hashed_password) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def valid_password?(_, _) do | ||||||
|  |     Bcrypt.no_user_verify() | ||||||
|  |     false | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Validates the current password otherwise adds an error to the changeset. | ||||||
|  |   """ | ||||||
|  |   def validate_current_password(changeset, password) do | ||||||
|  |     changeset = cast(changeset, %{current_password: password}, [:current_password]) | ||||||
|  |  | ||||||
|  |     if valid_password?(changeset.data, password) do | ||||||
|  |       changeset | ||||||
|  |     else | ||||||
|  |       add_error(changeset, :current_password, "is not valid") | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										79
									
								
								lib/tradex/accounts/user_notifier.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								lib/tradex/accounts/user_notifier.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | |||||||
|  | defmodule Tradex.Accounts.UserNotifier do | ||||||
|  |   import Swoosh.Email | ||||||
|  |  | ||||||
|  |   alias Tradex.Mailer | ||||||
|  |  | ||||||
|  |   # Delivers the email using the application mailer. | ||||||
|  |   defp deliver(recipient, subject, body) do | ||||||
|  |     email = | ||||||
|  |       new() | ||||||
|  |       |> to(recipient) | ||||||
|  |       |> from({"Tradex", "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 | ||||||
							
								
								
									
										179
									
								
								lib/tradex/accounts/user_token.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								lib/tradex/accounts/user_token.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | |||||||
|  | defmodule Tradex.Accounts.UserToken do | ||||||
|  |   use Tradex.Schema | ||||||
|  |   import Ecto.Query | ||||||
|  |   alias Tradex.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, Tradex.Accounts.User | ||||||
|  |  | ||||||
|  |     timestamps(type: :utc_datetime, updated_at: false) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Generates a token that will be stored in a signed place, | ||||||
|  |   such as session or cookie. As they are signed, those | ||||||
|  |   tokens do not need to be hashed. | ||||||
|  |  | ||||||
|  |   The reason why we store session tokens in the database, even | ||||||
|  |   though Phoenix already provides a session cookie, is because | ||||||
|  |   Phoenix' default session cookies are not persisted, they are | ||||||
|  |   simply signed and potentially encrypted. This means they are | ||||||
|  |   valid indefinitely, unless you change the signing/encryption | ||||||
|  |   salt. | ||||||
|  |  | ||||||
|  |   Therefore, storing them allows individual user | ||||||
|  |   sessions to be expired. The token system can also be extended | ||||||
|  |   to store additional data, such as the device used for logging in. | ||||||
|  |   You could then use this information to display all valid sessions | ||||||
|  |   and devices in the UI and allow users to explicitly expire any | ||||||
|  |   session they deem invalid. | ||||||
|  |   """ | ||||||
|  |   def build_session_token(user) do | ||||||
|  |     token = :crypto.strong_rand_bytes(@rand_size) | ||||||
|  |     {token, %UserToken{token: token, context: "session", user_id: user.id}} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Checks if the token is valid and returns its underlying lookup query. | ||||||
|  |  | ||||||
|  |   The query returns the user found by the token, if any. | ||||||
|  |  | ||||||
|  |   The token is valid if it matches the value in the database and it has | ||||||
|  |   not expired (after @session_validity_in_days). | ||||||
|  |   """ | ||||||
|  |   def verify_session_token_query(token) do | ||||||
|  |     query = | ||||||
|  |       from token in by_token_and_context_query(token, "session"), | ||||||
|  |         join: user in assoc(token, :user), | ||||||
|  |         where: token.inserted_at > ago(@session_validity_in_days, "day"), | ||||||
|  |         select: user | ||||||
|  |  | ||||||
|  |     {:ok, query} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Builds a token and its hash to be delivered to the user's email. | ||||||
|  |  | ||||||
|  |   The non-hashed token is sent to the user email while the | ||||||
|  |   hashed part is stored in the database. The original token cannot be reconstructed, | ||||||
|  |   which means anyone with read-only access to the database cannot directly use | ||||||
|  |   the token in the application to gain access. Furthermore, if the user changes | ||||||
|  |   their email in the system, the tokens sent to the previous email are no longer | ||||||
|  |   valid. | ||||||
|  |  | ||||||
|  |   Users can easily adapt the existing code to provide other types of delivery methods, | ||||||
|  |   for example, by phone numbers. | ||||||
|  |   """ | ||||||
|  |   def build_email_token(user, context) do | ||||||
|  |     build_hashed_token(user, context, user.email) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp build_hashed_token(user, context, sent_to) do | ||||||
|  |     token = :crypto.strong_rand_bytes(@rand_size) | ||||||
|  |     hashed_token = :crypto.hash(@hash_algorithm, token) | ||||||
|  |  | ||||||
|  |     {Base.url_encode64(token, padding: false), | ||||||
|  |      %UserToken{ | ||||||
|  |        token: hashed_token, | ||||||
|  |        context: context, | ||||||
|  |        sent_to: sent_to, | ||||||
|  |        user_id: user.id | ||||||
|  |      }} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Checks if the token is valid and returns its underlying lookup query. | ||||||
|  |  | ||||||
|  |   The query returns the user found by the token, if any. | ||||||
|  |  | ||||||
|  |   The given token is valid if it matches its hashed counterpart in the | ||||||
|  |   database and the user email has not changed. This function also checks | ||||||
|  |   if the token is being used within a certain period, depending on the | ||||||
|  |   context. The default contexts supported by this function are either | ||||||
|  |   "confirm", for account confirmation emails, and "reset_password", | ||||||
|  |   for resetting the password. For verifying requests to change the email, | ||||||
|  |   see `verify_change_email_token_query/2`. | ||||||
|  |   """ | ||||||
|  |   def verify_email_token_query(token, context) do | ||||||
|  |     case Base.url_decode64(token, padding: false) do | ||||||
|  |       {:ok, decoded_token} -> | ||||||
|  |         hashed_token = :crypto.hash(@hash_algorithm, decoded_token) | ||||||
|  |         days = days_for_context(context) | ||||||
|  |  | ||||||
|  |         query = | ||||||
|  |           from token in by_token_and_context_query(hashed_token, context), | ||||||
|  |             join: user in assoc(token, :user), | ||||||
|  |             where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, | ||||||
|  |             select: user | ||||||
|  |  | ||||||
|  |         {:ok, query} | ||||||
|  |  | ||||||
|  |       :error -> | ||||||
|  |         :error | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp days_for_context("confirm"), do: @confirm_validity_in_days | ||||||
|  |   defp days_for_context("reset_password"), do: @reset_password_validity_in_days | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Checks if the token is valid and returns its underlying lookup query. | ||||||
|  |  | ||||||
|  |   The query returns the user found by the token, if any. | ||||||
|  |  | ||||||
|  |   This is used to validate requests to change the user | ||||||
|  |   email. It is different from `verify_email_token_query/2` precisely because | ||||||
|  |   `verify_email_token_query/2` validates the email has not changed, which is | ||||||
|  |   the starting point by this function. | ||||||
|  |  | ||||||
|  |   The given token is valid if it matches its hashed counterpart in the | ||||||
|  |   database and if it has not expired (after @change_email_validity_in_days). | ||||||
|  |   The context must always start with "change:". | ||||||
|  |   """ | ||||||
|  |   def verify_change_email_token_query(token, "change:" <> _ = context) do | ||||||
|  |     case Base.url_decode64(token, padding: false) do | ||||||
|  |       {:ok, decoded_token} -> | ||||||
|  |         hashed_token = :crypto.hash(@hash_algorithm, decoded_token) | ||||||
|  |  | ||||||
|  |         query = | ||||||
|  |           from token in by_token_and_context_query(hashed_token, context), | ||||||
|  |             where: token.inserted_at > ago(@change_email_validity_in_days, "day") | ||||||
|  |  | ||||||
|  |         {:ok, query} | ||||||
|  |  | ||||||
|  |       :error -> | ||||||
|  |         :error | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Returns the token struct for the given token value and context. | ||||||
|  |   """ | ||||||
|  |   def by_token_and_context_query(token, context) do | ||||||
|  |     from UserToken, where: [token: ^token, context: ^context] | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Gets all tokens for the given user for the given contexts. | ||||||
|  |   """ | ||||||
|  |   def by_user_and_contexts_query(user, :all) do | ||||||
|  |     from t in UserToken, where: t.user_id == ^user.id | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def by_user_and_contexts_query(user, [_ | _] = contexts) do | ||||||
|  |     from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										13
									
								
								lib/tradex/schema.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								lib/tradex/schema.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | defmodule Tradex.Schema do | ||||||
|  |   defmacro __using__(opts) do | ||||||
|  |     quote do | ||||||
|  |       use Ecto.Schema | ||||||
|  |  | ||||||
|  |       import Ecto | ||||||
|  |       import Ecto.Query | ||||||
|  |  | ||||||
|  |       @primary_key {:id, UUIDv7, autogenerate: true} | ||||||
|  |       @foreign_key_type :binary_id | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -12,6 +12,47 @@ | |||||||
|     </script> |     </script> | ||||||
|   </head> |   </head> | ||||||
|   <body class="bg-white"> |   <body class="bg-white"> | ||||||
|  |     <ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end"> | ||||||
|  |       <%= if @current_user do %> | ||||||
|  |         <li class="text-[0.8125rem] leading-6 text-zinc-900"> | ||||||
|  |           {@current_user.email} | ||||||
|  |         </li> | ||||||
|  |         <li> | ||||||
|  |           <.link | ||||||
|  |             href={~p"/users/settings"} | ||||||
|  |             class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" | ||||||
|  |           > | ||||||
|  |             Settings | ||||||
|  |           </.link> | ||||||
|  |         </li> | ||||||
|  |         <li> | ||||||
|  |           <.link | ||||||
|  |             href={~p"/users/log_out"} | ||||||
|  |             method="delete" | ||||||
|  |             class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" | ||||||
|  |           > | ||||||
|  |             Log out | ||||||
|  |           </.link> | ||||||
|  |         </li> | ||||||
|  |       <% else %> | ||||||
|  |         <li> | ||||||
|  |           <.link | ||||||
|  |             href={~p"/users/register"} | ||||||
|  |             class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" | ||||||
|  |           > | ||||||
|  |             Register | ||||||
|  |           </.link> | ||||||
|  |         </li> | ||||||
|  |         <li> | ||||||
|  |           <.link | ||||||
|  |             href={~p"/users/log_in"} | ||||||
|  |             class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" | ||||||
|  |           > | ||||||
|  |             Log in | ||||||
|  |           </.link> | ||||||
|  |         </li> | ||||||
|  |       <% end %> | ||||||
|  |     </ul> | ||||||
|     {@inner_content} |     {@inner_content} | ||||||
|   </body> |   </body> | ||||||
| </html> | </html> | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								lib/tradex_web/controllers/user_session_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								lib/tradex_web/controllers/user_session_controller.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | |||||||
|  | defmodule TradexWeb.UserSessionController do | ||||||
|  |   use TradexWeb, :controller | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |   alias TradexWeb.UserAuth | ||||||
|  |  | ||||||
|  |   def create(conn, %{"_action" => "registered"} = params) do | ||||||
|  |     create(conn, params, "Account created successfully!") | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def create(conn, %{"_action" => "password_updated"} = params) do | ||||||
|  |     conn | ||||||
|  |     |> put_session(:user_return_to, ~p"/users/settings") | ||||||
|  |     |> create(params, "Password updated successfully!") | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def create(conn, params) do | ||||||
|  |     create(conn, params, "Welcome back!") | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp create(conn, %{"user" => user_params}, info) do | ||||||
|  |     %{"email" => email, "password" => password} = user_params | ||||||
|  |  | ||||||
|  |     if user = Accounts.get_user_by_email_and_password(email, password) do | ||||||
|  |       conn | ||||||
|  |       |> put_flash(:info, info) | ||||||
|  |       |> UserAuth.log_in_user(user, user_params) | ||||||
|  |     else | ||||||
|  |       # In order to prevent user enumeration attacks, don't disclose whether the email is registered. | ||||||
|  |       conn | ||||||
|  |       |> put_flash(:error, "Invalid email or password") | ||||||
|  |       |> put_flash(:email, String.slice(email, 0, 160)) | ||||||
|  |       |> redirect(to: ~p"/users/log_in") | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def delete(conn, _params) do | ||||||
|  |     conn | ||||||
|  |     |> put_flash(:info, "Logged out successfully.") | ||||||
|  |     |> UserAuth.log_out_user() | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										51
									
								
								lib/tradex_web/live/user_confirmation_instructions_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								lib/tradex_web/live/user_confirmation_instructions_live.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | defmodule TradexWeb.UserConfirmationInstructionsLive do | ||||||
|  |   use TradexWeb, :live_view | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |  | ||||||
|  |   def render(assigns) do | ||||||
|  |     ~H""" | ||||||
|  |     <div class="mx-auto max-w-sm"> | ||||||
|  |       <.header class="text-center"> | ||||||
|  |         No confirmation instructions received? | ||||||
|  |         <:subtitle>We'll send a new confirmation link to your inbox</:subtitle> | ||||||
|  |       </.header> | ||||||
|  |  | ||||||
|  |       <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions"> | ||||||
|  |         <.input field={@form[:email]} type="email" placeholder="Email" required /> | ||||||
|  |         <:actions> | ||||||
|  |           <.button phx-disable-with="Sending..." class="w-full"> | ||||||
|  |             Resend confirmation instructions | ||||||
|  |           </.button> | ||||||
|  |         </:actions> | ||||||
|  |       </.simple_form> | ||||||
|  |  | ||||||
|  |       <p class="text-center mt-4"> | ||||||
|  |         <.link href={~p"/users/register"}>Register</.link> | ||||||
|  |         | <.link href={~p"/users/log_in"}>Log in</.link> | ||||||
|  |       </p> | ||||||
|  |     </div> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def mount(_params, _session, socket) do | ||||||
|  |     {:ok, assign(socket, form: to_form(%{}, as: "user"))} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do | ||||||
|  |     if user = Accounts.get_user_by_email(email) do | ||||||
|  |       Accounts.deliver_user_confirmation_instructions( | ||||||
|  |         user, | ||||||
|  |         &url(~p"/users/confirm/#{&1}") | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     info = | ||||||
|  |       "If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." | ||||||
|  |  | ||||||
|  |     {:noreply, | ||||||
|  |      socket | ||||||
|  |      |> put_flash(:info, info) | ||||||
|  |      |> redirect(to: ~p"/")} | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										58
									
								
								lib/tradex_web/live/user_confirmation_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								lib/tradex_web/live/user_confirmation_live.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | |||||||
|  | defmodule TradexWeb.UserConfirmationLive do | ||||||
|  |   use TradexWeb, :live_view | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |  | ||||||
|  |   def render(%{live_action: :edit} = assigns) do | ||||||
|  |     ~H""" | ||||||
|  |     <div class="mx-auto max-w-sm"> | ||||||
|  |       <.header class="text-center">Confirm Account</.header> | ||||||
|  |  | ||||||
|  |       <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account"> | ||||||
|  |         <input type="hidden" name={@form[:token].name} value={@form[:token].value} /> | ||||||
|  |         <:actions> | ||||||
|  |           <.button phx-disable-with="Confirming..." class="w-full">Confirm my account</.button> | ||||||
|  |         </:actions> | ||||||
|  |       </.simple_form> | ||||||
|  |  | ||||||
|  |       <p class="text-center mt-4"> | ||||||
|  |         <.link href={~p"/users/register"}>Register</.link> | ||||||
|  |         | <.link href={~p"/users/log_in"}>Log in</.link> | ||||||
|  |       </p> | ||||||
|  |     </div> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def mount(%{"token" => token}, _session, socket) do | ||||||
|  |     form = to_form(%{"token" => token}, as: "user") | ||||||
|  |     {:ok, assign(socket, form: form), temporary_assigns: [form: nil]} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   # Do not log in the user after confirmation to avoid a | ||||||
|  |   # leaked token giving the user access to the account. | ||||||
|  |   def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do | ||||||
|  |     case Accounts.confirm_user(token) do | ||||||
|  |       {:ok, _} -> | ||||||
|  |         {:noreply, | ||||||
|  |          socket | ||||||
|  |          |> put_flash(:info, "User confirmed successfully.") | ||||||
|  |          |> redirect(to: ~p"/")} | ||||||
|  |  | ||||||
|  |       :error -> | ||||||
|  |         # If there is a current user and the account was already confirmed, | ||||||
|  |         # then odds are that the confirmation link was already visited, either | ||||||
|  |         # by some automation or by the user themselves, so we redirect without | ||||||
|  |         # a warning message. | ||||||
|  |         case socket.assigns do | ||||||
|  |           %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> | ||||||
|  |             {:noreply, redirect(socket, to: ~p"/")} | ||||||
|  |  | ||||||
|  |           %{} -> | ||||||
|  |             {:noreply, | ||||||
|  |              socket | ||||||
|  |              |> put_flash(:error, "User confirmation link is invalid or it has expired.") | ||||||
|  |              |> redirect(to: ~p"/")} | ||||||
|  |         end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										50
									
								
								lib/tradex_web/live/user_forgot_password_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								lib/tradex_web/live/user_forgot_password_live.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | |||||||
|  | defmodule TradexWeb.UserForgotPasswordLive do | ||||||
|  |   use TradexWeb, :live_view | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |  | ||||||
|  |   def render(assigns) do | ||||||
|  |     ~H""" | ||||||
|  |     <div class="mx-auto max-w-sm"> | ||||||
|  |       <.header class="text-center"> | ||||||
|  |         Forgot your password? | ||||||
|  |         <:subtitle>We'll send a password reset link to your inbox</:subtitle> | ||||||
|  |       </.header> | ||||||
|  |  | ||||||
|  |       <.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 | ||||||
|  |           </.button> | ||||||
|  |         </:actions> | ||||||
|  |       </.simple_form> | ||||||
|  |       <p class="text-center text-sm mt-4"> | ||||||
|  |         <.link href={~p"/users/register"}>Register</.link> | ||||||
|  |         | <.link href={~p"/users/log_in"}>Log in</.link> | ||||||
|  |       </p> | ||||||
|  |     </div> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def mount(_params, _session, socket) do | ||||||
|  |     {:ok, assign(socket, form: to_form(%{}, as: "user"))} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do | ||||||
|  |     if user = Accounts.get_user_by_email(email) do | ||||||
|  |       Accounts.deliver_user_reset_password_instructions( | ||||||
|  |         user, | ||||||
|  |         &url(~p"/users/reset_password/#{&1}") | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     info = | ||||||
|  |       "If your email is in our system, you will receive instructions to reset your password shortly." | ||||||
|  |  | ||||||
|  |     {:noreply, | ||||||
|  |      socket | ||||||
|  |      |> put_flash(:info, info) | ||||||
|  |      |> redirect(to: ~p"/")} | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										43
									
								
								lib/tradex_web/live/user_login_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								lib/tradex_web/live/user_login_live.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | |||||||
|  | defmodule TradexWeb.UserLoginLive do | ||||||
|  |   use TradexWeb, :live_view | ||||||
|  |  | ||||||
|  |   def render(assigns) do | ||||||
|  |     ~H""" | ||||||
|  |     <div class="mx-auto max-w-sm"> | ||||||
|  |       <.header class="text-center"> | ||||||
|  |         Log in to account | ||||||
|  |         <:subtitle> | ||||||
|  |           Don't have an account? | ||||||
|  |           <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> | ||||||
|  |             Sign up | ||||||
|  |           </.link> | ||||||
|  |           for an account now. | ||||||
|  |         </:subtitle> | ||||||
|  |       </.header> | ||||||
|  |  | ||||||
|  |       <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore"> | ||||||
|  |         <.input field={@form[:email]} type="email" label="Email" required /> | ||||||
|  |         <.input field={@form[:password]} type="password" label="Password" required /> | ||||||
|  |  | ||||||
|  |         <:actions> | ||||||
|  |           <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> | ||||||
|  |           <.link href={~p"/users/reset_password"} class="text-sm font-semibold"> | ||||||
|  |             Forgot your password? | ||||||
|  |           </.link> | ||||||
|  |         </:actions> | ||||||
|  |         <:actions> | ||||||
|  |           <.button phx-disable-with="Logging in..." class="w-full"> | ||||||
|  |             Log in <span aria-hidden="true">→</span> | ||||||
|  |           </.button> | ||||||
|  |         </:actions> | ||||||
|  |       </.simple_form> | ||||||
|  |     </div> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def mount(_params, _session, socket) do | ||||||
|  |     email = Phoenix.Flash.get(socket.assigns.flash, :email) | ||||||
|  |     form = to_form(%{"email" => email}, as: "user") | ||||||
|  |     {:ok, assign(socket, form: form), temporary_assigns: [form: form]} | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										87
									
								
								lib/tradex_web/live/user_registration_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								lib/tradex_web/live/user_registration_live.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | |||||||
|  | defmodule TradexWeb.UserRegistrationLive do | ||||||
|  |   use TradexWeb, :live_view | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |   alias Tradex.Accounts.User | ||||||
|  |  | ||||||
|  |   def render(assigns) do | ||||||
|  |     ~H""" | ||||||
|  |     <div class="mx-auto max-w-sm"> | ||||||
|  |       <.header class="text-center"> | ||||||
|  |         Register for an account | ||||||
|  |         <:subtitle> | ||||||
|  |           Already registered? | ||||||
|  |           <.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline"> | ||||||
|  |             Log in | ||||||
|  |           </.link> | ||||||
|  |           to your account now. | ||||||
|  |         </:subtitle> | ||||||
|  |       </.header> | ||||||
|  |  | ||||||
|  |       <.simple_form | ||||||
|  |         for={@form} | ||||||
|  |         id="registration_form" | ||||||
|  |         phx-submit="save" | ||||||
|  |         phx-change="validate" | ||||||
|  |         phx-trigger-action={@trigger_submit} | ||||||
|  |         action={~p"/users/log_in?_action=registered"} | ||||||
|  |         method="post" | ||||||
|  |       > | ||||||
|  |         <.error :if={@check_errors}> | ||||||
|  |           Oops, something went wrong! Please check the errors below. | ||||||
|  |         </.error> | ||||||
|  |  | ||||||
|  |         <.input field={@form[:email]} type="email" label="Email" required /> | ||||||
|  |         <.input field={@form[:password]} type="password" label="Password" required /> | ||||||
|  |  | ||||||
|  |         <:actions> | ||||||
|  |           <.button phx-disable-with="Creating account..." class="w-full">Create an account</.button> | ||||||
|  |         </:actions> | ||||||
|  |       </.simple_form> | ||||||
|  |     </div> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def mount(_params, _session, socket) do | ||||||
|  |     changeset = Accounts.change_user_registration(%User{}) | ||||||
|  |  | ||||||
|  |     socket = | ||||||
|  |       socket | ||||||
|  |       |> assign(trigger_submit: false, check_errors: false) | ||||||
|  |       |> assign_form(changeset) | ||||||
|  |  | ||||||
|  |     {:ok, socket, temporary_assigns: [form: nil]} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("save", %{"user" => user_params}, socket) do | ||||||
|  |     case Accounts.register_user(user_params) do | ||||||
|  |       {:ok, user} -> | ||||||
|  |         {:ok, _} = | ||||||
|  |           Accounts.deliver_user_confirmation_instructions( | ||||||
|  |             user, | ||||||
|  |             &url(~p"/users/confirm/#{&1}") | ||||||
|  |           ) | ||||||
|  |  | ||||||
|  |         changeset = Accounts.change_user_registration(user) | ||||||
|  |         {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)} | ||||||
|  |  | ||||||
|  |       {:error, %Ecto.Changeset{} = changeset} -> | ||||||
|  |         {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("validate", %{"user" => user_params}, socket) do | ||||||
|  |     changeset = Accounts.change_user_registration(%User{}, user_params) | ||||||
|  |     {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp assign_form(socket, %Ecto.Changeset{} = changeset) do | ||||||
|  |     form = to_form(changeset, as: "user") | ||||||
|  |  | ||||||
|  |     if changeset.valid? do | ||||||
|  |       assign(socket, form: form, check_errors: false) | ||||||
|  |     else | ||||||
|  |       assign(socket, form: form) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										89
									
								
								lib/tradex_web/live/user_reset_password_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								lib/tradex_web/live/user_reset_password_live.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | |||||||
|  | defmodule TradexWeb.UserResetPasswordLive do | ||||||
|  |   use TradexWeb, :live_view | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |  | ||||||
|  |   def render(assigns) do | ||||||
|  |     ~H""" | ||||||
|  |     <div class="mx-auto max-w-sm"> | ||||||
|  |       <.header class="text-center">Reset Password</.header> | ||||||
|  |  | ||||||
|  |       <.simple_form | ||||||
|  |         for={@form} | ||||||
|  |         id="reset_password_form" | ||||||
|  |         phx-submit="reset_password" | ||||||
|  |         phx-change="validate" | ||||||
|  |       > | ||||||
|  |         <.error :if={@form.errors != []}> | ||||||
|  |           Oops, something went wrong! Please check the errors below. | ||||||
|  |         </.error> | ||||||
|  |  | ||||||
|  |         <.input field={@form[:password]} type="password" label="New password" required /> | ||||||
|  |         <.input | ||||||
|  |           field={@form[:password_confirmation]} | ||||||
|  |           type="password" | ||||||
|  |           label="Confirm new password" | ||||||
|  |           required | ||||||
|  |         /> | ||||||
|  |         <:actions> | ||||||
|  |           <.button phx-disable-with="Resetting..." class="w-full">Reset Password</.button> | ||||||
|  |         </:actions> | ||||||
|  |       </.simple_form> | ||||||
|  |  | ||||||
|  |       <p class="text-center text-sm mt-4"> | ||||||
|  |         <.link href={~p"/users/register"}>Register</.link> | ||||||
|  |         | <.link href={~p"/users/log_in"}>Log in</.link> | ||||||
|  |       </p> | ||||||
|  |     </div> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def mount(params, _session, socket) do | ||||||
|  |     socket = assign_user_and_token(socket, params) | ||||||
|  |  | ||||||
|  |     form_source = | ||||||
|  |       case socket.assigns do | ||||||
|  |         %{user: user} -> | ||||||
|  |           Accounts.change_user_password(user) | ||||||
|  |  | ||||||
|  |         _ -> | ||||||
|  |           %{} | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |     {:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   # Do not log in the user after reset password to avoid a | ||||||
|  |   # leaked token giving the user access to the account. | ||||||
|  |   def handle_event("reset_password", %{"user" => user_params}, socket) do | ||||||
|  |     case Accounts.reset_user_password(socket.assigns.user, user_params) do | ||||||
|  |       {:ok, _} -> | ||||||
|  |         {:noreply, | ||||||
|  |          socket | ||||||
|  |          |> put_flash(:info, "Password reset successfully.") | ||||||
|  |          |> redirect(to: ~p"/users/log_in")} | ||||||
|  |  | ||||||
|  |       {:error, changeset} -> | ||||||
|  |         {:noreply, assign_form(socket, Map.put(changeset, :action, :insert))} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("validate", %{"user" => user_params}, socket) do | ||||||
|  |     changeset = Accounts.change_user_password(socket.assigns.user, user_params) | ||||||
|  |     {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp assign_user_and_token(socket, %{"token" => token}) do | ||||||
|  |     if user = Accounts.get_user_by_reset_password_token(token) do | ||||||
|  |       assign(socket, user: user, token: token) | ||||||
|  |     else | ||||||
|  |       socket | ||||||
|  |       |> put_flash(:error, "Reset password link is invalid or it has expired.") | ||||||
|  |       |> redirect(to: ~p"/") | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp assign_form(socket, %{} = source) do | ||||||
|  |     assign(socket, :form, to_form(source, as: "user")) | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										167
									
								
								lib/tradex_web/live/user_settings_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								lib/tradex_web/live/user_settings_live.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,167 @@ | |||||||
|  | defmodule TradexWeb.UserSettingsLive do | ||||||
|  |   use TradexWeb, :live_view | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |  | ||||||
|  |   def render(assigns) do | ||||||
|  |     ~H""" | ||||||
|  |     <.header class="text-center"> | ||||||
|  |       Account Settings | ||||||
|  |       <:subtitle>Manage your account email address and password settings</:subtitle> | ||||||
|  |     </.header> | ||||||
|  |  | ||||||
|  |     <div class="space-y-12 divide-y"> | ||||||
|  |       <div> | ||||||
|  |         <.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</.button> | ||||||
|  |           </:actions> | ||||||
|  |         </.simple_form> | ||||||
|  |       </div> | ||||||
|  |       <div> | ||||||
|  |         <.simple_form | ||||||
|  |           for={@password_form} | ||||||
|  |           id="password_form" | ||||||
|  |           action={~p"/users/log_in?_action=password_updated"} | ||||||
|  |           method="post" | ||||||
|  |           phx-change="validate_password" | ||||||
|  |           phx-submit="update_password" | ||||||
|  |           phx-trigger-action={@trigger_submit} | ||||||
|  |         > | ||||||
|  |           <input | ||||||
|  |             name={@password_form[:email].name} | ||||||
|  |             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</.button> | ||||||
|  |           </:actions> | ||||||
|  |         </.simple_form> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     """ | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def mount(%{"token" => token}, _session, socket) do | ||||||
|  |     socket = | ||||||
|  |       case Accounts.update_user_email(socket.assigns.current_user, token) do | ||||||
|  |         :ok -> | ||||||
|  |           put_flash(socket, :info, "Email changed successfully.") | ||||||
|  |  | ||||||
|  |         :error -> | ||||||
|  |           put_flash(socket, :error, "Email change link is invalid or it has expired.") | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |     {:ok, push_navigate(socket, to: ~p"/users/settings")} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def mount(_params, _session, socket) do | ||||||
|  |     user = socket.assigns.current_user | ||||||
|  |     email_changeset = Accounts.change_user_email(user) | ||||||
|  |     password_changeset = Accounts.change_user_password(user) | ||||||
|  |  | ||||||
|  |     socket = | ||||||
|  |       socket | ||||||
|  |       |> assign(:current_password, nil) | ||||||
|  |       |> assign(:email_form_current_password, nil) | ||||||
|  |       |> assign(:current_email, user.email) | ||||||
|  |       |> assign(:email_form, to_form(email_changeset)) | ||||||
|  |       |> assign(:password_form, to_form(password_changeset)) | ||||||
|  |       |> assign(:trigger_submit, false) | ||||||
|  |  | ||||||
|  |     {:ok, socket} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("validate_email", params, socket) do | ||||||
|  |     %{"current_password" => password, "user" => user_params} = params | ||||||
|  |  | ||||||
|  |     email_form = | ||||||
|  |       socket.assigns.current_user | ||||||
|  |       |> Accounts.change_user_email(user_params) | ||||||
|  |       |> Map.put(:action, :validate) | ||||||
|  |       |> to_form() | ||||||
|  |  | ||||||
|  |     {:noreply, assign(socket, email_form: email_form, email_form_current_password: password)} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("update_email", params, socket) do | ||||||
|  |     %{"current_password" => password, "user" => user_params} = params | ||||||
|  |     user = socket.assigns.current_user | ||||||
|  |  | ||||||
|  |     case Accounts.apply_user_email(user, password, user_params) do | ||||||
|  |       {:ok, applied_user} -> | ||||||
|  |         Accounts.deliver_user_update_email_instructions( | ||||||
|  |           applied_user, | ||||||
|  |           user.email, | ||||||
|  |           &url(~p"/users/settings/confirm_email/#{&1}") | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         info = "A link to confirm your email change has been sent to the new address." | ||||||
|  |         {:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)} | ||||||
|  |  | ||||||
|  |       {:error, changeset} -> | ||||||
|  |         {:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("validate_password", params, socket) do | ||||||
|  |     %{"current_password" => password, "user" => user_params} = params | ||||||
|  |  | ||||||
|  |     password_form = | ||||||
|  |       socket.assigns.current_user | ||||||
|  |       |> Accounts.change_user_password(user_params) | ||||||
|  |       |> Map.put(:action, :validate) | ||||||
|  |       |> to_form() | ||||||
|  |  | ||||||
|  |     {:noreply, assign(socket, password_form: password_form, current_password: password)} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_event("update_password", params, socket) do | ||||||
|  |     %{"current_password" => password, "user" => user_params} = params | ||||||
|  |     user = socket.assigns.current_user | ||||||
|  |  | ||||||
|  |     case Accounts.update_user_password(user, password, user_params) do | ||||||
|  |       {:ok, user} -> | ||||||
|  |         password_form = | ||||||
|  |           user | ||||||
|  |           |> Accounts.change_user_password(user_params) | ||||||
|  |           |> to_form() | ||||||
|  |  | ||||||
|  |         {:noreply, assign(socket, trigger_submit: true, password_form: password_form)} | ||||||
|  |  | ||||||
|  |       {:error, changeset} -> | ||||||
|  |         {:noreply, assign(socket, password_form: to_form(changeset))} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -1,6 +1,8 @@ | |||||||
| defmodule TradexWeb.Router do | defmodule TradexWeb.Router do | ||||||
|   use TradexWeb, :router |   use TradexWeb, :router | ||||||
|  |  | ||||||
|  |   import TradexWeb.UserAuth | ||||||
|  |  | ||||||
|   pipeline :browser do |   pipeline :browser do | ||||||
|     plug :accepts, ["html"] |     plug :accepts, ["html"] | ||||||
|     plug :fetch_session |     plug :fetch_session | ||||||
| @@ -8,6 +10,7 @@ defmodule TradexWeb.Router do | |||||||
|     plug :put_root_layout, html: {TradexWeb.Layouts, :root} |     plug :put_root_layout, html: {TradexWeb.Layouts, :root} | ||||||
|     plug :protect_from_forgery |     plug :protect_from_forgery | ||||||
|     plug :put_secure_browser_headers |     plug :put_secure_browser_headers | ||||||
|  |     plug :fetch_current_user | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   pipeline :api do |   pipeline :api do | ||||||
| @@ -41,4 +44,42 @@ defmodule TradexWeb.Router do | |||||||
|       forward "/mailbox", Plug.Swoosh.MailboxPreview |       forward "/mailbox", Plug.Swoosh.MailboxPreview | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   ## Authentication routes | ||||||
|  |  | ||||||
|  |   scope "/", TradexWeb do | ||||||
|  |     pipe_through [:browser, :redirect_if_user_is_authenticated] | ||||||
|  |  | ||||||
|  |     live_session :redirect_if_user_is_authenticated, | ||||||
|  |       on_mount: [{TradexWeb.UserAuth, :redirect_if_user_is_authenticated}] do | ||||||
|  |       live "/users/register", UserRegistrationLive, :new | ||||||
|  |       live "/users/log_in", UserLoginLive, :new | ||||||
|  |       live "/users/reset_password", UserForgotPasswordLive, :new | ||||||
|  |       live "/users/reset_password/:token", UserResetPasswordLive, :edit | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     post "/users/log_in", UserSessionController, :create | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   scope "/", TradexWeb do | ||||||
|  |     pipe_through [:browser, :require_authenticated_user] | ||||||
|  |  | ||||||
|  |     live_session :require_authenticated_user, | ||||||
|  |       on_mount: [{TradexWeb.UserAuth, :ensure_authenticated}] do | ||||||
|  |       live "/users/settings", UserSettingsLive, :edit | ||||||
|  |       live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   scope "/", TradexWeb do | ||||||
|  |     pipe_through [:browser] | ||||||
|  |  | ||||||
|  |     delete "/users/log_out", UserSessionController, :delete | ||||||
|  |  | ||||||
|  |     live_session :current_user, | ||||||
|  |       on_mount: [{TradexWeb.UserAuth, :mount_current_user}] do | ||||||
|  |       live "/users/confirm/:token", UserConfirmationLive, :edit | ||||||
|  |       live "/users/confirm", UserConfirmationInstructionsLive, :new | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										229
									
								
								lib/tradex_web/user_auth.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								lib/tradex_web/user_auth.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,229 @@ | |||||||
|  | defmodule TradexWeb.UserAuth do | ||||||
|  |   use TradexWeb, :verified_routes | ||||||
|  |  | ||||||
|  |   import Plug.Conn | ||||||
|  |   import Phoenix.Controller | ||||||
|  |  | ||||||
|  |   alias Tradex.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 "_tradex_web_user_remember_me" | ||||||
|  |   @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Logs the user in. | ||||||
|  |  | ||||||
|  |   It renews the session ID and clears the whole session | ||||||
|  |   to avoid fixation attacks. See the renew_session | ||||||
|  |   function to customize this behaviour. | ||||||
|  |  | ||||||
|  |   It also sets a `:live_socket_id` key in the session, | ||||||
|  |   so LiveView sessions are identified and automatically | ||||||
|  |   disconnected on log out. The line can be safely removed | ||||||
|  |   if you are not using LiveView. | ||||||
|  |   """ | ||||||
|  |   def log_in_user(conn, user, params \\ %{}) do | ||||||
|  |     token = Accounts.generate_user_session_token(user) | ||||||
|  |     user_return_to = get_session(conn, :user_return_to) | ||||||
|  |  | ||||||
|  |     conn | ||||||
|  |     |> renew_session() | ||||||
|  |     |> put_token_in_session(token) | ||||||
|  |     |> maybe_write_remember_me_cookie(token, params) | ||||||
|  |     |> redirect(to: user_return_to || signed_in_path(conn)) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do | ||||||
|  |     put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp maybe_write_remember_me_cookie(conn, _token, _params) do | ||||||
|  |     conn | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   # This function renews the session ID and erases the whole | ||||||
|  |   # session to avoid fixation attacks. If there is any data | ||||||
|  |   # in the session you may want to preserve after log in/log out, | ||||||
|  |   # you must explicitly fetch the session data before clearing | ||||||
|  |   # and then immediately set it after clearing, for example: | ||||||
|  |   # | ||||||
|  |   #     defp renew_session(conn) do | ||||||
|  |   #       preferred_locale = get_session(conn, :preferred_locale) | ||||||
|  |   # | ||||||
|  |   #       conn | ||||||
|  |   #       |> configure_session(renew: true) | ||||||
|  |   #       |> clear_session() | ||||||
|  |   #       |> put_session(:preferred_locale, preferred_locale) | ||||||
|  |   #     end | ||||||
|  |   # | ||||||
|  |   defp renew_session(conn) do | ||||||
|  |     delete_csrf_token() | ||||||
|  |  | ||||||
|  |     conn | ||||||
|  |     |> configure_session(renew: true) | ||||||
|  |     |> clear_session() | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Logs the user out. | ||||||
|  |  | ||||||
|  |   It clears all session data for safety. See renew_session. | ||||||
|  |   """ | ||||||
|  |   def log_out_user(conn) do | ||||||
|  |     user_token = get_session(conn, :user_token) | ||||||
|  |     user_token && Accounts.delete_user_session_token(user_token) | ||||||
|  |  | ||||||
|  |     if live_socket_id = get_session(conn, :live_socket_id) do | ||||||
|  |       TradexWeb.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 TradexWeb.PageLive do | ||||||
|  |         use TradexWeb, :live_view | ||||||
|  |  | ||||||
|  |         on_mount {TradexWeb.UserAuth, :mount_current_user} | ||||||
|  |         ... | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |   Or use the `live_session` of your router to invoke the on_mount callback: | ||||||
|  |  | ||||||
|  |       live_session :authenticated, on_mount: [{TradexWeb.UserAuth, :ensure_authenticated}] do | ||||||
|  |         live "/profile", ProfileLive, :index | ||||||
|  |       end | ||||||
|  |   """ | ||||||
|  |   def on_mount(:mount_current_user, _params, session, socket) do | ||||||
|  |     {:cont, mount_current_user(socket, session)} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def on_mount(:ensure_authenticated, _params, session, socket) do | ||||||
|  |     socket = mount_current_user(socket, session) | ||||||
|  |  | ||||||
|  |     if socket.assigns.current_user do | ||||||
|  |       {:cont, socket} | ||||||
|  |     else | ||||||
|  |       socket = | ||||||
|  |         socket | ||||||
|  |         |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") | ||||||
|  |         |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") | ||||||
|  |  | ||||||
|  |       {:halt, socket} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do | ||||||
|  |     socket = mount_current_user(socket, session) | ||||||
|  |  | ||||||
|  |     if socket.assigns.current_user do | ||||||
|  |       {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} | ||||||
|  |     else | ||||||
|  |       {:cont, socket} | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp mount_current_user(socket, session) do | ||||||
|  |     Phoenix.Component.assign_new(socket, :current_user, fn -> | ||||||
|  |       if user_token = session["user_token"] do | ||||||
|  |         Accounts.get_user_by_session_token(user_token) | ||||||
|  |       end | ||||||
|  |     end) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Used for routes that require the user to not be authenticated. | ||||||
|  |   """ | ||||||
|  |   def redirect_if_user_is_authenticated(conn, _opts) do | ||||||
|  |     if conn.assigns[:current_user] do | ||||||
|  |       conn | ||||||
|  |       |> redirect(to: signed_in_path(conn)) | ||||||
|  |       |> halt() | ||||||
|  |     else | ||||||
|  |       conn | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   @doc """ | ||||||
|  |   Used for routes that require the user to be authenticated. | ||||||
|  |  | ||||||
|  |   If you want to enforce the user email is confirmed before | ||||||
|  |   they use the application at all, here would be a good place. | ||||||
|  |   """ | ||||||
|  |   def require_authenticated_user(conn, _opts) do | ||||||
|  |     if conn.assigns[:current_user] do | ||||||
|  |       conn | ||||||
|  |     else | ||||||
|  |       conn | ||||||
|  |       |> put_flash(:error, "You must log in to access this page.") | ||||||
|  |       |> maybe_store_return_to() | ||||||
|  |       |> redirect(to: ~p"/users/log_in") | ||||||
|  |       |> halt() | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp put_token_in_session(conn, token) do | ||||||
|  |     conn | ||||||
|  |     |> put_session(:user_token, token) | ||||||
|  |     |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp maybe_store_return_to(%{method: "GET"} = conn) do | ||||||
|  |     put_session(conn, :user_return_to, current_path(conn)) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   defp maybe_store_return_to(conn), do: conn | ||||||
|  |  | ||||||
|  |   defp signed_in_path(_conn), do: ~p"/" | ||||||
|  | end | ||||||
							
								
								
									
										6
									
								
								mix.exs
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								mix.exs
									
									
									
									
									
								
							| @@ -32,6 +32,7 @@ defmodule Tradex.MixProject do | |||||||
|   # Type `mix help deps` for examples and options. |   # Type `mix help deps` for examples and options. | ||||||
|   defp deps do |   defp deps do | ||||||
|     [ |     [ | ||||||
|  |       {:bcrypt_elixir, "~> 3.0"}, | ||||||
|       {:phoenix, "~> 1.7.18"}, |       {:phoenix, "~> 1.7.18"}, | ||||||
|       {:phoenix_ecto, "~> 4.5"}, |       {:phoenix_ecto, "~> 4.5"}, | ||||||
|       {:ecto_sql, "~> 3.10"}, |       {:ecto_sql, "~> 3.10"}, | ||||||
| @@ -57,7 +58,10 @@ defmodule Tradex.MixProject do | |||||||
|       {:gettext, "~> 0.26"}, |       {:gettext, "~> 0.26"}, | ||||||
|       {:jason, "~> 1.2"}, |       {:jason, "~> 1.2"}, | ||||||
|       {:dns_cluster, "~> 0.1.1"}, |       {:dns_cluster, "~> 0.1.1"}, | ||||||
|       {:bandit, "~> 1.5"} |       {:bandit, "~> 1.5"}, | ||||||
|  |       {:uuidv7, "~> 1.0"}, | ||||||
|  |       {:igniter, "~> 0.5"}, | ||||||
|  |       {:oban, "~> 2.19"} | ||||||
|     ] |     ] | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										4
									
								
								mix.lock
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								mix.lock
									
									
									
									
									
								
							| @@ -1,11 +1,14 @@ | |||||||
| %{ | %{ | ||||||
|   "bandit": {:hex, :bandit, "1.6.6", "f2019a95261d400579075df5bc15641ba8e446cc4777ede6b4ec19e434c3340d", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "ceb19bf154bc2c07ee0c9addf407d817c48107e36a66351500846fc325451bf9"}, |   "bandit": {:hex, :bandit, "1.6.6", "f2019a95261d400579075df5bc15641ba8e446cc4777ede6b4ec19e434c3340d", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "ceb19bf154bc2c07ee0c9addf407d817c48107e36a66351500846fc325451bf9"}, | ||||||
|  |   "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.2.0", "feab711974beba4cb348147170346fe097eea2e840db4e012a145e180ed4ab75", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "563e92a6c77d667b19c5f4ba17ab6d440a085696bdf4c68b9b0f5b30bc5422b8"}, | ||||||
|   "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"}, |   "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"}, | ||||||
|  |   "comeonin": {:hex, :comeonin, "5.5.0", "364d00df52545c44a139bad919d7eacb55abf39e86565878e17cebb787977368", [:mix], [], "hexpm", "6287fc3ba0aad34883cbe3f7949fc1d1e738e5ccdce77165bc99490aa69f47fb"}, | ||||||
|   "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, |   "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, | ||||||
|   "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, |   "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, | ||||||
|   "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, |   "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, | ||||||
|   "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, |   "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, | ||||||
|   "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, |   "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, | ||||||
|  |   "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, | ||||||
|   "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, |   "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, | ||||||
|   "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, |   "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, | ||||||
|   "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, |   "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, | ||||||
| @@ -36,6 +39,7 @@ | |||||||
|   "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, |   "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, | ||||||
|   "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, |   "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, | ||||||
|   "thousand_island": {:hex, :thousand_island, "1.3.9", "095db3e2650819443e33237891271943fad3b7f9ba341073947581362582ab5a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25ab4c07badadf7f87adb4ab414e0ed374e5f19e72503aa85132caa25776e54f"}, |   "thousand_island": {:hex, :thousand_island, "1.3.9", "095db3e2650819443e33237891271943fad3b7f9ba341073947581362582ab5a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25ab4c07badadf7f87adb4ab414e0ed374e5f19e72503aa85132caa25776e54f"}, | ||||||
|  |   "uuidv7": {:hex, :uuidv7, "1.0.0", "659179b2e248b98f96e7e988b882d369c055b6ae7a836237ccca52cd4d0f6988", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "0ecd337108456f7d8b1a9a54ef435443d3f8c10a5b685bd866ef9e396b444cbc"}, | ||||||
|   "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, |   "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, | ||||||
|   "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, |   "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,31 @@ | |||||||
|  | defmodule Tradex.Repo.Migrations.CreateUsersAuthTables do | ||||||
|  |   use Ecto.Migration | ||||||
|  |  | ||||||
|  |   def change do | ||||||
|  |     execute "CREATE EXTENSION IF NOT EXISTS citext", "" | ||||||
|  |  | ||||||
|  |     create table(:users, primary_key: false) do | ||||||
|  |       add :id, :binary_id, primary_key: true | ||||||
|  |       add :email, :citext, null: false | ||||||
|  |       add :hashed_password, :string, null: false | ||||||
|  |       add :confirmed_at, :utc_datetime | ||||||
|  |  | ||||||
|  |       timestamps(type: :utc_datetime) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     create unique_index(:users, [:email]) | ||||||
|  |  | ||||||
|  |     create table(:users_tokens, primary_key: false) do | ||||||
|  |       add :id, :binary_id, primary_key: true | ||||||
|  |       add :user_id, references(:users, on_delete: :delete_all, type: :binary_id), null: false | ||||||
|  |       add :token, :binary, null: false | ||||||
|  |       add :context, :string, null: false | ||||||
|  |       add :sent_to, :string | ||||||
|  |  | ||||||
|  |       timestamps(type: :utc_datetime, updated_at: false) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     create index(:users_tokens, [:user_id]) | ||||||
|  |     create unique_index(:users_tokens, [:context, :token]) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -35,4 +35,30 @@ defmodule TradexWeb.ConnCase do | |||||||
|     Tradex.DataCase.setup_sandbox(tags) |     Tradex.DataCase.setup_sandbox(tags) | ||||||
|     {:ok, conn: Phoenix.ConnTest.build_conn()} |     {:ok, conn: Phoenix.ConnTest.build_conn()} | ||||||
|   end |   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 = Tradex.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 = Tradex.Accounts.generate_user_session_token(user) | ||||||
|  |  | ||||||
|  |     conn | ||||||
|  |     |> Phoenix.ConnTest.init_test_session(%{}) | ||||||
|  |     |> Plug.Conn.put_session(:user_token, token) | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
							
								
								
									
										31
									
								
								test/support/fixtures/accounts_fixtures.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								test/support/fixtures/accounts_fixtures.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | defmodule Tradex.AccountsFixtures do | ||||||
|  |   @moduledoc """ | ||||||
|  |   This module defines test helpers for creating | ||||||
|  |   entities via the `Tradex.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() | ||||||
|  |       |> Tradex.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 | ||||||
							
								
								
									
										508
									
								
								test/tradex/accounts_test.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										508
									
								
								test/tradex/accounts_test.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,508 @@ | |||||||
|  | defmodule Tradex.AccountsTest do | ||||||
|  |   use Tradex.DataCase | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |  | ||||||
|  |   import Tradex.AccountsFixtures | ||||||
|  |   alias Tradex.Accounts.{User, UserToken} | ||||||
|  |  | ||||||
|  |   describe "get_user_by_email/1" do | ||||||
|  |     test "does not return the user if the email does not exist" do | ||||||
|  |       refute Accounts.get_user_by_email("unknown@example.com") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "returns the user if the email exists" do | ||||||
|  |       %{id: id} = user = user_fixture() | ||||||
|  |       assert %User{id: ^id} = Accounts.get_user_by_email(user.email) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "get_user_by_email_and_password/2" do | ||||||
|  |     test "does not return the user if the email does not exist" do | ||||||
|  |       refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not return the user if the password is not valid" do | ||||||
|  |       user = user_fixture() | ||||||
|  |       refute Accounts.get_user_by_email_and_password(user.email, "invalid") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "returns the user if the email and password are valid" do | ||||||
|  |       %{id: id} = user = user_fixture() | ||||||
|  |  | ||||||
|  |       assert %User{id: ^id} = | ||||||
|  |                Accounts.get_user_by_email_and_password(user.email, valid_user_password()) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "get_user!/1" do | ||||||
|  |     test "raises if id is invalid" do | ||||||
|  |       assert_raise Ecto.NoResultsError, fn -> | ||||||
|  |         Accounts.get_user!(-1) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "returns the user with the given id" do | ||||||
|  |       %{id: id} = user = user_fixture() | ||||||
|  |       assert %User{id: ^id} = Accounts.get_user!(user.id) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "register_user/1" do | ||||||
|  |     test "requires email and password to be set" do | ||||||
|  |       {:error, changeset} = Accounts.register_user(%{}) | ||||||
|  |  | ||||||
|  |       assert %{ | ||||||
|  |                password: ["can't be blank"], | ||||||
|  |                email: ["can't be blank"] | ||||||
|  |              } = errors_on(changeset) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates email and password when given" do | ||||||
|  |       {:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"}) | ||||||
|  |  | ||||||
|  |       assert %{ | ||||||
|  |                email: ["must have the @ sign and no spaces"], | ||||||
|  |                password: ["should be at least 12 character(s)"] | ||||||
|  |              } = errors_on(changeset) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates maximum values for email and password for security" do | ||||||
|  |       too_long = String.duplicate("db", 100) | ||||||
|  |       {:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long}) | ||||||
|  |       assert "should be at most 160 character(s)" in errors_on(changeset).email | ||||||
|  |       assert "should be at most 72 character(s)" in errors_on(changeset).password | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates email uniqueness" do | ||||||
|  |       %{email: email} = user_fixture() | ||||||
|  |       {:error, changeset} = Accounts.register_user(%{email: email}) | ||||||
|  |       assert "has already been taken" in errors_on(changeset).email | ||||||
|  |  | ||||||
|  |       # Now try with the upper cased email too, to check that email case is ignored. | ||||||
|  |       {:error, changeset} = Accounts.register_user(%{email: String.upcase(email)}) | ||||||
|  |       assert "has already been taken" in errors_on(changeset).email | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "registers users with a hashed password" do | ||||||
|  |       email = unique_user_email() | ||||||
|  |       {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) | ||||||
|  |       assert user.email == email | ||||||
|  |       assert is_binary(user.hashed_password) | ||||||
|  |       assert is_nil(user.confirmed_at) | ||||||
|  |       assert is_nil(user.password) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "change_user_registration/2" do | ||||||
|  |     test "returns a changeset" do | ||||||
|  |       assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{}) | ||||||
|  |       assert changeset.required == [:password, :email] | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "allows fields to be set" do | ||||||
|  |       email = unique_user_email() | ||||||
|  |       password = valid_user_password() | ||||||
|  |  | ||||||
|  |       changeset = | ||||||
|  |         Accounts.change_user_registration( | ||||||
|  |           %User{}, | ||||||
|  |           valid_user_attributes(email: email, password: password) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |       assert changeset.valid? | ||||||
|  |       assert get_change(changeset, :email) == email | ||||||
|  |       assert get_change(changeset, :password) == password | ||||||
|  |       assert is_nil(get_change(changeset, :hashed_password)) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "change_user_email/2" do | ||||||
|  |     test "returns a user changeset" do | ||||||
|  |       assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) | ||||||
|  |       assert changeset.required == [:email] | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "apply_user_email/3" do | ||||||
|  |     setup do | ||||||
|  |       %{user: user_fixture()} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "requires email to change", %{user: user} do | ||||||
|  |       {:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{}) | ||||||
|  |       assert %{email: ["did not change"]} = errors_on(changeset) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates email", %{user: user} do | ||||||
|  |       {:error, changeset} = | ||||||
|  |         Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"}) | ||||||
|  |  | ||||||
|  |       assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates maximum value for email for security", %{user: user} do | ||||||
|  |       too_long = String.duplicate("db", 100) | ||||||
|  |  | ||||||
|  |       {:error, changeset} = | ||||||
|  |         Accounts.apply_user_email(user, valid_user_password(), %{email: too_long}) | ||||||
|  |  | ||||||
|  |       assert "should be at most 160 character(s)" in errors_on(changeset).email | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates email uniqueness", %{user: user} do | ||||||
|  |       %{email: email} = user_fixture() | ||||||
|  |       password = valid_user_password() | ||||||
|  |  | ||||||
|  |       {:error, changeset} = Accounts.apply_user_email(user, password, %{email: email}) | ||||||
|  |  | ||||||
|  |       assert "has already been taken" in errors_on(changeset).email | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates current password", %{user: user} do | ||||||
|  |       {:error, changeset} = | ||||||
|  |         Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()}) | ||||||
|  |  | ||||||
|  |       assert %{current_password: ["is not valid"]} = errors_on(changeset) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "applies the email without persisting it", %{user: user} do | ||||||
|  |       email = unique_user_email() | ||||||
|  |       {:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email}) | ||||||
|  |       assert user.email == email | ||||||
|  |       assert Accounts.get_user!(user.id).email != email | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "deliver_user_update_email_instructions/3" do | ||||||
|  |     setup do | ||||||
|  |       %{user: user_fixture()} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "sends token through notification", %{user: user} do | ||||||
|  |       token = | ||||||
|  |         extract_user_token(fn url -> | ||||||
|  |           Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) | ||||||
|  |         end) | ||||||
|  |  | ||||||
|  |       {:ok, token} = Base.url_decode64(token, padding: false) | ||||||
|  |       assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) | ||||||
|  |       assert user_token.user_id == user.id | ||||||
|  |       assert user_token.sent_to == user.email | ||||||
|  |       assert user_token.context == "change:current@example.com" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "update_user_email/2" do | ||||||
|  |     setup do | ||||||
|  |       user = user_fixture() | ||||||
|  |       email = unique_user_email() | ||||||
|  |  | ||||||
|  |       token = | ||||||
|  |         extract_user_token(fn url -> | ||||||
|  |           Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) | ||||||
|  |         end) | ||||||
|  |  | ||||||
|  |       %{user: user, token: token, email: email} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "updates the email with a valid token", %{user: user, token: token, email: email} do | ||||||
|  |       assert Accounts.update_user_email(user, token) == :ok | ||||||
|  |       changed_user = Repo.get!(User, user.id) | ||||||
|  |       assert changed_user.email != user.email | ||||||
|  |       assert changed_user.email == email | ||||||
|  |       assert changed_user.confirmed_at | ||||||
|  |       assert changed_user.confirmed_at != user.confirmed_at | ||||||
|  |       refute Repo.get_by(UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not update email with invalid token", %{user: user} do | ||||||
|  |       assert Accounts.update_user_email(user, "oops") == :error | ||||||
|  |       assert Repo.get!(User, user.id).email == user.email | ||||||
|  |       assert Repo.get_by(UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not update email if user email changed", %{user: user, token: token} do | ||||||
|  |       assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error | ||||||
|  |       assert Repo.get!(User, user.id).email == user.email | ||||||
|  |       assert Repo.get_by(UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not update email if token expired", %{user: user, token: token} do | ||||||
|  |       {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) | ||||||
|  |       assert Accounts.update_user_email(user, token) == :error | ||||||
|  |       assert Repo.get!(User, user.id).email == user.email | ||||||
|  |       assert Repo.get_by(UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "change_user_password/2" do | ||||||
|  |     test "returns a user changeset" do | ||||||
|  |       assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) | ||||||
|  |       assert changeset.required == [:password] | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "allows fields to be set" do | ||||||
|  |       changeset = | ||||||
|  |         Accounts.change_user_password(%User{}, %{ | ||||||
|  |           "password" => "new valid password" | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert changeset.valid? | ||||||
|  |       assert get_change(changeset, :password) == "new valid password" | ||||||
|  |       assert is_nil(get_change(changeset, :hashed_password)) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "update_user_password/3" do | ||||||
|  |     setup do | ||||||
|  |       %{user: user_fixture()} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates password", %{user: user} do | ||||||
|  |       {:error, changeset} = | ||||||
|  |         Accounts.update_user_password(user, valid_user_password(), %{ | ||||||
|  |           password: "not valid", | ||||||
|  |           password_confirmation: "another" | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert %{ | ||||||
|  |                password: ["should be at least 12 character(s)"], | ||||||
|  |                password_confirmation: ["does not match password"] | ||||||
|  |              } = errors_on(changeset) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates maximum values for password for security", %{user: user} do | ||||||
|  |       too_long = String.duplicate("db", 100) | ||||||
|  |  | ||||||
|  |       {:error, changeset} = | ||||||
|  |         Accounts.update_user_password(user, valid_user_password(), %{password: too_long}) | ||||||
|  |  | ||||||
|  |       assert "should be at most 72 character(s)" in errors_on(changeset).password | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates current password", %{user: user} do | ||||||
|  |       {:error, changeset} = | ||||||
|  |         Accounts.update_user_password(user, "invalid", %{password: valid_user_password()}) | ||||||
|  |  | ||||||
|  |       assert %{current_password: ["is not valid"]} = errors_on(changeset) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "updates the password", %{user: user} do | ||||||
|  |       {:ok, user} = | ||||||
|  |         Accounts.update_user_password(user, valid_user_password(), %{ | ||||||
|  |           password: "new valid password" | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert is_nil(user.password) | ||||||
|  |       assert Accounts.get_user_by_email_and_password(user.email, "new valid password") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "deletes all tokens for the given user", %{user: user} do | ||||||
|  |       _ = Accounts.generate_user_session_token(user) | ||||||
|  |  | ||||||
|  |       {:ok, _} = | ||||||
|  |         Accounts.update_user_password(user, valid_user_password(), %{ | ||||||
|  |           password: "new valid password" | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       refute Repo.get_by(UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "generate_user_session_token/1" do | ||||||
|  |     setup do | ||||||
|  |       %{user: user_fixture()} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "generates a token", %{user: user} do | ||||||
|  |       token = Accounts.generate_user_session_token(user) | ||||||
|  |       assert user_token = Repo.get_by(UserToken, token: token) | ||||||
|  |       assert user_token.context == "session" | ||||||
|  |  | ||||||
|  |       # Creating the same token for another user should fail | ||||||
|  |       assert_raise Ecto.ConstraintError, fn -> | ||||||
|  |         Repo.insert!(%UserToken{ | ||||||
|  |           token: user_token.token, | ||||||
|  |           user_id: user_fixture().id, | ||||||
|  |           context: "session" | ||||||
|  |         }) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "get_user_by_session_token/1" do | ||||||
|  |     setup do | ||||||
|  |       user = user_fixture() | ||||||
|  |       token = Accounts.generate_user_session_token(user) | ||||||
|  |       %{user: user, token: token} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "returns user by token", %{user: user, token: token} do | ||||||
|  |       assert session_user = Accounts.get_user_by_session_token(token) | ||||||
|  |       assert session_user.id == user.id | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not return user for invalid token" do | ||||||
|  |       refute Accounts.get_user_by_session_token("oops") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not return user for expired token", %{token: token} do | ||||||
|  |       {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) | ||||||
|  |       refute Accounts.get_user_by_session_token(token) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "delete_user_session_token/1" do | ||||||
|  |     test "deletes the token" do | ||||||
|  |       user = user_fixture() | ||||||
|  |       token = Accounts.generate_user_session_token(user) | ||||||
|  |       assert Accounts.delete_user_session_token(token) == :ok | ||||||
|  |       refute Accounts.get_user_by_session_token(token) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "deliver_user_confirmation_instructions/2" do | ||||||
|  |     setup do | ||||||
|  |       %{user: user_fixture()} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "sends token through notification", %{user: user} do | ||||||
|  |       token = | ||||||
|  |         extract_user_token(fn url -> | ||||||
|  |           Accounts.deliver_user_confirmation_instructions(user, url) | ||||||
|  |         end) | ||||||
|  |  | ||||||
|  |       {:ok, token} = Base.url_decode64(token, padding: false) | ||||||
|  |       assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) | ||||||
|  |       assert user_token.user_id == user.id | ||||||
|  |       assert user_token.sent_to == user.email | ||||||
|  |       assert user_token.context == "confirm" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "confirm_user/1" do | ||||||
|  |     setup do | ||||||
|  |       user = user_fixture() | ||||||
|  |  | ||||||
|  |       token = | ||||||
|  |         extract_user_token(fn url -> | ||||||
|  |           Accounts.deliver_user_confirmation_instructions(user, url) | ||||||
|  |         end) | ||||||
|  |  | ||||||
|  |       %{user: user, token: token} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "confirms the email with a valid token", %{user: user, token: token} do | ||||||
|  |       assert {:ok, confirmed_user} = Accounts.confirm_user(token) | ||||||
|  |       assert confirmed_user.confirmed_at | ||||||
|  |       assert confirmed_user.confirmed_at != user.confirmed_at | ||||||
|  |       assert Repo.get!(User, user.id).confirmed_at | ||||||
|  |       refute Repo.get_by(UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not confirm with invalid token", %{user: user} do | ||||||
|  |       assert Accounts.confirm_user("oops") == :error | ||||||
|  |       refute Repo.get!(User, user.id).confirmed_at | ||||||
|  |       assert Repo.get_by(UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not confirm email if token expired", %{user: user, token: token} do | ||||||
|  |       {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) | ||||||
|  |       assert Accounts.confirm_user(token) == :error | ||||||
|  |       refute Repo.get!(User, user.id).confirmed_at | ||||||
|  |       assert Repo.get_by(UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "deliver_user_reset_password_instructions/2" do | ||||||
|  |     setup do | ||||||
|  |       %{user: user_fixture()} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "sends token through notification", %{user: user} do | ||||||
|  |       token = | ||||||
|  |         extract_user_token(fn url -> | ||||||
|  |           Accounts.deliver_user_reset_password_instructions(user, url) | ||||||
|  |         end) | ||||||
|  |  | ||||||
|  |       {:ok, token} = Base.url_decode64(token, padding: false) | ||||||
|  |       assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) | ||||||
|  |       assert user_token.user_id == user.id | ||||||
|  |       assert user_token.sent_to == user.email | ||||||
|  |       assert user_token.context == "reset_password" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "get_user_by_reset_password_token/1" do | ||||||
|  |     setup do | ||||||
|  |       user = user_fixture() | ||||||
|  |  | ||||||
|  |       token = | ||||||
|  |         extract_user_token(fn url -> | ||||||
|  |           Accounts.deliver_user_reset_password_instructions(user, url) | ||||||
|  |         end) | ||||||
|  |  | ||||||
|  |       %{user: user, token: token} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "returns the user with valid token", %{user: %{id: id}, token: token} do | ||||||
|  |       assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token) | ||||||
|  |       assert Repo.get_by(UserToken, user_id: id) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not return the user with invalid token", %{user: user} do | ||||||
|  |       refute Accounts.get_user_by_reset_password_token("oops") | ||||||
|  |       assert Repo.get_by(UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not return the user if token expired", %{user: user, token: token} do | ||||||
|  |       {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) | ||||||
|  |       refute Accounts.get_user_by_reset_password_token(token) | ||||||
|  |       assert Repo.get_by(UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "reset_user_password/2" do | ||||||
|  |     setup do | ||||||
|  |       %{user: user_fixture()} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates password", %{user: user} do | ||||||
|  |       {:error, changeset} = | ||||||
|  |         Accounts.reset_user_password(user, %{ | ||||||
|  |           password: "not valid", | ||||||
|  |           password_confirmation: "another" | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert %{ | ||||||
|  |                password: ["should be at least 12 character(s)"], | ||||||
|  |                password_confirmation: ["does not match password"] | ||||||
|  |              } = errors_on(changeset) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "validates maximum values for password for security", %{user: user} do | ||||||
|  |       too_long = String.duplicate("db", 100) | ||||||
|  |       {:error, changeset} = Accounts.reset_user_password(user, %{password: too_long}) | ||||||
|  |       assert "should be at most 72 character(s)" in errors_on(changeset).password | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "updates the password", %{user: user} do | ||||||
|  |       {:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"}) | ||||||
|  |       assert is_nil(updated_user.password) | ||||||
|  |       assert Accounts.get_user_by_email_and_password(user.email, "new valid password") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "deletes all tokens for the given user", %{user: user} do | ||||||
|  |       _ = Accounts.generate_user_session_token(user) | ||||||
|  |       {:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"}) | ||||||
|  |       refute Repo.get_by(UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "inspect/2 for the User module" do | ||||||
|  |     test "does not include password" do | ||||||
|  |       refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										113
									
								
								test/tradex_web/controllers/user_session_controller_test.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								test/tradex_web/controllers/user_session_controller_test.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | |||||||
|  | defmodule TradexWeb.UserSessionControllerTest do | ||||||
|  |   use TradexWeb.ConnCase, async: true | ||||||
|  |  | ||||||
|  |   import Tradex.AccountsFixtures | ||||||
|  |  | ||||||
|  |   setup do | ||||||
|  |     %{user: user_fixture()} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "POST /users/log_in" do | ||||||
|  |     test "logs the user in", %{conn: conn, user: user} do | ||||||
|  |       conn = | ||||||
|  |         post(conn, ~p"/users/log_in", %{ | ||||||
|  |           "user" => %{"email" => user.email, "password" => valid_user_password()} | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert get_session(conn, :user_token) | ||||||
|  |       assert redirected_to(conn) == ~p"/" | ||||||
|  |  | ||||||
|  |       # Now do a logged in request and assert on the menu | ||||||
|  |       conn = get(conn, ~p"/") | ||||||
|  |       response = html_response(conn, 200) | ||||||
|  |       assert response =~ user.email | ||||||
|  |       assert response =~ ~p"/users/settings" | ||||||
|  |       assert response =~ ~p"/users/log_out" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "logs the user in with remember me", %{conn: conn, user: user} do | ||||||
|  |       conn = | ||||||
|  |         post(conn, ~p"/users/log_in", %{ | ||||||
|  |           "user" => %{ | ||||||
|  |             "email" => user.email, | ||||||
|  |             "password" => valid_user_password(), | ||||||
|  |             "remember_me" => "true" | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert conn.resp_cookies["_tradex_web_user_remember_me"] | ||||||
|  |       assert redirected_to(conn) == ~p"/" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "logs the user in with return to", %{conn: conn, user: user} do | ||||||
|  |       conn = | ||||||
|  |         conn | ||||||
|  |         |> init_test_session(user_return_to: "/foo/bar") | ||||||
|  |         |> post(~p"/users/log_in", %{ | ||||||
|  |           "user" => %{ | ||||||
|  |             "email" => user.email, | ||||||
|  |             "password" => valid_user_password() | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert redirected_to(conn) == "/foo/bar" | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "login following registration", %{conn: conn, user: user} do | ||||||
|  |       conn = | ||||||
|  |         conn | ||||||
|  |         |> post(~p"/users/log_in", %{ | ||||||
|  |           "_action" => "registered", | ||||||
|  |           "user" => %{ | ||||||
|  |             "email" => user.email, | ||||||
|  |             "password" => valid_user_password() | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert redirected_to(conn) == ~p"/" | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "login following password update", %{conn: conn, user: user} do | ||||||
|  |       conn = | ||||||
|  |         conn | ||||||
|  |         |> post(~p"/users/log_in", %{ | ||||||
|  |           "_action" => "password_updated", | ||||||
|  |           "user" => %{ | ||||||
|  |             "email" => user.email, | ||||||
|  |             "password" => valid_user_password() | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert redirected_to(conn) == ~p"/users/settings" | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "redirects to login page with invalid credentials", %{conn: conn} do | ||||||
|  |       conn = | ||||||
|  |         post(conn, ~p"/users/log_in", %{ | ||||||
|  |           "user" => %{"email" => "invalid@email.com", "password" => "invalid_password"} | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" | ||||||
|  |       assert redirected_to(conn) == ~p"/users/log_in" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "DELETE /users/log_out" do | ||||||
|  |     test "logs the user out", %{conn: conn, user: user} do | ||||||
|  |       conn = conn |> log_in_user(user) |> delete(~p"/users/log_out") | ||||||
|  |       assert redirected_to(conn) == ~p"/" | ||||||
|  |       refute get_session(conn, :user_token) | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "succeeds even if the user is not logged in", %{conn: conn} do | ||||||
|  |       conn = delete(conn, ~p"/users/log_out") | ||||||
|  |       assert redirected_to(conn) == ~p"/" | ||||||
|  |       refute get_session(conn, :user_token) | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -0,0 +1,67 @@ | |||||||
|  | defmodule TradexWeb.UserConfirmationInstructionsLiveTest do | ||||||
|  |   use TradexWeb.ConnCase, async: true | ||||||
|  |  | ||||||
|  |   import Phoenix.LiveViewTest | ||||||
|  |   import Tradex.AccountsFixtures | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |   alias Tradex.Repo | ||||||
|  |  | ||||||
|  |   setup do | ||||||
|  |     %{user: user_fixture()} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "Resend confirmation" do | ||||||
|  |     test "renders the resend confirmation page", %{conn: conn} do | ||||||
|  |       {:ok, _lv, html} = live(conn, ~p"/users/confirm") | ||||||
|  |       assert html =~ "Resend confirmation instructions" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "sends a new confirmation token", %{conn: conn, user: user} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/confirm") | ||||||
|  |  | ||||||
|  |       {:ok, conn} = | ||||||
|  |         lv | ||||||
|  |         |> form("#resend_confirmation_form", user: %{email: user.email}) | ||||||
|  |         |> render_submit() | ||||||
|  |         |> follow_redirect(conn, ~p"/") | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ | ||||||
|  |                "If your email is in our system" | ||||||
|  |  | ||||||
|  |       assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do | ||||||
|  |       Repo.update!(Accounts.User.confirm_changeset(user)) | ||||||
|  |  | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/confirm") | ||||||
|  |  | ||||||
|  |       {:ok, conn} = | ||||||
|  |         lv | ||||||
|  |         |> form("#resend_confirmation_form", user: %{email: user.email}) | ||||||
|  |         |> render_submit() | ||||||
|  |         |> follow_redirect(conn, ~p"/") | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ | ||||||
|  |                "If your email is in our system" | ||||||
|  |  | ||||||
|  |       refute Repo.get_by(Accounts.UserToken, user_id: user.id) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not send confirmation token if email is invalid", %{conn: conn} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/confirm") | ||||||
|  |  | ||||||
|  |       {:ok, conn} = | ||||||
|  |         lv | ||||||
|  |         |> form("#resend_confirmation_form", user: %{email: "unknown@example.com"}) | ||||||
|  |         |> render_submit() | ||||||
|  |         |> follow_redirect(conn, ~p"/") | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ | ||||||
|  |                "If your email is in our system" | ||||||
|  |  | ||||||
|  |       assert Repo.all(Accounts.UserToken) == [] | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										89
									
								
								test/tradex_web/live/user_confirmation_live_test.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								test/tradex_web/live/user_confirmation_live_test.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | |||||||
|  | defmodule TradexWeb.UserConfirmationLiveTest do | ||||||
|  |   use TradexWeb.ConnCase, async: true | ||||||
|  |  | ||||||
|  |   import Phoenix.LiveViewTest | ||||||
|  |   import Tradex.AccountsFixtures | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |   alias Tradex.Repo | ||||||
|  |  | ||||||
|  |   setup do | ||||||
|  |     %{user: user_fixture()} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "Confirm user" do | ||||||
|  |     test "renders confirmation page", %{conn: conn} do | ||||||
|  |       {:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token") | ||||||
|  |       assert html =~ "Confirm Account" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "confirms the given token once", %{conn: conn, user: user} do | ||||||
|  |       token = | ||||||
|  |         extract_user_token(fn url -> | ||||||
|  |           Accounts.deliver_user_confirmation_instructions(user, url) | ||||||
|  |         end) | ||||||
|  |  | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> form("#confirmation_form") | ||||||
|  |         |> render_submit() | ||||||
|  |         |> follow_redirect(conn, "/") | ||||||
|  |  | ||||||
|  |       assert {:ok, conn} = result | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ | ||||||
|  |                "User confirmed successfully" | ||||||
|  |  | ||||||
|  |       assert Accounts.get_user!(user.id).confirmed_at | ||||||
|  |       refute get_session(conn, :user_token) | ||||||
|  |       assert Repo.all(Accounts.UserToken) == [] | ||||||
|  |  | ||||||
|  |       # when not logged in | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> form("#confirmation_form") | ||||||
|  |         |> render_submit() | ||||||
|  |         |> follow_redirect(conn, "/") | ||||||
|  |  | ||||||
|  |       assert {:ok, conn} = result | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ | ||||||
|  |                "User confirmation link is invalid or it has expired" | ||||||
|  |  | ||||||
|  |       # when logged in | ||||||
|  |       conn = | ||||||
|  |         build_conn() | ||||||
|  |         |> log_in_user(user) | ||||||
|  |  | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}") | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> form("#confirmation_form") | ||||||
|  |         |> render_submit() | ||||||
|  |         |> follow_redirect(conn, "/") | ||||||
|  |  | ||||||
|  |       assert {:ok, conn} = result | ||||||
|  |       refute Phoenix.Flash.get(conn.assigns.flash, :error) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not confirm email with invalid token", %{conn: conn, user: user} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token") | ||||||
|  |  | ||||||
|  |       {:ok, conn} = | ||||||
|  |         lv | ||||||
|  |         |> form("#confirmation_form") | ||||||
|  |         |> render_submit() | ||||||
|  |         |> follow_redirect(conn, ~p"/") | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ | ||||||
|  |                "User confirmation link is invalid or it has expired" | ||||||
|  |  | ||||||
|  |       refute Accounts.get_user!(user.id).confirmed_at | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										63
									
								
								test/tradex_web/live/user_forgot_password_live_test.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								test/tradex_web/live/user_forgot_password_live_test.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | |||||||
|  | defmodule TradexWeb.UserForgotPasswordLiveTest do | ||||||
|  |   use TradexWeb.ConnCase, async: true | ||||||
|  |  | ||||||
|  |   import Phoenix.LiveViewTest | ||||||
|  |   import Tradex.AccountsFixtures | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |   alias Tradex.Repo | ||||||
|  |  | ||||||
|  |   describe "Forgot password page" do | ||||||
|  |     test "renders email page", %{conn: conn} do | ||||||
|  |       {:ok, lv, html} = live(conn, ~p"/users/reset_password") | ||||||
|  |  | ||||||
|  |       assert html =~ "Forgot your password?" | ||||||
|  |       assert has_element?(lv, ~s|a[href="#{~p"/users/register"}"]|, "Register") | ||||||
|  |       assert has_element?(lv, ~s|a[href="#{~p"/users/log_in"}"]|, "Log in") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "redirects if already logged in", %{conn: conn} do | ||||||
|  |       result = | ||||||
|  |         conn | ||||||
|  |         |> log_in_user(user_fixture()) | ||||||
|  |         |> live(~p"/users/reset_password") | ||||||
|  |         |> follow_redirect(conn, ~p"/") | ||||||
|  |  | ||||||
|  |       assert {:ok, _conn} = result | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "Reset link" do | ||||||
|  |     setup do | ||||||
|  |       %{user: user_fixture()} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "sends a new reset password token", %{conn: conn, user: user} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/reset_password") | ||||||
|  |  | ||||||
|  |       {:ok, conn} = | ||||||
|  |         lv | ||||||
|  |         |> form("#reset_password_form", user: %{"email" => user.email}) | ||||||
|  |         |> render_submit() | ||||||
|  |         |> follow_redirect(conn, "/") | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" | ||||||
|  |  | ||||||
|  |       assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == | ||||||
|  |                "reset_password" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not send reset password token if email is invalid", %{conn: conn} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/reset_password") | ||||||
|  |  | ||||||
|  |       {:ok, conn} = | ||||||
|  |         lv | ||||||
|  |         |> form("#reset_password_form", user: %{"email" => "unknown@example.com"}) | ||||||
|  |         |> render_submit() | ||||||
|  |         |> follow_redirect(conn, "/") | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system" | ||||||
|  |       assert Repo.all(Accounts.UserToken) == [] | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										87
									
								
								test/tradex_web/live/user_login_live_test.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								test/tradex_web/live/user_login_live_test.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | |||||||
|  | defmodule TradexWeb.UserLoginLiveTest do | ||||||
|  |   use TradexWeb.ConnCase, async: true | ||||||
|  |  | ||||||
|  |   import Phoenix.LiveViewTest | ||||||
|  |   import Tradex.AccountsFixtures | ||||||
|  |  | ||||||
|  |   describe "Log in page" do | ||||||
|  |     test "renders log in page", %{conn: conn} do | ||||||
|  |       {:ok, _lv, html} = live(conn, ~p"/users/log_in") | ||||||
|  |  | ||||||
|  |       assert html =~ "Log in" | ||||||
|  |       assert html =~ "Register" | ||||||
|  |       assert html =~ "Forgot your password?" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "redirects if already logged in", %{conn: conn} do | ||||||
|  |       result = | ||||||
|  |         conn | ||||||
|  |         |> log_in_user(user_fixture()) | ||||||
|  |         |> live(~p"/users/log_in") | ||||||
|  |         |> follow_redirect(conn, "/") | ||||||
|  |  | ||||||
|  |       assert {:ok, _conn} = result | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "user login" do | ||||||
|  |     test "redirects if user login with valid credentials", %{conn: conn} do | ||||||
|  |       password = "123456789abcd" | ||||||
|  |       user = user_fixture(%{password: password}) | ||||||
|  |  | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/log_in") | ||||||
|  |  | ||||||
|  |       form = | ||||||
|  |         form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true}) | ||||||
|  |  | ||||||
|  |       conn = submit_form(form, conn) | ||||||
|  |  | ||||||
|  |       assert redirected_to(conn) == ~p"/" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "redirects to login page with a flash error if there are no valid credentials", %{ | ||||||
|  |       conn: conn | ||||||
|  |     } do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/log_in") | ||||||
|  |  | ||||||
|  |       form = | ||||||
|  |         form(lv, "#login_form", | ||||||
|  |           user: %{email: "test@email.com", password: "123456", remember_me: true} | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |       conn = submit_form(form, conn) | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" | ||||||
|  |  | ||||||
|  |       assert redirected_to(conn) == "/users/log_in" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "login navigation" do | ||||||
|  |     test "redirects to registration page when the Register button is clicked", %{conn: conn} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/log_in") | ||||||
|  |  | ||||||
|  |       {:ok, _login_live, login_html} = | ||||||
|  |         lv | ||||||
|  |         |> element(~s|main a:fl-contains("Sign up")|) | ||||||
|  |         |> render_click() | ||||||
|  |         |> follow_redirect(conn, ~p"/users/register") | ||||||
|  |  | ||||||
|  |       assert login_html =~ "Register" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "redirects to forgot password page when the Forgot Password button is clicked", %{ | ||||||
|  |       conn: conn | ||||||
|  |     } do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/log_in") | ||||||
|  |  | ||||||
|  |       {:ok, conn} = | ||||||
|  |         lv | ||||||
|  |         |> element(~s|main a:fl-contains("Forgot your password?")|) | ||||||
|  |         |> render_click() | ||||||
|  |         |> follow_redirect(conn, ~p"/users/reset_password") | ||||||
|  |  | ||||||
|  |       assert conn.resp_body =~ "Forgot your password?" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										87
									
								
								test/tradex_web/live/user_registration_live_test.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								test/tradex_web/live/user_registration_live_test.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | |||||||
|  | defmodule TradexWeb.UserRegistrationLiveTest do | ||||||
|  |   use TradexWeb.ConnCase, async: true | ||||||
|  |  | ||||||
|  |   import Phoenix.LiveViewTest | ||||||
|  |   import Tradex.AccountsFixtures | ||||||
|  |  | ||||||
|  |   describe "Registration page" do | ||||||
|  |     test "renders registration page", %{conn: conn} do | ||||||
|  |       {:ok, _lv, html} = live(conn, ~p"/users/register") | ||||||
|  |  | ||||||
|  |       assert html =~ "Register" | ||||||
|  |       assert html =~ "Log in" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "redirects if already logged in", %{conn: conn} do | ||||||
|  |       result = | ||||||
|  |         conn | ||||||
|  |         |> log_in_user(user_fixture()) | ||||||
|  |         |> live(~p"/users/register") | ||||||
|  |         |> follow_redirect(conn, "/") | ||||||
|  |  | ||||||
|  |       assert {:ok, _conn} = result | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "renders errors for invalid data", %{conn: conn} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/register") | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> element("#registration_form") | ||||||
|  |         |> render_change(user: %{"email" => "with spaces", "password" => "too short"}) | ||||||
|  |  | ||||||
|  |       assert result =~ "Register" | ||||||
|  |       assert result =~ "must have the @ sign and no spaces" | ||||||
|  |       assert result =~ "should be at least 12 character" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "register user" do | ||||||
|  |     test "creates account and logs the user in", %{conn: conn} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/register") | ||||||
|  |  | ||||||
|  |       email = unique_user_email() | ||||||
|  |       form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) | ||||||
|  |       render_submit(form) | ||||||
|  |       conn = follow_trigger_action(form, conn) | ||||||
|  |  | ||||||
|  |       assert redirected_to(conn) == ~p"/" | ||||||
|  |  | ||||||
|  |       # Now do a logged in request and assert on the menu | ||||||
|  |       conn = get(conn, "/") | ||||||
|  |       response = html_response(conn, 200) | ||||||
|  |       assert response =~ email | ||||||
|  |       assert response =~ "Settings" | ||||||
|  |       assert response =~ "Log out" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "renders errors for duplicated email", %{conn: conn} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/register") | ||||||
|  |  | ||||||
|  |       user = user_fixture(%{email: "test@email.com"}) | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> form("#registration_form", | ||||||
|  |           user: %{"email" => user.email, "password" => "valid_password"} | ||||||
|  |         ) | ||||||
|  |         |> render_submit() | ||||||
|  |  | ||||||
|  |       assert result =~ "has already been taken" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "registration navigation" do | ||||||
|  |     test "redirects to login page when the Log in button is clicked", %{conn: conn} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/register") | ||||||
|  |  | ||||||
|  |       {:ok, _login_live, login_html} = | ||||||
|  |         lv | ||||||
|  |         |> element(~s|main a:fl-contains("Log in")|) | ||||||
|  |         |> render_click() | ||||||
|  |         |> follow_redirect(conn, ~p"/users/log_in") | ||||||
|  |  | ||||||
|  |       assert login_html =~ "Log in" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										118
									
								
								test/tradex_web/live/user_reset_password_live_test.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								test/tradex_web/live/user_reset_password_live_test.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,118 @@ | |||||||
|  | defmodule TradexWeb.UserResetPasswordLiveTest do | ||||||
|  |   use TradexWeb.ConnCase, async: true | ||||||
|  |  | ||||||
|  |   import Phoenix.LiveViewTest | ||||||
|  |   import Tradex.AccountsFixtures | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |  | ||||||
|  |   setup do | ||||||
|  |     user = user_fixture() | ||||||
|  |  | ||||||
|  |     token = | ||||||
|  |       extract_user_token(fn url -> | ||||||
|  |         Accounts.deliver_user_reset_password_instructions(user, url) | ||||||
|  |       end) | ||||||
|  |  | ||||||
|  |     %{token: token, user: user} | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "Reset password page" do | ||||||
|  |     test "renders reset password with valid token", %{conn: conn, token: token} do | ||||||
|  |       {:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}") | ||||||
|  |  | ||||||
|  |       assert html =~ "Reset Password" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not render reset password with invalid token", %{conn: conn} do | ||||||
|  |       {:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid") | ||||||
|  |  | ||||||
|  |       assert to == %{ | ||||||
|  |                flash: %{"error" => "Reset password link is invalid or it has expired."}, | ||||||
|  |                to: ~p"/" | ||||||
|  |              } | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "renders errors for invalid data", %{conn: conn, token: token} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> element("#reset_password_form") | ||||||
|  |         |> render_change( | ||||||
|  |           user: %{"password" => "secret12", "password_confirmation" => "secret123456"} | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |       assert result =~ "should be at least 12 character" | ||||||
|  |       assert result =~ "does not match password" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "Reset Password" do | ||||||
|  |     test "resets password once", %{conn: conn, token: token, user: user} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") | ||||||
|  |  | ||||||
|  |       {:ok, conn} = | ||||||
|  |         lv | ||||||
|  |         |> form("#reset_password_form", | ||||||
|  |           user: %{ | ||||||
|  |             "password" => "new valid password", | ||||||
|  |             "password_confirmation" => "new valid password" | ||||||
|  |           } | ||||||
|  |         ) | ||||||
|  |         |> render_submit() | ||||||
|  |         |> follow_redirect(conn, ~p"/users/log_in") | ||||||
|  |  | ||||||
|  |       refute get_session(conn, :user_token) | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" | ||||||
|  |       assert Accounts.get_user_by_email_and_password(user.email, "new valid password") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not reset password on invalid data", %{conn: conn, token: token} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> form("#reset_password_form", | ||||||
|  |           user: %{ | ||||||
|  |             "password" => "too short", | ||||||
|  |             "password_confirmation" => "does not match" | ||||||
|  |           } | ||||||
|  |         ) | ||||||
|  |         |> render_submit() | ||||||
|  |  | ||||||
|  |       assert result =~ "Reset Password" | ||||||
|  |       assert result =~ "should be at least 12 character(s)" | ||||||
|  |       assert result =~ "does not match password" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "Reset password navigation" do | ||||||
|  |     test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") | ||||||
|  |  | ||||||
|  |       {:ok, conn} = | ||||||
|  |         lv | ||||||
|  |         |> element(~s|main a:fl-contains("Log in")|) | ||||||
|  |         |> render_click() | ||||||
|  |         |> follow_redirect(conn, ~p"/users/log_in") | ||||||
|  |  | ||||||
|  |       assert conn.resp_body =~ "Log in" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "redirects to registration page when the Register button is clicked", %{ | ||||||
|  |       conn: conn, | ||||||
|  |       token: token | ||||||
|  |     } do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}") | ||||||
|  |  | ||||||
|  |       {:ok, conn} = | ||||||
|  |         lv | ||||||
|  |         |> element(~s|main a:fl-contains("Register")|) | ||||||
|  |         |> render_click() | ||||||
|  |         |> follow_redirect(conn, ~p"/users/register") | ||||||
|  |  | ||||||
|  |       assert conn.resp_body =~ "Register" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										210
									
								
								test/tradex_web/live/user_settings_live_test.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								test/tradex_web/live/user_settings_live_test.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,210 @@ | |||||||
|  | defmodule TradexWeb.UserSettingsLiveTest do | ||||||
|  |   use TradexWeb.ConnCase, async: true | ||||||
|  |  | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |   import Phoenix.LiveViewTest | ||||||
|  |   import Tradex.AccountsFixtures | ||||||
|  |  | ||||||
|  |   describe "Settings page" do | ||||||
|  |     test "renders settings page", %{conn: conn} do | ||||||
|  |       {:ok, _lv, html} = | ||||||
|  |         conn | ||||||
|  |         |> log_in_user(user_fixture()) | ||||||
|  |         |> live(~p"/users/settings") | ||||||
|  |  | ||||||
|  |       assert html =~ "Change Email" | ||||||
|  |       assert html =~ "Change Password" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "redirects if user is not logged in", %{conn: conn} do | ||||||
|  |       assert {:error, redirect} = live(conn, ~p"/users/settings") | ||||||
|  |  | ||||||
|  |       assert {:redirect, %{to: path, flash: flash}} = redirect | ||||||
|  |       assert path == ~p"/users/log_in" | ||||||
|  |       assert %{"error" => "You must log in to access this page."} = flash | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "update email form" do | ||||||
|  |     setup %{conn: conn} do | ||||||
|  |       password = valid_user_password() | ||||||
|  |       user = user_fixture(%{password: password}) | ||||||
|  |       %{conn: log_in_user(conn, user), user: user, password: password} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "updates the user email", %{conn: conn, password: password, user: user} do | ||||||
|  |       new_email = unique_user_email() | ||||||
|  |  | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/settings") | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> form("#email_form", %{ | ||||||
|  |           "current_password" => password, | ||||||
|  |           "user" => %{"email" => new_email} | ||||||
|  |         }) | ||||||
|  |         |> render_submit() | ||||||
|  |  | ||||||
|  |       assert result =~ "A link to confirm your email" | ||||||
|  |       assert Accounts.get_user_by_email(user.email) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "renders errors with invalid data (phx-change)", %{conn: conn} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/settings") | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> element("#email_form") | ||||||
|  |         |> render_change(%{ | ||||||
|  |           "action" => "update_email", | ||||||
|  |           "current_password" => "invalid", | ||||||
|  |           "user" => %{"email" => "with spaces"} | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert result =~ "Change Email" | ||||||
|  |       assert result =~ "must have the @ sign and no spaces" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/settings") | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> form("#email_form", %{ | ||||||
|  |           "current_password" => "invalid", | ||||||
|  |           "user" => %{"email" => user.email} | ||||||
|  |         }) | ||||||
|  |         |> render_submit() | ||||||
|  |  | ||||||
|  |       assert result =~ "Change Email" | ||||||
|  |       assert result =~ "did not change" | ||||||
|  |       assert result =~ "is not valid" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "update password form" do | ||||||
|  |     setup %{conn: conn} do | ||||||
|  |       password = valid_user_password() | ||||||
|  |       user = user_fixture(%{password: password}) | ||||||
|  |       %{conn: log_in_user(conn, user), user: user, password: password} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "updates the user password", %{conn: conn, user: user, password: password} do | ||||||
|  |       new_password = valid_user_password() | ||||||
|  |  | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/settings") | ||||||
|  |  | ||||||
|  |       form = | ||||||
|  |         form(lv, "#password_form", %{ | ||||||
|  |           "current_password" => password, | ||||||
|  |           "user" => %{ | ||||||
|  |             "email" => user.email, | ||||||
|  |             "password" => new_password, | ||||||
|  |             "password_confirmation" => new_password | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       render_submit(form) | ||||||
|  |  | ||||||
|  |       new_password_conn = follow_trigger_action(form, conn) | ||||||
|  |  | ||||||
|  |       assert redirected_to(new_password_conn) == ~p"/users/settings" | ||||||
|  |  | ||||||
|  |       assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ | ||||||
|  |                "Password updated successfully" | ||||||
|  |  | ||||||
|  |       assert Accounts.get_user_by_email_and_password(user.email, new_password) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "renders errors with invalid data (phx-change)", %{conn: conn} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/settings") | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> element("#password_form") | ||||||
|  |         |> render_change(%{ | ||||||
|  |           "current_password" => "invalid", | ||||||
|  |           "user" => %{ | ||||||
|  |             "password" => "too short", | ||||||
|  |             "password_confirmation" => "does not match" | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |       assert result =~ "Change Password" | ||||||
|  |       assert result =~ "should be at least 12 character(s)" | ||||||
|  |       assert result =~ "does not match password" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "renders errors with invalid data (phx-submit)", %{conn: conn} do | ||||||
|  |       {:ok, lv, _html} = live(conn, ~p"/users/settings") | ||||||
|  |  | ||||||
|  |       result = | ||||||
|  |         lv | ||||||
|  |         |> form("#password_form", %{ | ||||||
|  |           "current_password" => "invalid", | ||||||
|  |           "user" => %{ | ||||||
|  |             "password" => "too short", | ||||||
|  |             "password_confirmation" => "does not match" | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |         |> render_submit() | ||||||
|  |  | ||||||
|  |       assert result =~ "Change Password" | ||||||
|  |       assert result =~ "should be at least 12 character(s)" | ||||||
|  |       assert result =~ "does not match password" | ||||||
|  |       assert result =~ "is not valid" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "confirm email" do | ||||||
|  |     setup %{conn: conn} do | ||||||
|  |       user = user_fixture() | ||||||
|  |       email = unique_user_email() | ||||||
|  |  | ||||||
|  |       token = | ||||||
|  |         extract_user_token(fn url -> | ||||||
|  |           Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) | ||||||
|  |         end) | ||||||
|  |  | ||||||
|  |       %{conn: log_in_user(conn, user), token: token, email: email, user: user} | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do | ||||||
|  |       {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") | ||||||
|  |  | ||||||
|  |       assert {:live_redirect, %{to: path, flash: flash}} = redirect | ||||||
|  |       assert path == ~p"/users/settings" | ||||||
|  |       assert %{"info" => message} = flash | ||||||
|  |       assert message == "Email changed successfully." | ||||||
|  |       refute Accounts.get_user_by_email(user.email) | ||||||
|  |       assert Accounts.get_user_by_email(email) | ||||||
|  |  | ||||||
|  |       # use confirm token again | ||||||
|  |       {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") | ||||||
|  |       assert {:live_redirect, %{to: path, flash: flash}} = redirect | ||||||
|  |       assert path == ~p"/users/settings" | ||||||
|  |       assert %{"error" => message} = flash | ||||||
|  |       assert message == "Email change link is invalid or it has expired." | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not update email with invalid token", %{conn: conn, user: user} do | ||||||
|  |       {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops") | ||||||
|  |       assert {:live_redirect, %{to: path, flash: flash}} = redirect | ||||||
|  |       assert path == ~p"/users/settings" | ||||||
|  |       assert %{"error" => message} = flash | ||||||
|  |       assert message == "Email change link is invalid or it has expired." | ||||||
|  |       assert Accounts.get_user_by_email(user.email) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "redirects if user is not logged in", %{token: token} do | ||||||
|  |       conn = build_conn() | ||||||
|  |       {:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}") | ||||||
|  |       assert {:redirect, %{to: path, flash: flash}} = redirect | ||||||
|  |       assert path == ~p"/users/log_in" | ||||||
|  |       assert %{"error" => message} = flash | ||||||
|  |       assert message == "You must log in to access this page." | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										272
									
								
								test/tradex_web/user_auth_test.exs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										272
									
								
								test/tradex_web/user_auth_test.exs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,272 @@ | |||||||
|  | defmodule TradexWeb.UserAuthTest do | ||||||
|  |   use TradexWeb.ConnCase, async: true | ||||||
|  |  | ||||||
|  |   alias Phoenix.LiveView | ||||||
|  |   alias Tradex.Accounts | ||||||
|  |   alias TradexWeb.UserAuth | ||||||
|  |   import Tradex.AccountsFixtures | ||||||
|  |  | ||||||
|  |   @remember_me_cookie "_tradex_web_user_remember_me" | ||||||
|  |  | ||||||
|  |   setup %{conn: conn} do | ||||||
|  |     conn = | ||||||
|  |       conn | ||||||
|  |       |> Map.replace!(:secret_key_base, TradexWeb.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" | ||||||
|  |       TradexWeb.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: TradexWeb.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: TradexWeb.Endpoint, | ||||||
|  |         assigns: %{__changed__: %{}, flash: %{}} | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       {:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket) | ||||||
|  |       assert updated_socket.assigns.current_user == nil | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "on_mount :redirect_if_user_is_authenticated" do | ||||||
|  |     test "redirects if there is an authenticated  user ", %{conn: conn, user: user} do | ||||||
|  |       user_token = Accounts.generate_user_session_token(user) | ||||||
|  |       session = conn |> put_session(:user_token, user_token) |> get_session() | ||||||
|  |  | ||||||
|  |       assert {:halt, _updated_socket} = | ||||||
|  |                UserAuth.on_mount( | ||||||
|  |                  :redirect_if_user_is_authenticated, | ||||||
|  |                  %{}, | ||||||
|  |                  session, | ||||||
|  |                  %LiveView.Socket{} | ||||||
|  |                ) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "doesn't redirect if there is no authenticated user", %{conn: conn} do | ||||||
|  |       session = conn |> get_session() | ||||||
|  |  | ||||||
|  |       assert {:cont, _updated_socket} = | ||||||
|  |                UserAuth.on_mount( | ||||||
|  |                  :redirect_if_user_is_authenticated, | ||||||
|  |                  %{}, | ||||||
|  |                  session, | ||||||
|  |                  %LiveView.Socket{} | ||||||
|  |                ) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "redirect_if_user_is_authenticated/2" do | ||||||
|  |     test "redirects if user is authenticated", %{conn: conn, user: user} do | ||||||
|  |       conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) | ||||||
|  |       assert conn.halted | ||||||
|  |       assert redirected_to(conn) == ~p"/" | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not redirect if user is not authenticated", %{conn: conn} do | ||||||
|  |       conn = UserAuth.redirect_if_user_is_authenticated(conn, []) | ||||||
|  |       refute conn.halted | ||||||
|  |       refute conn.status | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe "require_authenticated_user/2" do | ||||||
|  |     test "redirects if user is not authenticated", %{conn: conn} do | ||||||
|  |       conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) | ||||||
|  |       assert conn.halted | ||||||
|  |  | ||||||
|  |       assert redirected_to(conn) == ~p"/users/log_in" | ||||||
|  |  | ||||||
|  |       assert Phoenix.Flash.get(conn.assigns.flash, :error) == | ||||||
|  |                "You must log in to access this page." | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "stores the path to redirect to on GET", %{conn: conn} do | ||||||
|  |       halted_conn = | ||||||
|  |         %{conn | path_info: ["foo"], query_string: ""} | ||||||
|  |         |> fetch_flash() | ||||||
|  |         |> UserAuth.require_authenticated_user([]) | ||||||
|  |  | ||||||
|  |       assert halted_conn.halted | ||||||
|  |       assert get_session(halted_conn, :user_return_to) == "/foo" | ||||||
|  |  | ||||||
|  |       halted_conn = | ||||||
|  |         %{conn | path_info: ["foo"], query_string: "bar=baz"} | ||||||
|  |         |> fetch_flash() | ||||||
|  |         |> UserAuth.require_authenticated_user([]) | ||||||
|  |  | ||||||
|  |       assert halted_conn.halted | ||||||
|  |       assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" | ||||||
|  |  | ||||||
|  |       halted_conn = | ||||||
|  |         %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} | ||||||
|  |         |> fetch_flash() | ||||||
|  |         |> UserAuth.require_authenticated_user([]) | ||||||
|  |  | ||||||
|  |       assert halted_conn.halted | ||||||
|  |       refute get_session(halted_conn, :user_return_to) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     test "does not redirect if user is authenticated", %{conn: conn, user: user} do | ||||||
|  |       conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) | ||||||
|  |       refute conn.halted | ||||||
|  |       refute conn.status | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
		Reference in New Issue
	
	Block a user