improvement: copy over example with auth to ash-hq

This commit is contained in:
Zach Daniel 2022-08-05 22:08:23 -04:00
parent de5a4e7b02
commit 9728cdee78
72 changed files with 3210 additions and 13 deletions

View file

@ -12,13 +12,18 @@ config :ash_hq,
config :ash, allow_flow: true
config :ash_hq, ash_apis: [AshHq.Docs]
config :ash_hq, ash_apis: [AshHq.Docs, AshHq.Accounts]
config :ash_hq, AshHq.Docs,
resources: [
registry: AshHq.Docs.Registry
]
config :ash_hq, AshHq.Accounts,
resources: [
registry: AshHq.Accounts.Registry
]
config :ash_hq, AshHq.Repo,
timeout: :timer.minutes(10),
ownership_timeout: :timer.minutes(10)

View file

@ -81,3 +81,5 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
config :ash_hq, AshHq.Mailer, adapter: Bamboo.TestAdapter

View file

@ -49,3 +49,7 @@ config :logger, level: :info
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
config :ash_hq, AshHq.Mailer,
adapter: Bamboo.PostmarkAdapter,
api_key: {:system, "POSTMARK_API_KEY"}

View file

@ -28,3 +28,5 @@ config :logger, level: :warn
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
config :ash_hq, AshHq.Mailer, adapter: Bamboo.TestAdapter

View file

@ -0,0 +1,3 @@
defmodule AshHq.Accounts do
use Ash.Api, otp_app: :ash_hq
end

View file

@ -0,0 +1,41 @@
defmodule AshHq.Accounts.EmailNotifier do
def notify(%Ash.Notifier.Notification{
resource: AshHq.Accounts.UserToken,
action: %{name: :build_email_token},
metadata: %{
user: user,
url: url,
confirm?: true
}
}) do
AshHq.Accounts.Emails.deliver_confirmation_instructions(user, url)
end
def notify(%Ash.Notifier.Notification{
resource: AshHq.Accounts.UserToken,
action: %{name: :build_email_token},
metadata: %{
user: user,
url: url,
update?: true
}
}) do
AshHq.Accounts.Emails.deliver_update_email_instructions(user, url)
end
def notify(%Ash.Notifier.Notification{
resource: AshHq.Accounts.UserToken,
action: %{name: :build_email_token},
metadata: %{
user: user,
url: url,
reset?: true
}
}) do
AshHq.Accounts.Emails.deliver_reset_password_instructions(user, url)
end
def notify(_other) do
:ok
end
end

View file

@ -0,0 +1,64 @@
defmodule AshHq.Accounts.Emails do
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
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
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
# 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
end

View file

@ -0,0 +1,15 @@
defmodule AshHq.Accounts.Preparations.DetermineDaysForToken do
use Ash.Resource.Preparation
def determine_days_for_token() do
{__MODULE__, []}
end
def prepare(query, _opts, _) do
Ash.Query.put_context(
query,
:days_for_token,
AshHq.Accounts.User.Helpers.days_for_token(Ash.Query.get_argument(query, :context))
)
end
end

View file

@ -0,0 +1,19 @@
defmodule AshHq.Accounts.Preparations.SetHashedToken do
use Ash.Resource.Preparation
@hash_algorithm :sha256
def prepare(query, _opts, _) do
case Ash.Query.get_argument(query, :token) do
nil ->
query
token ->
Ash.Query.put_context(
query,
:hashed_token,
:crypto.hash(@hash_algorithm, token)
)
end
end
end

View file

@ -0,0 +1,9 @@
defmodule AshHq.Accounts.Registry do
use Ash.Registry,
extensions: [Ash.Registry.ResourceValidations]
entries do
entry AshHq.Accounts.User
entry AshHq.Accounts.UserToken
end
end

View file

@ -0,0 +1,53 @@
defmodule AshHq.Accounts.User.Changes.CreateEmailConfirmationToken do
@moduledoc "A change that triggers an email token build and an email notification"
use Ash.Resource.Change
def create_email_confirmation_token, do: {__MODULE__, []}
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn changeset, user ->
AshHq.Accounts.UserToken
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:build_email_token,
email: user.email,
context: "confirm",
sent_to: user.email,
user: user
)
|> AshHq.Accounts.create(return_notifications?: true)
|> case do
{:ok, email_token, notifications} ->
{:ok,
%{
user
| __metadata__:
Map.put(user.__metadata__, :token, email_token.__metadata__.url_token)
}, Enum.map(notifications, &set_metadata(&1, user, changeset, email_token))}
{:error, error} ->
{:error, error}
end
end)
end
defp set_metadata(notification, user, changeset, email_token) do
url =
case Ash.Changeset.get_argument(changeset, :confirmation_url_fun) do
nil ->
nil
fun ->
fun.(email_token.__metadata__.url_token)
end
%{
notification
| metadata: %{
user: user,
url: url,
confirm?: true
}
}
end
end

View file

@ -0,0 +1,53 @@
defmodule AshHq.Accounts.User.Changes.CreateEmailUpdateToken do
@moduledoc "A change that triggers an email token build and an email notification"
use Ash.Resource.Change
def create_email_update_token, do: {__MODULE__, []}
def change(original_changeset, _opts, _context) do
Ash.Changeset.after_action(original_changeset, fn changeset, user ->
AshHq.Accounts.UserToken
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:build_email_token,
email: user.email,
context: "change:#{user.email}",
sent_to: original_changeset.attributes[:email],
user: user
)
|> AshHq.Accounts.create(return_notifications?: true)
|> case do
{:ok, email_token, notifications} ->
{:ok,
%{
user
| __metadata__:
Map.put(user.__metadata__, :token, email_token.__metadata__.url_token)
}, Enum.map(notifications, &set_metadata(&1, user, changeset, email_token))}
{:error, error} ->
{:error, error}
end
end)
end
defp set_metadata(notification, user, changeset, email_token) do
url =
case Ash.Changeset.get_argument(changeset, :update_url_fun) do
nil ->
nil
fun ->
fun.(email_token.__metadata__.url_token)
end
%{
notification
| metadata: %{
user: user,
url: url,
update?: true
}
}
end
end

View file

@ -0,0 +1,49 @@
defmodule AshHq.Accounts.User.Changes.CreateResetPasswordToken do
@moduledoc "A change that triggers an reset password token build and an email notification"
use Ash.Resource.Change
def create_reset_password_token, do: {__MODULE__, []}
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn changeset, user ->
AshHq.Accounts.UserToken
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:build_email_token,
email: user.email,
context: "reset_password",
sent_to: user.email,
user: user
)
|> AshHq.Accounts.create(return_notifications?: true)
|> case do
{:ok, email_token, notifications} ->
{:ok, %{user | __metadata__: Map.put(user.__metadata__, :token, email_token.token)},
Enum.map(notifications, &set_metadata(&1, user, changeset, email_token))}
{:error, error} ->
{:error, error}
end
end)
end
defp set_metadata(notification, user, changeset, email_token) do
url =
case Ash.Changeset.get_argument(changeset, :reset_password_url_fun) do
nil ->
nil
fun ->
fun.(email_token.__metadata__.url_token)
end
%{
notification
| metadata: %{
user: user,
url: url,
reset?: true
}
}
end
end

View file

@ -0,0 +1,29 @@
defmodule AshHq.Accounts.User.Changes.DeleteConfirmTokens do
@moduledoc "A change that deletes all confirm tokens for a user, if the `delete_confirm_tokens` argument is present"
use Ash.Resource.Change
require Ash.Query
def delete_confirm_tokens, do: {__MODULE__, []}
def change(changeset, _opts, _context) do
if Ash.Changeset.get_argument(changeset, :delete_confirm_tokens) do
Ash.Changeset.after_action(changeset, fn _changeset, user ->
days = AshHq.Accounts.User.Helpers.days_for_token("confirm")
{:ok, query} =
AshHq.Accounts.UserToken
|> Ash.Query.filter(
created_at > ago(^days, :day) and context == "confirm" and
sent_to == user.email
)
|> Ash.Query.data_layer_query()
AshHq.Repo.delete_all(query)
{:ok, user}
end)
else
changeset
end
end
end

View file

@ -0,0 +1,23 @@
defmodule AshHq.Accounts.User.Changes.DeleteEmailChangeTokens do
@moduledoc "A change that deletes all email change tokens for a user"
use Ash.Resource.Change
require Ash.Query
def change(original_changeset, _opts, _context) do
Ash.Changeset.after_action(original_changeset, fn changeset, user ->
email = original_changeset.data.email
context = "change:#{email}"
token = Ash.Changeset.get_argument(changeset, :token)
{:ok, query} =
AshHq.Accounts.UserToken
|> Ash.Query.filter(token == ^token and context == ^context)
|> Ash.Query.data_layer_query()
AshHq.Repo.delete_all(query)
{:ok, user}
end)
end
end

View file

@ -0,0 +1,39 @@
defmodule AshHq.Accounts.User.Changes.GetEmailFromToken do
@moduledoc "A change that fetches the token for an email change"
use Ash.Resource.Change
def get_email_from_token do
{__MODULE__, []}
end
def init(_), do: {:ok, []}
def change(changeset, _opts, _) do
changeset
|> Ash.Changeset.before_action(fn changeset ->
if changeset.valid? do
token = Ash.Changeset.get_argument(changeset, :token)
AshHq.Accounts.UserToken
|> Ash.Query.for_read(:verify_email_token,
token: token,
context: "change:#{changeset.data.email}"
)
|> AshHq.Accounts.read_one()
|> case do
{:ok, %{sent_to: new_email}} ->
Ash.Changeset.change_attribute(changeset, :email, new_email)
_ ->
Ash.Changeset.add_error(changeset,
field: :token,
message: "Could not find corresponding token"
)
end
else
changeset
end
end)
end
end

View file

@ -0,0 +1,25 @@
defmodule AshHq.Accounts.User.Changes.HashPassword do
@moduledoc "A change that hashes the `password` attribute for valid changes"
use Ash.Resource.Change
alias Ash.Changeset
def hash_password do
{__MODULE__, []}
end
def init(_), do: {:ok, []}
def change(changeset, _opts, _) do
Changeset.before_action(changeset, fn changeset ->
case Changeset.get_argument(changeset, :password) do
nil ->
changeset
value ->
Changeset.change_attribute(changeset, :hashed_password, Bcrypt.hash_pwd_salt(value))
end
end)
end
end

View file

@ -0,0 +1,19 @@
defmodule AshHq.Accounts.User.Changes.RemoveAllTokens do
use Ash.Resource.Change
require Ash.Query
def remove_all_tokens, do: {__MODULE__, []}
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn _changeset, user ->
{:ok, query} =
AshHq.Accounts.UserToken
|> Ash.Query.filter(token.user_id == ^user.id)
|> Ash.Query.data_layer_query()
AshHq.Repo.delete_all(query)
{:ok, user}
end)
end
end

View file

@ -0,0 +1,22 @@
defmodule AshHq.Accounts.User.Helpers do
@reset_password_validity_in_days 1
@confirm_validity_in_days 7
@change_email_validity_in_days 7
@session_validity_in_days 60
def days_for_token("reset_password"), do: @reset_password_validity_in_days
def days_for_token("confirm"), do: @confirm_validity_in_days
def days_for_token("session"), do: @session_validity_in_days
def days_for_token("change:" <> _), do: @change_email_validity_in_days
def valid_password?(%AshHq.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
end

View file

@ -0,0 +1,28 @@
defmodule AshHq.Accounts.User.Preparations.DecodeToken do
use Ash.Resource.Preparation
alias Ash.Error.Query.InvalidArgument
def prepare(query, _opts, _) do
case Ash.Query.get_argument(query, :token) do
nil ->
query
token ->
case Base.url_decode64(token, padding: false) do
{:ok, decoded} ->
Ash.Query.set_argument(
query,
:token,
decoded
)
:error ->
Ash.Query.add_error(
query,
InvalidArgument.exception(field: :token, message: "could not be decoded")
)
end
end
end
end

View file

@ -0,0 +1,19 @@
defmodule AshHq.Accounts.User.Preparations.ValidatePassword do
use Ash.Resource.Preparation
def prepare(query, _opts, _) do
Ash.Query.after_action(query, fn
query, [result] ->
password = Ash.Query.get_argument(query, :password)
if AshHq.Accounts.User.Helpers.valid_password?(result, password) do
{:ok, [result]}
else
{:ok, []}
end
_, _ ->
{:ok, []}
end)
end
end

View file

@ -0,0 +1,174 @@
defmodule AshHq.Accounts.User do
use Ash.Resource,
data_layer: AshPostgres.DataLayer
alias AshHq.Accounts.Preparations, warn: false
alias AshHq.Accounts.User.Preparations, as: UserPreparations, warn: false
alias AshHq.Accounts.User.Changes, warn: false
alias AshHq.Accounts.User.Validations, warn: false
identities do
identity :unique_email, [:email]
end
postgres do
table "users"
repo AshHq.Repo
end
actions do
defaults [:read]
read :by_email_and_password do
argument :email, :string, allow_nil?: false, sensitive?: true
argument :password, :string, allow_nil?: false, sensitive?: true
prepare UserPreparations.ValidatePassword
filter expr(email == ^arg(:email))
end
read :by_token do
argument :token, :url_encoded_binary, allow_nil?: false
argument :context, :string, allow_nil?: false
prepare Preparations.DetermineDaysForToken
filter expr(
token.token == ^arg(:token) and token.context == ^arg(:context) and
token.created_at > ago(^context(:days_for_token), :day)
)
end
read :with_verified_email_token do
argument :token, :url_encoded_binary, allow_nil?: false
argument :context, :string, allow_nil?: false
prepare Preparations.SetHashedToken
prepare Preparations.DetermineDaysForToken
filter expr(
token.created_at > ago(^context(:days_for_token), :day) and
token.token == ^context(:hashed_token) and token.context == ^arg(:context) and
token.sent_to == email
)
end
create :register do
accept [:email]
argument :password, :string,
allow_nil?: false,
constraints: [
max_length: 80,
min_length: 12
]
change Changes.HashPassword
end
update :deliver_user_confirmation_instructions do
accept []
argument :confirmation_url_fun, :function do
constraints arity: 1
end
validate attribute_equals(:confirmed_at, nil), message: "already confirmed"
change Changes.CreateEmailConfirmationToken
end
update :deliver_update_email_instructions do
accept [:email]
argument :current_password, :string, allow_nil?: false
argument :update_url_fun, :function do
constraints arity: 1
end
validate Validations.ValidateCurrentPassword
validate changing(:email)
change prevent_change(:email)
change Changes.CreateEmailUpdateToken
end
update :deliver_user_reset_password_instructions do
accept []
argument :reset_password_url_fun, :function do
constraints arity: 1
end
change Changes.CreateResetPasswordToken
end
update :logout do
accept []
change Changes.RemoveAllTokens
end
update :change_email do
accept []
argument :token, :url_encoded_binary
change Changes.GetEmailFromToken
change Changes.DeleteEmailChangeTokens
end
update :change_password do
accept []
argument :password, :string,
allow_nil?: false,
constraints: [
max_length: 80,
min_length: 12
]
argument :password_confirmation, :string, allow_nil?: false
argument :current_password, :string
validate confirm(:password, :password_confirmation)
validate Validations.ValidateCurrentPassword
change Changes.HashPassword
change Changes.RemoveAllTokens
end
update :confirm do
accept []
argument :delete_confirm_tokens, :boolean, default: false
change set_attribute(:confirmed_at, &DateTime.utc_now/0)
change Changes.DeleteConfirmTokens
end
end
attributes do
uuid_primary_key :id
attribute :email, :ci_string,
allow_nil?: false,
constraints: [
max_length: 160
]
attribute :confirmed_at, :utc_datetime_usec
attribute :hashed_password, :string, private?: true
create_timestamp :created_at
update_timestamp :updated_at
end
relationships do
has_one :token, AshHq.Accounts.UserToken,
destination_field: :user_id,
private?: true
end
validations do
validate match(:email, ~r/^[^\s]+@[^\s]+$/, "must have the @ sign and no spaces")
end
end

View file

@ -0,0 +1,14 @@
defmodule AshHq.Accounts.User.Validations.ValidateCurrentPassword do
use Ash.Resource.Validation
@impl true
def validate(changeset, _) do
password = Ash.Changeset.get_argument(changeset, :current_password)
if AshHq.Accounts.User.Helpers.valid_password?(changeset.data, password) do
:ok
else
{:error, "invalid"}
end
end
end

View file

@ -0,0 +1,7 @@
defmodule AshHq.Accounts.User.Validations do
alias AshHq.Accounts.User.Validations
def validate_current_password() do
{Validations.ValidateCurrentPassword, []}
end
end

View file

@ -0,0 +1,31 @@
defmodule AshHq.Accounts.UserToken.Changes.BuildHashedToken do
@moduledoc "A change that sets the session token based on the user id"
use Ash.Resource.Change
@rand_size 32
@hash_algorithm :sha256
def build_hashed_token() do
{__MODULE__, []}
end
def change(changeset, _opts, _context) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
changeset
|> Ash.Changeset.change_attribute(:token, hashed_token)
|> Ash.Changeset.after_action(fn _changeset, result ->
metadata =
Map.put(result.__metadata__, :url_token, Base.url_encode64(token, padding: false))
{:ok,
%{
result
| __metadata__: metadata
}}
end)
end
end

View file

@ -0,0 +1,27 @@
defmodule AshHq.Accounts.UserToken.Changes.BuildSessionToken do
@moduledoc "A change that sets the session token based on the user id"
use Ash.Resource.Change
@rand_size 32
def build_session_token() do
{__MODULE__, []}
end
def change(changeset, _opts, _context) do
token = :crypto.strong_rand_bytes(@rand_size)
changeset
|> Ash.Changeset.change_attribute(:token, token)
|> Ash.Changeset.after_action(fn _changeset, result ->
metadata =
Map.put(result.__metadata__, :url_token, Base.url_encode64(token, padding: false))
{:ok,
%{
result
| __metadata__: metadata
}}
end)
end
end

View file

@ -0,0 +1,70 @@
defmodule AshHq.Accounts.UserToken do
use Ash.Resource,
data_layer: AshPostgres.DataLayer,
notifiers: [AshHq.Accounts.EmailNotifier]
alias AshHq.Accounts.UserToken.Changes, warn: false
alias AshHq.Accounts.Preparations, warn: false
postgres do
table "user_tokens"
repo AshHq.Repo
references do
reference :user, on_delete: :delete, on_update: :update
end
end
identities do
identity :token_context, [:context, :token]
end
actions do
defaults [:read]
read :verify_email_token do
argument :token, :url_encoded_binary, allow_nil?: false
argument :context, :string, allow_nil?: false
prepare Preparations.SetHashedToken
prepare Preparations.DetermineDaysForToken
filter expr(
token == ^context(:hashed_token) and context == ^arg(:context) and
created_at > ago(^context(:days_for_token), :day)
)
end
create :build_session_token do
primary? true
argument :user, :map
change manage_relationship(:user, type: :replace)
change set_attribute(:context, "session")
change Changes.BuildSessionToken
end
create :build_email_token do
accept [:sent_to, :context]
argument :user, :map
change manage_relationship(:user, type: :replace)
change Changes.BuildHashedToken
end
end
attributes do
uuid_primary_key :id
attribute :token, :binary
attribute :context, :string
attribute :sent_to, :string
create_timestamp :created_at
end
relationships do
belongs_to :user, AshHq.Accounts.User
end
end

35
lib/ash_hq/emails.ex Normal file
View file

@ -0,0 +1,35 @@
defmodule AshHq.Emails do
import Bamboo.Email
use Bamboo.Phoenix, view: AshHqWeb.EmailView
@from "test@example.com"
def welcome_email(%{email: email}) do
base_email()
|> subject("Welcome!")
|> to(email)
|> render("welcome.html",
title: "Thank you for signing up",
preheader: "Thank you for signing up to the app."
)
|> premail()
end
defp base_email do
new_email()
|> from(@from)
# Set default layout
|> put_html_layout({AshHqWeb.LayoutView, "email.html"})
# Set default text layout
|> put_text_layout({AshHqWeb.LayoutView, "email.text"})
end
defp premail(email) do
html = Premailex.to_inline_css(email.html_body)
text = Premailex.to_text(email.html_body)
email
|> html_body(html)
|> text_body(text)
end
end

17
lib/ash_hq/guardian.ex Normal file
View file

@ -0,0 +1,17 @@
defmodule AshHq.Guardian do
use Guardian, otp_app: :ash_hq
alias AshHq.Accounts
def subject_for_token(resource, _claims) do
sub = to_string(resource.id)
{:ok, sub}
end
def resource_from_claims(claims) do
id = claims["sub"]
resource = Accounts.get!(Accounts.User, id)
{:ok, resource}
end
end

View file

@ -0,0 +1,177 @@
defmodule AshHqWeb.UserAuth do
import Plug.Conn
import Phoenix.Controller
alias AshHq.Accounts
alias AshHqWeb.Router.Helpers, as: Routes
require Ash.Query
# 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 "_reference_live_app_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.UserToken
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:build_session_token, %{user: user})
|> Accounts.create!()
|> Map.get(:token)
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)
if user_token do
{:ok, query} =
AshHq.Accounts.UserToken
|> Ash.Query.filter(token == ^user_token and context == "session")
|> Ash.Query.data_layer_query()
AshHq.Repo.delete_all(query)
end
if live_socket_id = get_session(conn, :live_socket_id) do
AshHqWeb.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 =
if user_token do
AshHq.Accounts.User
|> Ash.Query.for_read(:by_token, token: user_token, context: "session")
|> AshHq.Accounts.read_one!()
end
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: "/"
def put_session_layout(conn, _opts) do
conn
|> put_layout(false)
|> put_root_layout({AshHqWeb.LayoutView, :session})
end
end

View file

@ -0,0 +1,76 @@
defmodule AshHqWeb.UserConfirmationController do
use AshHqWeb, :controller
alias AshHq.Accounts
require Ash.Query
def new(conn, _params) do
render(conn, "new.html")
end
def create(conn, %{"user" => %{"email" => email}}) do
user =
AshHq.Accounts.User
|> Ash.Query.filter(email == ^email)
|> AshHq.Accounts.read_one!()
if user do
user
|> Ash.Changeset.new()
|> Ash.Changeset.for_update(:deliver_user_confirmation_instructions, %{
confirmation_url_fun: &Routes.user_confirmation_url(conn, :confirm, &1)
})
|> Accounts.update()
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
result =
AshHq.Accounts.User
|> Ash.Query.for_read(:with_verified_email_token, token: token, context: "confirm")
|> AshHq.Accounts.read_one!()
|> case do
nil ->
:error
user ->
user
|> Ash.Changeset.new()
|> Ash.Changeset.for_update(:confirm, %{delete_confirm_tokens: true, token: token})
|> AshHq.Accounts.update()
end
case result do
{:ok, _} ->
conn
|> put_flash(:info, "Account 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, "Account confirmation link is invalid or it has expired.")
|> redirect(to: "/")
end
end
end
end

View file

@ -0,0 +1,35 @@
defmodule AshHqWeb.UserRegistrationController do
use AshHqWeb, :controller
alias AshHq.Accounts
alias AshHq.Accounts.User
alias AshHqWeb.UserAuth
def new(conn, _params) do
form = AshPhoenix.Form.for_create(User, :register, as: "user")
render(conn, "new.html", form: form)
end
def create(conn, %{"user" => user_params}) do
User
|> AshPhoenix.Form.for_create(:register, api: AshHq.Accounts, as: "user")
|> AshPhoenix.Form.validate(user_params)
|> AshPhoenix.Form.submit()
|> case do
{:ok, user} ->
user
|> Ash.Changeset.for_update(:deliver_user_confirmation_instructions, %{
confirmation_url_fun: &Routes.user_confirmation_url(conn, :confirm, &1)
})
|> Accounts.update!()
conn
|> put_flash(:info, "User created successfully.")
|> UserAuth.log_in_user(user)
{:error, form} ->
render(conn, "new.html", form: form)
end
end
end

View file

@ -0,0 +1,76 @@
defmodule AshHqWeb.UserResetPasswordController do
use AshHqWeb, :controller
alias AshHq.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
case Accounts.get(Accounts.User, email: email) do
{:ok, user} ->
user
|> Ash.Changeset.new()
|> Ash.Changeset.for_update(:deliver_user_reset_password_instructions,
reset_password_url_fun: &Routes.user_reset_password_url(conn, :edit, &1)
)
|> Accounts.update!()
{:error, _} ->
nil
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",
form: AshPhoenix.Form.for_update(conn.assigns.user, :change_password, as: "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
conn.assigns.user
|> AshPhoenix.Form.for_update(:change_password, api: AshHq.Accounts, as: "user")
|> AshPhoenix.Form.validate(user_params)
|> AshPhoenix.Form.submit()
|> case do
{:ok, _} ->
conn
|> put_flash(:info, "Password reset successfully.")
|> redirect(to: Routes.user_session_path(conn, :new))
{:error, form} ->
render(conn, "edit.html", form: form)
end
end
defp get_user_by_reset_password_token(conn, _opts) do
%{"token" => token} = conn.params
user =
Accounts.User
|> Ash.Query.for_read(:by_token, token: token, context: "reset_password")
|> Accounts.read_one!()
if user 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,29 @@
defmodule AshHqWeb.UserSessionController do
use AshHqWeb, :controller
alias AshHq.Accounts
alias AshHqWeb.UserAuth
def new(conn, _params) do
render(conn, "new.html", error_message: nil)
end
def create(conn, %{"user" => user_params}) do
Accounts.User
|> Ash.Query.for_read(:by_email_and_password, user_params)
|> Accounts.read_one()
|> case do
{:ok, user} when not is_nil(user) ->
UserAuth.log_in_user(conn, user, user_params)
_ ->
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,97 @@
defmodule AshHqWeb.UserSettingsController do
use AshHqWeb, :controller
alias AshHqWeb.UserAuth
plug :assign_email_and_password_forms
def edit(conn, _params) do
render(conn, "edit.html")
end
def update(conn, %{"action" => "update_email"} = params) do
params =
Map.merge(
params["user"],
%{
"update_url_fun" => &Routes.user_settings_url(conn, :confirm_email, &1),
"current_password" => params["current_password"]
}
)
conn.assigns.current_user
|> AshPhoenix.Form.for_update(
:deliver_update_email_instructions,
api: AshHq.Accounts,
as: "user"
)
|> AshPhoenix.Form.validate(params)
|> AshPhoenix.Form.submit()
|> case do
{:ok, _user} ->
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, form} ->
render(conn, "edit.html", email_form: form)
end
end
def update(conn, %{"action" => "update_password"} = params) do
params =
Map.merge(
params["user"],
%{
"current_password" => params["current_password"]
}
)
conn.assigns.current_user
|> AshPhoenix.Form.for_update(
:change_password,
api: AshHq.Accounts,
as: "user"
)
|> AshPhoenix.Form.validate(params)
|> AshPhoenix.Form.submit()
|> case 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, form} ->
render(conn, "edit.html", password_form: form)
end
end
def confirm_email(conn, %{"token" => token}) do
conn.assigns.current_user
|> Ash.Changeset.for_update(:change_email, %{token: token})
|> AshHq.Accounts.update()
|> case do
{:ok, _} ->
conn
|> put_flash(:info, "Email changed successfully.")
|> redirect(to: Routes.user_settings_path(conn, :edit))
{:error, _form} ->
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_forms(conn, _opts) do
user = conn.assigns.current_user
conn
|> assign(:email_form, AshPhoenix.Form.for_update(user, :change_email, as: "user"))
|> assign(:password_form, AshPhoenix.Form.for_update(user, :change_password, as: "user"))
end
end

View file

@ -0,0 +1,7 @@
defmodule AshHqWeb.AuthAccessPipeline do
use Guardian.Plug.Pipeline, otp_app: :ash_hq
plug Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"}
plug Guardian.Plug.EnsureAuthenticated
plug Guardian.Plug.LoadResource, allow_blank: true
end

View file

@ -0,0 +1,11 @@
defmodule AshHqWeb.AuthErrorHandler do
import Plug.Conn
@behaviour Guardian.Plug.ErrorHandler
@impl Guardian.Plug.ErrorHandler
def auth_error(conn, {type, _reason}, _opts) do
body = Jason.encode!(%{message: to_string(type)})
send_resp(conn, 401, body)
end
end

View file

@ -21,5 +21,6 @@ defmodule AshHqWeb.SessionPlug do
Plug.Conn.put_session(conn, cookie, value)
end
end)
|> Plug.Conn.assign(:configured_theme, conn.assigns[:configured_theme] || "dark")
end
end

View file

@ -1,6 +1,8 @@
defmodule AshHqWeb.Router do
use AshHqWeb, :router
import AshHqWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
@ -8,6 +10,7 @@ defmodule AshHqWeb.Router do
plug :put_root_layout, {AshHqWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
plug AshHqWeb.SessionPlug
end
@ -15,6 +18,10 @@ defmodule AshHqWeb.Router do
plug :accepts, ["json"]
end
pipeline :api_authenticated do
plug AshHqWeb.AuthAccessPipeline
end
scope "/", AshHqWeb do
pipe_through :api
post "/import/:library", ImportController, :import
@ -35,6 +42,43 @@ defmodule AshHqWeb.Router do
end
end
## Authentication routes
scope "/", AshHqWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated, :put_session_layout]
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 "/", AshHqWeb 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 "/", AshHqWeb do
pipe_through [:browser]
get "/users/log_out", UserSessionController, :delete
delete "/users/log_out", UserSessionController, :delete
get "/users/confirm", UserConfirmationController, :new
post "/users/confirm", UserConfirmationController, :create
get "/users/confirm/:token", UserConfirmationController, :confirm
end
if Mix.env() == :dev do
forward "/sent_emails", Bamboo.SentEmailViewerPlug
end
# Other scopes may use custom stacks.
# scope "/api", AshHqWeb do
# pipe_through :api

View file

@ -0,0 +1 @@
<p>Thanks for joining</p>

View file

@ -0,0 +1,308 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title><%= assigns[:title] %></title>
<style>
/* -------------------------------------
GLOBAL
A very basic CSS reset
------------------------------------- */
* {
margin: 0;
padding: 0;
font-family: "Helvetica Neue", "Helvetica", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
}
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6;
}
/* Let's make sure all tables have defaults */
table td {
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
body {
background-color: #f6f6f6;
}
.body-wrap {
background-color: #f6f6f6;
width: 100%;
}
.container {
display: block !important;
max-width: 600px !important;
margin: 0 auto !important;
/* makes it centered */
clear: both !important;
}
.content {
max-width: 600px;
margin: 0 auto;
display: block;
padding: 20px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #fff;
border: 1px solid #e9e9e9;
border-radius: 3px;
}
.content-wrap {
padding: 20px;
}
.content-block {
padding: 0 0 20px;
}
.header {
width: 100%;
margin-bottom: 20px;
}
.footer {
width: 100%;
clear: both;
color: #999;
padding: 20px;
}
.footer a {
color: #999;
}
.footer p, .footer a, .footer unsubscribe, .footer td {
font-size: 12px;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1, h2, h3 {
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
color: #000;
margin: 40px 0 0;
line-height: 1.2;
font-weight: 400;
}
h1 {
font-size: 32px;
font-weight: 500;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 18px;
}
h4 {
font-size: 14px;
font-weight: 600;
}
p, ul, ol {
margin-bottom: 10px;
font-weight: normal;
}
p li, ul li, ol li {
margin-left: 5px;
list-style-position: inside;
}
/* -------------------------------------
LINKS & BUTTONS
------------------------------------- */
a {
color: #348eda;
text-decoration: underline;
}
.btn-primary {
text-decoration: none;
color: #FFF;
background-color: #348eda;
border: solid #348eda;
border-width: 10px 20px;
line-height: 2;
font-weight: bold;
text-align: center;
cursor: pointer;
display: inline-block;
border-radius: 5px;
text-transform: capitalize;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.aligncenter {
text-align: center;
}
.alignright {
text-align: right;
}
.alignleft {
text-align: left;
}
.clear {
clear: both;
}
/* -------------------------------------
ALERTS
Change the class depending on warning email, good email or bad email
------------------------------------- */
.alert {
font-size: 16px;
color: #fff;
font-weight: 500;
padding: 20px;
text-align: center;
border-radius: 3px 3px 0 0;
}
.alert a {
color: #fff;
text-decoration: none;
font-weight: 500;
font-size: 16px;
}
.alert.alert-warning {
background: #ff9f00;
}
.alert.alert-bad {
background: #d0021b;
}
.alert.alert-good {
background: #68b90f;
}
/* -------------------------------------
INVOICE
Styles for the billing table
------------------------------------- */
.invoice {
margin: 40px auto;
text-align: left;
width: 80%;
}
.invoice td {
padding: 5px 0;
}
.invoice .invoice-items {
width: 100%;
}
.invoice .invoice-items td {
border-top: #eee 1px solid;
}
.invoice .invoice-items .total td {
border-top: 2px solid #333;
border-bottom: 2px solid #333;
font-weight: 700;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 640px) {
h1, h2, h3, h4 {
font-weight: 600 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
width: 100% !important;
}
.content, .content-wrapper {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
}
</style>
</head>
<body>
<!-- start preheader -->
<div class="preheader" style="display: none; max-width: 0; max-height: 0; overflow: hidden; font-size: 1px; line-height: 1px; color: #fff; opacity: 0;">
<%= assigns[:preheader] %>
</div>
<!-- end preheader -->
<table class="body-wrap">
<tr>
<td></td>
<td class="container" width="600">
<div class="content">
<table class="main" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="content-wrap">
<%= @inner_content %>
</td>
</tr>
</table>
<div class="footer">
<table width="100%">
<tr>
<td class="aligncenter content-block">
AshHq - 2020
</td>
</tr>
</table>
</div>
</div>
</td>
<td></td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1 @@
<%= @inner_content %>

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" class={"h-full #{@configured_theme || "dark"}"}>
<html lang="en" class={"h-full #{@configured_theme}"}>
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>

View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<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] || "AshHq", suffix: " · Phoenix Framework" %>
<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="flex flex-col h-full bg-gray-700">
<header>
<nav class="flex flex-wrap items-center justify-between py-2 px-4 fixed top-0 right-0 left-0 z-30 bg-gray-700">
<a class="inline-block mr-4 py-1 text-xl text-gray-300" href="#"><i class="fas fa-bolt"></i> AshHq</a>
</nav>
</header>
<main role="main" class="container mx-auto mb-8 px-4 max-w-6xl">
<p class="alert alert-info alert-left toast" role="alert"><%= get_flash(@conn, :info) %></p>
<p class="alert alert-danger alert-left toast" role="alert"><%= get_flash(@conn, :error) %></p>
<div class="flex justify-center p-2">
<div class="card w-full max-w-xs">
<div class="card-body">
<%= @inner_content %>
</div>
</div>
</div>
</main>
<footer class="footer bg-gray-800 text-gray-600 mt-auto py-6">
<div class="container mx-auto px-4 max-w-6xl">
<span class="text-muted">AshHq</span>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,15 @@
<h1>Resend confirmation instructions</h1>
<%= form_for :user, Routes.user_confirmation_path(@conn, :create), fn f -> %>
<%= label f, :email %>
<%= text_input f, :email, required: true %>
<div>
<%= submit "Resend confirmation instructions" %>
</div>
<% end %>
<p>
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
</p>

View file

@ -0,0 +1,33 @@
<h5 class="px-5 pb-2 text-lg leading-normal text-center">Register</h5>
<%= form_for @form, Routes.user_registration_path(@conn, :create), fn f -> %>
<%= if @form.submitted_once? do %>
<div class="alert alert-danger">
<small>Oops, something went wrong! Please check the errors below.</small>
</div>
<% end %>
<div class="mt-4 mb-4">
<%= label f, :email, class: "form-label" %>
<%= text_input f, :email, required: true, class: "form-input" %>
<%= error_tag f, :email %>
</div>
<div class="mb-4">
<%= label f, :password, class: "form-label" %>
<%= password_input f, :password, required: true, class: "form-input" %>
<%= error_tag f, :password %>
</div>
<div class="mt-6">
<%= submit "Register", class: "btn btn-dark w-full" %>
</div>
<% end %>
<div class="mt-3 text-center text-gray-600">
<small>Already have an account?</small>
</div>
<div class="mt-3">
<%= link "Log in", to: Routes.user_session_path(@conn, :new), class: "btn btn-link btn-sm" %>
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new), class: "btn btn-link btn-sm" %>
</div>

View file

@ -0,0 +1,33 @@
<h5 class="px-5 pb-2 text-lg leading-normal text-center">Reset Password</h5>
<%= form_for @form, Routes.user_reset_password_path(@conn, :update, @token), fn f -> %>
<%= if @form.submitted_once? do %>
<div class="alert alert-danger">
<small>Oops, something went wrong! Please check the errors below.</small>
</div>
<% end %>
<div class="mt-4 mb-4">
<%= label f, :password, "New password", class: "form-label" %>
<%= password_input f, :password, required: true, class: "form-input" %>
<%= error_tag f, :password %>
</div>
<div class="mb-4">
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<%= password_input f, :password_confirmation, required: true, class: "form-input" %>
<%= error_tag f, :password_confirmation %>
</div>
<div class="mt-3">
<%= submit "Reset password", class: "btn btn-dark w-full" %>
</div>
<% end %>
<div class="mt-3 text-center text-gray-600">
<small>I do rememebr my password</small>
</div>
<div class="mt-3">
<%= link "Register", to: Routes.user_registration_path(@conn, :new), class: "btn btn-link btn-sm" %>
<%= link "Log in", to: Routes.user_session_path(@conn, :new), class: "btn btn-link btn-sm" %>
</div>

View file

@ -0,0 +1,20 @@
<h5 class="px-5 pb-2 text-lg leading-normal text-center">Forgot your password?</h5>
<%= form_for :user, Routes.user_reset_password_path(@conn, :create), fn f -> %>
<div class="mt-4 mb-4">
<%= label f, :email, class: "form-label" %>
<%= text_input f, :email, required: true, class: "form-input" %>
</div>
<div class="mt-3">
<%= submit "Send password reset instructions", class: "btn btn-dark w-full" %>
</div>
<% end %>
<div class="mt-3 text-center text-gray-600">
<small>I do rememebr my password</small>
</div>
<div class="mt-3">
<%= link "Register", to: Routes.user_registration_path(@conn, :new), class: "btn btn-link btn-sm" %>
<%= link "Log in", to: Routes.user_session_path(@conn, :new), class: "btn btn-link btn-sm" %>
</div>

View file

@ -0,0 +1,36 @@
<h5 class="px-5 pb-2 text-lg leading-normal text-center">Log in</h5>
<%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user], fn f -> %>
<%= if @error_message do %>
<div class="alert alert-danger">
<small><%= @error_message %></small>
</div>
<% end %>
<div class="mt-4 mb-4">
<%= label f, :email, class: "form-label" %>
<%= text_input f, :email, required: true, class: "form-input" %>
</div>
<div class="mb-4">
<%= label f, :password, class: "form-label" %>
<%= password_input f, :password, required: true, class: "form-input" %>
</div>
<%= label f, :remember_me, class: "form-label" do %>
<%= checkbox f, :remember_me, class: "form-checkbox" %>
<span class="ml-1">Keep me logged in for 60 days</span>
<% end %>
<div class="mt-6">
<%= submit "Log in", class: "btn btn-dark w-full" %>
</div>
<% end %>
<div class="mt-3 text-center text-gray-600">
<small>Already have an account?</small>
</div>
<div class="mt-3">
<%= link "Register", to: Routes.user_registration_path(@conn, :new), class: "btn btn-link btn-sm" %>
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new), class: "btn btn-link btn-sm" %>
</div>

View file

@ -0,0 +1,74 @@
<div class="flex flex-wrap">
<div class="w-full md:w-2/3">
<div class="shadow card">
<div class="card-header">
<h5 class="card-title">Settings</h5>
</div>
<div class="card-body prose">
<h4>Change e-mail</h4>
<%= form_for @email_form, Routes.user_settings_path(@conn, :update), fn f -> %>
<%= if @email_form.submitted_once? 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="mb-6">
<%= label f, :email, class: "form-label" %>
<%= text_input f, :email, required: true, class: "form-input" %>
<%= error_tag f, :email %>
</div>
<div class="mb-6">
<%= label f, :current_password, for: "current_password_for_email" %>
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_email", class: "form-input" %>
<%= error_tag f, :current_password %>
</div>
<div class="mt-8 mb-2">
<%= submit "Change e-mail", class: "btn btn-primary btn-sm" %>
</div>
<% end %>
<h4>Change password</h4>
<%= form_for @password_form, Routes.user_settings_path(@conn, :update), fn f -> %>
<%= if @password_form.submitted_once? 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="mb-6">
<%= label f, :password, "New password", class: "form-label" %>
<%= password_input f, :password, required: true, class: "form-input" %>
<%= error_tag f, :password %>
</div>
<div class="mb-6">
<%= label f, :password_confirmation, "Confirm new password", class: "form-label" %>
<%= password_input f, :password_confirmation, required: true, class: "form-input" %>
<%= error_tag f, :password_confirmation %>
</div>
<div class="mb-6">
<%= label f, :current_password, for: "current_password_for_password", class: "form-label" %>
<%= password_input f, :current_password, required: true, name: "current_password", id: "current_password_for_password", class: "form-input" %>
<%= error_tag f, :current_password %>
</div>
<div class="mt-8 mb-2">
<%= submit "Change password", class: "btn btn-primary btn-sm" %>
</div>
<% end %>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,3 @@
defmodule AshHqWeb.EmailView do
use AshHqWeb, :view
end

View file

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

View file

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

View file

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

View file

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

View file

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

12
mix.exs
View file

@ -52,6 +52,13 @@ defmodule AshHq.MixProject do
{:makeup_eex, "~> 0.1.1"},
{:makeup_js, "~> 0.1.0"},
{:makeup_sql, "~> 0.1.0"},
# Bamboo for Emailing
{:bamboo, "~> 2.2"},
{:premailex, "~> 0.3.0"},
{:bamboo_postmark, "~> 1.0"},
# Authentication
{:guardian, "~> 2.0"},
{:bcrypt_elixir, "~> 3.0"},
# Phoenix/Core dependencies
{:phoenix, "~> 1.6.6"},
{:phoenix_ecto, "~> 4.4"},
@ -62,7 +69,7 @@ defmodule AshHq.MixProject do
{:phoenix_live_view, "~> 0.17.5"},
{:nimble_options, "~> 0.4.0", override: true},
{:finch, "~> 0.10.2"},
{:floki, ">= 0.30.0", only: :test},
{:floki, ">= 0.30.0"},
{:phoenix_live_dashboard, "~> 0.6"},
{:esbuild, "~> 0.3", runtime: Mix.env() == :dev},
{:swoosh, "~> 1.3"},
@ -71,6 +78,9 @@ defmodule AshHq.MixProject do
{:gettext, "~> 0.18"},
{:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"},
# Dependencies
{:sobelow, "~> 0.8", only: :dev},
{:credo, "~> 1.4", only: [:dev, :test], runtime: false},
{:elixir_sense, github: "elixir-lsp/elixir_sense"}
]
end

View file

@ -1,15 +1,21 @@
%{
"ash": {:git, "https://github.com/ash-project/ash.git", "6c277a1a599e693bf9967859b3e2f9f7cb739526", []},
"ash": {:git, "https://github.com/ash-project/ash.git", "fe12f40056661e84e702b3fb50badef1d9f3c99f", []},
"ash_phoenix": {:git, "https://github.com/ash-project/ash_phoenix.git", "538784765f5c38cde1b9b527aa348b62d625c01f", []},
"ash_postgres": {:git, "https://github.com/ash-project/ash_postgres.git", "e20e68e73af334dec540786b9275fcdf0cb86731", []},
"bamboo": {:hex, :bamboo, "2.2.0", "f10a406d2b7f5123eb1f02edfa043c259db04b47ab956041f279eaac776ef5ce", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8c3b14ba7d2f40cb4be04128ed1e2aff06d91d9413d38bafb4afccffa3ade4fc"},
"bamboo_postmark": {:hex, :bamboo_postmark, "1.0.0", "37e3dea3d06b79a17b6b98ef9261f8f4488619c6283f19306f93d3b636d6f9fb", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:hackney, ">= 1.6.5", [hex: :hackney, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "443b3fb9e00a5d092ccfc91cfe3dbecab2a931114d4dc5e1e70f28f6c640c63d"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"castore": {:hex, :castore, "0.1.17", "ba672681de4e51ed8ec1f74ed624d104c0db72742ea1a5e74edbc770c815182f", [:mix], [], "hexpm", "d9844227ed52d26e7519224525cb6868650c272d4a3d327ce3ca5570c12163f9"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
"comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"credo": {:hex, :credo, "1.6.6", "f51f8d45db1af3b2e2f7bee3e6d3c871737bda4a91bff00c5eec276517d1a19c", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "625520ce0984ee0f9f1f198165cd46fa73c1e59a17ebc520038b8fce056a5bdc"},
"db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"docsh": {:hex, :docsh, "0.7.2", "f893d5317a0e14269dd7fe79cf95fb6b9ba23513da0480ec6e77c73221cae4f2", [:rebar3], [{:providers, "1.8.1", [hex: :providers, repo: "hexpm", optional: false]}], "hexpm", "4e7db461bb07540d2bc3d366b8513f0197712d0495bb85744f367d3815076134"},
@ -27,11 +33,13 @@
"floki": {:hex, :floki, "0.32.1", "dfe3b8db3b793939c264e6f785bca01753d17318d144bd44b407fb3493acaa87", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "d4b91c713e4a784a3f7b1e3cc016eefc619f6b1c3898464222867cafd3c681a3"},
"getopt": {:hex, :getopt, "1.0.1", "c73a9fa687b217f2ff79f68a3b637711bb1936e712b521d8ce466b29cbf7808a", [:rebar3], [], "hexpm", "53e1ab83b9ceb65c9672d3e7a35b8092e9bdc9b3ee80721471a161c10c59959c"},
"gettext": {:hex, :gettext, "0.20.0", "75ad71de05f2ef56991dbae224d35c68b098dd0e26918def5bb45591d5c8d429", [:mix], [], "hexpm", "1c03b177435e93a47441d7f681a7040bd2a816ece9e2666d1c9001035121eb3d"},
"guardian": {:hex, :guardian, "2.2.4", "3dafdc19665411c96b2796d184064d691bc08813a132da5119e39302a252b755", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "6f83d4309c16ec2469da8606bb2a9815512cc2fac1595ad34b79940a224eb110"},
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
"hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
"jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"},
"kino": {:hex, :kino, "0.6.2", "3e8463ea19551f368c3dcbbf39d36b2627a33916598bfe87f51adc9aaab453fb", [:mix], [{:table, "~> 0.1.2", [hex: :table, repo: "hexpm", optional: false]}], "hexpm", "488cd83fa6efcdb4d5289c25daf842c44b33508fea048eb98f58132afc4ed513"},
"makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
"makeup_eex": {:hex, :makeup_eex, "0.1.1", "89352d5da318d97ae27bbcc87201f274504d2b71ede58ca366af6a5fbed9508d", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.16", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d111a0994eaaab09ef1a4b3b313ef806513bb4652152c26c0d7ca2be8402a964"},
@ -42,7 +50,7 @@
"makeup_js": {:hex, :makeup_js, "0.1.0", "ffa8ce9db95d14dcd09045334539d5992d540d63598c592d4805b7674bdd6675", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "3f0c1a5eb52c9737b1679c926574e83bb260ccdedf08b58ee96cca7c685dea75"},
"makeup_sql": {:hex, :makeup_sql, "0.1.0", "197a8a0a38e83885f73767530739bb8f990aecf7fd1597d3141608c14f5f233e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "556e23ff88ad2fb8c44e393467cfba0c4f980cbe90316deaf48a1362f58cd118"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"},
"mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mint": {:hex, :mint, "1.4.1", "49b3b6ea35a9a38836d2ad745251b01ca9ec062f7cb66f546bf22e6699137126", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "cd261766e61011a9079cccf8fa9d826e7a397c24fbedf0e11b49312bea629b58"},
"nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"},
@ -62,9 +70,11 @@
"plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
"postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"},
"premailex": {:hex, :premailex, "0.3.16", "25c0c9c969f0025bbfdb06834f8f0fbd46e5ec50f5c252e6492165802ffbd2a6", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:floki, "~> 0.19", [hex: :floki, repo: "hexpm", optional: false]}, {:meeseeks, "~> 0.11", [hex: :meeseeks, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "c6b042f89ca63025dfbe3ef54fdbbe9d5f043b7c33d8e58f43a41d13a9475111"},
"providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"req": {:hex, :req, "0.2.1", "5d4ee7bc6666cd4d77e95f89ce75ca0ca73b6a25eeebbe2e7bc60cdd56d73865", [:mix], [{:finch, "~> 0.9.1", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}], "hexpm", "ababd5c8a334848bde2bc3c2f518df22211c8533d863d15bfefa04796abc3633"},
"sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"},
"sourceror": {:hex, :sourceror, "0.11.1", "1b80efe84330beefb6b3da95b75c1e1cdefe9dc785bf4c5064fae251a8af615c", [:mix], [], "hexpm", "22b6828ee5572f6cec75cc6357f3ca6c730a02954cef0302c428b3dba31e5e74"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"stemmer": {:hex, :stemmer, "1.1.0", "71221331ced40832b47e6989a12dd9de1b15c982043d1014742be83c34ec9e79", [:mix], [], "hexpm", "0cb5faf73476b84500e371ff39fd9a494f60ab31d991689c1cd53b920556228f"},

View file

@ -0,0 +1,54 @@
defmodule AshHq.Repo.Migrations.MigrateResources17 do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:users, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
add :email, :citext, null: false
add :confirmed_at, :utc_datetime_usec
add :hashed_password, :text
add :created_at, :utc_datetime_usec, null: false, default: fragment("now()")
add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
end
create unique_index(:users, [:email], name: "users_unique_email_index")
create table(:user_tokens, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
add :token, :binary
add :context, :text
add :sent_to, :text
add :created_at, :utc_datetime_usec, null: false, default: fragment("now()")
add :user_id,
references(:users,
column: :id,
name: "user_tokens_user_id_fkey",
type: :uuid,
prefix: "public"
)
end
create unique_index(:user_tokens, [:context, :token], name: "user_tokens_token_context_index")
end
def down do
drop_if_exists unique_index(:user_tokens, [:context, :token],
name: "user_tokens_token_context_index"
)
drop constraint(:user_tokens, "user_tokens_user_id_fkey")
drop table(:user_tokens)
drop_if_exists unique_index(:users, [:email], name: "users_unique_email_index")
drop table(:users)
end
end

View file

@ -0,0 +1,39 @@
defmodule AshHq.Repo.Migrations.MigrateResources18 do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
drop constraint(:user_tokens, "user_tokens_user_id_fkey")
alter table(:user_tokens) do
modify :user_id,
references(:users,
column: :id,
prefix: "public",
name: "user_tokens_user_id_fkey",
type: :uuid,
on_delete: :delete_all,
on_update: :update_all
)
end
end
def down do
drop constraint(:user_tokens, "user_tokens_user_id_fkey")
alter table(:user_tokens) do
modify :user_id,
references(:users,
column: :id,
prefix: "public",
name: "user_tokens_user_id_fkey",
type: :uuid
)
end
end
end

View file

@ -0,0 +1,103 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v4()\")",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "token",
"type": "binary"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "context",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "sent_to",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "created_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": {
"destination_field": "id",
"destination_field_default": null,
"destination_field_generated": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "user_tokens_user_id_fkey",
"on_delete": null,
"on_update": null,
"schema": "public",
"table": "users"
},
"size": null,
"source": "user_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "8178A5C3E180680B70C2401979351AE4A4B18838DE28B810126C4444F5BB6C52",
"identities": [
{
"base_filter": null,
"index_name": "user_tokens_token_context_index",
"keys": [
"context",
"token"
],
"name": "token_context"
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshHq.Repo",
"schema": null,
"table": "user_tokens"
}

View file

@ -0,0 +1,103 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v4()\")",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "token",
"type": "binary"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "context",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "sent_to",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "created_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": {
"destination_field": "id",
"destination_field_default": null,
"destination_field_generated": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "user_tokens_user_id_fkey",
"on_delete": "delete",
"on_update": "update",
"schema": "public",
"table": "users"
},
"size": null,
"source": "user_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "A88F124054393CBF189CE20D9C0B77757E0D4525EEFE6E692859FA2A1639C68D",
"identities": [
{
"base_filter": null,
"index_name": "user_tokens_token_context_index",
"keys": [
"context",
"token"
],
"name": "token_context"
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshHq.Repo",
"schema": null,
"table": "user_tokens"
}

View file

@ -0,0 +1,88 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v4()\")",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "confirmed_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "created_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "911D80F3EF0DA8A68F61091FB3469DC99799FDD928D501996CA227E9E0DA0F0C",
"identities": [
{
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
"email"
],
"name": "unique_email"
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshHq.Repo",
"schema": null,
"table": "users"
}

View file

@ -1,8 +0,0 @@
defmodule AshHqWeb.PageControllerTest do
use AshHqWeb.ConnCase
test "GET /", %{conn: conn} do
conn = get(conn, "/")
assert html_response(conn, 200) =~ "Welcome to Phoenix!"
end
end

View file

@ -0,0 +1,196 @@
defmodule AshHqWeb.UserAuthTest do
use AshHqWeb.ConnCase, async: true
alias AshHq.Accounts
alias AshHqWeb.UserAuth
import AshHq.AccountsFixtures
@remember_me_cookie "_reference_live_app_web_user_remember_me"
setup %{conn: conn} do
conn =
conn
|> Map.replace!(:secret_key_base, AshHqWeb.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) == "/"
assert AshHq.Accounts.User
|> Ash.Query.for_read(:by_token, token: token, context: "session")
|> AshHq.Accounts.read_one!()
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.UserToken
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:build_session_token, user: user)
|> Accounts.create!()
|> Map.get(:token)
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) == "/"
refute AshHq.Accounts.User
|> Ash.Query.for_read(:by_token, token: user_token, context: "session")
|> AshHq.Accounts.read_one!()
end
test "broadcasts to the given live_socket_id", %{conn: conn} do
live_socket_id = "users_sessions:abcdef-token"
AshHqWeb.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: "users_sessions:abcdef-token"
}
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) == "/"
end
end
describe "fetch_current_user/2" do
test "authenticates user from session", %{conn: conn, user: user} do
user_token =
Accounts.UserToken
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:build_session_token, user: user)
|> Accounts.create!()
|> Map.get(:token)
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 get_session(conn, :user_token) == user_token
assert conn.assigns.current_user.id == user.id
end
test "does not authenticate if data is missing", %{conn: conn, user: user} do
_ =
Accounts.UserToken
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:build_session_token, user: user)
|> Accounts.create!()
|> Map.get(:token)
conn = UserAuth.fetch_current_user(conn, [])
refute get_session(conn, :user_token)
refute conn.assigns.current_user
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) == "/"
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) == Routes.user_session_path(conn, :new)
assert get_flash(conn, :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

View file

@ -0,0 +1,102 @@
defmodule AshHqWeb.UserConfirmationControllerTest do
use AshHqWeb.ConnCase, async: true
alias AshHq.Accounts
alias AshHq.Repo
import AshHq.AccountsFixtures
setup do
user = user_fixture()
%{user: user}
end
describe "GET /users/confirm" do
test "renders the confirmation page", %{conn: conn} do
conn = get(conn, Routes.user_confirmation_path(conn, :new))
response = html_response(conn, 200)
assert response =~ "<h1>Resend confirmation instructions</h1>"
end
end
describe "POST /users/confirm" do
@tag :capture_log
test "sends a new confirmation token", %{conn: conn, user: user} do
conn =
post(conn, Routes.user_confirmation_path(conn, :create), %{
"user" => %{"email" => user.email}
})
assert redirected_to(conn) == "/"
assert get_flash(conn, :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 account is confirmed", %{conn: conn, user: user} do
user
|> Ash.Changeset.for_update(:confirm)
|> Accounts.update!()
conn =
post(conn, Routes.user_confirmation_path(conn, :create), %{
"user" => %{"email" => user.email}
})
assert redirected_to(conn) == "/"
assert get_flash(conn, :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
conn =
post(conn, Routes.user_confirmation_path(conn, :create), %{
"user" => %{"email" => "unknown@example.com"}
})
assert redirected_to(conn) == "/"
assert get_flash(conn, :info) =~ "If your email is in our system"
assert Repo.all(Accounts.UserToken) == []
end
end
describe "GET /users/confirm/:token" do
test "confirms the given token once", %{conn: conn, user: user} do
token =
user
|> Ash.Changeset.for_update(:deliver_user_confirmation_instructions)
|> Accounts.update!()
|> Map.get(:__metadata__)
|> Map.get(:token)
conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token))
assert redirected_to(conn) == "/"
assert get_flash(conn, :info) && get_flash(conn, :info) =~ "Account confirmed successfully"
assert Accounts.get!(Accounts.User, user.id).confirmed_at
refute get_session(conn, :user_token)
assert Repo.all(Accounts.UserToken) == []
# When not logged in
conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token))
assert redirected_to(conn) == "/"
assert get_flash(conn, :error) =~ "Account confirmation link is invalid or it has expired"
# When logged in
conn =
build_conn()
|> log_in_user(user)
|> get(Routes.user_confirmation_path(conn, :confirm, token))
assert redirected_to(conn) == "/"
refute get_flash(conn, :error)
end
test "does not confirm email with invalid token", %{conn: conn, user: user} do
conn = get(conn, Routes.user_confirmation_path(conn, :confirm, "oops"))
assert redirected_to(conn) == "/"
assert get_flash(conn, :error) =~ "Account confirmation link is invalid or it has expired"
refute Accounts.get!(Accounts.User, user.id).confirmed_at
end
end
end

View file

@ -0,0 +1,53 @@
defmodule AshHqWeb.UserRegistrationControllerTest do
use AshHqWeb.ConnCase, async: true
import AshHq.AccountsFixtures
describe "GET /users/register" do
test "renders registration page", %{conn: conn} do
conn = get(conn, Routes.user_registration_path(conn, :new))
response = html_response(conn, 200)
assert response =~ "Register</h5>"
assert response =~ "Log in</a>"
assert response =~ "Log in</a>"
end
test "redirects if already logged in", %{conn: conn} do
conn = conn |> log_in_user(user_fixture()) |> get(Routes.user_registration_path(conn, :new))
assert redirected_to(conn) == "/"
end
end
describe "POST /users/register" do
@tag :capture_log
test "creates account and logs the user in", %{conn: conn} do
email = unique_user_email()
conn =
post(conn, Routes.user_registration_path(conn, :create), %{
"user" => %{"email" => email, "password" => valid_user_password()}
})
assert get_session(conn, :user_token)
assert redirected_to(conn) =~ "/"
# Now do a logged in request and assert on the menu
conn = get(conn, "/")
response = html_response(conn, 200)
assert response =~ "Ash Framework"
end
test "render errors for invalid data", %{conn: conn} do
conn =
post(conn, Routes.user_registration_path(conn, :create), %{
"user" => %{"email" => "with spaces", "password" => "too short"}
})
response = html_response(conn, 200)
assert response =~ "Register</h5>"
assert response =~ "must have the @ sign and no spaces"
assert response =~ "length must be greater than or equal to 12"
end
end
end

View file

@ -0,0 +1,124 @@
defmodule AshHqWeb.UserResetPasswordControllerTest do
use AshHqWeb.ConnCase, async: true
alias AshHq.Accounts
alias AshHq.Repo
import AshHq.AccountsFixtures
setup do
%{user: user_fixture()}
end
describe "GET /users/reset_password" do
test "renders the reset password page", %{conn: conn} do
conn = get(conn, Routes.user_reset_password_path(conn, :new))
response = html_response(conn, 200)
assert response =~ "Forgot your password?</h5>"
end
end
describe "POST /users/reset_password" do
@tag :capture_log
test "sends a new reset password token", %{conn: conn, user: user} do
conn =
post(conn, Routes.user_reset_password_path(conn, :create), %{
"user" => %{"email" => user.email}
})
assert redirected_to(conn) == "/"
assert get_flash(conn, :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
conn =
post(conn, Routes.user_reset_password_path(conn, :create), %{
"user" => %{"email" => "unknown@example.com"}
})
assert redirected_to(conn) == "/"
assert get_flash(conn, :info) =~ "If your email is in our system"
assert Repo.all(Accounts.UserToken) == []
end
end
describe "GET /users/reset_password/:token" do
setup %{user: user} do
token =
user
|> Ash.Changeset.for_update(:deliver_user_reset_password_instructions)
|> Accounts.update!()
|> Map.get(:__metadata__)
|> Map.get(:token)
%{token: token}
end
test "renders reset password", %{conn: conn, token: token} do
conn = get(conn, Routes.user_reset_password_path(conn, :edit, token))
assert html_response(conn, 200) =~ "Reset Password</h5>"
end
test "does not render reset password with invalid token", %{conn: conn} do
conn = get(conn, Routes.user_reset_password_path(conn, :edit, "oops"))
assert redirected_to(conn) == "/"
assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired"
end
end
describe "PUT /users/reset_password/:token" do
setup %{user: user} do
token =
user
|> Ash.Changeset.for_update(:deliver_user_reset_password_instructions)
|> Accounts.update!()
|> Map.get(:__metadata__)
|> Map.get(:token)
%{token: token}
end
test "resets password once", %{conn: conn, user: user, token: token} do
conn =
put(conn, Routes.user_reset_password_path(conn, :update, token), %{
"user" => %{
"current_password" => "hello world!",
"password" => "new valid password",
"password_confirmation" => "new valid password"
}
})
assert redirected_to(conn) == Routes.user_session_path(conn, :new)
refute get_session(conn, :user_token)
assert get_flash(conn, :info) =~ "Password reset successfully"
assert Accounts.User
|> Ash.Query.for_read(:by_email_and_password, %{
email: user.email,
password: "new valid password"
})
|> Accounts.read_one!()
end
test "does not reset password on invalid data", %{conn: conn, token: token} do
conn =
put(conn, Routes.user_reset_password_path(conn, :update, token), %{
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
response = html_response(conn, 200)
assert response =~ "Reset Password</h5>"
assert response =~ "length must be greater than or equal to 12"
assert response =~ "Confirmation did not match value"
end
test "does not reset password with invalid token", %{conn: conn} do
conn = put(conn, Routes.user_reset_password_path(conn, :update, "oops"))
assert redirected_to(conn) == "/"
assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired"
end
end
end

View file

@ -0,0 +1,97 @@
defmodule AshHqWeb.UserSessionControllerTest do
use AshHqWeb.ConnCase, async: true
import AshHq.AccountsFixtures
setup do
%{user: user_fixture()}
end
describe "GET /users/log_in" do
test "renders log in page", %{conn: conn} do
conn = get(conn, Routes.user_session_path(conn, :new))
response = html_response(conn, 200)
assert response =~ "Log in</h5>"
assert response =~ "Forgot your password?</a>"
assert response =~ "Register</a>"
end
test "redirects if already logged in", %{conn: conn, user: user} do
conn = conn |> log_in_user(user) |> get(Routes.user_session_path(conn, :new))
assert redirected_to(conn) == "/"
end
end
describe "POST /users/log_in" do
test "logs the user in", %{conn: conn, user: user} do
conn =
post(conn, Routes.user_session_path(conn, :create), %{
"user" => %{"email" => user.email, "password" => valid_user_password()}
})
assert get_session(conn, :user_token)
assert redirected_to(conn) =~ "/"
# Now do a logged in request and assert on the menu
conn = get(conn, "/")
response = html_response(conn, 200)
assert response =~ "Ash Framework"
end
test "logs the user in with remember me", %{conn: conn, user: user} do
conn =
post(conn, Routes.user_session_path(conn, :create), %{
"user" => %{
"email" => user.email,
"password" => valid_user_password(),
"remember_me" => "true"
}
})
assert conn.resp_cookies["_reference_live_app_web_user_remember_me"]
assert redirected_to(conn) =~ "/"
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(Routes.user_session_path(conn, :create), %{
"user" => %{
"email" => user.email,
"password" => valid_user_password()
}
})
assert redirected_to(conn) == "/foo/bar"
end
test "emits error message with invalid credentials", %{conn: conn, user: user} do
conn =
post(conn, Routes.user_session_path(conn, :create), %{
"user" => %{"email" => user.email, "password" => "invalid password"}
})
response = html_response(conn, 200)
assert response =~ "Log in</h5>"
assert response =~ "Invalid email or password"
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(Routes.user_session_path(conn, :delete))
assert redirected_to(conn) == "/"
refute get_session(conn, :user_token)
assert get_flash(conn, :info) =~ "Logged out successfully"
end
test "succeeds even if the user is not logged in", %{conn: conn} do
conn = delete(conn, Routes.user_session_path(conn, :delete))
assert redirected_to(conn) == "/"
refute get_session(conn, :user_token)
assert get_flash(conn, :info) =~ "Logged out successfully"
end
end
end

View file

@ -0,0 +1,145 @@
defmodule AshHqWeb.UserSettingsControllerTest do
use AshHqWeb.ConnCase, async: true
alias AshHq.Accounts
import AshHq.AccountsFixtures
setup :register_and_log_in_user
describe "GET /users/settings" do
test "renders settings page", %{conn: conn} do
conn = get(conn, Routes.user_settings_path(conn, :edit))
response = html_response(conn, 200)
assert response =~ "Settings</h5>"
end
test "redirects if user is not logged in" do
conn = build_conn()
conn = get(conn, Routes.user_settings_path(conn, :edit))
assert redirected_to(conn) == Routes.user_session_path(conn, :new)
end
end
describe "PUT /users/settings (change password form)" do
test "updates the user password and resets tokens", %{conn: conn, user: user} do
new_password_conn =
put(conn, Routes.user_settings_path(conn, :update), %{
"action" => "update_password",
"current_password" => valid_user_password(),
"user" => %{
"password" => "new valid password",
"password_confirmation" => "new valid password"
}
})
assert redirected_to(new_password_conn) == Routes.user_settings_path(conn, :edit)
assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
assert get_flash(new_password_conn, :info) =~ "Password updated successfully"
assert Accounts.User
|> Ash.Query.for_read(:by_email_and_password, %{
email: user.email,
password: "new valid password"
})
|> Accounts.read_one!()
end
test "does not update password on invalid data", %{conn: conn} do
old_password_conn =
put(conn, Routes.user_settings_path(conn, :update), %{
"action" => "update_password",
"current_password" => "invalid",
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
response = html_response(old_password_conn, 200)
assert response =~ "Settings</h5>"
assert response =~ "length must be greater than or equal to 12"
assert response =~ "Confirmation did not match value"
assert response =~ "errors below"
assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token)
end
end
describe "PUT /users/settings (change email form)" do
@tag :capture_log
test "updates the user email", %{conn: conn, user: user} do
conn =
put(conn, Routes.user_settings_path(conn, :update), %{
"action" => "update_email",
"current_password" => valid_user_password(),
"user" => %{"email" => unique_user_email()}
})
assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
assert get_flash(conn, :info) =~ "A link to confirm your email"
assert Accounts.get!(Accounts.User,
email: user.email
)
end
test "does not update email on invalid data", %{conn: conn} do
conn =
put(conn, Routes.user_settings_path(conn, :update), %{
"action" => "update_email",
"current_password" => "invalid",
"user" => %{"email" => "with spaces"}
})
response = html_response(conn, 200)
assert response =~ "Settings</h5>"
assert response =~ "must have the @ sign and no spaces"
end
end
describe "GET /users/settings/confirm_email/:token" do
setup %{user: user} do
email = unique_user_email()
token =
user
|> Ash.Changeset.for_update(:deliver_update_email_instructions, %{
email: email,
current_password: valid_user_password()
})
|> Accounts.update!()
|> Map.get(:__metadata__)
|> Map.get(:token)
%{token: token, email: email}
end
test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do
conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
assert get_flash(conn, :info) =~ "Email changed successfully"
refute Accounts.get!(Accounts.User, [email: user.email], error?: false)
assert Accounts.get!(Accounts.User, email: email)
conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired"
end
test "does not update email with invalid token", %{conn: conn, user: user} do
conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops"))
assert redirected_to(conn) == Routes.user_settings_path(conn, :edit)
assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired"
assert Accounts.get!(Accounts.User, email: user.email)
end
test "redirects if user is not logged in", %{token: token} do
conn = build_conn()
conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
assert redirected_to(conn) == Routes.user_session_path(conn, :new)
end
end
end

View file

@ -36,4 +36,36 @@ defmodule AshHqWeb.ConnCase do
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
@doc """
Setup helper that registers and logs in users.
setup :register_and_log_in_user
It stores an updated connection and a registered user in the
test context.
"""
def register_and_log_in_user(%{conn: conn}) do
user = AshHq.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 =
AshHq.Accounts.UserToken
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:build_session_token, user: user)
|> AshHq.Accounts.create!()
|> Map.get(:__metadata__)
|> Map.get(:url_token)
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:user_token, token)
end
end

View file

@ -0,0 +1,21 @@
defmodule AshHq.AccountsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `AshHq.Accounts` context.
"""
def unique_user_email, do: "user#{System.unique_integer()}@example.com"
def valid_user_password, do: "hello world!"
def user_fixture(attrs \\ %{}) do
params =
Enum.into(attrs, %{
email: unique_user_email(),
password: valid_user_password()
})
AshHq.Accounts.User
|> Ash.Changeset.for_create(:register, params)
|> AshHq.Accounts.create!()
end
end