refactored for new project name
This commit is contained in:
parent
0039146cd4
commit
82ab1d1ea5
113 changed files with 417 additions and 412 deletions
lib/shift73k
452
lib/shift73k/accounts.ex
Normal file
452
lib/shift73k/accounts.ex
Normal file
|
@ -0,0 +1,452 @@
|
|||
defmodule Shift73k.Accounts do
|
||||
@moduledoc """
|
||||
The Accounts context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Shift73k.Repo
|
||||
alias Shift73k.Accounts.{User, UserToken, UserNotifier}
|
||||
alias Shift73kWeb.UserAuth
|
||||
|
||||
## Database getters
|
||||
|
||||
@doc """
|
||||
Returns the list of users.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_users()
|
||||
[%User{}, ...]
|
||||
|
||||
"""
|
||||
def list_users do
|
||||
Repo.all(User)
|
||||
end
|
||||
|
||||
@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)
|
||||
|
||||
@doc """
|
||||
Gets a single user.
|
||||
|
||||
Returns nil if the User does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user(123)
|
||||
%User{}
|
||||
|
||||
iex> get_user(456)
|
||||
nil
|
||||
|
||||
"""
|
||||
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 role for user registration. First user to register gets :admin role.
|
||||
Following users get :user role. Used by registration controller/liveview.
|
||||
Admins can choose role for new users they create, and modify other uses.
|
||||
"""
|
||||
def registration_role, do: (Repo.exists?(User) && :user) || :admin
|
||||
|
||||
def logout_user(%User{} = user) do
|
||||
# Delete all user tokens
|
||||
Repo.delete_all(UserToken.user_and_contexts_query(user, :all))
|
||||
|
||||
# Broadcast to all liveviews to immediately disconnect the user
|
||||
Shift73kWeb.Endpoint.broadcast_from(
|
||||
self(),
|
||||
UserAuth.pubsub_topic(),
|
||||
"logout_user",
|
||||
%{
|
||||
user: user
|
||||
}
|
||||
)
|
||||
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)
|
||||
end
|
||||
|
||||
## User Management: updates, deletes
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking user updates.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_update(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_update(user, attrs \\ %{})
|
||||
def change_user_update(nil, _), do: nil
|
||||
|
||||
def change_user_update(%User{} = user, attrs) do
|
||||
User.update_changeset(user, attrs, hash_password: true)
|
||||
end
|
||||
|
||||
# @doc """
|
||||
# Returns an `%Ecto.Changeset{}` for tracking singer_name updates.
|
||||
# """
|
||||
# def change_singer_name_update(%User{} = user, attrs \\ %{}) do
|
||||
# User.update_singer_name_changeset(user, attrs)
|
||||
# end
|
||||
|
||||
@doc """
|
||||
Updates the user given with attributes given
|
||||
"""
|
||||
def update_user(user, attrs) do
|
||||
user
|
||||
|> User.update_changeset(attrs, hash_password: true)
|
||||
|> Repo.update()
|
||||
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)
|
||||
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, %{"current_password" => curr_pw} = attrs) do
|
||||
user
|
||||
|> User.email_changeset(attrs)
|
||||
|> User.validate_current_password(curr_pw)
|
||||
|> Ecto.Changeset.apply_action(:update)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user email using the given token.
|
||||
|
||||
If the token matches, the user email is updated and the token is deleted.
|
||||
The confirmed_at date is also updated to the current time.
|
||||
"""
|
||||
def update_user_email(user, token) do
|
||||
context = "change:#{user.email}"
|
||||
|
||||
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
|
||||
%UserToken{sent_to: email} <- Repo.one(query),
|
||||
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
|
||||
:ok
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp user_email_multi(user, email, context) do
|
||||
changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset()
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, changeset)
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context]))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delivers the update email instructions to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_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)
|
||||
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, %{"current_password" => curr_pw} = attrs) do
|
||||
changeset =
|
||||
user
|
||||
|> User.password_changeset(attrs)
|
||||
|> User.validate_current_password(curr_pw)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, changeset)
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
## Session
|
||||
|
||||
@doc """
|
||||
Generates a session token.
|
||||
"""
|
||||
def generate_user_session_token(user) do
|
||||
{token, user_token} = UserToken.build_session_token(user)
|
||||
Repo.insert!(user_token)
|
||||
token
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user with the given signed token.
|
||||
"""
|
||||
def get_user_by_session_token(token) do
|
||||
{:ok, query} = UserToken.verify_session_token_query(token)
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes the signed token with the given context.
|
||||
"""
|
||||
def delete_session_token(token) do
|
||||
Repo.delete_all(UserToken.token_and_context_query(token, "session"))
|
||||
:ok
|
||||
end
|
||||
|
||||
## Confirmation
|
||||
|
||||
@doc """
|
||||
Delivers the confirmation email instructions to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1))
|
||||
{:error, :already_confirmed}
|
||||
|
||||
"""
|
||||
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
|
||||
when is_function(confirmation_url_fun, 1) do
|
||||
if user.confirmed_at do
|
||||
{:error, :already_confirmed}
|
||||
else
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms a user by the given token.
|
||||
|
||||
If the token matches, the user account is marked as confirmed
|
||||
and the token is deleted.
|
||||
"""
|
||||
def confirm_user(token) do
|
||||
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
|
||||
%User{} = user <- Repo.one(query),
|
||||
{:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do
|
||||
{:ok, user}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp confirm_user_multi(user) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, User.confirm_changeset(user))
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"]))
|
||||
end
|
||||
|
||||
## Reset password
|
||||
|
||||
@doc """
|
||||
Delivers the reset password email to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
|
||||
when is_function(reset_password_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user by reset password token.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_reset_password_token("validtoken")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_reset_password_token("invalidtoken")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_reset_password_token(token) do
|
||||
with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
|
||||
%User{} = user <- Repo.one(query) do
|
||||
user
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resets the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def reset_user_password(user, attrs) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
## Management Actions
|
||||
|
||||
@doc """
|
||||
Deletes a user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_user(user)
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> delete_user(user)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_user(nil), do: {:error, false}
|
||||
def delete_user(%User{} = user), do: Repo.delete(user)
|
||||
end
|
196
lib/shift73k/accounts/user.ex
Normal file
196
lib/shift73k/accounts/user.ex
Normal file
|
@ -0,0 +1,196 @@
|
|||
defmodule Shift73k.Accounts.User do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import EctoEnum
|
||||
|
||||
@roles [
|
||||
user: "Basic user level",
|
||||
manager: "Can create users, update user emails & passwords",
|
||||
admin: "Can delete users and change user roles"
|
||||
]
|
||||
|
||||
defenum(RolesEnum, :role, Keyword.keys(@roles))
|
||||
|
||||
@max_email 254
|
||||
@min_password 6
|
||||
@max_password 80
|
||||
|
||||
@derive {Inspect, except: [:password]}
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
# @foreign_key_type :binary_id
|
||||
schema "users" do
|
||||
field(:email, :string)
|
||||
field(:password, :string, virtual: true)
|
||||
field(:hashed_password, :string)
|
||||
field(:confirmed_at, :naive_datetime)
|
||||
|
||||
field(:role, RolesEnum, default: :user)
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def max_email, do: @max_email
|
||||
def min_password, do: @min_password
|
||||
def max_password, do: @max_password
|
||||
def roles, do: @roles
|
||||
|
||||
@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`.
|
||||
"""
|
||||
def registration_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email, :password, :role])
|
||||
|> validate_role()
|
||||
|> validate_email()
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
# def update_changeset(user, attrs, opts \\ [])
|
||||
|
||||
# def update_changeset(user, %{"password" => ""} = attrs, _),
|
||||
# do: update_changeset_no_pw(user, attrs)
|
||||
|
||||
# def update_changeset(user, %{password: ""} = attrs, _), do: update_changeset_no_pw(user, attrs)
|
||||
|
||||
def update_changeset(user, attrs, opts) do
|
||||
user
|
||||
|> cast(attrs, [:email, :password, :role])
|
||||
|> validate_role()
|
||||
|> validate_email()
|
||||
|> validate_password_not_required(opts)
|
||||
end
|
||||
|
||||
# def update_changeset_no_pw(user, attrs) do
|
||||
# user
|
||||
# |> cast(attrs, [:email, :role])
|
||||
# |> validate_role()
|
||||
# |> validate_email()
|
||||
# end
|
||||
|
||||
defp role_validator(:role, role) do
|
||||
(RolesEnum.valid_value?(role) && []) || [role: "invalid user role"]
|
||||
end
|
||||
|
||||
defp validate_role(changeset) do
|
||||
changeset
|
||||
|> validate_required([:role])
|
||||
|> validate_change(:role, &role_validator/2)
|
||||
end
|
||||
|
||||
defp validate_email_format(changeset) do
|
||||
r_email = ~r/^[\w.!#$%&’*+\-\/=?\^`{|}~]+@[a-z0-9-]+(\.[a-z0-9-]+)*$/i
|
||||
|
||||
changeset
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, r_email, message: "must be a valid email address")
|
||||
|> validate_length(:email, max: @max_email)
|
||||
end
|
||||
|
||||
defp validate_email(changeset) do
|
||||
changeset
|
||||
|> validate_email_format()
|
||||
|> unsafe_validate_unique(:email, Shift73k.Repo)
|
||||
|> unique_constraint(:email)
|
||||
end
|
||||
|
||||
defp validate_password(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:password])
|
||||
|> validate_password_not_required(opts)
|
||||
end
|
||||
|
||||
defp validate_password_not_required(changeset, opts) do
|
||||
changeset
|
||||
|> validate_length(:password, min: @min_password, max: @max_password)
|
||||
# |> 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
|
||||
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|
||||
|> delete_change(:password)
|
||||
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) do
|
||||
user
|
||||
|> cast(attrs, [:email])
|
||||
|> validate_email()
|
||||
|> case do
|
||||
%{changes: %{email: _}} = changeset -> changeset
|
||||
%{} = changeset -> add_error(changeset, :email, "did not change")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the password.
|
||||
"""
|
||||
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 = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
change(user, confirmed_at: now)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies the password.
|
||||
|
||||
If there is no user or the user doesn't have a password, we call
|
||||
`Bcrypt.no_user_verify/0` to avoid timing attacks.
|
||||
"""
|
||||
def valid_password?(%Shift73k.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
|
||||
if valid_password?(changeset.data, password) do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :current_password, "is not valid")
|
||||
end
|
||||
end
|
||||
end
|
70
lib/shift73k/accounts/user_notifier.ex
Normal file
70
lib/shift73k/accounts/user_notifier.ex
Normal file
|
@ -0,0 +1,70 @@
|
|||
defmodule Shift73k.Accounts.UserNotifier do
|
||||
alias Shift73k.Mailer
|
||||
alias Shift73k.Mailer.UserEmail
|
||||
|
||||
@doc """
|
||||
Deliver instructions to confirm account.
|
||||
"""
|
||||
def deliver_confirmation_instructions(user, url) do
|
||||
user
|
||||
|> UserEmail.compose("Confirm Your Account", """
|
||||
|
||||
==============================
|
||||
|
||||
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.
|
||||
|
||||
==============================
|
||||
""")
|
||||
|> Mailer.deliver_later()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to reset a user password.
|
||||
"""
|
||||
def deliver_reset_password_instructions(user, url) do
|
||||
user
|
||||
|> UserEmail.compose("Reset Your Password", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can reset your password by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
|> Mailer.deliver_later()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to update a user email.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
user
|
||||
|> UserEmail.compose("Change Your E-mail", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can change your email by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
|> Mailer.deliver_later()
|
||||
end
|
||||
end
|
144
lib/shift73k/accounts/user_token.ex
Normal file
144
lib/shift73k/accounts/user_token.ex
Normal file
|
@ -0,0 +1,144 @@
|
|||
defmodule Shift73k.Accounts.UserToken do
|
||||
use Ecto.Schema
|
||||
import Ecto.Query
|
||||
|
||||
@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
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "users_tokens" do
|
||||
field(:token, :binary)
|
||||
field(:context, :string)
|
||||
field(:sent_to, :string)
|
||||
belongs_to(:user, Shift73k.Accounts.User)
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a token that will be stored in a signed place,
|
||||
such as session or cookie. As they are signed, those
|
||||
tokens do not need to be hashed.
|
||||
"""
|
||||
def build_session_token(user) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
{token, %Shift73k.Accounts.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.
|
||||
"""
|
||||
def verify_session_token_query(token) do
|
||||
query =
|
||||
from(token in token_and_context_query(token, "session"),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(@session_validity_in_days, "day"),
|
||||
select: user
|
||||
)
|
||||
|
||||
{:ok, query}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds a token with a hashed counter part.
|
||||
|
||||
The non-hashed token is sent to the user email while the
|
||||
hashed part is stored in the database, to avoid reconstruction.
|
||||
The token is valid for a week as long as users don't change
|
||||
their email.
|
||||
"""
|
||||
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),
|
||||
%Shift73k.Accounts.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.
|
||||
"""
|
||||
def verify_email_token_query(token, context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
days = days_for_context(context)
|
||||
|
||||
query =
|
||||
from(token in token_and_context_query(hashed_token, context),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
|
||||
select: user
|
||||
)
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp days_for_context("confirm"), do: @confirm_validity_in_days
|
||||
defp days_for_context("reset_password"), do: @reset_password_validity_in_days
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user token record.
|
||||
"""
|
||||
def verify_change_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)
|
||||
|
||||
query =
|
||||
from(token in token_and_context_query(hashed_token, context),
|
||||
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
|
||||
)
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the given token with the given context.
|
||||
"""
|
||||
def token_and_context_query(token, context) do
|
||||
from(Shift73k.Accounts.UserToken, where: [token: ^token, context: ^context])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all tokens for the given user for the given contexts.
|
||||
"""
|
||||
def user_and_contexts_query(user, :all) do
|
||||
from(t in Shift73k.Accounts.UserToken, where: t.user_id == ^user.id)
|
||||
end
|
||||
|
||||
def user_and_contexts_query(user, [_ | _] = contexts) do
|
||||
from(t in Shift73k.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts)
|
||||
end
|
||||
end
|
34
lib/shift73k/application.ex
Normal file
34
lib/shift73k/application.ex
Normal file
|
@ -0,0 +1,34 @@
|
|||
defmodule Shift73k.Application do
|
||||
# See https://hexdocs.pm/elixir/Application.html
|
||||
# for more information on OTP Applications
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
# Start the Ecto repository
|
||||
Shift73k.Repo,
|
||||
# Start the Telemetry supervisor
|
||||
Shift73kWeb.Telemetry,
|
||||
# Start the PubSub system
|
||||
{Phoenix.PubSub, name: Shift73k.PubSub},
|
||||
# Start the Endpoint (http/https)
|
||||
Shift73kWeb.Endpoint
|
||||
# Start a worker by calling: Shift73k.Worker.start_link(arg)
|
||||
# {Shift73k.Worker, arg}
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: Shift73k.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
# Tell Phoenix to update the endpoint configuration
|
||||
# whenever the application is updated.
|
||||
def config_change(changed, _new, removed) do
|
||||
Shift73kWeb.Endpoint.config_change(changed, removed)
|
||||
:ok
|
||||
end
|
||||
end
|
3
lib/shift73k/mailer.ex
Normal file
3
lib/shift73k/mailer.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule Shift73k.Mailer do
|
||||
use Bamboo.Mailer, otp_app: :shift73k
|
||||
end
|
17
lib/shift73k/mailer/user_email.ex
Normal file
17
lib/shift73k/mailer/user_email.ex
Normal file
|
@ -0,0 +1,17 @@
|
|||
defmodule Shift73k.Mailer.UserEmail do
|
||||
import Bamboo.Email
|
||||
|
||||
@mailer_vars Application.get_env(:shift73k, :app_global_vars,
|
||||
mailer_reply_to: "admin@example.com",
|
||||
mailer_from: {"Shift73k", "shift73k@example.com"}
|
||||
)
|
||||
|
||||
def compose(user, subject, body_text) do
|
||||
new_email()
|
||||
|> from(@mailer_vars[:mailer_from])
|
||||
|> to(user.email)
|
||||
|> put_header("Reply-To", @mailer_vars[:mailer_reply_to])
|
||||
|> subject(subject)
|
||||
|> text_body(body_text)
|
||||
end
|
||||
end
|
104
lib/shift73k/properties.ex
Normal file
104
lib/shift73k/properties.ex
Normal file
|
@ -0,0 +1,104 @@
|
|||
defmodule Shift73k.Properties do
|
||||
@moduledoc """
|
||||
The Properties context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Shift73k.Repo
|
||||
|
||||
alias Shift73k.Properties.Property
|
||||
|
||||
@doc """
|
||||
Returns the list of properties.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_properties()
|
||||
[%Property{}, ...]
|
||||
|
||||
"""
|
||||
def list_properties do
|
||||
Repo.all(Property)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single property.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Property does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_property!(123)
|
||||
%Property{}
|
||||
|
||||
iex> get_property!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_property!(id), do: Repo.get!(Property, id)
|
||||
|
||||
@doc """
|
||||
Creates a property.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_property(%{field: value})
|
||||
{:ok, %Property{}}
|
||||
|
||||
iex> create_property(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_property(attrs \\ %{}) do
|
||||
%Property{}
|
||||
|> Property.create_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a property.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_property(property, %{field: new_value})
|
||||
{:ok, %Property{}}
|
||||
|
||||
iex> update_property(property, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_property(%Property{} = property, attrs) do
|
||||
property
|
||||
|> Property.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a property.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_property(property)
|
||||
{:ok, %Property{}}
|
||||
|
||||
iex> delete_property(property)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_property(%Property{} = property) do
|
||||
Repo.delete(property)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking property changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_property(property)
|
||||
%Ecto.Changeset{data: %Property{}}
|
||||
|
||||
"""
|
||||
def change_property(%Property{} = property, attrs \\ %{}) do
|
||||
Property.changeset(property, attrs)
|
||||
end
|
||||
end
|
28
lib/shift73k/properties/property.ex
Normal file
28
lib/shift73k/properties/property.ex
Normal file
|
@ -0,0 +1,28 @@
|
|||
defmodule Shift73k.Properties.Property do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "properties" do
|
||||
field(:description, :string)
|
||||
field(:name, :string)
|
||||
field(:price, :decimal)
|
||||
field(:user_id, :binary_id)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def create_changeset(property, attrs) do
|
||||
property
|
||||
|> cast(attrs, [:name, :price, :description, :user_id])
|
||||
|> validate_required([:name, :price, :description, :user_id])
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(property, attrs) do
|
||||
property
|
||||
|> cast(attrs, [:name, :price, :description])
|
||||
|> validate_required([:name, :price, :description])
|
||||
end
|
||||
end
|
7
lib/shift73k/repo.ex
Normal file
7
lib/shift73k/repo.ex
Normal file
|
@ -0,0 +1,7 @@
|
|||
defmodule Shift73k.Repo do
|
||||
use Ecto.Repo,
|
||||
otp_app: :shift73k,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
|
||||
use Scrivener, page_size: 10
|
||||
end
|
10
lib/shift73k/util/dt.ex
Normal file
10
lib/shift73k/util/dt.ex
Normal file
|
@ -0,0 +1,10 @@
|
|||
defmodule Shift73k.Util.Dt do
|
||||
@app_vars Application.get_env(:shift73k, :app_global_vars, time_zone: "America/New_York")
|
||||
@time_zone @app_vars[:time_zone]
|
||||
|
||||
def ndt_to_local(%NaiveDateTime{} = ndt), do: Timex.to_datetime(ndt, @time_zone)
|
||||
|
||||
def format_dt_local(dt_local, fstr), do: Timex.format!(dt_local, fstr)
|
||||
|
||||
def format_ndt(%NaiveDateTime{} = ndt, fstr), do: ndt |> ndt_to_local() |> format_dt_local(fstr)
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue