improvement: move to ash_authentication (#71)

This commit is contained in:
Zach Daniel 2023-01-12 23:52:58 -05:00 committed by GitHub
parent 991b8a73bf
commit 00b3a8e1d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 1331 additions and 2620 deletions

View file

@ -7,7 +7,9 @@
:ash_graphql,
:surface,
:ash_admin,
:ash_csv
:ash_csv,
:ash_authentication,
:ash_authentication_phoenix
],
inputs: [
"*.{ex,exs}",

View file

@ -2,7 +2,13 @@ const colors = require("tailwindcss/colors");
module.exports = {
mode: "jit",
content: ["./js/**/*.js", "../lib/*_web/**/*.*ex", "../../../sunflower_ui/**/*.ex", "../priv/blog/**/*.md"],
content: [
"./js/**/*.js",
"../lib/*_web/**/*.*ex",
"../deps/sunflower_ui/**/*.ex",
"../priv/blog/**/*.md",
"../deps/ash_authentication_phoenix/**/*.ex"
],
darkMode: "class",
theme: {
extend: {

View file

@ -21,7 +21,15 @@ config :ash_hq, AshHq.Repo,
config :spark, :formatter,
remove_parens?: true,
"AshHq.Resource": [
type: Ash.Resource
type: Ash.Resource,
section_order: [
:authentication,
:token,
:attributes,
:relationships,
:policies,
:postgres
]
],
"Ash.Flow": []

View file

@ -22,6 +22,10 @@ config :git_ops,
manage_readme_version: "README.md",
version_tag_prefix: "v"
secret_key_base = "FxKFwVYhDFah3bLLXXqWdpdcLf5e5T1UyVM6XQp7kCt/Reg5yuAEI3upAVDRoP5e"
config :ash_hq, token_signing_secret: secret_key_base
# For development, we disable any cache and enable
# debugging and code reloading.
#
@ -35,7 +39,7 @@ config :ash_hq, AshHqWeb.Endpoint,
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "FxKFwVYhDFah3bLLXXqWdpdcLf5e5T1UyVM6XQp7kCt/Reg5yuAEI3upAVDRoP5e",
secret_key_base: secret_key_base,
watchers: [
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},

View file

@ -12,6 +12,23 @@ if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do
config :ash_hq, AshHqWeb.Endpoint, server: true
end
config :ash_hq, :github,
client_id: System.fetch_env("GITHUB_CLIENT_ID"),
client_secret: System.fetch_env("GITHUB_CLIENT_SECRET"),
redirect_uri: System.fetch_env("GITHUB_REDIRECT_URI")
host = System.get_env("PHX_HOST") || "localhost"
port = String.to_integer(System.get_env("PORT") || "4000")
ash_hq_url =
case port do
443 -> "https://#{host}"
80 -> "http://#{host}"
port -> "http://#{host}:#{port}"
end
config :ash_hq, url: ash_hq_url
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
@ -38,8 +55,8 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "ash-hq.org"
port = String.to_integer(System.get_env("PORT") || "4000")
config :ash_hq,
token_signing_secret: secret_key_base
config :ash_hq, AshHqWeb.Endpoint,
server: true,

View file

@ -13,11 +13,15 @@ config :ash_hq, AshHq.Repo,
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10
secret_key_base = "766JP3UO+dTbVfydE4RafFKbfUoudccDz1zS7x1N75WSiEJTq6dDR4r04+McH41m"
config :ash_hq, token_signing_secret: secret_key_base
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :ash_hq, AshHqWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "766JP3UO+dTbVfydE4RafFKbfUoudccDz1zS7x1N75WSiEJTq6dDR4r04+McH41m",
secret_key_base: secret_key_base,
server: false
config :ash_hq, cloak_key: "J6ED3yBWjlaOW/5byrukZTEryKa++yXWblJuhP91Qq8="

View file

@ -1,45 +0,0 @@
defmodule AshHq.Accounts.EmailNotifier do
@moduledoc """
Hooks into resource notifications on the user token resource to send emails
"""
def notify(%Ash.Notifier.Notification{
resource: AshHq.Accounts.UserToken,
action: %{name: :build_email_token},
metadata: %{
user: user,
url: url,
confirm?: true
}
}) do
AshHq.Accounts.Emails.deliver_confirmation_instructions(user, url)
end
def notify(%Ash.Notifier.Notification{
resource: AshHq.Accounts.UserToken,
action: %{name: :build_email_token},
metadata: %{
user: user,
url: url,
update?: true
}
}) do
AshHq.Accounts.Emails.deliver_update_email_instructions(user, url)
end
def notify(%Ash.Notifier.Notification{
resource: AshHq.Accounts.UserToken,
action: %{name: :build_email_token},
metadata: %{
user: user,
url: url,
reset?: true
}
}) do
AshHq.Accounts.Emails.deliver_reset_password_instructions(user, url)
end
def notify(_other) do
:ok
end
end

View file

@ -1,16 +0,0 @@
defmodule AshHq.Accounts.Preparations.DetermineDaysForToken do
@moduledoc """
Sets a `days_for_token` context on the query.
This corresponds to how many days the token should be considered valid. See `AshHq.Accounts.User.Helpers` for more.
"""
use Ash.Resource.Preparation
def prepare(query, _opts, _) do
Ash.Query.put_context(
query,
:days_for_token,
AshHq.Accounts.User.Helpers.days_for_token(Ash.Query.get_argument(query, :context))
)
end
end

View file

@ -1,23 +0,0 @@
defmodule AshHq.Accounts.Preparations.SetHashedToken do
@moduledoc """
Takes a provided token and hashes it, setting it as the context `hashed_token`
"""
use Ash.Resource.Preparation
@hash_algorithm :sha256
def prepare(query, _opts, _) do
case Ash.Query.get_argument(query, :token) do
nil ->
query
token ->
Ash.Query.put_context(
query,
:hashed_token,
:crypto.hash(@hash_algorithm, token)
)
end
end
end

View file

@ -1,58 +0,0 @@
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 change(changeset, opts, _context) do
if opts[:on_argument] && Ash.Changeset.get_argument(changeset, opts[:on_argument]) do
Ash.Changeset.after_action(changeset, fn changeset, user ->
AshHq.Accounts.UserToken
|> Ash.Changeset.for_create(
:build_email_token,
%{
email: user.email,
context: "confirm",
sent_to: user.email,
user: user.id
},
authorize?: false
)
|> 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)
else
changeset
end
end
defp set_metadata(notification, user, changeset, email_token) do
url =
case Ash.Changeset.get_argument(changeset, :confirmation_url_fun) do
nil ->
nil
fun ->
fun.(email_token.__metadata__.url_token)
end
%{
notification
| metadata: %{
user: user,
url: url,
confirm?: true
}
}
end
end

View file

@ -1,54 +0,0 @@
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 change(original_changeset, _opts, _context) do
Ash.Changeset.after_action(original_changeset, fn changeset, user ->
AshHq.Accounts.UserToken
|> Ash.Changeset.for_create(
:build_email_token,
%{
email: user.email,
context: "change:#{user.email}",
sent_to: original_changeset.attributes[:email],
user: user.id
},
authorize?: false
)
|> AshHq.Accounts.create(return_notifications?: true)
|> case do
{:ok, email_token, notifications} ->
{:ok,
%{
user
| __metadata__:
Map.put(user.__metadata__, :token, email_token.__metadata__.url_token)
}, Enum.map(notifications, &set_metadata(&1, user, changeset, email_token))}
{:error, error} ->
{:error, error}
end
end)
end
defp set_metadata(notification, user, changeset, email_token) do
url =
case Ash.Changeset.get_argument(changeset, :update_url_fun) do
nil ->
nil
fun ->
fun.(email_token.__metadata__.url_token)
end
%{
notification
| metadata: %{
user: user,
url: url,
update?: true
}
}
end
end

View file

@ -1,49 +0,0 @@
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 change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn changeset, user ->
AshHq.Accounts.UserToken
|> Ash.Changeset.for_create(
:build_email_token,
%{email: user.email, context: "reset_password", sent_to: user.email, user: user.id},
authorize?: false
)
|> 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, :reset_password_url_fun) do
nil ->
nil
fun ->
fun.(email_token.__metadata__.url_token)
end
%{
notification
| metadata: %{
user: user,
url: url,
reset?: true
}
}
end
end

View file

@ -1,27 +0,0 @@
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 change(changeset, _opts, _context) do
if Ash.Changeset.get_argument(changeset, :delete_confirm_tokens) do
Ash.Changeset.after_action(changeset, fn _changeset, user ->
days = AshHq.Accounts.User.Helpers.days_for_token("confirm")
{:ok, query} =
AshHq.Accounts.UserToken
|> Ash.Query.filter(
created_at > ago(^days, :day) and context == "confirm" and
sent_to == user.email
)
|> Ash.Query.data_layer_query()
AshHq.Repo.delete_all(query)
{:ok, user}
end)
else
changeset
end
end
end

View file

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

View file

@ -1,34 +0,0 @@
defmodule AshHq.Accounts.User.Changes.GetEmailFromToken do
@moduledoc "A change that fetches the token for an email change"
use Ash.Resource.Change
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}"},
authorize?: false
)
|> AshHq.Accounts.read_one()
|> case do
{:ok, %{sent_to: new_email}} ->
Ash.Changeset.change_attribute(changeset, :email, new_email)
_ ->
Ash.Changeset.add_error(changeset,
field: :token,
message: "Could not find corresponding token"
)
end
else
changeset
end
end)
end
end

View file

@ -1,19 +0,0 @@
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 change(changeset, _opts, _) do
Changeset.before_action(changeset, fn changeset ->
case Changeset.get_argument(changeset, :password) do
nil ->
changeset
value ->
Changeset.change_attribute(changeset, :hashed_password, Bcrypt.hash_pwd_salt(value))
end
end)
end
end

View file

@ -8,15 +8,19 @@ defmodule AshHq.Accounts.User.Changes.RemoveAllTokens do
require Ash.Query
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn _changeset, user ->
{:ok, query} =
AshHq.Accounts.UserToken
|> Ash.Query.filter(user_id == ^user.id)
|> Ash.Query.data_layer_query()
Ash.Changeset.after_action(
changeset,
fn _changeset, user ->
{:ok, query} =
AshHq.Accounts.UserToken
|> Ash.Query.filter(user_id == ^user.id)
|> Ash.Query.data_layer_query()
AshHq.Repo.delete_all(query)
AshHq.Repo.delete_all(query)
{:ok, user}
end)
{:ok, user}
end,
prepend?: true
)
end
end

View file

@ -1,23 +0,0 @@
defmodule AshHq.Accounts.User.Helpers do
@moduledoc "Contains values used in various places for authentication"
@reset_password_validity_in_days 1
@confirm_validity_in_days 7
@change_email_validity_in_days 7
@session_validity_in_days 60
def days_for_token("reset_password"), do: @reset_password_validity_in_days
def days_for_token("confirm"), do: @confirm_validity_in_days
def days_for_token("session"), do: @session_validity_in_days
def days_for_token("change:" <> _), do: @change_email_validity_in_days
def valid_password?(%AshHq.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Bcrypt.no_user_verify()
false
end
end

View file

@ -1,26 +0,0 @@
defmodule AshHq.Accounts.User.Preparations.ValidatePassword do
@moduledoc """
Given the result of a query for users, and a password argument, ensures that the `password` is valid.
If there is more or less than one result, or if the password is invalid, then this removes the results of the query.
In this way, you can't tell from the outside whether or not the password was invalid or there was no matching account.
"""
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
_, _ ->
Bcrypt.no_user_verify()
{:ok, []}
end)
end
end

View file

@ -0,0 +1,21 @@
defmodule AshHq.Accounts.User.Senders.SendConfirmationEmail do
@moduledoc """
Sends a confirmation email for initial sign up or email change.
"""
use AshAuthentication.Sender
use AshHqWeb, :verified_routes
def send(user, token, opts) do
if opts[:changeset] && opts[:changeset].action.name == :update_email do
AshHq.Accounts.Emails.deliver_update_email_instructions(
%{user | email: Ash.Changeset.get_attribute(opts[:changeset], :email)},
AshHqWeb.Routes.url(~p"/auth/user/confirm?confirm=#{token}")
)
else
AshHq.Accounts.Emails.deliver_confirmation_instructions(
user,
AshHqWeb.Routes.url(~p"/auth/user/confirm?confirm=#{token}")
)
end
end
end

View file

@ -0,0 +1,14 @@
defmodule AshHq.Accounts.User.Senders.SendPasswordResetEmail do
@moduledoc """
Sends a password reset email
"""
use AshAuthentication.Sender
use AshHqWeb, :verified_routes
def send(user, token, _) do
AshHq.Accounts.Emails.deliver_reset_password_instructions(
user,
~p"/password-reset/#{token}"
)
end
end

View file

@ -3,51 +3,47 @@ defmodule AshHq.Accounts.User do
use AshHq.Resource,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
authorizers: [Ash.Policy.Authorizer],
extensions: [AshAuthentication]
alias AshHq.Accounts.Preparations, warn: false
import Ash.Changeset
postgres do
table "users"
repo AshHq.Repo
end
require Ash.Query
policies do
policy action(:read) do
authorize_if expr(id == ^actor(:id))
authentication do
api AshHq.Accounts
strategies do
password :password do
identity_field :email
hashed_password_field :hashed_password
resettable do
sender AshHq.Accounts.User.Senders.SendPasswordResetEmail
end
end
github do
client_id AshHq.Accounts.Secrets
client_secret AshHq.Accounts.Secrets
redirect_uri AshHq.Accounts.Secrets
end
end
policy action(:change_password) do
description "Allow the user to change their own password"
authorize_if expr(id == ^actor(:id))
tokens do
enabled? true
token_resource AshHq.Accounts.UserToken
signing_secret AshHq.Accounts.Secrets
store_all_tokens? true
require_token_presence_for_authentication? true
end
policy action(:deliver_update_email_instructions) do
description "Allow a user to request an update their own email"
authorize_if expr(id == ^actor(:id))
end
add_ons do
confirmation :confirm do
monitor_fields [:email]
policy action(:by_email_and_password) do
description "Allow looking up by email/password combo (logging in) for unauthenticated users only."
forbid_if actor_present()
authorize_if always()
end
policy action(:deliver_user_confirmation_instructions) do
description "A logged in user can request email confirmation for themselves to be sent again"
authorize_if expr(id == ^actor(:id))
end
policy action(:update_merch_settings) do
description "A logged in user can update their merch settings"
authorize_if expr(id == ^actor(:id))
end
policy action(:register) do
description "Allow looking up by email/password combo (logging in) for unauthenticated users only."
forbid_if actor_present()
authorize_if always()
sender AshHq.Accounts.User.Senders.SendConfirmationEmail
end
end
end
@ -60,9 +56,7 @@ defmodule AshHq.Accounts.User do
max_length: 160
]
attribute :confirmed_at, :utc_datetime_usec
attribute :hashed_password, :string, private?: true
attribute :hashed_password, :string, private?: true, sensitive?: true
attribute :encrypted_name, AshHq.Types.EncryptedString
attribute :encrypted_address, AshHq.Types.EncryptedString
@ -79,111 +73,150 @@ defmodule AshHq.Accounts.User do
end
end
policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
policy action(:read) do
authorize_if expr(id == ^actor(:id))
end
policy action(:update_email) do
description "A logged in user can update their email"
authorize_if expr(id == ^actor(:id))
end
policy action(:resend_confirmation_instructions) do
description "A logged in user can request an email confirmation"
authorize_if expr(id == ^actor(:id))
end
policy action(:change_password) do
description "A logged in user can reset their password"
authorize_if expr(id == ^actor(:id))
end
policy action(:update_merch_settings) do
description "A logged in user can update their merch settings"
authorize_if expr(id == ^actor(:id))
end
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
create :register_with_github do
argument :user_info, :map do
allow_nil? false
end
prepare AshHq.Accounts.User.Preparations.ValidatePassword
argument :oauth_tokens, :map do
allow_nil? false
end
filter expr(email == ^arg(:email))
change fn changeset, _ ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)
changeset =
if user_info["email_verified"] do
Ash.Changeset.change_new_attribute_lazy(changeset, :confirmed_at, fn ->
DateTime.utc_now()
end)
else
changeset
end
Ash.Changeset.change_attributes(changeset, Map.take(user_info, ["email"]))
end
change AshAuthentication.GenerateTokenChange
upsert? true
upsert_identity :unique_email
end
read :by_token do
argument :token, :url_encoded_binary, allow_nil?: false
argument :context, :string, allow_nil?: false
prepare Preparations.DetermineDaysForToken
update :change_password do
accept []
filter expr(
token.token == ^arg(:token) and token.context == ^arg(:context) and
token.created_at > ago(^context(:days_for_token), :day)
)
argument :current_password, :string do
sensitive? true
allow_nil? false
end
argument :password, :string do
sensitive? true
allow_nil? false
end
argument :password_confirmation, :string do
sensitive? true
allow_nil? false
end
validate confirm(:password, :password_confirmation)
validate {AshHq.Accounts.User.Validations.ValidateCurrentPassword,
argument: :current_password} do
only_when_valid? true
before_action? true
end
change set_context(%{strategy_name: :password})
change AshAuthentication.Strategy.Password.HashPasswordChange
end
read :with_verified_email_token do
argument :token, :url_encoded_binary, allow_nil?: false
argument :context, :string, allow_nil?: false
prepare AshHq.Accounts.Preparations.SetHashedToken
prepare AshHq.Accounts.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
update :update_email do
accept [:email]
argument :password, :string,
allow_nil?: false,
constraints: [
min_length: 6
]
argument :confirm, :boolean, default: true
argument :confirmation_url_fun, :function do
constraints arity: 1
argument :current_password, :string do
sensitive? true
allow_nil? false
end
change AshHq.Accounts.User.Changes.HashPassword
change {AshHq.Accounts.User.Changes.CreateEmailConfirmationToken, on_argument: :confirm}
end
update :deliver_user_confirmation_instructions do
accept []
argument :confirmation_url_fun, :function do
constraints arity: 1
validate {AshHq.Accounts.User.Validations.ValidateCurrentPassword,
argument: :current_password} do
only_when_valid? true
before_action? true
end
validate attribute_equals(:confirmed_at, nil), message: "already confirmed"
change AshHq.Accounts.User.Changes.CreateEmailConfirmationToken
end
update :deliver_update_email_instructions do
accept [:email]
update :resend_confirmation_instructions do
accept []
argument :current_password, :string, allow_nil?: false
change fn changeset, _context ->
Ash.Changeset.before_action(changeset, fn changeset ->
case AshHq.Accounts.UserToken.email_token_for_user(changeset.data.id,
authorize?: false
) do
{:ok, %{extra_data: %{"email" => changing_to}}} ->
temp_changeset = %{
changeset
| attributes: Map.put(changeset.attributes, :email, changing_to)
}
argument :update_url_fun, :function do
constraints arity: 1
strategy = AshAuthentication.Info.strategy!(changeset.resource, :confirm)
{:ok, token} =
AshAuthentication.AddOn.Confirmation.confirmation_token(
strategy,
temp_changeset,
changeset.data
)
AshHq.Accounts.User.Senders.SendConfirmationEmail.send(changeset.data, token, [])
changeset
_ ->
Ash.Changeset.add_error(changeset, "Could not determine what email to use")
end
end)
end
validate AshHq.Accounts.User.Validations.ValidateCurrentPassword
validate changing(:email)
change prevent_change(:email)
change AshHq.Accounts.User.Changes.CreateEmailUpdateToken
end
update :deliver_user_reset_password_instructions do
accept []
argument :reset_password_url_fun, :function do
constraints arity: 1
end
change AshHq.Accounts.User.Changes.CreateResetPasswordToken
end
update :logout do
accept []
change AshHq.Accounts.User.Changes.RemoveAllTokens
end
update :change_email do
accept []
argument :token, :url_encoded_binary
change AshHq.Accounts.User.Changes.GetEmailFromToken
change AshHq.Accounts.User.Changes.DeleteEmailChangeTokens
end
update :update_merch_settings do
@ -194,58 +227,12 @@ defmodule AshHq.Accounts.User do
change set_attribute(:encrypted_address, arg(:address))
change set_attribute(:encrypted_name, arg(:name))
end
end
update :change_password do
accept []
argument :password, :string,
allow_nil?: false,
constraints: [
min_length: 6
]
argument :password_confirmation, :string, allow_nil?: false
argument :current_password, :string
validate fn changeset ->
if get_argument(changeset, :password) ==
get_argument(changeset, :password_confirmation) do
:ok
else
{:error, field: :password, message: "password does not match"}
end
end
validate AshHq.Accounts.User.Validations.ValidateCurrentPassword
change AshHq.Accounts.User.Changes.HashPassword
change AshHq.Accounts.User.Changes.RemoveAllTokens
end
update :reset_password do
accept []
argument :password, :string,
allow_nil?: false,
constraints: [
min_length: 6
]
argument :password_confirmation, :string, allow_nil?: false
validate confirm(:password, :password_confirmation)
change AshHq.Accounts.User.Changes.HashPassword
change AshHq.Accounts.User.Changes.RemoveAllTokens
end
update :confirm do
accept []
argument :delete_confirm_tokens, :boolean, default: false
change set_attribute(:confirmed_at, &DateTime.utc_now/0)
change AshHq.Accounts.User.Changes.DeleteConfirmTokens
end
code_interface do
define_for AshHq.Accounts
define :resend_confirmation_instructions
define :register_with_password, args: [:email, :password, :password_confirmation]
end
resource do
@ -255,7 +242,14 @@ defmodule AshHq.Accounts.User do
end
identities do
identity :unique_email, [:email]
identity :unique_email, [:email] do
eager_check_with AshHq.Accounts
end
end
changes do
change AshHq.Accounts.User.Changes.RemoveAllTokens,
where: [action_is(:password_reset_with_password)]
end
validations do

View file

@ -8,13 +8,15 @@ 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)
def validate(changeset, opts) do
strategy = AshAuthentication.Info.strategy!(changeset.resource, :password)
plaintext_password = Ash.Changeset.get_argument(changeset, opts[:argument])
hashed_password = Map.get(changeset.data, strategy.hashed_password_field)
if AshHq.Accounts.User.Helpers.valid_password?(changeset.data, password) do
if strategy.hash_provider.valid?(plaintext_password, hashed_password) do
:ok
else
{:error, [field: :password, message: "is incorrect"]}
{:error, [field: opts[:argument], message: "is incorrect"]}
end
end
end

View file

@ -1,27 +0,0 @@
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 change(changeset, _opts, _context) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
changeset
|> Ash.Changeset.change_attribute(:token, hashed_token)
|> Ash.Changeset.after_action(fn _changeset, result ->
metadata =
Map.put(result.__metadata__, :url_token, Base.url_encode64(token, padding: false))
{:ok,
%{
result
| __metadata__: metadata
}}
end)
end
end

View file

@ -1,23 +0,0 @@
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 change(changeset, _opts, _context) do
token = :crypto.strong_rand_bytes(@rand_size)
changeset
|> Ash.Changeset.change_attribute(:token, token)
|> Ash.Changeset.after_action(fn _changeset, result ->
metadata =
Map.put(result.__metadata__, :url_token, Base.url_encode64(token, padding: false))
{:ok,
%{
result
| __metadata__: metadata
}}
end)
end
end

View file

@ -3,19 +3,22 @@ defmodule AshHq.Accounts.UserToken do
use AshHq.Resource,
data_layer: AshPostgres.DataLayer,
notifiers: [AshHq.Accounts.EmailNotifier],
authorizers: [Ash.Policy.Authorizer]
authorizers: [Ash.Policy.Authorizer],
extensions: [AshAuthentication.TokenResource]
postgres do
table "user_tokens"
repo AshHq.Repo
token do
api AshHq.Accounts
end
references do
reference :user, on_delete: :delete, on_update: :update
end
relationships do
belongs_to :user, AshHq.Accounts.User
end
policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
policy always() do
description """
There are currently no usages of user tokens resource that should be publicly
@ -26,57 +29,35 @@ defmodule AshHq.Accounts.UserToken do
end
end
attributes do
uuid_primary_key :id
postgres do
table "user_tokens"
repo AshHq.Repo
attribute :token, :binary
attribute :context, :string
attribute :sent_to, :string
create_timestamp :created_at
end
relationships do
belongs_to :user, AshHq.Accounts.User
references do
reference :user, on_delete: :delete, on_update: :update
end
end
actions do
defaults [:read]
defaults [:read, :destroy]
read :verify_email_token do
argument :token, :url_encoded_binary, allow_nil?: false
argument :context, :string, allow_nil?: false
prepare AshHq.Accounts.Preparations.SetHashedToken
prepare AshHq.Accounts.Preparations.DetermineDaysForToken
read :email_token_for_user do
get? true
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, :uuid
change manage_relationship(:user, type: :append_and_remove)
change fn changeset, _ ->
Ash.Changeset.change_attribute(changeset, :context, "session")
argument :user_id, :uuid do
allow_nil? false
end
change AshHq.Accounts.UserToken.Changes.BuildSessionToken
prepare build(sort: [updated_at: :desc], limit: 1)
filter expr(purpose == "confirm" and not is_nil(extra_data[:email]))
end
end
create :build_email_token do
accept [:sent_to, :context]
argument :user, :uuid
change manage_relationship(:user, type: :append_and_remove)
change AshHq.Accounts.UserToken.Changes.BuildHashedToken
end
code_interface do
define_for AshHq.Accounts
define :destroy
define :email_token_for_user, args: [:user_id]
end
resource do
@ -88,4 +69,19 @@ defmodule AshHq.Accounts.UserToken do
identities do
identity :token_context, [:context, :token]
end
changes do
change fn changeset, _ ->
case changeset.context[:ash_authentication][:user] do
nil ->
changeset
user ->
Ash.Changeset.manage_relationship(changeset, :user, user,
type: :append_and_remove
)
end
end,
on: [:create]
end
end

View file

@ -0,0 +1,17 @@
defmodule AshHq.Accounts.Secrets do
@moduledoc "Secrets adapter for AshHq authentication"
use AshAuthentication.Secret
@github_secret_keys ~w(client_id client_secret redirect_uri)a
def secret_for([:authentication, :tokens, :signing_secret], AshHq.Accounts.User, _) do
Application.fetch_env(:ash_hq, :token_signing_secret)
end
def secret_for([:authentication, :strategies, :github, key], AshHq.Accounts.User, _)
when key in @github_secret_keys do
with {:ok, value} <- Application.fetch_env(:ash_hq, :github) do
Keyword.fetch(value, key)
end
end
end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 850 KiB

View file

@ -5,35 +5,6 @@ defmodule AshHq.Docs.Dsl do
data_layer: AshPostgres.DataLayer,
extensions: [AshHq.Docs.Extensions.Search, AshHq.Docs.Extensions.RenderMarkdown]
postgres do
table "dsls"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
search do
doc_attribute :doc
load_for_search [
:extension_order,
:extension_type,
:extension_name,
:version_name,
:library_name,
:library_id
]
sanitized_name_attribute :sanitized_path
use_path_for_name? true
end
render_markdown do
render_attributes doc: :doc_html
end
attributes do
uuid_primary_key :id
@ -74,6 +45,26 @@ defmodule AshHq.Docs.Dsl do
timestamps()
end
search do
doc_attribute :doc
load_for_search [
:extension_order,
:extension_type,
:extension_name,
:version_name,
:library_name,
:library_id
]
sanitized_name_attribute :sanitized_path
use_path_for_name? true
end
render_markdown do
render_attributes doc: :doc_html
end
relationships do
belongs_to :library_version, AshHq.Docs.LibraryVersion do
allow_nil? true
@ -88,6 +79,15 @@ defmodule AshHq.Docs.Dsl do
has_many :dsls, __MODULE__
end
postgres do
table "dsls"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
actions do
defaults [:destroy]

View file

@ -5,24 +5,6 @@ defmodule AshHq.Docs.Extension do
data_layer: AshPostgres.DataLayer,
extensions: [AshHq.Docs.Extensions.Search, AshHq.Docs.Extensions.RenderMarkdown]
postgres do
table "extensions"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
search do
doc_attribute :doc
load_for_search library_version: [:library_display_name, :library_name]
end
render_markdown do
render_attributes doc: :doc_html
end
attributes do
uuid_primary_key :id
@ -58,6 +40,15 @@ defmodule AshHq.Docs.Extension do
timestamps()
end
search do
doc_attribute :doc
load_for_search library_version: [:library_display_name, :library_name]
end
render_markdown do
render_attributes doc: :doc_html
end
relationships do
belongs_to :library_version, AshHq.Docs.LibraryVersion do
allow_nil? true
@ -67,6 +58,15 @@ defmodule AshHq.Docs.Extension do
has_many :options, AshHq.Docs.Option
end
postgres do
table "extensions"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
actions do
defaults [:update, :destroy]

View file

@ -5,35 +5,6 @@ defmodule AshHq.Docs.Function do
data_layer: AshPostgres.DataLayer,
extensions: [AshHq.Docs.Extensions.Search, AshHq.Docs.Extensions.RenderMarkdown]
postgres do
table "functions"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
search do
doc_attribute :doc
load_for_search [
:version_name,
:library_name,
:module_name,
:library_id
]
type "Code"
show_docs_on :module_sanitized_name
end
render_markdown do
render_attributes doc: :doc_html, heads: :heads_html
header_ids? false
end
attributes do
uuid_primary_key :id
@ -79,6 +50,26 @@ defmodule AshHq.Docs.Function do
timestamps()
end
search do
doc_attribute :doc
load_for_search [
:version_name,
:library_name,
:module_name,
:library_id
]
type "Code"
show_docs_on :module_sanitized_name
end
render_markdown do
render_attributes doc: :doc_html, heads: :heads_html
header_ids? false
end
relationships do
belongs_to :library_version, AshHq.Docs.LibraryVersion do
allow_nil? true
@ -89,6 +80,15 @@ defmodule AshHq.Docs.Function do
end
end
postgres do
table "functions"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
actions do
defaults [:update, :destroy]

View file

@ -9,42 +9,6 @@ defmodule AshHq.Docs.Guide do
AshAdmin.Resource
]
postgres do
repo AshHq.Repo
table "guides"
references do
reference :library_version, on_delete: :delete
end
end
search do
doc_attribute :text
show_docs_on [:sanitized_name, :sanitized_route]
type "Guides"
load_for_search library_version: [:library_name, :library_display_name]
end
render_markdown do
render_attributes text: :text_html
end
graphql do
type :guide
queries do
list :list_guides, :read_for_version
end
end
admin do
form do
field :text do
type :markdown
end
end
end
attributes do
uuid_primary_key :id
@ -89,12 +53,48 @@ defmodule AshHq.Docs.Guide do
timestamps()
end
search do
doc_attribute :text
show_docs_on [:sanitized_name, :sanitized_route]
type "Guides"
load_for_search library_version: [:library_name, :library_display_name]
end
render_markdown do
render_attributes text: :text_html
end
graphql do
type :guide
queries do
list :list_guides, :read_for_version
end
end
admin do
form do
field :text do
type :markdown
end
end
end
relationships do
belongs_to :library_version, AshHq.Docs.LibraryVersion do
allow_nil? true
end
end
postgres do
repo AshHq.Repo
table "guides"
references do
reference :library_version, on_delete: :delete
end
end
actions do
defaults [:create, :update, :destroy]

View file

@ -3,11 +3,6 @@ defmodule AshHq.Docs.Library do
use AshHq.Resource,
data_layer: AshPostgres.DataLayer
postgres do
table "libraries"
repo AshHq.Repo
end
attributes do
uuid_primary_key :id
@ -37,6 +32,11 @@ defmodule AshHq.Docs.Library do
has_many :versions, AshHq.Docs.LibraryVersion
end
postgres do
table "libraries"
repo AshHq.Repo
end
actions do
defaults [:read, :create, :update, :destroy]

View file

@ -5,17 +5,6 @@ defmodule AshHq.Docs.LibraryVersion do
data_layer: AshPostgres.DataLayer,
extensions: [AshHq.Docs.Extensions.Search, AshHq.Docs.Extensions.RenderMarkdown]
postgres do
table "library_versions"
repo AshHq.Repo
end
search do
name_attribute :version
library_version_attribute :id
load_for_search [:library_name, :library_display_name]
end
attributes do
uuid_primary_key :id
@ -26,6 +15,12 @@ defmodule AshHq.Docs.LibraryVersion do
timestamps()
end
search do
name_attribute :version
library_version_attribute :id
load_for_search [:library_name, :library_display_name]
end
relationships do
belongs_to :library, AshHq.Docs.Library do
allow_nil? true
@ -37,6 +32,11 @@ defmodule AshHq.Docs.LibraryVersion do
has_many :mix_tasks, AshHq.Docs.MixTask
end
postgres do
table "library_versions"
repo AshHq.Repo
end
actions do
defaults [:update, :destroy]

View file

@ -5,33 +5,6 @@ defmodule AshHq.Docs.MixTask do
data_layer: AshPostgres.DataLayer,
extensions: [AshHq.Docs.Extensions.Search, AshHq.Docs.Extensions.RenderMarkdown]
postgres do
table "mix_tasks"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
search do
doc_attribute :doc
load_for_search [
:version_name,
:library_name,
:library_id
]
item_type "Mix Task"
type "Mix Tasks"
end
render_markdown do
render_attributes doc: :doc_html
end
attributes do
uuid_primary_key :id
@ -64,12 +37,39 @@ defmodule AshHq.Docs.MixTask do
timestamps()
end
search do
doc_attribute :doc
load_for_search [
:version_name,
:library_name,
:library_id
]
item_type "Mix Task"
type "Mix Tasks"
end
render_markdown do
render_attributes doc: :doc_html
end
relationships do
belongs_to :library_version, AshHq.Docs.LibraryVersion do
allow_nil? true
end
end
postgres do
table "mix_tasks"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
actions do
defaults [:update, :destroy]

View file

@ -5,31 +5,6 @@ defmodule AshHq.Docs.Module do
data_layer: AshPostgres.DataLayer,
extensions: [AshHq.Docs.Extensions.Search, AshHq.Docs.Extensions.RenderMarkdown]
postgres do
table "modules"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
search do
doc_attribute :doc
load_for_search [
:version_name,
:library_name,
:library_id
]
type "Code"
end
render_markdown do
render_attributes doc: :doc_html
end
attributes do
uuid_primary_key :id
@ -62,6 +37,22 @@ defmodule AshHq.Docs.Module do
timestamps()
end
search do
doc_attribute :doc
load_for_search [
:version_name,
:library_name,
:library_id
]
type "Code"
end
render_markdown do
render_attributes doc: :doc_html
end
relationships do
belongs_to :library_version, AshHq.Docs.LibraryVersion do
allow_nil? true
@ -70,6 +61,15 @@ defmodule AshHq.Docs.Module do
has_many :functions, AshHq.Docs.Function
end
postgres do
table "modules"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
actions do
defaults [:update, :destroy]

View file

@ -5,37 +5,6 @@ defmodule AshHq.Docs.Option do
data_layer: AshPostgres.DataLayer,
extensions: [AshHq.Docs.Extensions.Search, AshHq.Docs.Extensions.RenderMarkdown]
postgres do
table "options"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
search do
doc_attribute :doc
load_for_search [
:extension_order,
:extension_type,
:extension_name,
:version_name,
:library_name,
:library_id
]
sanitized_name_attribute :sanitized_path
use_path_for_name? true
add_name_to_path? false
show_docs_on :dsl_sanitized_path
end
render_markdown do
render_attributes doc: :doc_html
end
attributes do
uuid_primary_key :id
@ -76,6 +45,28 @@ defmodule AshHq.Docs.Option do
timestamps()
end
search do
doc_attribute :doc
load_for_search [
:extension_order,
:extension_type,
:extension_name,
:version_name,
:library_name,
:library_id
]
sanitized_name_attribute :sanitized_path
use_path_for_name? true
add_name_to_path? false
show_docs_on :dsl_sanitized_path
end
render_markdown do
render_attributes doc: :doc_html
end
relationships do
belongs_to :dsl, AshHq.Docs.Dsl do
allow_nil? true
@ -90,6 +81,15 @@ defmodule AshHq.Docs.Option do
end
end
postgres do
table "options"
repo AshHq.Repo
references do
reference :library_version, on_delete: :delete
end
end
actions do
defaults [:update, :destroy]

View file

@ -17,6 +17,12 @@ defmodule AshHqWeb do
and import those modules here.
"""
def verified_routes do
quote do
unquote(use_verified_routes())
end
end
def controller do
quote do
use Phoenix.Controller, namespace: AshHqWeb
@ -92,6 +98,7 @@ defmodule AshHqWeb do
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
import Phoenix.LiveView.Helpers
import Phoenix.Component
# Import basic rendering functionality (render, render_layout, etc)
import Phoenix.View
@ -99,9 +106,24 @@ defmodule AshHqWeb do
import AshHqWeb.ErrorHelpers
import AshHqWeb.Gettext
alias AshHqWeb.Router.Helpers, as: Routes
unquote(use_verified_routes())
end
end
@spec use_verified_routes :: Macro.t()
def use_verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: AshHqWeb.Endpoint,
router: AshHqWeb.Router,
statics: AshHqWeb.static_paths()
end
end
def static_paths do
~w(assets fonts images favicon.ico robots.txt)
end
@doc """
When used, dispatch to the appropriate controller/view/etc.
"""

View file

@ -0,0 +1,9 @@
defmodule AshHqWeb.AuthOverrides do
@moduledoc "UI overrides for authentication views"
use AshAuthentication.Phoenix.Overrides
alias AshAuthentication.Phoenix.Components
override Components.HorizontalRule do
set :root_class, "hidden"
end
end

View file

@ -1,11 +1,11 @@
defmodule AshHqWeb.Components.AppView.TopBar do
@moduledoc "The global top navigation bar"
use Surface.Component
use AshHqWeb, :component
prop live_action, :atom, required: true
prop toggle_theme, :event, required: true
prop configured_theme, :string, required: true
prop current_user, :any
prop(live_action, :atom, required: true)
prop(toggle_theme, :event, required: true)
prop(configured_theme, :string, required: true)
prop(current_user, :any)
alias AshHqWeb.Components.SearchBar
alias AshHqWeb.Router.Helpers, as: Routes
@ -150,7 +150,7 @@ defmodule AshHqWeb.Components.AppView.TopBar do
</LivePatch>
</div>
<div class="py-1" role="none">
<Form for={:sign_out} action={Routes.user_session_path(AshHqWeb.Endpoint, :delete)} method="post">
<Form for={:sign_out} action={~p'/sign-out'} method="get">
<button
label="logout"
type="submit"
@ -165,15 +165,12 @@ defmodule AshHqWeb.Components.AppView.TopBar do
</div>
{#else}
<div class="py-1" role="none">
<LivePatch
to={Routes.app_view_path(AshHqWeb.Endpoint, :log_in)}
class="dark:text-white group flex items-center px-4 py-2 text-sm"
>
<a href={~p'/sign-in'} class="dark:text-white group flex items-center px-4 py-2 text-sm">
<div class="flex items-center">
<Heroicons.Outline.UserAddIcon class="mr-3 h-5 w-5 text-base-light-400 group-hover:text-base-light-500" />
Sign In
</div>
</LivePatch>
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,74 @@
defmodule AshHqWeb.AuthController do
use AshHqWeb, :controller
use AshAuthentication.Phoenix.Controller
require Logger
def success(conn, _activity, user, _token) do
return_to = get_session(conn, :return_to) || "/"
conn
|> delete_session(:return_to)
|> store_in_session(user)
|> assign(:current_user, user)
|> redirect(to: return_to)
end
def failure(
conn,
{:password, :sign_in},
%AshAuthentication.Errors.AuthenticationFailed{}
) do
conn
|> put_flash(
:error,
"Username or password is incorrect"
)
|> redirect(to: "/sign-in")
end
def failure(conn, activity, reason) do
stacktrace =
case reason do
%{stacktrace: %{stacktrace: stacktrace}} -> stacktrace
_ -> nil
end
Logger.error("""
Something went wrong in authentication
activity: #{inspect(activity)}
reason: #{Exception.format(:error, reason, stacktrace || [])}
""")
conn
|> put_flash(
:error,
"Something went wrong"
)
|> redirect(to: "/sign-in")
end
def sign_out(conn, _params) do
return_to = get_session(conn, :return_to) || "/"
token = Plug.Conn.get_session(conn, "user_token")
if token do
AshHq.Accounts.UserToken
|> AshAuthentication.TokenResource.Actions.get_token(%{"token" => token})
|> case do
{:ok, [token]} ->
AshHq.Accounts.UserToken.destroy!(token, authorize?: false)
_ ->
:ok
end
end
conn
|> clear_session()
|> redirect(to: return_to)
end
end

View file

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

View file

@ -1,41 +0,0 @@
defmodule AshHqWeb.UserSessionController do
use AshHqWeb, :controller
alias AshHqWeb.UserAuth
def log_in(conn, %{"log_in" => %{"token" => token} = params}) do
token = Base.url_decode64!(token, padding: false)
UserAuth.log_in_with_token(conn, token, params["remember_me"] == "true", params["return_to"])
end
def log_in(conn, %{"log_in" => %{"email" => email, "password" => password} = params}) do
AshHq.Accounts.User
|> Ash.Query.for_read(:by_email_and_password, %{email: email, password: password},
authorize?: false
)
|> AshHq.Accounts.read_one()
|> case do
{:ok, nil} ->
redirect(conn, to: "/")
{:ok, user} ->
token = AshHqWeb.UserAuth.create_token_for_user(user)
UserAuth.log_in_with_token(
conn,
token,
params["remember_me"] == "true",
params["return_to"]
)
{:error, _} ->
redirect(conn, to: "/")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end

View file

@ -1,20 +0,0 @@
defmodule AshHqWeb.UserSettingsController do
use AshHqWeb, :controller
def confirm_email(conn, %{"token" => token}) do
conn.assigns.current_user
|> Ash.Changeset.for_update(:change_email, %{token: token}, authorize?: false)
|> AshHq.Accounts.update()
|> case do
{:ok, _} ->
conn
|> put_flash(:info, "Email changed successfully.")
|> redirect(to: Routes.app_view_path(conn, :user_settings))
{:error, _form} ->
conn
|> put_flash(:error, "Email change link is invalid or it has expired.")
|> redirect(to: Routes.app_view_path(conn, :user_settings))
end
end
end

View file

@ -20,7 +20,7 @@ defmodule AshHqWeb.Endpoint do
at: "/",
from: :ash_hq,
gzip: false,
only: ~w(assets fonts images favicon.ico robots.txt)
only: AshHqWeb.static_paths()
# Need to figure out CSP yet
# plug PlugContentSecurityPolicy

View file

@ -3,48 +3,22 @@ defmodule AshHqWeb.LiveUserAuth do
Helpers for authenticating users in liveviews
"""
alias AshHqWeb.Router.Helpers, as: Routes
import Phoenix.Component
use AshHqWeb, :verified_routes
@doc """
Sets the current user on each mount of a liveview
"""
def on_mount(:live_user, _params, session, socket) do
{:cont,
Phoenix.Component.assign(
socket,
:current_user,
AshHqWeb.UserAuth.user_for_session_token(session["user_token"])
)}
end
def on_mount(:live_user_required, _params, session, socket) do
case AshHqWeb.UserAuth.user_for_session_token(session["user_token"]) do
nil ->
{:halt, Phoenix.LiveView.redirect(socket, to: "/users/log_in")}
user ->
{:cont,
Phoenix.Component.assign(
socket,
:current_user,
user
)}
def on_mount(:live_user_optional, _params, _session, socket) do
if socket.assigns[:current_user] do
{:cont, socket}
else
{:cont, assign(socket, :current_user, nil)}
end
end
def on_mount(:live_user_not_allowed, _params, session, socket) do
case AshHqWeb.UserAuth.user_for_session_token(session["user_token"]) do
nil ->
{:cont,
Phoenix.Component.assign(
socket,
:current_user,
nil
)}
_user ->
{:halt,
Phoenix.LiveView.redirect(socket, to: Routes.app_view_path(AshHqWeb.Endpoint, :home))}
def on_mount(:live_user_required, _params, _session, socket) do
if socket.assigns[:current_user] do
{:cont, socket}
else
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")}
end
end
end

View file

@ -1,159 +0,0 @@
defmodule AshHqWeb.Pages.LogIn do
@moduledoc "Log in page"
use Surface.LiveComponent
alias Surface.Components.{Form, LivePatch}
alias Surface.Components.Form.{
Checkbox,
ErrorTag,
Field,
HiddenInput,
Label,
PasswordInput,
Submit,
TextInput
}
alias AshHqWeb.Router.Helpers, as: Routes
data log_in_form, :map
data token, :string, default: nil
data trigger_action, :boolean, default: false
def render(assigns) do
~F"""
<div class="container flex flex-wrap mx-auto">
<div class="w-full md:w-2/3 ml-2">
<Form
class="space-y-8"
opts={id: @log_in_form.id}
for={@log_in_form}
change="validate_log_in"
submit="log_in"
trigger_action={@trigger_action}
action={Routes.user_session_path(AshHqWeb.Endpoint, :log_in)}
>
{#if @token}
<Field name={:token}>
<HiddenInput value={@token} />
</Field>
{/if}
<div class="space-y-8 sm:space-y-5">
<div>
<h3 class="text-lg leading-6 font-medium">Log In</h3>
{#if @log_in_form.submitted_once?}
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
{/if}
</div>
<div class="mt-6 sm:mt-5 space-y-6 sm:space-y-5">
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<Field name={:email}>
<Label class="block text-sm font-medium sm:mt-px sm:pt-2">Email</Label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<TextInput
class="flex-1 text-black block w-full focus:ring-primary-light-600 focus:border-primary-light-600 min-w-0 rounded-md sm:text-sm border-base-light-300"
opts={autocomplete: "email"}
/>
</div>
{#if @log_in_form.submitted_once?}
<ErrorTag />
{/if}
</div>
</Field>
</div>
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<Field name={:password}>
<Label class="block text-sm font-medium sm:mt-px sm:pt-2">Password</Label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<PasswordInput
value={AshPhoenix.Form.value(@log_in_form, :password)}
class="flex-1 text-black block w-full focus:ring-primary-light-500 focus:border-primary-light-600 min-w-0 rounded-md sm:text-sm border-base-light-300"
/>
</div>
{#if @log_in_form.submitted_once?}
<ErrorTag />
{/if}
</div>
</Field>
</div>
<Field name={:remember_me}>
<Label class="block text-sm font-medium sm:mt-px sm:pt-2">Remember Me</Label>
<div class="mt-1">
<div class="flex rounded-md shadow-sm">
<Checkbox class="text-black block focus:ring--500 focus:border-primary-light-600 min-w-0 rounded-md sm:text-sm border-base-light-300" />
</div>
{#if @log_in_form.submitted_once?}
<ErrorTag />
{/if}
</div>
</Field>
</div>
</div>
<div class="pt-5">
<div class="flex justify-end">
<Submit class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md bg-primary-light-600 hover:bg-primary-light-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-light-600 text-white">Log In</Submit>
</div>
</div>
</Form>
<LivePatch to={Routes.app_view_path(AshHqWeb.Endpoint, :register)}>Register?</LivePatch> |
<LivePatch to={Routes.app_view_path(AshHqWeb.Endpoint, :reset_password)}>Forgot Password?</LivePatch>
</div>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok, socket |> assign(assigns) |> assign_form()}
end
defp assign_form(socket) do
assign(socket,
log_in_form:
AshPhoenix.Form.for_read(
AshHq.Accounts.User,
:by_email_and_password,
as: "log_in",
api: AshHq.Accounts
)
)
end
@impl true
def handle_event("validate_log_in", %{"log_in" => params}, socket) do
{:noreply,
assign(socket,
log_in_form: AshPhoenix.Form.validate(socket.assigns.log_in_form, params)
)}
end
@impl true
def handle_event("log_in", %{"log_in" => params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.log_in_form, params: params, read_one?: true) do
{:ok, nil} ->
{:noreply,
socket
|> put_flash(:error, "Invalid username or password")
|> push_redirect(to: Routes.app_view_path(AshHqWeb.Endpoint, :log_in))}
{:ok, user} ->
token = AshHqWeb.UserAuth.create_token_for_user(user)
{:noreply,
socket
|> assign(:trigger_action, true)
|> assign(:token, Base.url_encode64(token, padding: false))}
{:error, form} ->
{:noreply, assign(socket, log_in_form: form)}
end
end
end

View file

@ -1,149 +0,0 @@
defmodule AshHqWeb.Pages.Register do
@moduledoc "Log in page"
use Surface.LiveComponent
alias Surface.Components.{Form, LivePatch}
alias Surface.Components.Form.{
Checkbox,
ErrorTag,
Field,
Label,
PasswordInput,
Submit,
TextInput
}
alias AshHqWeb.Router.Helpers, as: Routes
data register_form, :map
def render(assigns) do
~F"""
<div class="container flex flex-wrap mx-auto">
<div class="w-full md:w-2/3 ml-2">
<Form class="space-y-8" for={@register_form} change="validate_register" submit="register">
<div class="space-y-8 sm:space-y-5">
<div>
<h3 class="text-lg leading-6 font-medium">Register</h3>
{#if @register_form.submitted_once?}
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
{/if}
</div>
<div class="mt-6 sm:mt-5 space-y-6 sm:space-y-5">
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<Field name={:email}>
<Label class="block text-sm font-medium sm:mt-px sm:pt-2">Email</Label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<TextInput
class="flex-1 text-black block w-full focus:ring-primary-light-600 focus:border-primary-light-600 min-w-0 rounded-md sm:text-sm border-base-light-300"
opts={autocomplete: "email"}
/>
</div>
{#if @register_form.submitted_once?}
<ErrorTag />
{/if}
</div>
</Field>
</div>
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<Field name={:password}>
<Label class="block text-sm font-medium sm:mt-px sm:pt-2">Password</Label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<PasswordInput
value={AshPhoenix.Form.value(@register_form, :password)}
class="flex-1 text-black block w-full focus:ring--500 focus:border-primary-light-600 min-w-0 rounded-md sm:text-sm border-base-light-300"
/>
</div>
{#if @register_form.submitted_once?}
<ErrorTag />
{/if}
</div>
</Field>
</div>
<Field name={:remember_me}>
<Label class="block text-sm font-medium sm:mt-px sm:pt-2">Remember Me</Label>
<div class="mt-1">
<div class="flex rounded-md shadow-sm">
<Checkbox class="text-black block focus:ring-primary-light-500 focus:border-primary-light-600 min-w-0 rounded-md sm:text-sm border-base-light-300" />
</div>
{#if @register_form.submitted_once?}
<ErrorTag />
{/if}
</div>
</Field>
</div>
</div>
<div class="pt-5">
<div class="flex justify-end">
<Submit class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md bg-primary-light-600 hover:bg-primary-light-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-light-600 text-white">Sign Up</Submit>
</div>
</div>
</Form>
<LivePatch to={Routes.app_view_path(AshHqWeb.Endpoint, :log_in)}>Already Have an account?</LivePatch>
</div>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok, socket |> assign(assigns) |> assign_form()}
end
defp assign_form(socket) do
assign(socket,
register_form:
AshPhoenix.Form.for_create(
AshHq.Accounts.User,
:register,
as: "register",
api: AshHq.Accounts
)
)
end
@impl true
def handle_event("validate_register", %{"register" => params}, socket) do
{:noreply,
assign(socket,
register_form: AshPhoenix.Form.validate(socket.assigns.register_form, params)
)}
end
@impl true
def handle_event("register", %{"register" => params}, socket) do
params =
Map.put(
params,
"confirmation_url_fun",
&Routes.user_confirmation_url(AshHqWeb.Endpoint, :confirm, &1)
)
case AshPhoenix.Form.submit(socket.assigns.register_form, params: params) do
{:ok, user} ->
token = AshHqWeb.UserAuth.create_token_for_user(user)
{:noreply,
redirect(socket,
to:
Routes.user_session_path(AshHqWeb.Endpoint, :log_in, %{
"log_in" => %{
"token" => Base.url_encode64(token, padding: false),
"remember_me" => params["remember_me"] || "false"
}
})
)}
{:error, form} ->
{:noreply, assign(socket, register_form: form)}
end
end
end

View file

@ -1,195 +0,0 @@
defmodule AshHqWeb.Pages.ResetPassword do
@moduledoc "Log in page"
use Surface.LiveComponent
alias Surface.Components.{Form, LivePatch}
alias Surface.Components.Form.{
ErrorTag,
Field,
Label,
PasswordInput,
Submit,
TextInput
}
alias AshHqWeb.Router.Helpers, as: Routes
prop params, :map
data error, :boolean, default: false
data password_reset_form, :any
def render(assigns) do
~F"""
<div class="container flex flex-wrap mx-auto">
<div class="w-full md:w-2/3 ml-2">
{#if @password_reset_form}
<Form class="space-y-8" for={@password_reset_form} submit="reset_password">
<div class="space-y-8 sm:space-y-5">
<div>
<h3 class="text-lg leading-6 font-medium">Reset Password</h3>
{#if @password_reset_form.submitted_once?}
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
{/if}
</div>
<div class="mt-6 sm:mt-5 space-y-6 sm:space-y-5">
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<Field name={:password}>
<Label class="block text-sm font-medium sm:mt-px sm:pt-2">Password</Label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<PasswordInput class="flex-1 text-black block w-full focus:ring-primary-light-600 focus:border-primary-light-600 min-w-0 rounded-md sm:text-sm border-base-light-300" />
</div>
<ErrorTag />
</div>
</Field>
</div>
</div>
<div class="mt-6 sm:mt-5 space-y-6 sm:space-y-5">
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<Field name={:password_confirmation}>
<Label class="block text-sm font-medium sm:mt-px sm:pt-2">Password Confirmation</Label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<PasswordInput class="flex-1 text-black block w-full focus:ring-primary-light-600 focus:border-primary-light-600 min-w-0 rounded-md sm:text-sm border-base-light-300" />
</div>
<ErrorTag />
</div>
</Field>
</div>
</div>
</div>
<div class="pt-5">
<div class="flex justify-end">
<Submit class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md bg-primary-light-600 hover:bg-primary-light-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-light-600 text-white">Reset Password</Submit>
</div>
</div>
</Form>
{#else}
<Form class="space-y-8" for={:request_password_reset} submit="request_password_reset">
<div class="space-y-8 sm:space-y-5">
<div>
<h3 class="text-lg leading-6 font-medium">Choose New Password</h3>
{#if @error}
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
{/if}
</div>
<div class="mt-6 sm:mt-5 space-y-6 sm:space-y-5">
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:pt-5">
<Field name={:email}>
<Label class="block text-sm font-medium sm:mt-px sm:pt-2">Email</Label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<div class="max-w-lg flex rounded-md shadow-sm">
<TextInput
class="flex-1 text-black block w-full focus:ring-primary-light-600 focus:border-primary-light-600 min-w-0 rounded-md sm:text-sm border-base-light-300"
opts={autocomplete: "email"}
/>
</div>
<ErrorTag />
</div>
</Field>
</div>
</div>
</div>
<div class="pt-5">
<div class="flex justify-end">
<Submit class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md bg-primary-light-600 hover:bg-primary-light-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-light-600 text-white">Send Password Reset Email</Submit>
</div>
</div>
</Form>
<LivePatch to={Routes.app_view_path(AshHqWeb.Endpoint, :log_in)}>Remember your password?</LivePatch>
{/if}
</div>
</div>
"""
end
def update(assigns, socket) do
{:ok, socket |> assign(assigns) |> assign_reset_form()}
end
defp assign_reset_form(socket) do
case socket.assigns[:params]["token"] do
nil ->
assign(socket, password_reset_form: nil)
token ->
user =
AshHq.Accounts.User
|> Ash.Query.for_read(
:with_verified_email_token,
%{token: token, context: "reset_password"},
authorize?: false
)
|> AshHq.Accounts.read_one!()
if user do
assign(
socket,
:password_reset_form,
AshPhoenix.Form.for_update(user, :reset_password,
as: "reset_password",
api: AshHq.Accounts,
authorize?: false
)
)
else
socket
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|> assign(password_reset_form: nil)
end
end
end
@impl true
def handle_event("reset_password", %{"reset_password" => params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.password_reset_form,
params: params
) do
{:ok, _result} ->
{:noreply,
socket
|> put_flash(:info, "Successfully reset password.")
|> push_redirect(to: Routes.app_view_path(AshHqWeb.Endpoint, :log_in))}
{:error, form} ->
{:noreply, assign(socket, password_reset_form: form)}
end
end
@impl true
def handle_event(
"request_password_reset",
%{"request_password_reset" => %{"email" => email}},
socket
) do
with {:ok, user} <-
AshHq.Accounts.get(AshHq.Accounts.User, [email: String.trim(email)], authorize?: false) do
user
|> Ash.Changeset.for_update(
:deliver_user_reset_password_instructions,
%{reset_password_url_fun: &Routes.app_view_url(AshHqWeb.Endpoint, :reset_password, &1)},
authorize?: false
)
|> AshHq.Accounts.update!()
end
{:noreply,
socket
|> put_flash(
:info,
"If your email is in our system, you will receive instructions to reset your password shortly."
)
|> push_redirect(to: Routes.app_view_path(AshHqWeb.Endpoint, :reset_password))}
end
end

View file

@ -2,7 +2,6 @@ defmodule AshHqWeb.Pages.UserSettings do
@moduledoc "User settings page"
use Surface.LiveComponent
alias AshHqWeb.Router.Helpers, as: Routes
alias Surface.Components.Form
alias Surface.Components.Form.{
@ -15,13 +14,13 @@ defmodule AshHqWeb.Pages.UserSettings do
TextInput
}
prop current_user, :map, required: true
prop(current_user, :map, required: true)
data email_form, :map
data password_form, :map
data merch_form, :map
data address, :string
data name, :string
data(email_form, :map)
data(password_form, :map)
data(merch_form, :map)
data(address, :string)
data(name, :string)
def render(assigns) do
~F"""
@ -255,7 +254,7 @@ defmodule AshHqWeb.Pages.UserSettings do
email_form:
AshPhoenix.Form.for_update(
socket.assigns.current_user,
:deliver_update_email_instructions,
:update_email,
as: "update_email",
api: AshHq.Accounts,
actor: socket.assigns.current_user
@ -289,13 +288,6 @@ defmodule AshHqWeb.Pages.UserSettings do
@impl true
def handle_event("save_update_email", %{"update_email" => params}, socket) do
params =
Map.put(
params,
"update_url_fun",
&Routes.user_settings_url(AshHqWeb.Endpoint, :confirm_email, &1)
)
case AshPhoenix.Form.submit(socket.assigns.email_form, params: params) do
{:ok, _result} ->
{:noreply,
@ -359,15 +351,9 @@ defmodule AshHqWeb.Pages.UserSettings do
@impl true
def handle_event("resend_confirmation_instructions", _, socket) do
socket.assigns.current_user
|> Ash.Changeset.for_update(
:deliver_user_confirmation_instructions,
%{
confirmation_url_fun: &Routes.user_confirmation_url(AshHqWeb.Endpoint, :confirm, &1)
},
AshHq.Accounts.User.resend_confirmation_instructions!(socket.assigns.current_user,
actor: socket.assigns.current_user
)
|> AshHq.Accounts.update!()
{:noreply, socket}
end

View file

@ -1,7 +1,7 @@
defmodule AshHqWeb.Router do
use AshHqWeb, :router
use AshAuthentication.Phoenix.Router
import AshHqWeb.UserAuth
import AshAdmin.Router
pipeline :browser do
@ -12,25 +12,38 @@ defmodule AshHqWeb.Router do
plug :protect_from_forgery
plug AshHqWeb.SessionPlug
plug :assign_user_agent
end
pipeline :dead_view_authentication do
plug :fetch_current_user
plug :load_from_session
end
pipeline :api do
plug :accepts, ["json"]
plug :load_from_bearer
end
pipeline :admin_basic_auth do
plug :basic_auth
end
scope "/", AshHqWeb do
pipe_through :browser
reset_route []
sign_in_route overrides: [AshHqWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default]
sign_out_route AuthController
auth_routes_for(AshHq.Accounts.User, to: AuthController)
end
scope "/", AshHqWeb do
pipe_through :browser
live_session :main,
on_mount: [{AshHqWeb.InitAssigns, :default}, {AshHqWeb.LiveUserAuth, :live_user}],
on_mount: [
AshAuthentication.Phoenix.LiveSession,
{AshHqWeb.LiveUserAuth, :live_user_optional},
{AshHqWeb.InitAssigns, :default}
],
session: {AshAuthentication.Phoenix.LiveSession, :generate_session, []},
root_layout: {AshHqWeb.LayoutView, :root} do
live "/", AppViewLive, :home
live "/media", AppViewLive, :media
@ -49,20 +62,13 @@ defmodule AshHqWeb.Router do
get "/unsubscribe", MailingListController, :unsubscribe
end
live_session :unauthenticated_only,
on_mount: [
{AshHqWeb.InitAssigns, :default},
{AshHqWeb.LiveUserAuth, :live_user_not_allowed}
],
root_layout: {AshHqWeb.LayoutView, :root} do
live "/users/log_in", AppViewLive, :log_in
live "/users/register", AppViewLive, :register
live "/users/reset_password", AppViewLive, :reset_password
live "/users/reset_password/:token", AppViewLive, :reset_password
end
live_session :authenticated_only,
on_mount: [{AshHqWeb.InitAssigns, :default}, {AshHqWeb.LiveUserAuth, :live_user_required}],
on_mount: [
AshAuthentication.Phoenix.LiveSession,
{AshHqWeb.InitAssigns, :default},
{AshHqWeb.LiveUserAuth, :live_user_required}
],
session: {AshAuthentication.Phoenix.LiveSession, :generate_session, []},
root_layout: {AshHqWeb.LayoutView, :root} do
live "/users/settings", AppViewLive, :user_settings
end
@ -80,34 +86,6 @@ defmodule AshHqWeb.Router do
interface: :playground
end
## Authentication routes
scope "/", AshHqWeb do
pipe_through [
:browser,
:dead_view_authentication,
:redirect_if_user_is_authenticated,
:put_session_layout
]
get "/users/new_session", UserSessionController, :log_in
post "/users/new_session", UserSessionController, :log_in
end
scope "/", AshHqWeb do
pipe_through [:browser, :dead_view_authentication, :require_authenticated_user]
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
end
scope "/", AshHqWeb do
pipe_through [:browser, :dead_view_authentication]
post "/users/log_out", UserSessionController, :delete
post "/users/confirm", UserConfirmationController, :create
get "/users/confirm/:token", UserConfirmationController, :confirm
end
# Enables LiveDashboard only for development
#
# If you want to use the LiveDashboard in production, you should put
@ -119,7 +97,7 @@ defmodule AshHqWeb.Router do
scope "/" do
if Mix.env() in [:dev, :test] do
pipe_through [:browser, :dead_view_authentication]
pipe_through [:browser]
else
pipe_through [:browser, :admin_basic_auth]
end
@ -138,7 +116,7 @@ defmodule AshHqWeb.Router do
# node running the Phoenix server.
if Mix.env() == :dev do
scope "/dev" do
pipe_through [:browser, :dead_view_authentication]
pipe_through [:browser]
forward "/mailbox", Plug.Swoosh.MailboxPreview
end

6
lib/ash_hq_web/routes.ex Normal file
View file

@ -0,0 +1,6 @@
defmodule AshHqWeb.Routes do
@moduledoc "Route helpers"
def url(path) do
Application.get_env(:ash_hq, :url) <> "/" <> String.trim_leading(path, "/")
end
end

View file

@ -38,6 +38,21 @@
</script>
</head>
<body class="h-full">
<%= case live_flash(@flash, :info) do %>
<% nil -> %>
<% flash -> %>
<p class="alert alert-info" role="alert" phx-click="lv:clear-flash" phx-value-key="info">
<%= flash %>
</p>
<% end %>
<%= case live_flash(@flash, :error) do %>
<% nil -> %>
<% flash -> %>
<p class="alert alert-danger" role="alert" phx-click="lv:clear-flash" phx-value-key="error">
<%= flash %>
</p>
<% end %>
<%= @inner_content %>
<%= if @live_action == :docs do %>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>

View file

@ -1,30 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "AshHq" %>
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/assets/app.css") %>"/>
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/assets/app.js") %>"></script>
</head>
<body class="flex flex-col h-full bg-base-light-700">
<header>
<nav class="flex flex-wrap items-center justify-between py-2 px-4 fixed top-0 right-0 left-0 z-30 bg-base-light-700">
<a class="inline-block mr-4 py-1 text-xl text-base-light-300" href="#"><i class="fas fa-bolt"></i> AshHq</a>
</nav>
</header>
<main role="main" class="container mx-auto mb-8 px-4 max-w-6xl">
<div class="flex justify-center p-2">
<div class="card w-full max-w-xs">
<div class="card-body">
<%= @inner_content %>
</div>
</div>
</div>
</main>
</body>
</html>

View file

@ -1,199 +0,0 @@
defmodule AshHqWeb.UserAuth do
@moduledoc """
Helpers for authenticating, logging in and logging out users.
"""
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 = create_token_for_user(user)
log_in_with_token(conn, token, params["remember_me"] == "true")
end
def log_in_with_token(conn, token, remember_me?, return_to \\ nil) do
user_return_to = 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, remember_me?)
|> redirect(to: user_return_to || signed_in_path(conn))
end
@doc """
Gets a token for a user, effectively "logging them in".
This is used in liveviews, after which the token is sent to
the session creation endpoint, which stores the token in the session.
"""
def create_token_for_user(user) do
Accounts.UserToken
|> Ash.Changeset.for_create(:build_session_token, %{user: user.id}, authorize?: false)
|> Accounts.create!()
|> Map.get(:token)
end
defp maybe_write_remember_me_cookie(conn, token, 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: Routes.app_view_path(AshHqWeb.Endpoint, :home))
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)
assign(conn, :current_user, user_for_session_token(user_token))
end
@doc """
Gets the user corresponding to a given session token.
If the session token is nil or does not exist, then `nil` is returned.
"""
def user_for_session_token(nil), do: nil
def user_for_session_token(user_token) do
AshHq.Accounts.User
|> Ash.Query.for_read(:by_token, %{token: user_token, context: "session"}, authorize?: false)
|> AshHq.Accounts.read_one!()
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.app_view_path(conn, :log_in))
|> halt()
end
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: "/"
def put_session_layout(conn, _opts) do
conn
|> put_layout(false)
|> put_root_layout({AshHqWeb.LayoutView, :session})
end
end

View file

@ -5,7 +5,7 @@ defmodule AshHqWeb.AppViewLive do
alias AshHqWeb.Components.AppView.TopBar
alias AshHqWeb.Components.{CatalogueModal, Search}
alias AshHqWeb.Pages.{Blog, Docs, Home, LogIn, Media, Register, ResetPassword, UserSettings}
alias AshHqWeb.Pages.{Blog, Docs, Home, Media, UserSettings}
alias Phoenix.LiveView.JS
alias Surface.Components.Context
require Ash.Query
@ -118,12 +118,6 @@ defmodule AshHqWeb.AppViewLive do
/>
{#match :user_settings}
<UserSettings id="user_settings" current_user={@current_user} />
{#match :log_in}
<LogIn id="log_in" />
{#match :register}
<Register id="register" />
{#match :reset_password}
<ResetPassword id="reset_password" params={@params} />
{#match :media}
<Media id="media" />
{/case}

View file

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

View file

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

View file

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

View file

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

View file

@ -45,6 +45,8 @@ defmodule AshHq.MixProject do
{:ash_phoenix, github: "ash-project/ash_phoenix", override: true},
{:ash_graphql, github: "ash-project/ash_graphql"},
{:ash_json_api, github: "ash-project/ash_json_api"},
{:ash_authentication, github: "team-alembic/ash_authentication", override: true},
{:ash_authentication_phoenix, github: "team-alembic/ash_authentication_phoenix"},
{:absinthe_plug, "~> 1.5"},
{:ash_blog, github: "ash-project/ash_blog"},
{:ash_csv, github: "ash-project/ash_csv"},
@ -79,7 +81,7 @@ defmodule AshHq.MixProject do
{:ecto_psql_extras, "~> 0.6"},
{:phoenix_ecto, "~> 4.4"},
# Phoenix/Core dependencies
{:phoenix, "~> 1.7.0-rc.0", override: true},
{:phoenix, "~> 1.7.0-rc.1", override: true},
{:phoenix_view, "~> 2.0"},
{:ecto_sql, "~> 3.6"},
{:postgrex, ">= 0.0.0"},

View file

@ -1,17 +1,20 @@
%{
"absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"},
"ash": {:git, "https://github.com/ash-project/ash.git", "1ccec692cb76f76bc33164dedbf9ee14ed8a8359", []},
"ash": {:git, "https://github.com/ash-project/ash.git", "1eaeacc7486e50d70138baaf65133cef5e8b5869", []},
"ash_admin": {:git, "https://github.com/ash-project/ash_admin.git", "cdb3b469abeeadddda884e7cfcf67c6fea10f9ef", []},
"ash_authentication": {:git, "https://github.com/team-alembic/ash_authentication.git", "161c8ab7e8a9bb38955b6c1872246e065327ce62", []},
"ash_authentication_phoenix": {:git, "https://github.com/team-alembic/ash_authentication_phoenix.git", "fbe5272f874532b3119033ddb1dfb49d71f7b571", []},
"ash_blog": {:git, "https://github.com/ash-project/ash_blog.git", "9254773dfedabfc7987af6326a62885c24c3655b", []},
"ash_csv": {:git, "https://github.com/ash-project/ash_csv.git", "77187f6e4505ed4d88598bf87e56983a6a74a456", []},
"ash_graphql": {:git, "https://github.com/ash-project/ash_graphql.git", "09fe8ce9bbbdf03d12b17e5037235a7752326e59", []},
"ash_json_api": {:git, "https://github.com/ash-project/ash_json_api.git", "5594f18d07d0771b4bc8b3d3db99cc8c08958a66", []},
"ash_phoenix": {:git, "https://github.com/ash-project/ash_phoenix.git", "b205c9bb4f153f855420f11d766cfe515930631e", []},
"ash_postgres": {:hex, :ash_postgres, "1.2.2", "0da0d9e1878f8b24829a8971e497e7bafe5596aced3604ba39d96e70174a8b11", [:mix], [{:ash, ">= 2.4.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "8796f1de22863451a445f33a0874904145efeecfff75175f6c1ce62754c8df4e"},
"assent": {:hex, :assent, "0.2.1", "46ad0ed92b72330f38c60bc03c528e8408475dc386f48d4ecd18833cfa581b9f", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "58c558b6029ffa287e15b38c8e07cd99f0b24e4846c52abad0c0a6225c4873bc"},
"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.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
"castore": {:hex, :castore, "0.1.20", "62a0126cbb7cb3e259257827b9190f88316eb7aa3fdac01fd6f2dfd64e7f46e9", [:mix], [], "hexpm", "a020b7650529c986c454a4035b6b13a328e288466986307bea3aadb4c95ac98a"},
"castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"},
"certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
"cloak": {:hex, :cloak, "1.1.2", "7e0006c2b0b98d976d4f559080fabefd81f0e0a50a3c4b621f85ceeb563e80bb", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "940d5ac4fcd51b252930fd112e319ea5ae6ab540b722f3ca60a85666759b9585"},
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
@ -52,6 +55,8 @@
"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.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
"joken": {:hex, :joken, "2.5.0", "09be497d804b8115eb6f07615cef2e60c2a1008fb89dc0aef0d4c4b4609b99aa", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "22b25c89617c5ed8ca7b31026340a25ea0f9ca7160f9706b79be9ed81fdf74e7"},
"jose": {:hex, :jose, "1.11.5", "3bc2d75ffa5e2c941ca93e5696b54978323191988eb8d225c2e663ddfefd515e", [:mix, :rebar3], [], "hexpm", "dcd3b215bafe02ea7c5b23dafd3eb8062a5cd8f2d904fd9caa323d37034ab384"},
"json_xema": {:hex, :json_xema, "0.4.2", "85de190f597a98ce9da436b8a59c97ef561a6ab6017255df8b494babefd6fb10", [:mix], [{:conv_case, "~> 0.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:xema, "~> 0.11", [hex: :xema, repo: "hexpm", optional: false]}], "hexpm", "5516213758667d21669e0d63ea287238d277519527bac6c02140a5e34c1fda80"},
"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"},
@ -70,12 +75,12 @@
"nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"},
"parallel_stream": {:hex, :parallel_stream, "1.1.0", "f52f73eb344bc22de335992377413138405796e0d0ad99d995d9977ac29f1ca9", [:mix], [], "hexpm", "684fd19191aedfaf387bbabbeb8ff3c752f0220c8112eb907d797f4592d6e871"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"phoenix": {:hex, :phoenix, "1.7.0-rc.0", "8e328572f496b5170e879da94baa57c5f878f354d50eac052c9a7c6d57c2cf54", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ed503f6c55184afc0a453e44e6ab2a09f014f59b7fdd682313fdc52ec2f82859"},
"phoenix": {:hex, :phoenix, "1.7.0-rc.1", "28d6591441347ba68da9750771cec6fe18ce040c91095a46d5d332804d5037d5", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "96d98dcf161b2784fd08a52fd480729a9eeae33773440b4e7a89d1e7e804af52"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.7.2", "97cc4ff2dba1ebe504db72cb45098cb8e91f11160528b980bd282cc45c73b29c", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0e5fdf063c7a3b620c566a30fcf68b7ee02e5e46fe48ee46a6ec3ba382dc05b7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.18.3", "2e3d009422addf8b15c3dccc65ce53baccbe26f7cfd21d264680b5867789a9c1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c8845177a866e017dcb7083365393c8f00ab061b8b6b2bda575891079dce81b2"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.18.6", "460c36977643d76fc8e0b6b3c4bba703c0ef21abc74233cf7dc15d1c1696832f", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce2768fb44c3c370df13fc4f0dc70623b662a93a201d8d7d87c4ba6542bc6b73"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
"phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"},
"phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"},
@ -87,9 +92,10 @@
"postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [: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", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"},
"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"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"},
"sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"},
"spark": {:hex, :spark, "0.3.1", "87b5bf790bfa99b4b8e748fe0b6c8bf0db29d73c861d346d072a1f1083e73cb3", [:mix], [{:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "425fa3f404d377cac4493c181142443daf778988d43c6319e1e3435d3af515df"},
"spark": {:hex, :spark, "0.3.4", "0084ce931c0e444194d5198b6f872c74c85607b6e5672436056e9f5b6fa41139", [:mix], [{:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "7816d3a43916c5fac2bb2308f9a3442644950e5e269014e4cdc5b2cab0bab5e0"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
"sunflower_ui": {:git, "https://github.com/zachdaniel/sunflower_ui.git", "3ec87f33e003693e6db2329f9d6d8ac59983cf17", []},
@ -98,7 +104,7 @@
"swoosh": {:hex, :swoosh, "1.8.1", "b1694d57c01852f50f7d4e6a74f5d119b2d8722d6a82f7288703c3e448ddbbf8", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e64a93d71d1e1e32db681cf7870697495c9cb2df4a5484f4d91ded326ccd3cbb"},
"table_rex": {:hex, :table_rex, "3.1.1", "0c67164d1714b5e806d5067c1e96ff098ba7ae79413cc075973e17c38a587caa", [:mix], [], "hexpm", "678a23aba4d670419c23c17790f9dcd635a4a89022040df7d5d772cb21012490"},
"tails": {:hex, :tails, "0.1.1", "4d912b8c4e5bf244f2e899fee76b2d2fb99d2c4740defaa00ed5570266ee87f4", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4d273173487f32db0040d901a312a6bf347ba5159b6c2fdf080830649839569c"},
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
"telemetry": {:hex, :telemetry, "1.2.0", "a8ce551485a9a3dac8d523542de130eafd12e40bbf76cf0ecd2528f24e812a44", [:rebar3], [], "hexpm", "1427e73667b9a2002cf1f26694c422d5c905df889023903c4518921d53e3e883"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
"typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},

View file

@ -0,0 +1,51 @@
defmodule AshHq.Repo.Migrations.MigrateResources36 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
alter table(:users) do
modify(:hashed_password, :text, null: false)
end
alter table(:user_tokens) do
remove(:sent_to)
remove(:context)
remove(:token)
remove(:id)
add(:updated_at, :utc_datetime_usec, null: false, default: fragment("now()"))
add(:extra_data, :map)
add(:purpose, :text, null: false)
add(:expires_at, :utc_datetime, null: false)
add(:jti, :text, null: false, primary_key: true)
end
drop_if_exists(
unique_index(:user_tokens, [:context, :token], name: "user_tokens_token_context_index")
)
end
def down do
create unique_index(:user_tokens, [:context, :token], name: "user_tokens_token_context_index")
alter table(:user_tokens) do
remove(:jti)
remove(:expires_at)
remove(:purpose)
remove(:extra_data)
remove(:updated_at)
add(:id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true)
add(:context, :text)
add(:sent_to, :text)
end
alter table(:users) do
modify(:hashed_password, :text, null: true)
end
end
end

View file

@ -0,0 +1,21 @@
defmodule AshHq.Repo.Migrations.MigrateResources37 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
alter table(:users) do
modify(:hashed_password, :text, null: true)
end
end
def down do
alter table(:users) do
modify(:hashed_password, :text, null: false)
end
end
end

View file

@ -0,0 +1,25 @@
defmodule AshHq.Repo.Migrations.MigrateResources38 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
execute("""
DELETE FROM user_tokens
""")
alter table(:user_tokens) do
add(:subject, :text, null: false)
end
end
def down do
alter table(:user_tokens) do
remove(:subject)
end
end
end

View file

@ -68,7 +68,7 @@ AshHq.Docs.Library.create!(
name: "ash_authentication",
display_name: "AshAuthentication",
order: 55,
repo_org: "alembic",
repo_org: "team-alembic",
description: """
Provides drop-in support for user authentication with various strategies and tons of customizability.
"""
@ -82,7 +82,7 @@ AshHq.Docs.Library.create!(
name: "ash_authentication_phoenix",
display_name: "AshAuthenticationPhoenix",
order: 56,
repo_org: "alembic",
repo_org: "team-alembic",
description: """
Phoenix helpers and UI components in support of AshAuthentication.
"""

View file

@ -0,0 +1,103 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"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": null,
"size": null,
"source": "extra_data",
"type": "map"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "purpose",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "expires_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "jti",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": {
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_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": "2ED4D56A2D45C3D5EA222BF1D3D97B80290F1B41F1A0638EA3DD61839798E998",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshHq.Repo",
"schema": null,
"table": "user_tokens"
}

View file

@ -0,0 +1,113 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"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": null,
"size": null,
"source": "extra_data",
"type": "map"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "purpose",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "expires_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "subject",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "jti",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": {
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_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": "EEDCAEBD942F7D83FCD42C8A85C07B633D3A0A27396E6A40B2E29B490FCE5A49",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshHq.Repo",
"schema": null,
"table": "user_tokens"
}

View file

@ -0,0 +1,118 @@
{
"attributes": [
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "confirmed_at",
"type": "utc_datetime_usec"
},
{
"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?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "encrypted_name",
"type": "binary"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "encrypted_address",
"type": "binary"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "shirt_size",
"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": "EB9257B23461C24189B97F8C36CF47A3FB1F29A798907F16D43AC685C36A2641",
"identities": [
{
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
"email"
],
"name": "unique_email"
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshHq.Repo",
"schema": null,
"table": "users"
}

View file

@ -0,0 +1,118 @@
{
"attributes": [
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "confirmed_at",
"type": "utc_datetime_usec"
},
{
"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": "hashed_password",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "encrypted_name",
"type": "binary"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "encrypted_address",
"type": "binary"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "shirt_size",
"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": "F271D9D0C9CE358D56DD883EDB689726741C92534E44C708AB34AB1F821FF9A2",
"identities": [
{
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
"email"
],
"name": "unique_email"
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.AshHq.Repo",
"schema": null,
"table": "users"
}

View file

@ -1,199 +0,0 @@
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"},
authorize?: false
)
|> 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.for_create(:build_session_token, %{user: user.id}, authorize?: false)
|> 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"},
authorize?: false
)
|> 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.for_create(:build_session_token, %{user: user.id}, authorize?: false)
|> 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.for_create(:build_session_token, %{user: user.id}, authorize?: false)
|> 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.app_view_path(conn, :log_in)
assert Phoenix.Flash.get(conn.assigns[:flash], :error) ==
"You must log in to access this page."
end
test "stores the path to redirect to on GET", %{conn: conn} do
halted_conn =
%{conn | path_info: ["foo"], query_string: ""}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar=baz"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
refute get_session(halted_conn, :user_return_to)
end
test "does not redirect if user is authenticated", %{conn: conn, user: user} do
conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([])
refute conn.halted
refute conn.status
end
end
end

View file

@ -1,106 +0,0 @@
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 "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 Phoenix.Flash.get(conn.assigns[:flash], :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
Repo.delete_all(Accounts.UserToken)
user
|> Ash.Changeset.for_update(:confirm, %{}, authorize?: false)
|> Accounts.update!()
conn =
post(conn, Routes.user_confirmation_path(conn, :create), %{
"user" => %{"email" => user.email}
})
assert redirected_to(conn) == "/"
assert Phoenix.Flash.get(conn.assigns[:flash], :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
Repo.delete_all(Accounts.UserToken)
conn =
post(conn, Routes.user_confirmation_path(conn, :create), %{
"user" => %{"email" => "unknown@example.com"}
})
assert redirected_to(conn) == "/"
assert Phoenix.Flash.get(conn.assigns[:flash], :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, %{},
authorize?: false
)
|> Accounts.update!()
|> Map.get(:__metadata__)
|> Map.get(:token)
conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token))
assert redirected_to(conn) == "/"
assert Phoenix.Flash.get(conn.assigns[:flash], :info) &&
Phoenix.Flash.get(conn.assigns[:flash], :info) =~ "Account confirmed successfully"
assert Accounts.get!(Accounts.User, user.id, authorize?: false).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 Phoenix.Flash.get(conn.assigns[:flash], :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 Phoenix.Flash.get(conn.assigns[:flash], :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 Phoenix.Flash.get(conn.assigns[:flash], :error) =~
"Account confirmation link is invalid or it has expired"
refute Accounts.get!(Accounts.User, user.id, authorize?: false).confirmed_at
end
end
end

View file

@ -1,60 +0,0 @@
defmodule AshHqWeb.UserSessionControllerTest do
use AshHqWeb.ConnCase, async: true
import AshHq.AccountsFixtures
setup do
%{user: user_fixture()}
end
describe "POST /users/new_session/:token" do
test "logs the user in", %{conn: conn, user: user} do
token = AshHqWeb.UserAuth.create_token_for_user(user)
conn =
post(conn, Routes.user_session_path(conn, :log_in), %{
"log_in" => %{"token" => Base.url_encode64(token, padding: false)}
})
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
token = AshHqWeb.UserAuth.create_token_for_user(user)
conn =
post(conn, Routes.user_session_path(conn, :log_in), %{
"log_in" => %{
"token" => Base.url_encode64(token, padding: false),
"remember_me" => "true"
}
})
assert conn.resp_cookies["_reference_live_app_web_user_remember_me"]
assert redirected_to(conn) =~ "/"
end
end
describe "POST /users/log_out" do
test "logs the user out", %{conn: conn, user: user} do
conn = conn |> log_in_user(user) |> post(Routes.user_session_path(conn, :delete))
assert redirected_to(conn) == "/"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns[:flash], :info) =~ "Logged out successfully"
end
test "succeeds even if the user is not logged in", %{conn: conn} do
conn = post(conn, Routes.user_session_path(conn, :delete))
assert redirected_to(conn) == "/"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns[:flash], :info) =~ "Logged out successfully"
end
end
end

View file

@ -1,63 +0,0 @@
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/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(),
update_url_fun: &Routes.user_settings_url(AshHqWeb.Endpoint, :confirm_email, &1)
},
authorize?: false
)
|> 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.app_view_path(conn, :user_settings)
assert Phoenix.Flash.get(conn.assigns[:flash], :info) =~ "Email changed successfully"
refute Accounts.get!(Accounts.User, [email: user.email], authorize?: false, error?: false)
assert Accounts.get!(Accounts.User, [email: email], authorize?: false)
conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token))
assert redirected_to(conn) == Routes.app_view_path(conn, :user_settings)
assert Phoenix.Flash.get(conn.assigns[:flash], :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.app_view_path(conn, :user_settings)
assert Phoenix.Flash.get(conn.assigns[:flash], :error) =~
"Email change link is invalid or it has expired"
assert Accounts.get!(Accounts.User, [email: user.email], authorize?: false)
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.app_view_path(conn, :log_in)
end
end
end

View file

@ -1,46 +0,0 @@
defmodule AshHq.LogInTest do
use AshHqWeb.ConnCase
import Plug.Conn
import Phoenix.ConnTest
import Phoenix.LiveViewTest
@endpoint AshHqWeb.Endpoint
setup :register_user
describe "log in form" do
test "renders", %{conn: conn} do
{:ok, _view, html} = live(conn, "/users/log_in")
assert html =~ "Log In"
end
test "submission logs you in", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, "/users/log_in")
form = form(view, "form#log_in", log_in: %{email: user.email, password: "hello world!"})
assert form
|> render_submit() =~ "phx-trigger-action"
conn = follow_trigger_action(form, conn)
conn = fetch_session(conn)
assert get_session(conn, :user_token)
end
test "submission with a bad password does not log you in", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, "/users/log_in")
form = form(view, "form#log_in", log_in: %{email: user.email, password: "bad password!"})
assert {:ok, _view, html} =
form
|> render_submit()
|> follow_redirect(conn)
assert html =~ "Invalid username or password"
end
end
end

View file

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

View file

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

View file

@ -22,7 +22,7 @@ defmodule AshHq.SettingsTest do
view
|> element("form#update_email")
|> render_submit(%{
update_email: %{email: "new_email@example.com", current_password: "hello world!"}
update_email: %{email: "new_email@example.com", current_password: "password123"}
})
|> follow_redirect(conn)
@ -35,7 +35,7 @@ defmodule AshHq.SettingsTest do
view
|> element("form#update_email")
|> render_submit(%{
update_email: %{email: "new_email@example.com", current_password: "hello world!"}
update_email: %{email: "new_email@example.com", current_password: "password123"}
})
assert_received {:email, email}
@ -51,16 +51,18 @@ defmodule AshHq.SettingsTest do
view
|> element("form#update_email")
|> render_submit(%{
update_email: %{email: "new_email@example.com", current_password: "hello world!"}
update_email: %{email: "new_email@example.com", current_password: "password123"}
})
assert_received {:email, email}
assert %{"url" => url} = Regex.named_captures(~r/(?<url>http[^\s\"]*)/, email.html_body)
path = URI.parse(url).path
uri = URI.parse(url)
assert {:ok, _conn} = conn |> live(path) |> follow_redirect(conn, "/users/settings")
path = %{uri | authority: nil, host: nil, scheme: nil, port: nil} |> to_string()
assert {:ok, _conn} = conn |> live(path) |> follow_redirect(conn, "/")
new_user = AshHq.Accounts.reload!(user, authorize?: false)
assert to_string(new_user.email) == "new_email@example.com"
@ -84,7 +86,7 @@ defmodule AshHq.SettingsTest do
change_password: %{
password: "hello world2!",
password_confirmation: "hello world2!",
current_password: "hello world!"
current_password: "password123"
}
})
|> follow_redirect(conn)
@ -102,7 +104,7 @@ defmodule AshHq.SettingsTest do
change_password: %{
password: "hello world2!",
password_confirmation: "hello world2!",
current_password: "hello world!"
current_password: "password123"
}
})
|> follow_redirect(conn)
@ -110,7 +112,7 @@ defmodule AshHq.SettingsTest do
assert html =~ "Password has been successfully changed"
assert AshHq.Accounts.User
|> Ash.Query.for_read(:by_email_and_password, %{
|> Ash.Query.for_read(:sign_in_with_password, %{
email: user.email,
password: "hello world2!"
})

View file

@ -46,7 +46,8 @@ defmodule AshHqWeb.ConnCase do
test context.
"""
def register_and_log_in_user(%{conn: conn}) do
user = AshHq.AccountsFixtures.user_fixture()
user = test_user()
%{conn: log_in_user(conn, user), user: user}
end
@ -55,8 +56,30 @@ defmodule AshHqWeb.ConnCase do
setup :register_user
"""
def register_user(context) do
%{user: AshHq.AccountsFixtures.user_fixture()}
def register_user(_context) do
%{
user: test_user()
}
end
defp test_user do
user =
AshHq.Accounts.User.register_with_password!(
"test@example.com",
"password123",
"password123",
authorize?: false
)
receive do
{:email, _} ->
:ok
after
0 ->
:ok
end
user
end
@doc """
@ -65,15 +88,8 @@ defmodule AshHqWeb.ConnCase do
It returns an updated `conn`.
"""
def log_in_user(conn, user) do
token =
AshHq.Accounts.UserToken
|> Ash.Changeset.for_create(:build_session_token, %{user: user.id}, authorize?: false)
|> AshHq.Accounts.create!()
|> Map.get(:__metadata__)
|> Map.get(:url_token)
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:user_token, token)
|> Plug.Conn.put_session(:user_token, user.__metadata__.token)
end
end

View file

@ -1,28 +0,0 @@
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(),
confirmation_url_fun:
&AshHqWeb.Router.Helpers.user_confirmation_url(AshHqWeb.Endpoint, :confirm, &1)
})
user =
AshHq.Accounts.User
|> Ash.Changeset.for_create(:register, params, authorize?: false)
|> AshHq.Accounts.create!()
Swoosh.TestAssertions.assert_email_sent()
user
end
end

View file

@ -1 +1,2 @@
:erlang.system_flag(:backtrace_depth, 100)
ExUnit.start()