initial commit

This commit is contained in:
2021-03-11 21:12:55 -05:00
committed by csculley
commit c954fdc600
89 changed files with 20205 additions and 0 deletions

9
lib/cannery.ex Normal file
View File

@ -0,0 +1,9 @@
defmodule Cannery do
@moduledoc """
Cannery keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

349
lib/cannery/accounts.ex Normal file
View File

@ -0,0 +1,349 @@
defmodule Cannery.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Cannery.Repo
alias Cannery.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)
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, password, attrs) do
user
|> User.email_changeset(attrs)
|> User.validate_current_password(password)
|> Ecto.Changeset.apply_action(:update)
end
@doc """
Updates the user email using the given token.
If the token matches, the user email is updated and the token is deleted.
The confirmed_at date is also updated to the current time.
"""
def update_user_email(user, token) do
context = "change:#{user.email}"
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
:ok
else
_ -> :error
end
end
defp user_email_multi(user, email, context) do
changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset()
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context]))
end
@doc """
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, hash_password: false)
end
@doc """
Updates the user password.
## Examples
iex> update_user_password(user, "valid password", %{password: ...})
{:ok, %User{}}
iex> update_user_password(user, "invalid password", %{password: ...})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, password, attrs) do
changeset =
user
|> User.password_changeset(attrs)
|> User.validate_current_password(password)
Ecto.Multi.new()
|> Ecto.Multi.update(:user, changeset)
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
@doc """
Gets the user with the given signed token.
"""
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Deletes the signed token with the given context.
"""
def delete_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
end

View File

@ -0,0 +1,141 @@
defmodule Cannery.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
@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
timestamps()
end
@doc """
A user changeset for registration.
It is important to validate the length of both email and password.
Otherwise databases may truncate the email without warnings, which
could lead to unpredictable or insecure behaviour. Long passwords may
also be very expensive to hash for certain algorithms.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password])
|> validate_email()
|> validate_password(opts)
end
defp validate_email(changeset) do
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|> validate_length(:email, max: 160)
|> unsafe_validate_unique(:email, Cannery.Repo)
|> unique_constraint(:email)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 80)
# |> 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.
## 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 = 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?(%Cannery.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

View File

@ -0,0 +1,73 @@
defmodule Cannery.Accounts.UserNotifier do
# For simplicity, this module simply logs messages to the terminal.
# You should replace it by a proper email or notification tool, such as:
#
# * Swoosh - https://hexdocs.pm/swoosh
# * Bamboo - https://hexdocs.pm/bamboo
#
defp deliver(to, body) do
require Logger
Logger.debug(body)
{:ok, %{to: to, body: body}}
end
@doc """
Deliver instructions to confirm account.
"""
def deliver_confirmation_instructions(user, url) do
deliver(user.email, """
==============================
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, """
==============================
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, """
==============================
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

View File

@ -0,0 +1,141 @@
defmodule Cannery.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, Cannery.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, %Cannery.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),
%Cannery.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 Cannery.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 Cannery.Accounts.UserToken, where: t.user_id == ^user.id
end
def user_and_contexts_query(user, [_ | _] = contexts) do
from t in Cannery.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
end
end

View File

@ -0,0 +1,34 @@
defmodule Cannery.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
Cannery.Repo,
# Start the Telemetry supervisor
CanneryWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: Cannery.PubSub},
# Start the Endpoint (http/https)
CanneryWeb.Endpoint
# Start a worker by calling: Cannery.Worker.start_link(arg)
# {Cannery.Worker, arg}
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Cannery.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
CanneryWeb.Endpoint.config_change(changed, removed)
:ok
end
end

5
lib/cannery/repo.ex Normal file
View File

@ -0,0 +1,5 @@
defmodule Cannery.Repo do
use Ecto.Repo,
otp_app: :cannery,
adapter: Ecto.Adapters.Postgres
end

102
lib/cannery_web.ex Normal file
View File

@ -0,0 +1,102 @@
defmodule CanneryWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, views, channels and so on.
This can be used in your application as:
use CanneryWeb, :controller
use CanneryWeb, :view
The definitions below will be executed for every view,
controller, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define any helper function in modules
and import those modules here.
"""
def controller do
quote do
use Phoenix.Controller, namespace: CanneryWeb
import Plug.Conn
import CanneryWeb.Gettext
alias CanneryWeb.Router.Helpers, as: Routes
end
end
def view do
quote do
use Phoenix.View,
root: "lib/cannery_web/templates",
namespace: CanneryWeb
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
# Include shared imports and aliases for views
unquote(view_helpers())
end
end
def live_view do
quote do
use Phoenix.LiveView,
layout: {CanneryWeb.LayoutView, "live.html"}
unquote(view_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(view_helpers())
end
end
def router do
quote do
use Phoenix.Router
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
import CanneryWeb.Gettext
end
end
defp view_helpers do
quote do
# Use all HTML functionality (forms, tags, etc)
use Phoenix.HTML
# Import LiveView helpers (live_render, live_component, live_patch, etc)
import Phoenix.LiveView.Helpers
# Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View
import CanneryWeb.ErrorHelpers
import CanneryWeb.Gettext
alias CanneryWeb.Router.Helpers, as: Routes
end
end
@doc """
When used, dispatch to the appropriate controller/view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View File

@ -0,0 +1,35 @@
defmodule CanneryWeb.UserSocket do
use Phoenix.Socket
## Channels
# channel "room:*", CanneryWeb.RoomChannel
# Socket params are passed from the client and can
# be used to verify and authenticate a user. After
# verification, you can put default assigns into
# the socket that will be set for all channels, ie
#
# {:ok, assign(socket, :user_id, verified_user_id)}
#
# To deny connection, return `:error`.
#
# See `Phoenix.Token` documentation for examples in
# performing token verification on connect.
@impl true
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
# Socket id's are topics that allow you to identify all sockets for a given user:
#
# def id(socket), do: "user_socket:#{socket.assigns.user_id}"
#
# Would allow you to broadcast a "disconnect" event and terminate
# all active sockets and channels for a given user:
#
# CanneryWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
#
# Returning `nil` makes this socket anonymous.
@impl true
def id(_socket), do: nil
end

View File

@ -0,0 +1,7 @@
defmodule CanneryWeb.PageController do
use CanneryWeb, :controller
def index(conn, _params) do
render(conn, "index.html")
end
end

View File

@ -0,0 +1,149 @@
defmodule CanneryWeb.UserAuth do
import Plug.Conn
import Phoenix.Controller
alias Cannery.Accounts
alias CanneryWeb.Router.Helpers, as: Routes
# 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 "_cannery_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_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
end
defp maybe_write_remember_me_cookie(conn, _token, _params) do
conn
end
# This function renews the session ID and erases the whole
# session to avoid fixation attacks. If there is any data
# in the session you may want to preserve after log in/log out,
# you must explicitly fetch the session data before clearing
# and then immediately set it after clearing, for example:
#
# defp renew_session(conn) do
# preferred_locale = get_session(conn, :preferred_locale)
#
# conn
# |> configure_session(renew: true)
# |> clear_session()
# |> put_session(:preferred_locale, preferred_locale)
# end
#
defp renew_session(conn) do
conn
|> configure_session(renew: true)
|> clear_session()
end
@doc """
Logs the user out.
It clears all session data for safety. See renew_session.
"""
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
CanneryWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: "/")
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 user_token = get_session(conn, :user_token) do
{user_token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if user_token = conn.cookies[@remember_me_cookie] do
{user_token, put_session(conn, :user_token, user_token)}
else
{nil, conn}
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: Routes.user_session_path(conn, :new))
|> halt()
end
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: "/"
end

View File

@ -0,0 +1,53 @@
defmodule CanneryWeb.UserConfirmationController do
use CanneryWeb, :controller
alias Cannery.Accounts
def new(conn, _params) do
render(conn, "new.html")
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(conn, :confirm, &1)
)
end
# Regardless of the outcome, show an impartial success/error message.
conn
|> put_flash(
:info,
"If your email is in our system and it has not been confirmed yet, " <>
"you will receive an email with instructions shortly."
)
|> redirect(to: "/")
end
# Do not log in the user after confirmation to avoid a
# leaked token giving the user access to the account.
def confirm(conn, %{"token" => token}) do
case Accounts.confirm_user(token) do
{:ok, _} ->
conn
|> put_flash(:info, "User confirmed successfully.")
|> redirect(to: "/")
: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 conn.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
redirect(conn, to: "/")
%{} ->
conn
|> put_flash(:error, "User confirmation link is invalid or it has expired.")
|> redirect(to: "/")
end
end
end
end

View File

@ -0,0 +1,30 @@
defmodule CanneryWeb.UserRegistrationController do
use CanneryWeb, :controller
alias Cannery.Accounts
alias Cannery.Accounts.User
alias CanneryWeb.UserAuth
def new(conn, _params) do
changeset = Accounts.change_user_registration(%User{})
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(conn, :confirm, &1)
)
conn
|> put_flash(:info, "User created successfully.")
|> UserAuth.log_in_user(user)
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
end

View File

@ -0,0 +1,59 @@
defmodule CanneryWeb.UserResetPasswordController do
use CanneryWeb, :controller
alias Cannery.Accounts
plug :get_user_by_reset_password_token when action in [:edit, :update]
def new(conn, _params) do
render(conn, "new.html")
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
&Routes.user_reset_password_url(conn, :edit, &1)
)
end
# Regardless of the outcome, show an impartial success/error message.
conn
|> put_flash(
:info,
"If your email is in our system, you will receive instructions to reset your password shortly."
)
|> redirect(to: "/")
end
def edit(conn, _params) do
render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user))
end
# Do not log in the user after reset password to avoid a
# leaked token giving the user access to the account.
def update(conn, %{"user" => user_params}) do
case Accounts.reset_user_password(conn.assigns.user, user_params) do
{:ok, _} ->
conn
|> put_flash(:info, "Password reset successfully.")
|> redirect(to: Routes.user_session_path(conn, :new))
{:error, changeset} ->
render(conn, "edit.html", changeset: changeset)
end
end
defp get_user_by_reset_password_token(conn, _opts) do
%{"token" => token} = conn.params
if user = Accounts.get_user_by_reset_password_token(token) do
conn |> assign(:user, user) |> assign(:token, token)
else
conn
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|> redirect(to: "/")
|> halt()
end
end
end

View File

@ -0,0 +1,26 @@
defmodule CanneryWeb.UserSessionController do
use CanneryWeb, :controller
alias Cannery.Accounts
alias CanneryWeb.UserAuth
def new(conn, _params) do
render(conn, "new.html", error_message: nil)
end
def create(conn, %{"user" => user_params}) do
%{"email" => email, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, password) do
UserAuth.log_in_user(conn, user, user_params)
else
render(conn, "new.html", error_message: "Invalid email or password")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end

View File

@ -0,0 +1,74 @@
defmodule CanneryWeb.UserSettingsController do
use CanneryWeb, :controller
alias Cannery.Accounts
alias CanneryWeb.UserAuth
plug :assign_email_and_password_changesets
def edit(conn, _params) do
render(conn, "edit.html")
end
def update(conn, %{"action" => "update_email"} = params) do
%{"current_password" => password, "user" => user_params} = params
user = conn.assigns.current_user
case Accounts.apply_user_email(user, password, user_params) do
{:ok, applied_user} ->
Accounts.deliver_update_email_instructions(
applied_user,
user.email,
&Routes.user_settings_url(conn, :confirm_email, &1)
)
conn
|> put_flash(
:info,
"A link to confirm your email change has been sent to the new address."
)
|> redirect(to: Routes.user_settings_path(conn, :edit))
{:error, changeset} ->
render(conn, "edit.html", email_changeset: changeset)
end
end
def update(conn, %{"action" => "update_password"} = params) do
%{"current_password" => password, "user" => user_params} = params
user = conn.assigns.current_user
case Accounts.update_user_password(user, password, user_params) do
{:ok, user} ->
conn
|> put_flash(:info, "Password updated successfully.")
|> put_session(:user_return_to, Routes.user_settings_path(conn, :edit))
|> UserAuth.log_in_user(user)
{:error, changeset} ->
render(conn, "edit.html", password_changeset: changeset)
end
end
def confirm_email(conn, %{"token" => token}) do
case Accounts.update_user_email(conn.assigns.current_user, token) do
:ok ->
conn
|> put_flash(:info, "Email changed successfully.")
|> redirect(to: Routes.user_settings_path(conn, :edit))
:error ->
conn
|> put_flash(:error, "Email change link is invalid or it has expired.")
|> redirect(to: Routes.user_settings_path(conn, :edit))
end
end
defp assign_email_and_password_changesets(conn, _opts) do
user = conn.assigns.current_user
conn
|> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user))
end
end

View File

@ -0,0 +1,54 @@
defmodule CanneryWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :cannery
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_cannery_key",
signing_salt: "fxAnJltS"
]
socket "/socket", CanneryWeb.UserSocket,
websocket: true,
longpoll: false
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
# when deploying your static files in production.
plug Plug.Static,
at: "/",
from: :cannery,
gzip: false,
only: ~w(css fonts images js favicon.ico robots.txt)
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :cannery
end
plug Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug CanneryWeb.Router
end

View File

@ -0,0 +1,24 @@
defmodule CanneryWeb.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
By using [Gettext](https://hexdocs.pm/gettext),
your module gains a set of macros for translations, for example:
import CanneryWeb.Gettext
# Simple translation
gettext("Here is the string to translate")
# Plural translation
ngettext("Here is the string to translate",
"Here are the strings to translate",
3)
# Domain-based translation
dgettext("errors", "Here is the error message to translate")
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
use Gettext, otp_app: :cannery
end

View File

@ -0,0 +1,92 @@
defmodule CanneryWeb.Live.Component.Topbar do
use CanneryWeb, :live_component
alias CanneryWeb.{PageLive}
def mount(socket) do
{:ok, socket |> assign(results: [], title_content: nil)}
end
def update(assigns, socket) do
{:ok, socket |> assign(assigns)}
end
def render(assigns) do
~L"""
<header class="mb-8 px-8 py-4 w-full bg-primary-400">
<nav role="navigation">
<div class="flex flex-row justify-between items-center space-x-4">
<div class="flex flex-row justify-start items-center space-x-2">
<%= link to: Routes.page_path(CanneryWeb.Endpoint, :index) do %>
<h1 class="leading-5 text-xl text-white hover:underline">Cannery</h1>
<% end %>
<%= if @title_content do %>
<span>|</span>
<%= @title_content %>
<% end %>
</div>
<ul class="flex flex-row flex-wrap justify-center items-center
text-lg space-x-4 text-lg text-white">
<%# search %>
<form phx-change="suggest" phx-submit="search">
<input type="text" name="q" class="input input-primary"
placeholder="Search" list="results" autocomplete="off"/>
<datalist id="results">
<%= for {app, _vsn} <- @results do %>
<option value="<%= app %>"><%= app %></option>
<% end %>
</datalist>
</form>
<%# user settings %>
<%= if assigns |> Map.has_key?(:current_user) do %>
<li>
<%= @current_user.email %></li>
<li>
<%= link "Settings", class: "hover:underline",
to: Routes.user_settings_path(CanneryWeb.Endpoint, :edit) %>
</li>
<li>
<%= link "Log out", class: "hover:underline",
to: Routes.user_session_path(CanneryWeb.Endpoint, :delete), method: :delete %>
</li>
<%= if function_exported?(Routes, :live_dashboard_path, 2) do %>
<li>
<%= link "LiveDashboard", class: "hover:underline",
to: Routes.live_dashboard_path(CanneryWeb.Endpoint, :home) %>
</li>
<% end %>
<% else %>
<li>
<%= link "Register", class: "hover:underline",
to: Routes.user_registration_path(CanneryWeb.Endpoint, :new) %>
</li>
<li>
<%= link "Log in", class: "hover:underline",
to: Routes.user_session_path(CanneryWeb.Endpoint, :new) %>
</li>
<% end %>
</ul>
</div>
</nav>
<%= if live_flash(@flash, :info) do %>
<p class="alert alert-info" role="alert"
phx-click="lv:clear-flash" phx-value-key="info">
<%= live_flash(@flash, :info) %>
</p>
<% end %>
<%= if live_flash(@flash, :error) do %>
<p class="alert alert-danger" role="alert"
phx-click="lv:clear-flash" phx-value-key="error">
<%= live_flash(@flash, :error) %>
</p>
<% end %>
</header>
"""
end
end

View File

@ -0,0 +1,39 @@
defmodule CanneryWeb.PageLive do
use CanneryWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, query: "", results: %{})}
end
@impl true
def handle_event("suggest", %{"q" => query}, socket) do
{:noreply, assign(socket, results: search(query), query: query)}
end
@impl true
def handle_event("search", %{"q" => query}, socket) do
case search(query) do
%{^query => vsn} ->
{:noreply, redirect(socket, external: "https://hexdocs.pm/#{query}/#{vsn}")}
_ ->
{:noreply,
socket
|> put_flash(:error, "No dependencies found matching \"#{query}\"")
|> assign(results: %{}, query: query)}
end
end
defp search(query) do
if not CanneryWeb.Endpoint.config(:code_reloader) do
raise "action disabled when not in development"
end
for {app, desc, vsn} <- Application.started_applications(),
app = to_string(app),
String.starts_with?(app, query) and not List.starts_with?(desc, ~c"ERTS"),
into: %{},
do: {app, vsn}
end
end

View File

@ -0,0 +1,9 @@
<div class="flex flex-col justify-center items-center text-center space-y-4">
<h1 class="title text-primary-500 text-2xl">
Welcome to Cannery
</h1>
<p class="title text-primary-500 text-lg">
The Self-hosted Ammo Tracker Website
</p>
</div>

73
lib/cannery_web/router.ex Normal file
View File

@ -0,0 +1,73 @@
defmodule CanneryWeb.Router do
use CanneryWeb, :router
import CanneryWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {CanneryWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", CanneryWeb do
pipe_through :browser
live "/", PageLive, :index
end
# Enables LiveDashboard only for development
#
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router
scope "/" do
pipe_through :browser
live_dashboard "/dashboard", metrics: CanneryWeb.Telemetry
end
end
## Authentication routes
scope "/", CanneryWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
get "/users/log_in", UserSessionController, :new
post "/users/log_in", UserSessionController, :create
get "/users/reset_password", UserResetPasswordController, :new
post "/users/reset_password", UserResetPasswordController, :create
get "/users/reset_password/:token", UserResetPasswordController, :edit
put "/users/reset_password/:token", UserResetPasswordController, :update
end
scope "/", CanneryWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
end
scope "/", CanneryWeb do
pipe_through [:browser]
delete "/users/log_out", UserSessionController, :delete
get "/users/confirm", UserConfirmationController, :new
post "/users/confirm", UserConfirmationController, :create
get "/users/confirm/:token", UserConfirmationController, :confirm
end
end

View File

@ -0,0 +1,55 @@
defmodule CanneryWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
# Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
# Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
# Database Metrics
summary("cannery.repo.query.total_time", unit: {:native, :millisecond}),
summary("cannery.repo.query.decode_time", unit: {:native, :millisecond}),
summary("cannery.repo.query.query_time", unit: {:native, :millisecond}),
summary("cannery.repo.query.queue_time", unit: {:native, :millisecond}),
summary("cannery.repo.query.idle_time", unit: {:native, :millisecond}),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {CanneryWeb, :count_users, []}
]
end
end

View File

@ -0,0 +1,19 @@
<main role="main" class="container min-h-full min-w-full">
<header class="mb-4 px-8 py-4 w-full bg-primary-400">
<%= render "topbar.html", assigns %>
<%= if get_flash(@conn, :info) do %>
<p class="alert alert-info" role="alert">
<%= get_flash(@conn, :info) %>
</p>
<% end %>
<%= if get_flash(@conn, :error) do %>
<p class="alert alert-danger" role="alert">
<%= get_flash(@conn, :error) %>
</p>
<% end %>
</header>
<%= @inner_content %>
</main>

View File

@ -0,0 +1,5 @@
<main role="main" class="container min-w-full min-h-full">
<%= live_component CanneryWeb.Live.Component.Topbar %>
<%= @inner_content %>
</main>

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "Cannery", suffix: "" %>
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head>
<body class="m-0 p-0 min-w-full min-h-full">
<%= @inner_content %>
</body>
</html>

View File

@ -0,0 +1,40 @@
<nav role="navigation">
<div class="flex flex-row justify-between items-center space-x-4">
<%= link to: Routes.page_path(CanneryWeb.Endpoint, :index) do %>
<h1 class="leading-5 text-xl text-white hover:underline">Cannery</h1>
<% end %>
<ul class="flex flex-row flex-wrap justify-center items-center
space-x-4 text-lg text-white">
<%# user settings %>
<%= if assigns |> Map.has_key?(:current_user) && @current_user do %>
<li>
<%= @current_user.email %></li>
<li>
<%= link "Settings", class: "hover:underline",
to: Routes.user_settings_path(CanneryWeb.Endpoint, :edit) %>
</li>
<li>
<%= link "Log out", class: "hover:underline",
to: Routes.user_session_path(CanneryWeb.Endpoint, :delete), method: :delete %>
</li>
<%= if function_exported?(Routes, :live_dashboard_path, 2) do %>
<li>
<%= link "LiveDashboard", class: "hover:underline",
to: Routes.live_dashboard_path(CanneryWeb.Endpoint, :home) %>
</li>
<% end %>
<% else %>
<li>
<%= link "Register", class: "hover:underline",
to: Routes.user_registration_path(CanneryWeb.Endpoint, :new) %>
</li>
<li>
<%= link "Log in", class: "hover:underline",
to: Routes.user_session_path(CanneryWeb.Endpoint, :new) %>
</li>
<% end %>
</ul>
</div>
</nav>

View File

@ -0,0 +1,24 @@
<div class="flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-500 text-xl">
Resend confirmation instructions
</h1>
<%= form_for :user, Routes.user_confirmation_path(@conn, :create),
[class: "flex flex-col justify-center items-center space-y-4"], fn f -> %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :email, class: "title text-lg text-primary-500" %>
<%= email_input f, :email, required: true, class: "input input-primary col-span-2" %>
</div>
<%= submit "Resend confirmation instructions", class: "btn btn-primary" %>
<hr class="hr">
<div class="flex flex-row justify-center items-center space-x-4">
<%= link "Register", to: Routes.user_registration_path(@conn, :new),
class: "btn btn-primary" %>
<%= link "Log in", to: Routes.user_session_path(@conn, :new),
class: "btn btn-primary" %>
</div>
<% end %>
</div>

View File

@ -0,0 +1,37 @@
<div class="flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-500 text-xl">
Register
</h1>
<%= form_for @changeset, Routes.user_registration_path(@conn, :create),
[class: "flex flex-col justify-center items-center space-y-4"], fn f -> %>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :email, class: "title text-lg text-primary-500" %>
<%= email_input f, :email, required: true, class: "input input-primary col-span-2" %>
</div>
<%= error_tag f, :email %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :password, class: "title text-lg text-primary-500" %>
<%= password_input f, :password, required: true, class: "input input-primary col-span-2" %>
</div>
<%= error_tag f, :password %>
<%= submit "Register", class: "btn btn-primary" %>
<hr class="hr">
<div class="flex flex-row justify-center items-center space-x-4">
<%= link "Log in", to: Routes.user_session_path(@conn, :new),
class: "btn btn-primary" %>
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new),
class: "btn btn-primary" %>
</div>
<% end %>
</div>

View File

@ -0,0 +1,37 @@
<div class="flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-500 text-xl">
Reset password
</h1>
<%= form_for @changeset, Routes.user_reset_password_path(@conn, :update, @token),
[class: "flex flex-col justify-center items-center space-y-4"], fn f -> %>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :password, "New password", class: "title text-lg text-primary-500" %>
<%= password_input f, :password, required: true, class: "input input-primary col-span-2" %>
</div>
<%= error_tag f, :password %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :password_confirmation, "Confirm new password", class: "title text-lg text-primary-500" %>
<%= password_input f, :password_confirmation, required: true, class: "input input-primary col-span-2" %>
</div>
<%= error_tag f, :password_confirmation %>
<%= submit "Reset password", class: "btn btn-primary" %>
<hr class="hr">
<div class="flex flex-row justify-center items-center space-x-4">
<%= link "Register", to: Routes.user_registration_path(@conn, :new),
class: "btn btn-primary" %>
<%= link "Log in", to: Routes.user_session_path(@conn, :new),
class: "btn btn-primary" %>
</div>
<% end %>
</div>

View File

@ -0,0 +1,25 @@
<div class="flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-500 text-xl">
Forgot your password?
</h1>
<%= form_for :user, Routes.user_reset_password_path(@conn, :create),
[class: "flex flex-col justify-center items-center space-y-4"], fn f -> %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :email, class: "title text-lg text-primary-500" %>
<%= email_input f, :email, required: true, class: "input input-primary col-span-2" %>
</div>
<%= submit "Send instructions to reset password", class: "btn btn-primary" %>
<hr class="hr">
<div class="flex flex-row justify-center items-center space-x-4">
<%= link "Register", to: Routes.user_registration_path(@conn, :new),
class: "btn btn-primary" %>
<%= link "Log in", to: Routes.user_session_path(@conn, :new),
class: "btn btn-primary" %>
</div>
<% end %>
</div>

View File

@ -0,0 +1,41 @@
<div class="flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-500 text-xl">
Log in
</h1>
<%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user,
class: "flex flex-col justify-center items-center space-y-4"], fn f -> %>
<%= if @error_message do %>
<div class="alert alert-danger">
<p><%= @error_message %></p>
</div>
<% end %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :email, class: "title text-lg text-primary-500" %>
<%= email_input f, :email, required: true, class: "input input-primary col-span-2" %>
</div>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :password, class: "title text-lg text-primary-500" %>
<%= password_input f, :password, required: true, class: "input input-primary col-span-2" %>
</div>
<div class="flex flex-row justify-center items-center text-center space-x-4">
<%= label f, :remember_me, "Keep me logged in for 60 days",
class: "title text-lg text-primary-500" %>
<%= checkbox f, :remember_me, class: "checkbox" %>
</div>
<%= submit "Log in", class: "btn btn-primary" %>
<hr class="hr">
<div class="flex flex-row justify-center items-center space-x-4">
<%= link "Register", to: Routes.user_registration_path(@conn, :new),
class: "btn btn-primary" %>
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new),
class: "btn btn-primary" %>
</div>
<% end %>
</div>

View File

@ -0,0 +1,85 @@
<div class="flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-500 text-xl">
Settings
</h1>
<h3 class="title text-primary-500 text-lg">
Change email
</h3>
<%= form_for @email_changeset, Routes.user_settings_path(@conn, :update),
[class: "flex flex-col justify-center items-center space-y-4"], fn f -> %>
<%= if @email_changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<%= hidden_input f, :action, name: "action", value: "update_email" %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :email, class: "title text-lg text-primary-500" %>
<%= email_input f, :email, required: true, class: "input input-primary col-span-2" %>
</div>
<%= error_tag f, :email %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :current_password, for: "current_password_for_email", class: "title text-lg text-primary-500" %>
<%= password_input f, :current_password,
required: true,
name: "current_password",
id: "current_password_for_email",
class: "input input-primary col-span-2" %>
</div>
<%= error_tag f, :current_password %>
<%= submit "Change email", class: "btn btn-primary" %>
<% end %>
<h3 class="title text-primary-500 text-lg">
Change password
</h3>
<%= form_for @password_changeset, Routes.user_settings_path(@conn, :update),
[class: "flex flex-col justify-center items-center space-y-4"], fn f -> %>
<%= if @password_changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<%= hidden_input f, :action, name: "action", value: "update_password" %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :password, "New password",
class: "title text-lg text-primary-500" %>
<%= password_input f, :password,
required: true,
class: "input input-primary col-span-2" %>
</div>
<%= error_tag f, :password %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :password_confirmation, "Confirm new password",
class: "title text-lg text-primary-500" %>
<%= password_input f, :password_confirmation,
required: true,
class: "input input-primary col-span-2" %>
</div>
<%= error_tag f, :password_confirmation %>
<div class="grid grid-cols-3 justify-center items-center text-center space-x-4">
<%= label f, :current_password,
for: "current_password_for_password",
class: "title text-lg text-primary-500" %>
<%= password_input f, :current_password,
required: true,
name: "current_password",
id: "current_password_for_password",
class: "input input-primary col-span-2" %>
</div>
<%= error_tag f, :current_password %>
<%= submit "Change password", class: "btn btn-primary" %>
<% end %>
</div>

View File

@ -0,0 +1,47 @@
defmodule CanneryWeb.ErrorHelpers do
@moduledoc """
Conveniences for translating and building error messages.
"""
use Phoenix.HTML
@doc """
Generates tag for inlined form input errors.
"""
def error_tag(form, field) do
Enum.map(Keyword.get_values(form.errors, field), fn error ->
content_tag(:span, translate_error(error),
class: "invalid-feedback",
phx_feedback_for: input_name(form, field)
)
end)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate "is invalid" in the "errors" domain
# dgettext("errors", "is invalid")
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# Because the error messages we show in our forms and APIs
# are defined inside Ecto, we need to translate them dynamically.
# This requires us to call the Gettext module passing our gettext
# backend as first argument.
#
# Note we use the "errors" domain, which means translations
# should be written to the errors.po file. The :count option is
# set by Ecto and indicates we should also apply plural rules.
if count = opts[:count] do
Gettext.dngettext(CanneryWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(CanneryWeb.Gettext, "errors", msg, opts)
end
end
end

View File

@ -0,0 +1,16 @@
defmodule CanneryWeb.ErrorView do
use CanneryWeb, :view
# If you want to customize a particular status code
# for a certain format, you may uncomment below.
# def render("500.html", _assigns) do
# "Internal Server Error"
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.html" becomes
# "Not Found".
def template_not_found(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end

View File

@ -0,0 +1,11 @@
defmodule CanneryWeb.LayoutView do
use CanneryWeb, :view
def get_title(conn) do
if conn.assigns |> Map.has_key?(:title) do
"Cannery | #{conn.assigns.title}"
else
"Cannery"
end
end
end

View File

@ -0,0 +1,3 @@
defmodule CanneryWeb.PageView do
use CanneryWeb, :view
end

View File

@ -0,0 +1,3 @@
defmodule CanneryWeb.UserConfirmationView do
use CanneryWeb, :view
end

View File

@ -0,0 +1,3 @@
defmodule CanneryWeb.UserRegistrationView do
use CanneryWeb, :view
end

View File

@ -0,0 +1,3 @@
defmodule CanneryWeb.UserResetPasswordView do
use CanneryWeb, :view
end

View File

@ -0,0 +1,3 @@
defmodule CanneryWeb.UserSessionView do
use CanneryWeb, :view
end

View File

@ -0,0 +1,3 @@
defmodule CanneryWeb.UserSettingsView do
use CanneryWeb, :view
end