diff --git a/config/config.exs b/config/config.exs
index 2d79132..b69b614 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -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)
diff --git a/config/dev.exs b/config/dev.exs
index 39c8d0a..072707a 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -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
diff --git a/config/prod.exs b/config/prod.exs
index 5ffefd1..c013d85 100644
--- a/config/prod.exs
+++ b/config/prod.exs
@@ -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"}
diff --git a/config/test.exs b/config/test.exs
index 8ec05cf..36a2cbb 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -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
diff --git a/lib/ash_hq/accounts/accounts.ex b/lib/ash_hq/accounts/accounts.ex
new file mode 100644
index 0000000..9b62bbf
--- /dev/null
+++ b/lib/ash_hq/accounts/accounts.ex
@@ -0,0 +1,3 @@
+defmodule AshHq.Accounts do
+ use Ash.Api, otp_app: :ash_hq
+end
diff --git a/lib/ash_hq/accounts/email_notifier.ex b/lib/ash_hq/accounts/email_notifier.ex
new file mode 100644
index 0000000..b4adca6
--- /dev/null
+++ b/lib/ash_hq/accounts/email_notifier.ex
@@ -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
diff --git a/lib/ash_hq/accounts/emails.ex b/lib/ash_hq/accounts/emails.ex
new file mode 100644
index 0000000..62ebd2d
--- /dev/null
+++ b/lib/ash_hq/accounts/emails.ex
@@ -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
diff --git a/lib/ash_hq/accounts/preparations/determine_days_for_token.ex b/lib/ash_hq/accounts/preparations/determine_days_for_token.ex
new file mode 100644
index 0000000..1b276f5
--- /dev/null
+++ b/lib/ash_hq/accounts/preparations/determine_days_for_token.ex
@@ -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
diff --git a/lib/ash_hq/accounts/preparations/set_hashed_token.ex b/lib/ash_hq/accounts/preparations/set_hashed_token.ex
new file mode 100644
index 0000000..da73c11
--- /dev/null
+++ b/lib/ash_hq/accounts/preparations/set_hashed_token.ex
@@ -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
diff --git a/lib/ash_hq/accounts/registry.ex b/lib/ash_hq/accounts/registry.ex
new file mode 100644
index 0000000..31704a1
--- /dev/null
+++ b/lib/ash_hq/accounts/registry.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/changes/create_email_confirmation_token.ex b/lib/ash_hq/accounts/resources/user/changes/create_email_confirmation_token.ex
new file mode 100644
index 0000000..c4cad7a
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/changes/create_email_confirmation_token.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/changes/create_email_update_token.ex b/lib/ash_hq/accounts/resources/user/changes/create_email_update_token.ex
new file mode 100644
index 0000000..c714726
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/changes/create_email_update_token.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/changes/create_reset_password_token.ex b/lib/ash_hq/accounts/resources/user/changes/create_reset_password_token.ex
new file mode 100644
index 0000000..880ee45
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/changes/create_reset_password_token.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/changes/delete_confirm_tokens.ex b/lib/ash_hq/accounts/resources/user/changes/delete_confirm_tokens.ex
new file mode 100644
index 0000000..f2b5ab3
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/changes/delete_confirm_tokens.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/changes/delete_email_change_tokens.ex b/lib/ash_hq/accounts/resources/user/changes/delete_email_change_tokens.ex
new file mode 100644
index 0000000..795da82
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/changes/delete_email_change_tokens.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/changes/get_email_from_token.ex b/lib/ash_hq/accounts/resources/user/changes/get_email_from_token.ex
new file mode 100644
index 0000000..079a3ca
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/changes/get_email_from_token.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/changes/hash_password.ex b/lib/ash_hq/accounts/resources/user/changes/hash_password.ex
new file mode 100644
index 0000000..d38c818
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/changes/hash_password.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/changes/remove_all_tokens.ex b/lib/ash_hq/accounts/resources/user/changes/remove_all_tokens.ex
new file mode 100644
index 0000000..6005cf9
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/changes/remove_all_tokens.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/helpers.ex b/lib/ash_hq/accounts/resources/user/helpers.ex
new file mode 100644
index 0000000..c3c32b4
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/helpers.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/preparations/decode_token.ex b/lib/ash_hq/accounts/resources/user/preparations/decode_token.ex
new file mode 100644
index 0000000..4c86e80
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/preparations/decode_token.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/preparations/validate_password.ex b/lib/ash_hq/accounts/resources/user/preparations/validate_password.ex
new file mode 100644
index 0000000..c3593d0
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/preparations/validate_password.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/user.ex b/lib/ash_hq/accounts/resources/user/user.ex
new file mode 100644
index 0000000..f23d1cb
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/user.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/validations/validate_current_password.ex b/lib/ash_hq/accounts/resources/user/validations/validate_current_password.ex
new file mode 100644
index 0000000..8ecc352
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/validations/validate_current_password.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user/validations/validations.ex b/lib/ash_hq/accounts/resources/user/validations/validations.ex
new file mode 100644
index 0000000..3716e0a
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user/validations/validations.ex
@@ -0,0 +1,7 @@
+defmodule AshHq.Accounts.User.Validations do
+ alias AshHq.Accounts.User.Validations
+
+ def validate_current_password() do
+ {Validations.ValidateCurrentPassword, []}
+ end
+end
diff --git a/lib/ash_hq/accounts/resources/user_token/changes/build_hashed_token.ex b/lib/ash_hq/accounts/resources/user_token/changes/build_hashed_token.ex
new file mode 100644
index 0000000..de66f37
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user_token/changes/build_hashed_token.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user_token/changes/build_session_token.ex b/lib/ash_hq/accounts/resources/user_token/changes/build_session_token.ex
new file mode 100644
index 0000000..17b1f90
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user_token/changes/build_session_token.ex
@@ -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
diff --git a/lib/ash_hq/accounts/resources/user_token/user_token.ex b/lib/ash_hq/accounts/resources/user_token/user_token.ex
new file mode 100644
index 0000000..dd1018d
--- /dev/null
+++ b/lib/ash_hq/accounts/resources/user_token/user_token.ex
@@ -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
diff --git a/lib/ash_hq/emails.ex b/lib/ash_hq/emails.ex
new file mode 100644
index 0000000..3355d46
--- /dev/null
+++ b/lib/ash_hq/emails.ex
@@ -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
diff --git a/lib/ash_hq/guardian.ex b/lib/ash_hq/guardian.ex
new file mode 100644
index 0000000..f7c32b7
--- /dev/null
+++ b/lib/ash_hq/guardian.ex
@@ -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
diff --git a/lib/ash_hq_web/controllers/user_auth.ex b/lib/ash_hq_web/controllers/user_auth.ex
new file mode 100644
index 0000000..c1300b6
--- /dev/null
+++ b/lib/ash_hq_web/controllers/user_auth.ex
@@ -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
diff --git a/lib/ash_hq_web/controllers/user_confirmation_controller.ex b/lib/ash_hq_web/controllers/user_confirmation_controller.ex
new file mode 100644
index 0000000..cb237a8
--- /dev/null
+++ b/lib/ash_hq_web/controllers/user_confirmation_controller.ex
@@ -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
diff --git a/lib/ash_hq_web/controllers/user_registration_controller.ex b/lib/ash_hq_web/controllers/user_registration_controller.ex
new file mode 100644
index 0000000..42ea4ab
--- /dev/null
+++ b/lib/ash_hq_web/controllers/user_registration_controller.ex
@@ -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
diff --git a/lib/ash_hq_web/controllers/user_reset_password_controller.ex b/lib/ash_hq_web/controllers/user_reset_password_controller.ex
new file mode 100644
index 0000000..db6c5a7
--- /dev/null
+++ b/lib/ash_hq_web/controllers/user_reset_password_controller.ex
@@ -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
diff --git a/lib/ash_hq_web/controllers/user_session_controller.ex b/lib/ash_hq_web/controllers/user_session_controller.ex
new file mode 100644
index 0000000..5e933a0
--- /dev/null
+++ b/lib/ash_hq_web/controllers/user_session_controller.ex
@@ -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
diff --git a/lib/ash_hq_web/controllers/user_settings_controller.ex b/lib/ash_hq_web/controllers/user_settings_controller.ex
new file mode 100644
index 0000000..2916189
--- /dev/null
+++ b/lib/ash_hq_web/controllers/user_settings_controller.ex
@@ -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
diff --git a/lib/ash_hq_web/plugs/auth_access_pipeline.ex b/lib/ash_hq_web/plugs/auth_access_pipeline.ex
new file mode 100644
index 0000000..bd0f011
--- /dev/null
+++ b/lib/ash_hq_web/plugs/auth_access_pipeline.ex
@@ -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
diff --git a/lib/ash_hq_web/plugs/auth_error_handler.ex b/lib/ash_hq_web/plugs/auth_error_handler.ex
new file mode 100644
index 0000000..3c13539
--- /dev/null
+++ b/lib/ash_hq_web/plugs/auth_error_handler.ex
@@ -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
diff --git a/lib/ash_hq_web/plugs/session_plug.ex b/lib/ash_hq_web/plugs/session_plug.ex
index 4fb8e1f..b6dfe51 100644
--- a/lib/ash_hq_web/plugs/session_plug.ex
+++ b/lib/ash_hq_web/plugs/session_plug.ex
@@ -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
diff --git a/lib/ash_hq_web/router.ex b/lib/ash_hq_web/router.ex
index 0798b88..195a6e3 100644
--- a/lib/ash_hq_web/router.ex
+++ b/lib/ash_hq_web/router.ex
@@ -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
diff --git a/lib/ash_hq_web/templates/email/welcome.html.eex b/lib/ash_hq_web/templates/email/welcome.html.eex
new file mode 100644
index 0000000..01d9863
--- /dev/null
+++ b/lib/ash_hq_web/templates/email/welcome.html.eex
@@ -0,0 +1 @@
+
Thanks for joining
\ No newline at end of file
diff --git a/lib/ash_hq_web/templates/layout/email.html.heex b/lib/ash_hq_web/templates/layout/email.html.heex
new file mode 100644
index 0000000..ddd7ba5
--- /dev/null
+++ b/lib/ash_hq_web/templates/layout/email.html.heex
@@ -0,0 +1,308 @@
+
+
+
+
+
+ <%= assigns[:title] %>
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+ <%= @inner_content %>
+ |
+
+
+
+
+ |
+ |
+
+
+
+
diff --git a/lib/ash_hq_web/templates/layout/email.text.heex b/lib/ash_hq_web/templates/layout/email.text.heex
new file mode 100644
index 0000000..f3985a1
--- /dev/null
+++ b/lib/ash_hq_web/templates/layout/email.text.heex
@@ -0,0 +1 @@
+<%= @inner_content %>
\ No newline at end of file
diff --git a/lib/ash_hq_web/templates/layout/root.html.heex b/lib/ash_hq_web/templates/layout/root.html.heex
index 8d6118d..144f126 100644
--- a/lib/ash_hq_web/templates/layout/root.html.heex
+++ b/lib/ash_hq_web/templates/layout/root.html.heex
@@ -1,5 +1,5 @@
-
+
diff --git a/lib/ash_hq_web/templates/layout/session.html.eex b/lib/ash_hq_web/templates/layout/session.html.eex
new file mode 100644
index 0000000..599c90b
--- /dev/null
+++ b/lib/ash_hq_web/templates/layout/session.html.eex
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+ <%= csrf_meta_tag() %>
+ <%= live_title_tag assigns[:page_title] || "AshHq", suffix: " ยท Phoenix Framework" %>
+ "/>
+
+
+
+
+
+
+ <%= get_flash(@conn, :info) %>
+ <%= get_flash(@conn, :error) %>
+
+
+
+
+ <%= @inner_content %>
+
+
+
+
+
+
+
+
diff --git a/lib/ash_hq_web/templates/user_confirmation/new.html.eex b/lib/ash_hq_web/templates/user_confirmation/new.html.eex
new file mode 100644
index 0000000..4d4ca3b
--- /dev/null
+++ b/lib/ash_hq_web/templates/user_confirmation/new.html.eex
@@ -0,0 +1,15 @@
+Resend confirmation instructions
+
+<%= form_for :user, Routes.user_confirmation_path(@conn, :create), fn f -> %>
+ <%= label f, :email %>
+ <%= text_input f, :email, required: true %>
+
+
+ <%= submit "Resend confirmation instructions" %>
+
+<% end %>
+
+
+ <%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
+ <%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
+
diff --git a/lib/ash_hq_web/templates/user_registration/new.html.eex b/lib/ash_hq_web/templates/user_registration/new.html.eex
new file mode 100644
index 0000000..71677f6
--- /dev/null
+++ b/lib/ash_hq_web/templates/user_registration/new.html.eex
@@ -0,0 +1,33 @@
+Register
+<%= form_for @form, Routes.user_registration_path(@conn, :create), fn f -> %>
+ <%= if @form.submitted_once? do %>
+
+ Oops, something went wrong! Please check the errors below.
+
+ <% end %>
+
+
+ <%= label f, :email, class: "form-label" %>
+ <%= text_input f, :email, required: true, class: "form-input" %>
+ <%= error_tag f, :email %>
+
+
+
+ <%= label f, :password, class: "form-label" %>
+ <%= password_input f, :password, required: true, class: "form-input" %>
+ <%= error_tag f, :password %>
+
+
+
+ <%= submit "Register", class: "btn btn-dark w-full" %>
+
+<% end %>
+
+
+ Already have an account?
+
+
+
+ <%= 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" %>
+
diff --git a/lib/ash_hq_web/templates/user_reset_password/edit.html.eex b/lib/ash_hq_web/templates/user_reset_password/edit.html.eex
new file mode 100644
index 0000000..48f6cf3
--- /dev/null
+++ b/lib/ash_hq_web/templates/user_reset_password/edit.html.eex
@@ -0,0 +1,33 @@
+Reset Password
+<%= form_for @form, Routes.user_reset_password_path(@conn, :update, @token), fn f -> %>
+ <%= if @form.submitted_once? do %>
+
+ Oops, something went wrong! Please check the errors below.
+
+ <% end %>
+
+
+ <%= label f, :password, "New password", class: "form-label" %>
+ <%= password_input f, :password, required: true, class: "form-input" %>
+ <%= error_tag f, :password %>
+
+
+
+ <%= 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 %>
+
+
+
+ <%= submit "Reset password", class: "btn btn-dark w-full" %>
+
+<% end %>
+
+
+ I do rememebr my password
+
+
+
+ <%= 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" %>
+
diff --git a/lib/ash_hq_web/templates/user_reset_password/new.html.eex b/lib/ash_hq_web/templates/user_reset_password/new.html.eex
new file mode 100644
index 0000000..bb2f0ba
--- /dev/null
+++ b/lib/ash_hq_web/templates/user_reset_password/new.html.eex
@@ -0,0 +1,20 @@
+Forgot your password?
+<%= form_for :user, Routes.user_reset_password_path(@conn, :create), fn f -> %>
+
+ <%= label f, :email, class: "form-label" %>
+ <%= text_input f, :email, required: true, class: "form-input" %>
+
+
+
+ <%= submit "Send password reset instructions", class: "btn btn-dark w-full" %>
+
+<% end %>
+
+
+ I do rememebr my password
+
+
+
+ <%= 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" %>
+
diff --git a/lib/ash_hq_web/templates/user_session/new.html.eex b/lib/ash_hq_web/templates/user_session/new.html.eex
new file mode 100644
index 0000000..49ef00f
--- /dev/null
+++ b/lib/ash_hq_web/templates/user_session/new.html.eex
@@ -0,0 +1,36 @@
+Log in
+<%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user], fn f -> %>
+ <%= if @error_message do %>
+
+ <%= @error_message %>
+
+ <% end %>
+
+
+ <%= label f, :email, class: "form-label" %>
+ <%= text_input f, :email, required: true, class: "form-input" %>
+
+
+
+ <%= label f, :password, class: "form-label" %>
+ <%= password_input f, :password, required: true, class: "form-input" %>
+
+
+ <%= label f, :remember_me, class: "form-label" do %>
+ <%= checkbox f, :remember_me, class: "form-checkbox" %>
+ Keep me logged in for 60 days
+ <% end %>
+
+
+ <%= submit "Log in", class: "btn btn-dark w-full" %>
+
+<% end %>
+
+
+ Already have an account?
+
+
+
+ <%= 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" %>
+
diff --git a/lib/ash_hq_web/templates/user_settings/edit.html.eex b/lib/ash_hq_web/templates/user_settings/edit.html.eex
new file mode 100644
index 0000000..88531eb
--- /dev/null
+++ b/lib/ash_hq_web/templates/user_settings/edit.html.eex
@@ -0,0 +1,74 @@
+
+
+
+
+
+
Change e-mail
+
+ <%= form_for @email_form, Routes.user_settings_path(@conn, :update), fn f -> %>
+ <%= if @email_form.submitted_once? do %>
+
+
Oops, something went wrong! Please check the errors below.
+
+ <% end %>
+
+ <%= hidden_input f, :action, name: "action", value: "update_email" %>
+
+
+ <%= label f, :email, class: "form-label" %>
+ <%= text_input f, :email, required: true, class: "form-input" %>
+ <%= error_tag f, :email %>
+
+
+
+ <%= 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 %>
+
+
+
+ <%= submit "Change e-mail", class: "btn btn-primary btn-sm" %>
+
+ <% end %>
+
+
Change password
+
+ <%= form_for @password_form, Routes.user_settings_path(@conn, :update), fn f -> %>
+ <%= if @password_form.submitted_once? do %>
+
+
Oops, something went wrong! Please check the errors below.
+
+ <% end %>
+
+ <%= hidden_input f, :action, name: "action", value: "update_password" %>
+
+
+
+ <%= label f, :password, "New password", class: "form-label" %>
+ <%= password_input f, :password, required: true, class: "form-input" %>
+ <%= error_tag f, :password %>
+
+
+
+ <%= 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 %>
+
+
+
+ <%= 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 %>
+
+
+
+ <%= submit "Change password", class: "btn btn-primary btn-sm" %>
+
+ <% end %>
+
+
+
+
+
diff --git a/lib/ash_hq_web/views/email_view.ex b/lib/ash_hq_web/views/email_view.ex
new file mode 100644
index 0000000..aa5c831
--- /dev/null
+++ b/lib/ash_hq_web/views/email_view.ex
@@ -0,0 +1,3 @@
+defmodule AshHqWeb.EmailView do
+ use AshHqWeb, :view
+end
diff --git a/lib/ash_hq_web/views/user_confirmation_view.ex b/lib/ash_hq_web/views/user_confirmation_view.ex
new file mode 100644
index 0000000..a80d310
--- /dev/null
+++ b/lib/ash_hq_web/views/user_confirmation_view.ex
@@ -0,0 +1,3 @@
+defmodule AshHqWeb.UserConfirmationView do
+ use AshHqWeb, :view
+end
diff --git a/lib/ash_hq_web/views/user_registration_view.ex b/lib/ash_hq_web/views/user_registration_view.ex
new file mode 100644
index 0000000..838c00a
--- /dev/null
+++ b/lib/ash_hq_web/views/user_registration_view.ex
@@ -0,0 +1,3 @@
+defmodule AshHqWeb.UserRegistrationView do
+ use AshHqWeb, :view
+end
diff --git a/lib/ash_hq_web/views/user_reset_password_view.ex b/lib/ash_hq_web/views/user_reset_password_view.ex
new file mode 100644
index 0000000..832c43c
--- /dev/null
+++ b/lib/ash_hq_web/views/user_reset_password_view.ex
@@ -0,0 +1,3 @@
+defmodule AshHqWeb.UserResetPasswordView do
+ use AshHqWeb, :view
+end
diff --git a/lib/ash_hq_web/views/user_session_view.ex b/lib/ash_hq_web/views/user_session_view.ex
new file mode 100644
index 0000000..9fed808
--- /dev/null
+++ b/lib/ash_hq_web/views/user_session_view.ex
@@ -0,0 +1,3 @@
+defmodule AshHqWeb.UserSessionView do
+ use AshHqWeb, :view
+end
diff --git a/lib/ash_hq_web/views/user_settings_view.ex b/lib/ash_hq_web/views/user_settings_view.ex
new file mode 100644
index 0000000..794bc4a
--- /dev/null
+++ b/lib/ash_hq_web/views/user_settings_view.ex
@@ -0,0 +1,3 @@
+defmodule AshHqWeb.UserSettingsView do
+ use AshHqWeb, :view
+end
diff --git a/mix.exs b/mix.exs
index 108ed45..e87b157 100644
--- a/mix.exs
+++ b/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
diff --git a/mix.lock b/mix.lock
index a028c14..3761389 100644
--- a/mix.lock
+++ b/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"},
diff --git a/priv/repo/migrations/20220805214626_migrate_resources17.exs b/priv/repo/migrations/20220805214626_migrate_resources17.exs
new file mode 100644
index 0000000..30888bb
--- /dev/null
+++ b/priv/repo/migrations/20220805214626_migrate_resources17.exs
@@ -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
\ No newline at end of file
diff --git a/priv/repo/migrations/20220805222129_migrate_resources18.exs b/priv/repo/migrations/20220805222129_migrate_resources18.exs
new file mode 100644
index 0000000..2d59aa7
--- /dev/null
+++ b/priv/repo/migrations/20220805222129_migrate_resources18.exs
@@ -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
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/user_tokens/20220805214626.json b/priv/resource_snapshots/repo/user_tokens/20220805214626.json
new file mode 100644
index 0000000..618fdb2
--- /dev/null
+++ b/priv/resource_snapshots/repo/user_tokens/20220805214626.json
@@ -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"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/user_tokens/20220805222129.json b/priv/resource_snapshots/repo/user_tokens/20220805222129.json
new file mode 100644
index 0000000..4694abc
--- /dev/null
+++ b/priv/resource_snapshots/repo/user_tokens/20220805222129.json
@@ -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"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/users/20220805214626.json b/priv/resource_snapshots/repo/users/20220805214626.json
new file mode 100644
index 0000000..b17dc1c
--- /dev/null
+++ b/priv/resource_snapshots/repo/users/20220805214626.json
@@ -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"
+}
\ No newline at end of file
diff --git a/test/ash_hq_web/controllers/page_controller_test.exs b/test/ash_hq_web/controllers/page_controller_test.exs
deleted file mode 100644
index 9f04caa..0000000
--- a/test/ash_hq_web/controllers/page_controller_test.exs
+++ /dev/null
@@ -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
diff --git a/test/ash_hq_web/controllers/user_auth_test.exs b/test/ash_hq_web/controllers/user_auth_test.exs
new file mode 100644
index 0000000..a7b6c8e
--- /dev/null
+++ b/test/ash_hq_web/controllers/user_auth_test.exs
@@ -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
diff --git a/test/ash_hq_web/controllers/user_confirmation_controller_test.exs b/test/ash_hq_web/controllers/user_confirmation_controller_test.exs
new file mode 100644
index 0000000..17da582
--- /dev/null
+++ b/test/ash_hq_web/controllers/user_confirmation_controller_test.exs
@@ -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 =~ "Resend confirmation instructions
"
+ 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
diff --git a/test/ash_hq_web/controllers/user_registration_controller_test.exs b/test/ash_hq_web/controllers/user_registration_controller_test.exs
new file mode 100644
index 0000000..9e6ee2f
--- /dev/null
+++ b/test/ash_hq_web/controllers/user_registration_controller_test.exs
@@ -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"
+ assert response =~ "Log in"
+ assert response =~ "Log in"
+ 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"
+ assert response =~ "must have the @ sign and no spaces"
+ assert response =~ "length must be greater than or equal to 12"
+ end
+ end
+end
diff --git a/test/ash_hq_web/controllers/user_reset_password_controller_test.exs b/test/ash_hq_web/controllers/user_reset_password_controller_test.exs
new file mode 100644
index 0000000..56951bb
--- /dev/null
+++ b/test/ash_hq_web/controllers/user_reset_password_controller_test.exs
@@ -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?"
+ 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"
+ 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"
+ 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
diff --git a/test/ash_hq_web/controllers/user_session_controller_test.exs b/test/ash_hq_web/controllers/user_session_controller_test.exs
new file mode 100644
index 0000000..2e1ed3c
--- /dev/null
+++ b/test/ash_hq_web/controllers/user_session_controller_test.exs
@@ -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"
+ assert response =~ "Forgot your password?"
+ assert response =~ "Register"
+ 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"
+ 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
diff --git a/test/ash_hq_web/controllers/user_settings_controller_test.exs b/test/ash_hq_web/controllers/user_settings_controller_test.exs
new file mode 100644
index 0000000..198ad57
--- /dev/null
+++ b/test/ash_hq_web/controllers/user_settings_controller_test.exs
@@ -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"
+ 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"
+ 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"
+ 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
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index 619a718..4254c08 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -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
diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex
new file mode 100644
index 0000000..82e47b1
--- /dev/null
+++ b/test/support/fixtures/accounts_fixtures.ex
@@ -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