mirror of
https://github.com/ash-project/ash_hq.git
synced 2024-09-19 12:53:49 +12:00
improvement: copy over example with auth to ash-hq
This commit is contained in:
parent
de5a4e7b02
commit
9728cdee78
72 changed files with 3210 additions and 13 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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
|
||||
|
|
3
lib/ash_hq/accounts/accounts.ex
Normal file
3
lib/ash_hq/accounts/accounts.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule AshHq.Accounts do
|
||||
use Ash.Api, otp_app: :ash_hq
|
||||
end
|
41
lib/ash_hq/accounts/email_notifier.ex
Normal file
41
lib/ash_hq/accounts/email_notifier.ex
Normal 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
|
64
lib/ash_hq/accounts/emails.ex
Normal file
64
lib/ash_hq/accounts/emails.ex
Normal 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
|
15
lib/ash_hq/accounts/preparations/determine_days_for_token.ex
Normal file
15
lib/ash_hq/accounts/preparations/determine_days_for_token.ex
Normal 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
|
19
lib/ash_hq/accounts/preparations/set_hashed_token.ex
Normal file
19
lib/ash_hq/accounts/preparations/set_hashed_token.ex
Normal 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
|
9
lib/ash_hq/accounts/registry.ex
Normal file
9
lib/ash_hq/accounts/registry.ex
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
25
lib/ash_hq/accounts/resources/user/changes/hash_password.ex
Normal file
25
lib/ash_hq/accounts/resources/user/changes/hash_password.ex
Normal 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
|
|
@ -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
|
22
lib/ash_hq/accounts/resources/user/helpers.ex
Normal file
22
lib/ash_hq/accounts/resources/user/helpers.ex
Normal 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
|
|
@ -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
|
|
@ -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
|
174
lib/ash_hq/accounts/resources/user/user.ex
Normal file
174
lib/ash_hq/accounts/resources/user/user.ex
Normal 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
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
defmodule AshHq.Accounts.User.Validations do
|
||||
alias AshHq.Accounts.User.Validations
|
||||
|
||||
def validate_current_password() do
|
||||
{Validations.ValidateCurrentPassword, []}
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
70
lib/ash_hq/accounts/resources/user_token/user_token.ex
Normal file
70
lib/ash_hq/accounts/resources/user_token/user_token.ex
Normal 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
35
lib/ash_hq/emails.ex
Normal 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
17
lib/ash_hq/guardian.ex
Normal 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
|
177
lib/ash_hq_web/controllers/user_auth.ex
Normal file
177
lib/ash_hq_web/controllers/user_auth.ex
Normal 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
|
76
lib/ash_hq_web/controllers/user_confirmation_controller.ex
Normal file
76
lib/ash_hq_web/controllers/user_confirmation_controller.ex
Normal 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
|
35
lib/ash_hq_web/controllers/user_registration_controller.ex
Normal file
35
lib/ash_hq_web/controllers/user_registration_controller.ex
Normal 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
|
76
lib/ash_hq_web/controllers/user_reset_password_controller.ex
Normal file
76
lib/ash_hq_web/controllers/user_reset_password_controller.ex
Normal 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
|
29
lib/ash_hq_web/controllers/user_session_controller.ex
Normal file
29
lib/ash_hq_web/controllers/user_session_controller.ex
Normal 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
|
97
lib/ash_hq_web/controllers/user_settings_controller.ex
Normal file
97
lib/ash_hq_web/controllers/user_settings_controller.ex
Normal 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
|
7
lib/ash_hq_web/plugs/auth_access_pipeline.ex
Normal file
7
lib/ash_hq_web/plugs/auth_access_pipeline.ex
Normal 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
|
11
lib/ash_hq_web/plugs/auth_error_handler.ex
Normal file
11
lib/ash_hq_web/plugs/auth_error_handler.ex
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
1
lib/ash_hq_web/templates/email/welcome.html.eex
Normal file
1
lib/ash_hq_web/templates/email/welcome.html.eex
Normal file
|
@ -0,0 +1 @@
|
|||
<p>Thanks for joining</p>
|
308
lib/ash_hq_web/templates/layout/email.html.heex
Normal file
308
lib/ash_hq_web/templates/layout/email.html.heex
Normal 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>
|
1
lib/ash_hq_web/templates/layout/email.text.heex
Normal file
1
lib/ash_hq_web/templates/layout/email.text.heex
Normal file
|
@ -0,0 +1 @@
|
|||
<%= @inner_content %>
|
|
@ -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"/>
|
||||
|
|
38
lib/ash_hq_web/templates/layout/session.html.eex
Normal file
38
lib/ash_hq_web/templates/layout/session.html.eex
Normal 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>
|
15
lib/ash_hq_web/templates/user_confirmation/new.html.eex
Normal file
15
lib/ash_hq_web/templates/user_confirmation/new.html.eex
Normal 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>
|
33
lib/ash_hq_web/templates/user_registration/new.html.eex
Normal file
33
lib/ash_hq_web/templates/user_registration/new.html.eex
Normal 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>
|
33
lib/ash_hq_web/templates/user_reset_password/edit.html.eex
Normal file
33
lib/ash_hq_web/templates/user_reset_password/edit.html.eex
Normal 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>
|
20
lib/ash_hq_web/templates/user_reset_password/new.html.eex
Normal file
20
lib/ash_hq_web/templates/user_reset_password/new.html.eex
Normal 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>
|
36
lib/ash_hq_web/templates/user_session/new.html.eex
Normal file
36
lib/ash_hq_web/templates/user_session/new.html.eex
Normal 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>
|
74
lib/ash_hq_web/templates/user_settings/edit.html.eex
Normal file
74
lib/ash_hq_web/templates/user_settings/edit.html.eex
Normal 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>
|
3
lib/ash_hq_web/views/email_view.ex
Normal file
3
lib/ash_hq_web/views/email_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule AshHqWeb.EmailView do
|
||||
use AshHqWeb, :view
|
||||
end
|
3
lib/ash_hq_web/views/user_confirmation_view.ex
Normal file
3
lib/ash_hq_web/views/user_confirmation_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule AshHqWeb.UserConfirmationView do
|
||||
use AshHqWeb, :view
|
||||
end
|
3
lib/ash_hq_web/views/user_registration_view.ex
Normal file
3
lib/ash_hq_web/views/user_registration_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule AshHqWeb.UserRegistrationView do
|
||||
use AshHqWeb, :view
|
||||
end
|
3
lib/ash_hq_web/views/user_reset_password_view.ex
Normal file
3
lib/ash_hq_web/views/user_reset_password_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule AshHqWeb.UserResetPasswordView do
|
||||
use AshHqWeb, :view
|
||||
end
|
3
lib/ash_hq_web/views/user_session_view.ex
Normal file
3
lib/ash_hq_web/views/user_session_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule AshHqWeb.UserSessionView do
|
||||
use AshHqWeb, :view
|
||||
end
|
3
lib/ash_hq_web/views/user_settings_view.ex
Normal file
3
lib/ash_hq_web/views/user_settings_view.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule AshHqWeb.UserSettingsView do
|
||||
use AshHqWeb, :view
|
||||
end
|
12
mix.exs
12
mix.exs
|
@ -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
|
||||
|
|
14
mix.lock
14
mix.lock
|
@ -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"},
|
||||
|
|
54
priv/repo/migrations/20220805214626_migrate_resources17.exs
Normal file
54
priv/repo/migrations/20220805214626_migrate_resources17.exs
Normal 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
|
39
priv/repo/migrations/20220805222129_migrate_resources18.exs
Normal file
39
priv/repo/migrations/20220805222129_migrate_resources18.exs
Normal 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
|
103
priv/resource_snapshots/repo/user_tokens/20220805214626.json
Normal file
103
priv/resource_snapshots/repo/user_tokens/20220805214626.json
Normal 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"
|
||||
}
|
103
priv/resource_snapshots/repo/user_tokens/20220805222129.json
Normal file
103
priv/resource_snapshots/repo/user_tokens/20220805222129.json
Normal 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"
|
||||
}
|
88
priv/resource_snapshots/repo/users/20220805214626.json
Normal file
88
priv/resource_snapshots/repo/users/20220805214626.json
Normal 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"
|
||||
}
|
|
@ -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
|
196
test/ash_hq_web/controllers/user_auth_test.exs
Normal file
196
test/ash_hq_web/controllers/user_auth_test.exs
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
97
test/ash_hq_web/controllers/user_session_controller_test.exs
Normal file
97
test/ash_hq_web/controllers/user_session_controller_test.exs
Normal 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
|
145
test/ash_hq_web/controllers/user_settings_controller_test.exs
Normal file
145
test/ash_hq_web/controllers/user_settings_controller_test.exs
Normal 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
|
|
@ -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
|
||||
|
|
21
test/support/fixtures/accounts_fixtures.ex
Normal file
21
test/support/fixtures/accounts_fixtures.ex
Normal 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
|
Loading…
Reference in a new issue