Compare commits
No commits in common. "d7ac16960702725d6f17e25b1adf9bbd4ac226e3" and "4c3c5547fef27f8fe389af17a74f281ac1edb809" have entirely different histories.
d7ac169607
...
4c3c5547fe
52 changed files with 851 additions and 1441 deletions
.formatter.exs
config
lib
core.ex
mix.exsmix.lockcore
schema.exschema
web
components
controllers
admin_auth.exadmin_session_controller.exglobals.expage_controller.expage_html.ex
page_html
post_controller.expost_html.expost_html
status_controller.exstatus_html.exstatus_html
user_session_controller.exlive
admin_live.exadmin_login_live.expost_live.exstatus_live.exuser_login_live.exuser_registration_live.exuser_settings_live.ex
router.exuser_auth.expriv/repo/migrations
20250222164951_add_posts_table.exs20250222201807_add_statuses_table.exs20250324093345_create_users_auth_tables.exs
test
support
web/controllers
|
@ -1,5 +1,5 @@
|
|||
[
|
||||
import_deps: [:ecto, :ecto_sql, :phoenix],
|
||||
import_deps: [:ecto, :ecto_sql, :phoenix, :typed_struct],
|
||||
subdirectories: ["priv/*/migrations"],
|
||||
plugins: [Styler, Phoenix.LiveView.HTMLFormatter],
|
||||
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
import Config
|
||||
|
||||
# Only in tests, remove the complexity from the password hashing algorithm
|
||||
config :argon2_elixir, t_cost: 1, m_cost: 8
|
||||
|
||||
config :logger, level: :warning
|
||||
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Core do
|
||||
@moduledoc false
|
||||
use Boundary, deps: [Schema], exports: [Accounts]
|
||||
use Boundary, deps: [Schema], exports: [Author, Posts, Statuses]
|
||||
end
|
||||
|
|
|
@ -1,206 +0,0 @@
|
|||
defmodule Core.Accounts do
|
||||
@moduledoc """
|
||||
The Accounts context.
|
||||
"""
|
||||
|
||||
alias Core.Accounts.User
|
||||
alias Core.Accounts.UserToken
|
||||
alias Core.Repo
|
||||
|
||||
## Database getters
|
||||
|
||||
@doc """
|
||||
Gets a user by username and password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_username_and_password("foo@example.com", "correct_password")
|
||||
%Schema.User{}
|
||||
|
||||
iex> get_user_by_username_and_password("foo@example.com", "invalid_password")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_username_and_password(username, password) when is_binary(username) and is_binary(password) do
|
||||
user = Repo.get_by(Schema.User, username: username)
|
||||
if User.valid_password?(user, password), do: user
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single user.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the User does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user!(123)
|
||||
%Schema.User{}
|
||||
|
||||
iex> get_user!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_user!(id), do: Repo.get!(Schema.User, id)
|
||||
|
||||
## User registration
|
||||
|
||||
@doc """
|
||||
Registers a user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> register_user(%{field: value})
|
||||
{:ok, %Schema.User{}}
|
||||
|
||||
iex> register_user(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def register_user(attrs) do
|
||||
%Schema.User{}
|
||||
|> User.registration_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking user changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_registration(user)
|
||||
%Ecto.Changeset{data: %Schema.User{}}
|
||||
|
||||
"""
|
||||
def change_user_registration(%Schema.User{} = user, attrs \\ %{}) do
|
||||
User.registration_changeset(user, attrs, hash_password: false, validate_username: false)
|
||||
end
|
||||
|
||||
## Settings
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user username.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_username(user)
|
||||
%Ecto.Changeset{data: %Schema.User{}}
|
||||
|
||||
"""
|
||||
def change_user_username(user, attrs \\ %{}) do
|
||||
User.username_changeset(user, attrs, validate_username: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user username.
|
||||
|
||||
If the token matches, the user username is updated and the token is deleted.
|
||||
The confirmed_at date is also updated to the current time.
|
||||
"""
|
||||
def update_user_username(user, password, attrs) do
|
||||
user
|
||||
|> User.username_changeset(attrs)
|
||||
|> User.validate_current_password(password)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_password(user)
|
||||
%Ecto.Changeset{data: %Schema.User{}}
|
||||
|
||||
"""
|
||||
def change_user_password(user, attrs \\ %{}) do
|
||||
User.password_changeset(user, attrs, hash_password: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_user_password(user, "valid password", %{password: ...})
|
||||
{:ok, %Schema.User{}}
|
||||
|
||||
iex> update_user_password(user, "invalid password", %{password: ...})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_user_password(user, password, attrs) do
|
||||
changeset =
|
||||
user
|
||||
|> User.password_changeset(attrs)
|
||||
|> User.validate_current_password(password)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, changeset)
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.Query.for_user(user))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
## Session
|
||||
|
||||
@doc """
|
||||
Generates a session token.
|
||||
"""
|
||||
def generate_user_session_token(user) do
|
||||
{token, user_token} = UserToken.build_session_token(user)
|
||||
Repo.insert!(user_token)
|
||||
token
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user with the given signed token.
|
||||
"""
|
||||
def get_user_by_session_token(token) do
|
||||
token
|
||||
|> UserToken.Query.valid_session_token()
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes the signed token with the given context.
|
||||
"""
|
||||
def delete_user_session_token(token) do
|
||||
Repo.delete_all(UserToken.Query.where_token_and_context(token, "session"))
|
||||
:ok
|
||||
end
|
||||
|
||||
## Reset password
|
||||
|
||||
@doc """
|
||||
Resets the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
|
||||
{:ok, %Schema.User{}}
|
||||
|
||||
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def reset_user_password(user, attrs) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.Query.for_user(user))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns `true` if any users exist.
|
||||
"""
|
||||
def has_registered_user? do
|
||||
Repo.exists?(User.Query.has_users_query())
|
||||
end
|
||||
end
|
|
@ -1,155 +0,0 @@
|
|||
defmodule Core.Accounts.User do
|
||||
@moduledoc false
|
||||
import Ecto.Changeset
|
||||
|
||||
@doc """
|
||||
A user changeset for registration.
|
||||
|
||||
It is important to validate the length of both username and password.
|
||||
Otherwise databases may truncate the username without warnings, which
|
||||
could lead to unpredictable or insecure behaviour. Long passwords may
|
||||
also be very expensive to hash for certain algorithms.
|
||||
|
||||
## Options
|
||||
|
||||
* `:hash_password` - Hashes the password so it can be stored securely
|
||||
in the database and ensures the password field is cleared to prevent
|
||||
leaks in the logs. If password hashing is not needed and clearing the
|
||||
password field is not desired (like when using this changeset for
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
|
||||
* `:validate_username` - Validates the uniqueness of the username, in case
|
||||
you don't want to validate the uniqueness of the username (like when
|
||||
using this changeset for validations on a LiveView form before
|
||||
submitting the form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def registration_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:username, :password])
|
||||
|> validate_username(opts)
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
defp validate_username(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:username])
|
||||
|> validate_length(:username, max: 160)
|
||||
|> maybe_validate_unique_username(opts)
|
||||
end
|
||||
|
||||
defp validate_password(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:password])
|
||||
|> validate_length(:password, min: 12, max: 72)
|
||||
# Examples of additional password validation:
|
||||
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
|
||||
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
|
||||
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|
||||
|> maybe_hash_password(opts)
|
||||
end
|
||||
|
||||
defp maybe_hash_password(changeset, opts) do
|
||||
hash_password? = Keyword.get(opts, :hash_password, true)
|
||||
password = get_change(changeset, :password)
|
||||
|
||||
if hash_password? && password && changeset.valid? do
|
||||
changeset
|
||||
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
|
||||
# would keep the database transaction open longer and hurt performance.
|
||||
|> put_change(:hashed_password, Argon2.hash_pwd_salt(password))
|
||||
|> delete_change(:password)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_validate_unique_username(changeset, opts) do
|
||||
if Keyword.get(opts, :validate_username, true) do
|
||||
changeset
|
||||
|> unsafe_validate_unique(:username, Core.Repo)
|
||||
|> unique_constraint(:username)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the username.
|
||||
|
||||
It requires the username to change otherwise an error is added.
|
||||
"""
|
||||
def username_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:username])
|
||||
|> validate_username(opts)
|
||||
|> case do
|
||||
%{changes: %{username: _}} = changeset -> changeset
|
||||
%{} = changeset -> add_error(changeset, :username, "did not change")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the password.
|
||||
|
||||
## Options
|
||||
|
||||
* `:hash_password` - Hashes the password so it can be stored securely
|
||||
in the database and ensures the password field is cleared to prevent
|
||||
leaks in the logs. If password hashing is not needed and clearing the
|
||||
password field is not desired (like when using this changeset for
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def password_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:password])
|
||||
|> validate_confirmation(:password, message: "does not match password")
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies the password.
|
||||
|
||||
If there is no user or the user doesn't have a password, we call
|
||||
`Argon2.no_user_verify/0` to avoid timing attacks.
|
||||
"""
|
||||
def valid_password?(%Schema.User{hashed_password: hashed_password}, password)
|
||||
when is_binary(hashed_password) and byte_size(password) > 0 do
|
||||
Argon2.verify_pass(password, hashed_password)
|
||||
end
|
||||
|
||||
def valid_password?(_, _) do
|
||||
Argon2.no_user_verify()
|
||||
false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates the current password otherwise adds an error to the changeset.
|
||||
"""
|
||||
def validate_current_password(changeset, password) do
|
||||
changeset = cast(changeset, %{current_password: password}, [:current_password])
|
||||
|
||||
if valid_password?(changeset.data, password) do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :current_password, "is not valid")
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Query do
|
||||
@moduledoc false
|
||||
import Ecto.Query
|
||||
|
||||
def base do
|
||||
from _ in Schema.User, as: :users
|
||||
end
|
||||
|
||||
def has_users_query(query \\ base()) do
|
||||
query
|
||||
|> limit(1)
|
||||
|> select([users: _], true)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,68 +0,0 @@
|
|||
defmodule Core.Accounts.UserToken do
|
||||
@moduledoc false
|
||||
|
||||
@rand_size 32
|
||||
|
||||
@doc """
|
||||
Generates a token that will be stored in a signed place,
|
||||
such as session or cookie. As they are signed, those
|
||||
tokens do not need to be hashed.
|
||||
|
||||
The reason why we store session tokens in the database, even
|
||||
though Phoenix already provides a session cookie, is because
|
||||
Phoenix' default session cookies are not persisted, they are
|
||||
simply signed and potentially encrypted. This means they are
|
||||
valid indefinitely, unless you change the signing/encryption
|
||||
salt.
|
||||
|
||||
Therefore, storing them allows individual user
|
||||
sessions to be expired. The token system can also be extended
|
||||
to store additional data, such as the device used for logging in.
|
||||
You could then use this information to display all valid sessions
|
||||
and devices in the UI and allow users to explicitly expire any
|
||||
session they deem invalid.
|
||||
"""
|
||||
def build_session_token(user) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
{token, %Schema.UserToken{token: token, context: "session", user_id: user.id}}
|
||||
end
|
||||
|
||||
defmodule Query do
|
||||
@moduledoc false
|
||||
import Ecto.Query
|
||||
|
||||
@session_validity_in_days 60
|
||||
|
||||
def base do
|
||||
from Schema.UserToken, as: :user_tokens
|
||||
end
|
||||
|
||||
def join_users(query \\ base()) do
|
||||
join(query, :inner, [user_tokens: ut], u in assoc(ut, :user), as: :users)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user found by the token, if any.
|
||||
|
||||
The token is valid if it matches the value in the database and it has
|
||||
not expired (after @session_validity_in_days).
|
||||
"""
|
||||
def valid_session_token(query \\ base(), token) do
|
||||
query
|
||||
|> where_token_and_context(token, "session")
|
||||
|> join_users()
|
||||
|> where([user_tokens: ut], ut.inserted_at > ago(@session_validity_in_days, "day"))
|
||||
|> select([users: user], user)
|
||||
end
|
||||
|
||||
def where_token_and_context(query \\ base(), token, context) do
|
||||
where(query, [user_tokens: ut], ut.token == ^token and ut.context == ^context)
|
||||
end
|
||||
|
||||
def for_user(query \\ base(), user) do
|
||||
where(query, [user_tokens: t], t.user_id == ^user.id)
|
||||
end
|
||||
end
|
||||
end
|
46
lib/core/author.ex
Normal file
46
lib/core/author.ex
Normal file
|
@ -0,0 +1,46 @@
|
|||
defmodule Core.Author do
|
||||
@moduledoc """
|
||||
Properties of the author, Sloane
|
||||
|
||||
Modeled after the [h-card] specification.
|
||||
|
||||
[h-card]: https://microformats.org/wiki/h-card
|
||||
"""
|
||||
use TypedStruct
|
||||
|
||||
@public_properties ~w[name nickname url]a
|
||||
|
||||
typedstruct do
|
||||
field :name, String.t()
|
||||
field :given_name, String.t()
|
||||
field :additional_name, String.t()
|
||||
field :family_name, String.t()
|
||||
field :nickname, String.t()
|
||||
field :email, String.t()
|
||||
field :url, String.t()
|
||||
end
|
||||
|
||||
def sloane do
|
||||
%__MODULE__{
|
||||
name: "Sloane Perrault",
|
||||
given_name: "Sloane",
|
||||
additional_name: "Loretta",
|
||||
family_name: "Perrault",
|
||||
nickname: "sloanely_but_surely",
|
||||
email: "sloane@fastmail.com",
|
||||
url: "https://sloanelybutsurely.com"
|
||||
}
|
||||
end
|
||||
|
||||
def full do
|
||||
sloane()
|
||||
end
|
||||
|
||||
def public do
|
||||
author = full()
|
||||
|
||||
for key <- @public_properties, reduce: %__MODULE__{} do
|
||||
acc -> Map.put(acc, key, Map.get(author, key))
|
||||
end
|
||||
end
|
||||
end
|
37
lib/core/posts.ex
Normal file
37
lib/core/posts.ex
Normal file
|
@ -0,0 +1,37 @@
|
|||
defmodule Core.Posts do
|
||||
@moduledoc false
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
|
||||
alias Core.Repo
|
||||
|
||||
def changeset(%Schema.Post{} = post, attrs) do
|
||||
post
|
||||
|> cast(attrs, [:title, :body])
|
||||
|> validate_required([:body])
|
||||
end
|
||||
|
||||
def create_post(attrs) do
|
||||
%Schema.Post{}
|
||||
|> changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_post(post, attrs) do
|
||||
post
|
||||
|> changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def get_post!(id) do
|
||||
Repo.get!(Schema.Post, id)
|
||||
end
|
||||
|
||||
def list_posts do
|
||||
query =
|
||||
from post in Schema.Post,
|
||||
order_by: [desc: post.inserted_at]
|
||||
|
||||
Repo.all(query)
|
||||
end
|
||||
end
|
37
lib/core/statuses.ex
Normal file
37
lib/core/statuses.ex
Normal file
|
@ -0,0 +1,37 @@
|
|||
defmodule Core.Statuses do
|
||||
@moduledoc false
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
|
||||
alias Core.Repo
|
||||
|
||||
def changeset(%Schema.Status{} = status, attrs \\ %{}) do
|
||||
status
|
||||
|> cast(attrs, [:body])
|
||||
|> validate_required([:body])
|
||||
end
|
||||
|
||||
def create_status(attrs) do
|
||||
%Schema.Status{}
|
||||
|> changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_status(status, attrs) do
|
||||
status
|
||||
|> changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def get_status!(id) do
|
||||
Repo.get!(Schema.Status, id)
|
||||
end
|
||||
|
||||
def list_statuses do
|
||||
query =
|
||||
from status in Schema.Status,
|
||||
order_by: [desc: status.inserted_at]
|
||||
|
||||
Repo.all(query)
|
||||
end
|
||||
end
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Schema do
|
||||
@moduledoc false
|
||||
use Boundary, deps: [], exports: [User, UserToken]
|
||||
use Boundary, deps: [], exports: [Post, Status]
|
||||
end
|
||||
|
|
12
lib/schema/post.ex
Normal file
12
lib/schema/post.ex
Normal file
|
@ -0,0 +1,12 @@
|
|||
defmodule Schema.Post do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
schema "posts" do
|
||||
field :title, :string
|
||||
field :body, :string
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
11
lib/schema/status.ex
Normal file
11
lib/schema/status.ex
Normal file
|
@ -0,0 +1,11 @@
|
|||
defmodule Schema.Status do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
schema "statuses" do
|
||||
field :body
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
|
@ -1,15 +0,0 @@
|
|||
defmodule Schema.User do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "users" do
|
||||
field :username, :string
|
||||
field :password, :string, virtual: true, redact: true
|
||||
field :hashed_password, :string, redact: true
|
||||
field :current_password, :string, virtual: true, redact: true
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
end
|
|
@ -1,15 +0,0 @@
|
|||
defmodule Schema.UserToken do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "users_tokens" do
|
||||
field :token, :binary
|
||||
field :context, :string
|
||||
field :sent_to, :string
|
||||
belongs_to :user, Schema.User
|
||||
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
end
|
|
@ -4,70 +4,99 @@ defmodule Web.CoreComponents do
|
|||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
alias Phoenix.HTML.FormField
|
||||
attr :class, :string, default: nil
|
||||
attr :global, :global
|
||||
slot :inner_block
|
||||
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any
|
||||
attr :label, :string, default: nil
|
||||
attr :value, :any
|
||||
attr :type, :string, default: "text", values: ~w[text password]
|
||||
attr :field, FormField
|
||||
attr :errors, :list, default: []
|
||||
attr :rest, :global, include: ~w[disabled form pattern placeholder readonly required]
|
||||
def title(assigns) do
|
||||
~H"""
|
||||
<h1 class={["font-bold text-xl mb-3", @class]} {@global}>{render_slot(@inner_block)}</h1>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{field: %FormField{} = field} = assigns) do
|
||||
errors =
|
||||
if Phoenix.Component.used_input?(field) do
|
||||
field.errors
|
||||
else
|
||||
[]
|
||||
end
|
||||
attr :class, :string, default: nil
|
||||
attr :global, :global
|
||||
slot :inner_block
|
||||
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id, errors: Enum.map(errors, &translate_error/1))
|
||||
|> assign_new(:name, fn -> field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> input()
|
||||
def subtitle(assigns) do
|
||||
~H"""
|
||||
<h2 class={["font-bold text-lg mb-2", @class]} {@global}>{render_slot(@inner_block)}</h2>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
|
||||
attr :global, :global,
|
||||
include: ~w[navigate patch href replace method csrf_token download hreflang referrerpolicy rel target type]
|
||||
|
||||
slot :inner_block
|
||||
|
||||
def a(assigns) do
|
||||
~H"""
|
||||
<.link class={["hover:underline", @class]} {@global}>{render_slot(@inner_block)}</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :type, :string, default: "button"
|
||||
attr :global, :global
|
||||
slot :inner_block
|
||||
|
||||
def button(assigns) do
|
||||
~H"""
|
||||
<button type={@type} class={["hover:underline", @class]} {@global}>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :field, Phoenix.HTML.FormField, required: true
|
||||
attr :type, :string, default: "text"
|
||||
attr :global, :global, include: ~w[required placeholder]
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<textarea
|
||||
class={["px-2 py-1 border border-gray-400 rounded", @class]}
|
||||
id={@field.id}
|
||||
name={@field.name}
|
||||
{@global}
|
||||
>{@field.value}</textarea>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.label for={@id}>{@label}</.label>
|
||||
<input
|
||||
id={@id}
|
||||
type={@type}
|
||||
name={@name}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
{@rest}
|
||||
/>
|
||||
<.error :for={error <- @errors}>{error}</.error>
|
||||
</div>
|
||||
<input
|
||||
class={["px-2 py-1 border border-gray-400 rounded", @class]}
|
||||
type={@type}
|
||||
id={@field.id}
|
||||
name={@field.name}
|
||||
value={@field.value}
|
||||
{@global}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :for, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
@doc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
def label(assigns) do
|
||||
~H"""
|
||||
<label for={@for}>
|
||||
{render_slot(@inner_block)}
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
Heroicons come in three styles – outline, solid, and mini.
|
||||
By default, the outline style is used, but solid and mini may
|
||||
be applied by using the `-solid` and `-mini` suffix.
|
||||
|
||||
slot :inner_block, required: true
|
||||
You can customize the size and colors of the icons by setting
|
||||
width, height, and background color classes.
|
||||
|
||||
def error(assigns) do
|
||||
~H"""
|
||||
<p>
|
||||
<.icon name="hero-exclamation-circle-mini" class="h-5 w-5 flex-none" />
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
Icons are extracted from the `deps/heroicons` directory and bundled within
|
||||
your compiled app.css by the plugin in your `assets/tailwind.config.js`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.icon name="hero-x-mark-solid" />
|
||||
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :class, :string, default: nil
|
||||
|
||||
|
@ -77,25 +106,44 @@ defmodule Web.CoreComponents do
|
|||
"""
|
||||
end
|
||||
|
||||
def translate_error({msg, opts}) do
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
|
||||
end)
|
||||
end
|
||||
attr :format, :string, required: true
|
||||
attr :value, :any, default: nil
|
||||
attr :formatter, :atom, default: :default
|
||||
attr :timezone, :string, default: "America/New_York"
|
||||
attr :global, :global
|
||||
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
|
||||
attr :type, :string, default: "button", values: ~w[button submit]
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(assigns) do
|
||||
def timex(%{value: nil} = assigns) do
|
||||
~H"""
|
||||
<button type={@type} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
<time datetime="">--</time>
|
||||
"""
|
||||
end
|
||||
|
||||
def timex(%{value: value, timezone: timezone} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :local_value, fn ->
|
||||
case value do
|
||||
%DateTime{} = datetime ->
|
||||
datetime
|
||||
|
||||
%NaiveDateTime{} = naive ->
|
||||
naive
|
||||
|> DateTime.from_naive!("Etc/UTC")
|
||||
|> DateTime.shift_zone!(timezone)
|
||||
end
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<time
|
||||
datetime={Timex.format!(@local_value, "{ISO:Extended}")}
|
||||
title={Timex.format!(@local_value, "{Mshort} {D}, {YYYY}, {h12}:{m} {AM} {Zabbr}")}
|
||||
{@global}
|
||||
>
|
||||
{Timex.format!(@local_value, @format, timex_formatter(@formatter))}
|
||||
</time>
|
||||
"""
|
||||
end
|
||||
|
||||
defp timex_formatter(formatter) do
|
||||
Module.concat(Timex.Format.DateTime.Formatters, :string.titlecase("#{formatter}"))
|
||||
end
|
||||
end
|
||||
|
|
|
@ -10,4 +10,10 @@ defmodule Web.Layouts do
|
|||
use Web, :html
|
||||
|
||||
embed_templates "layouts/*"
|
||||
|
||||
defp post?("/writing/" <> _), do: true
|
||||
defp post?(_), do: false
|
||||
|
||||
defp status?("/microblog/" <> _), do: true
|
||||
defp status?(_), do: false
|
||||
end
|
||||
|
|
|
@ -1,22 +1,51 @@
|
|||
<div class="sticky top-0 z-50 flex flex-row bg-white justify-between py-1 md:mb-2 border-b border-gray-200">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<section class="flex flex-row gap-x-2 md:border-r border-gray-200 px-2">
|
||||
<.link href={~p"/"} class="font-bold group">
|
||||
💜 <span class="group-hover:underline">sloanelybutsurely.com</span>
|
||||
</.link>
|
||||
<.a href={~p"/"} class="font-bold">sloanelybutsurely.com</.a>
|
||||
<nav>
|
||||
<ul class="flex flex-row gap-x-2"></ul>
|
||||
<ul class="flex flex-row gap-x-2">
|
||||
<li>
|
||||
<.a href={~p"/writing"}>writing</.a>
|
||||
</li>
|
||||
<li>
|
||||
<.a href={~p"/microblog"}>microblog</.a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</section>
|
||||
<section :if={@admin?} class="flex flex-row gap-x-2 px-2">
|
||||
<nav>
|
||||
<ul class="flex flex-row gap-x-2">
|
||||
<li>
|
||||
<.a navigate={~p"/admin"}>admin</.a>
|
||||
</li>
|
||||
<li>
|
||||
<.a href={~p"/admin/statuses/new"}>new status</.a>
|
||||
</li>
|
||||
<li>
|
||||
<.a href={~p"/admin/posts/new"}>new post</.a>
|
||||
</li>
|
||||
<li :if={post?(@current_path)}>
|
||||
<.a href={~p"/admin/posts/#{@post}"}>edit post</.a>
|
||||
</li>
|
||||
<li :if={status?(@current_path)}>
|
||||
<.a href={~p"/admin/statuses/#{@status}"}>edit status</.a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<%= if is_nil(@current_user) do %>
|
||||
<.link class="px-2 text-transparent hover:text-current" href={~p"/admin/users/log_in"}>
|
||||
sign in
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link class="px-2" href={~p"/admin/users/log_out"} method="delete">sign out</.link>
|
||||
<% end %>
|
||||
<.a :if={@admin?} class="px-2" href={~p"/admin/session/destroy?return_to=#{@current_path}"}>
|
||||
sign out
|
||||
</.a>
|
||||
<.a
|
||||
:if={!@admin?}
|
||||
class="px-2 text-transparent hover:text-current"
|
||||
href={~p"/sign-in?return_to=#{@current_path}"}
|
||||
>
|
||||
sign in
|
||||
</.a>
|
||||
</div>
|
||||
|
||||
<main class="p-2 max-w-2xl mx-auto">
|
||||
|
|
|
@ -10,6 +10,11 @@
|
|||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
<%= if @load_trix? do %>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/trix@2.0.8/dist/trix.css" />
|
||||
<script type="text/javascript" src="https://unpkg.com/trix@2.0.8/dist/trix.umd.min.js">
|
||||
</script>
|
||||
<% end %>
|
||||
</head>
|
||||
<body class="bg-white">
|
||||
{@inner_content}
|
||||
|
|
62
lib/web/controllers/admin_auth.ex
Normal file
62
lib/web/controllers/admin_auth.ex
Normal file
|
@ -0,0 +1,62 @@
|
|||
defmodule Web.AdminAuth do
|
||||
@moduledoc false
|
||||
use Web, :verified_routes
|
||||
|
||||
import Phoenix.Controller
|
||||
import Plug.Conn
|
||||
|
||||
def log_in_admin(conn, params) do
|
||||
conn
|
||||
|> renew_session()
|
||||
|> put_session(:admin?, true)
|
||||
|> redirect(to: params["return_to"] || ~p"/admin")
|
||||
end
|
||||
|
||||
def log_out_admin(conn, params) do
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
Web.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> redirect(to: params["return_to"] || ~p"/")
|
||||
end
|
||||
|
||||
def mount_admin(%Plug.Conn{} = conn, _opts) do
|
||||
assign(conn, :admin?, admin?(conn))
|
||||
end
|
||||
|
||||
def require_admin(%Plug.Conn{assigns: %{admin?: true}} = conn, _opts) do
|
||||
conn
|
||||
end
|
||||
|
||||
def require_admin(conn, _opts) do
|
||||
redirect(conn, to: ~p"/sign-in?return_to=#{conn.request_path}")
|
||||
end
|
||||
|
||||
def correct_password?(password) do
|
||||
password_hash = Application.fetch_env!(:sloanely_but_surely, :password_hash)
|
||||
|
||||
Argon2.verify_pass(password, password_hash)
|
||||
end
|
||||
|
||||
def on_mount(:default, _params, session, socket) do
|
||||
{:cont, Phoenix.Component.assign(socket, :admin?, admin?(session))}
|
||||
end
|
||||
|
||||
## private
|
||||
|
||||
defp renew_session(conn) do
|
||||
delete_csrf_token()
|
||||
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
end
|
||||
|
||||
defp admin?(%Plug.Conn{} = conn) do
|
||||
Plug.Conn.get_session(conn, :admin?, false) == true
|
||||
end
|
||||
|
||||
defp admin?(%{} = session), do: Map.get(session, "admin?", false) == true
|
||||
end
|
21
lib/web/controllers/admin_session_controller.ex
Normal file
21
lib/web/controllers/admin_session_controller.ex
Normal file
|
@ -0,0 +1,21 @@
|
|||
defmodule Web.AdminSessionController do
|
||||
use Web, :controller
|
||||
|
||||
alias Web.AdminAuth
|
||||
|
||||
def create(conn, %{"password" => password} = params) do
|
||||
if AdminAuth.correct_password?(password) do
|
||||
AdminAuth.log_in_admin(conn, params)
|
||||
else
|
||||
redirect(conn, to: ~p"/sign-in")
|
||||
end
|
||||
end
|
||||
|
||||
def create(conn, _params) do
|
||||
redirect(conn, to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
def destroy(conn, params) do
|
||||
AdminAuth.log_out_admin(conn, params)
|
||||
end
|
||||
end
|
22
lib/web/controllers/globals.ex
Normal file
22
lib/web/controllers/globals.ex
Normal file
|
@ -0,0 +1,22 @@
|
|||
defmodule Web.Globals do
|
||||
@moduledoc false
|
||||
use Web, :live_view
|
||||
|
||||
def assign_globals(%Plug.Conn{} = conn, _opts) do
|
||||
conn
|
||||
|> Plug.Conn.assign(:current_path, conn.request_path)
|
||||
|> Plug.Conn.assign(:load_trix?, false)
|
||||
end
|
||||
|
||||
def on_mount(:default, _params, _session, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> attach_hook(:assign_handle_params_globals, :handle_params, fn _params, uri, socket ->
|
||||
%URI{path: current_path} = URI.parse(uri)
|
||||
{:cont, assign(socket, :current_path, current_path)}
|
||||
end)
|
||||
|> assign(:load_trix?, false)
|
||||
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
|
@ -2,6 +2,12 @@ defmodule Web.PageController do
|
|||
use Web, :controller
|
||||
|
||||
def home(conn, _params) do
|
||||
render(conn, :home)
|
||||
posts = Enum.take(Core.Posts.list_posts(), 5)
|
||||
statuses = Enum.take(Core.Statuses.list_statuses(), 10)
|
||||
|
||||
conn
|
||||
|> assign(:posts, posts)
|
||||
|> assign(:statuses, statuses)
|
||||
|> render(:home)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
defmodule Web.PageHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by PageController.
|
||||
|
||||
See the `page_html` directory for all templates available.
|
||||
"""
|
||||
use Web, :html
|
||||
|
||||
embed_templates "page_html/*"
|
||||
|
|
|
@ -1 +1,46 @@
|
|||
<h1>Home</h1>
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<section>
|
||||
<.title>
|
||||
<.a href={~p"/writing"}>writing</.a>
|
||||
</.title>
|
||||
<ul class="flex flex-col">
|
||||
<li :for={post <- @posts}>
|
||||
<.link href={~p"/writing/#{post}"} class="flex flex-row justify-between group">
|
||||
<span class="group-hover:underline">
|
||||
<%= if post.title do %>
|
||||
{post.title}
|
||||
<% else %>
|
||||
(no title)
|
||||
<% end %>
|
||||
</span>
|
||||
<.timex
|
||||
value={post.inserted_at}
|
||||
format="{YYYY}-{0M}-{0D}"
|
||||
class="text-gray-500 text-nowrap"
|
||||
/>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<.title>
|
||||
<.a href={~p"/microblog"}>microblog</.a>
|
||||
</.title>
|
||||
<ul class="flex flex-col">
|
||||
<li :for={status <- @statuses}>
|
||||
<.link href={~p"/microblog/#{status}"} class="flex flex-row justify-between group">
|
||||
<span class="group-hover:underline overflow-hidden text-ellipsis text-nowrap">
|
||||
{status.body}
|
||||
</span>
|
||||
<.timex
|
||||
value={status.inserted_at}
|
||||
format="{relative}"
|
||||
formatter={:relative}
|
||||
class="text-gray-500 text-nowrap"
|
||||
/>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
|
19
lib/web/controllers/post_controller.ex
Normal file
19
lib/web/controllers/post_controller.ex
Normal file
|
@ -0,0 +1,19 @@
|
|||
defmodule Web.PostController do
|
||||
use Web, :controller
|
||||
|
||||
def index(conn, _params) do
|
||||
posts = Core.Posts.list_posts()
|
||||
|
||||
conn
|
||||
|> assign(:posts, posts)
|
||||
|> render(:index)
|
||||
end
|
||||
|
||||
def show(conn, %{"post_id" => post_id}) do
|
||||
post = Core.Posts.get_post!(post_id)
|
||||
|
||||
conn
|
||||
|> assign(:post, post)
|
||||
|> render(:show)
|
||||
end
|
||||
end
|
6
lib/web/controllers/post_html.ex
Normal file
6
lib/web/controllers/post_html.ex
Normal file
|
@ -0,0 +1,6 @@
|
|||
defmodule Web.PostHTML do
|
||||
@moduledoc false
|
||||
use Web, :html
|
||||
|
||||
embed_templates "post_html/*"
|
||||
end
|
19
lib/web/controllers/post_html/index.html.heex
Normal file
19
lib/web/controllers/post_html/index.html.heex
Normal file
|
@ -0,0 +1,19 @@
|
|||
<.title>writing</.title>
|
||||
<ul class="flex flex-col">
|
||||
<li :for={post <- @posts}>
|
||||
<.link href={~p"/writing/#{post}"} class="flex flex-row justify-between group">
|
||||
<span class="group-hover:underline">
|
||||
<%= if post.title do %>
|
||||
{post.title}
|
||||
<% else %>
|
||||
(no title)
|
||||
<% end %>
|
||||
</span>
|
||||
<.timex
|
||||
value={post.inserted_at}
|
||||
format="{YYYY}-{0M}-{0D}"
|
||||
class="text-gray-500 text-nowrap"
|
||||
/>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
9
lib/web/controllers/post_html/show.html.heex
Normal file
9
lib/web/controllers/post_html/show.html.heex
Normal file
|
@ -0,0 +1,9 @@
|
|||
<article>
|
||||
<header class="flex flex-row">
|
||||
<h1 :if={@post.title} class="font-bold text-xl mb-3">{@post.title}</h1>
|
||||
</header>
|
||||
<section class="prose prose-cms max-w-none">{raw(@post.body)}</section>
|
||||
<footer class="mt-5 py-1 border-t border-gray-200 relative">
|
||||
<.link :if={@admin?} class="absolute right-0" href={~p"/admin/posts/#{@post}"}>edit</.link>
|
||||
</footer>
|
||||
</article>
|
19
lib/web/controllers/status_controller.ex
Normal file
19
lib/web/controllers/status_controller.ex
Normal file
|
@ -0,0 +1,19 @@
|
|||
defmodule Web.StatusController do
|
||||
use Web, :controller
|
||||
|
||||
def index(conn, _params) do
|
||||
statuses = Core.Statuses.list_statuses()
|
||||
|
||||
conn
|
||||
|> assign(:statuses, statuses)
|
||||
|> render(:index)
|
||||
end
|
||||
|
||||
def show(conn, %{"status_id" => status_id}) do
|
||||
status = Core.Statuses.get_status!(status_id)
|
||||
|
||||
conn
|
||||
|> assign(:status, status)
|
||||
|> render(:show)
|
||||
end
|
||||
end
|
6
lib/web/controllers/status_html.ex
Normal file
6
lib/web/controllers/status_html.ex
Normal file
|
@ -0,0 +1,6 @@
|
|||
defmodule Web.StatusHTML do
|
||||
@moduledoc false
|
||||
use Web, :html
|
||||
|
||||
embed_templates "status_html/*"
|
||||
end
|
16
lib/web/controllers/status_html/index.html.heex
Normal file
16
lib/web/controllers/status_html/index.html.heex
Normal file
|
@ -0,0 +1,16 @@
|
|||
<.title>microblog</.title>
|
||||
<ul class="flex flex-col">
|
||||
<li :for={status <- @statuses}>
|
||||
<.link href={~p"/microblog/#{status}"} class="flex flex-row justify-between group">
|
||||
<span class="group-hover:underline overflow-hidden text-ellipsis text-nowrap">
|
||||
{status.body}
|
||||
</span>
|
||||
<.timex
|
||||
value={status.inserted_at}
|
||||
format="{relative}"
|
||||
formatter={:relative}
|
||||
class="text-gray-500 text-nowrap"
|
||||
/>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
3
lib/web/controllers/status_html/show.html.heex
Normal file
3
lib/web/controllers/status_html/show.html.heex
Normal file
|
@ -0,0 +1,3 @@
|
|||
<article>
|
||||
<p>{@status.body}</p>
|
||||
</article>
|
|
@ -1,41 +0,0 @@
|
|||
defmodule Web.UserSessionController do
|
||||
use Web, :controller
|
||||
|
||||
alias Web.UserAuth
|
||||
|
||||
def create(conn, %{"_action" => "registered"} = params) do
|
||||
create(conn, params, "Account created successfully!")
|
||||
end
|
||||
|
||||
def create(conn, %{"_action" => "password_updated"} = params) do
|
||||
conn
|
||||
|> put_session(:user_return_to, ~p"/admin/users/settings")
|
||||
|> create(params, "Password updated successfully!")
|
||||
end
|
||||
|
||||
def create(conn, params) do
|
||||
create(conn, params, "Welcome back!")
|
||||
end
|
||||
|
||||
defp create(conn, %{"user" => user_params}, info) do
|
||||
%{"username" => username, "password" => password} = user_params
|
||||
|
||||
if user = Core.Accounts.get_user_by_username_and_password(username, password) do
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
else
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the username is registered.
|
||||
conn
|
||||
|> put_flash(:error, "Invalid username or password")
|
||||
|> put_flash(:username, String.slice(username, 0, 160))
|
||||
|> redirect(to: ~p"/admin/users/log_in")
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> UserAuth.log_out_user()
|
||||
end
|
||||
end
|
16
lib/web/live/admin_live.ex
Normal file
16
lib/web/live/admin_live.ex
Normal file
|
@ -0,0 +1,16 @@
|
|||
defmodule Web.AdminLive do
|
||||
@moduledoc false
|
||||
use Web, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<h1>AdminLive</h1>
|
||||
"""
|
||||
end
|
||||
end
|
39
lib/web/live/admin_login_live.ex
Normal file
39
lib/web/live/admin_login_live.ex
Normal file
|
@ -0,0 +1,39 @@
|
|||
defmodule Web.AdminLoginLive do
|
||||
@moduledoc false
|
||||
use Web, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
socket =
|
||||
assign(
|
||||
socket,
|
||||
form: to_form(%{"password" => "", "return_to" => params["return_to"]}),
|
||||
return_to: params["return_to"]
|
||||
)
|
||||
|
||||
{:ok, socket, layout: false}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<main class="flex flex-col w-screen h-screen fixed justify-center items-center">
|
||||
<.form for={@form} action={~p"/admin/session/create"} class="flex flex-col gap-y-2">
|
||||
<.input type="hidden" field={@form[:return_to]} />
|
||||
<.input type="password" placeholder="password" field={@form[:password]} required />
|
||||
<div class="flex flex-col items-end">
|
||||
<button type="submit" class="font-bold hover:underline">sign in</button>
|
||||
<.a href={cancel_href(@return_to)}>
|
||||
cancel
|
||||
</.a>
|
||||
</div>
|
||||
</.form>
|
||||
</main>
|
||||
"""
|
||||
end
|
||||
|
||||
defp cancel_href("/admin"), do: ~p"/"
|
||||
defp cancel_href("/admin/" <> _), do: ~p"/"
|
||||
defp cancel_href(nil), do: ~p"/"
|
||||
defp cancel_href(return_to), do: return_to
|
||||
end
|
76
lib/web/live/post_live.ex
Normal file
76
lib/web/live/post_live.ex
Normal file
|
@ -0,0 +1,76 @@
|
|||
defmodule Web.PostLive do
|
||||
@moduledoc false
|
||||
use Web, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
socket = assign(socket, :load_trix?, true)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _uri, %{assigns: %{live_action: :new}} = socket) do
|
||||
post = %Schema.Post{}
|
||||
changeset = Core.Posts.changeset(post, %{})
|
||||
|
||||
socket = assign(socket, post: post, form: to_form(changeset))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_params(%{"post_id" => post_id}, _uri, %{assigns: %{live_action: :edit}} = socket) do
|
||||
post = Core.Posts.get_post!(post_id)
|
||||
|
||||
changeset = Core.Posts.changeset(post, %{})
|
||||
|
||||
socket = assign(socket, post: post, form: to_form(changeset))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_post", %{"post" => attrs}, %{assigns: %{live_action: :new}} = socket) do
|
||||
socket =
|
||||
case Core.Posts.create_post(attrs) do
|
||||
{:ok, post} -> push_navigate(socket, to: ~p"/admin/posts/#{post}")
|
||||
{:error, changeset} -> assign(socket, form: to_form(changeset))
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("save_post", %{"post" => attrs}, %{assigns: %{post: post, live_action: :edit}} = socket) do
|
||||
socket =
|
||||
case Core.Posts.update_post(post, attrs) do
|
||||
{:ok, post} ->
|
||||
assign(socket,
|
||||
post: post,
|
||||
form:
|
||||
post
|
||||
|> Core.Posts.changeset(%{})
|
||||
|> to_form()
|
||||
)
|
||||
|
||||
{:error, changeset} ->
|
||||
assign(socket, form: to_form(changeset))
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.form for={@form} class="flex flex-col gap-y-2" phx-submit="save_post">
|
||||
<.input type="hidden" field={@form[:body]} />
|
||||
<.input class="text-lg" field={@form[:title]} placeholder="Title" />
|
||||
<div id="editor" phx-update="ignore">
|
||||
<trix-editor input={@form[:body].id} class="prose prose-cms max-w-none"></trix-editor>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="self-end">save</button>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
end
|
70
lib/web/live/status_live.ex
Normal file
70
lib/web/live/status_live.ex
Normal file
|
@ -0,0 +1,70 @@
|
|||
defmodule Web.StatusLive do
|
||||
@moduledoc false
|
||||
use Web, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _uri, %{assigns: %{live_action: :new}} = socket) do
|
||||
status = %Schema.Status{}
|
||||
|
||||
changeset = Core.Statuses.changeset(status, %{})
|
||||
|
||||
socket = assign(socket, status: status, form: to_form(changeset))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_params(%{"status_id" => status_id}, _uri, %{assigns: %{live_action: :edit}} = socket) do
|
||||
status = Core.Statuses.get_status!(status_id)
|
||||
|
||||
changeset = Core.Statuses.changeset(status, %{})
|
||||
|
||||
socket = assign(socket, status: status, form: to_form(changeset))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_status", %{"status" => attrs}, %{assigns: %{live_action: :new}} = socket) do
|
||||
socket =
|
||||
case Core.Statuses.create_status(attrs) do
|
||||
{:ok, status} -> push_navigate(socket, to: ~p"/admin/statuses/#{status}")
|
||||
{:error, changeset} -> assign(socket, form: to_form(changeset))
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("save_status", %{"status" => attrs}, %{assigns: %{status: status, live_action: :edit}} = socket) do
|
||||
socket =
|
||||
case Core.Statuses.update_status(status, attrs) do
|
||||
{:ok, status} ->
|
||||
assign(socket,
|
||||
status: status,
|
||||
form:
|
||||
status
|
||||
|> Core.Statuses.changeset(%{})
|
||||
|> to_form()
|
||||
)
|
||||
|
||||
{:error, changeset} ->
|
||||
assign(socket, form: to_form(changeset))
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.form for={@form} class="flex flex-col gap-y-2" phx-submit="save_status">
|
||||
<.input type="textarea" field={@form[:body]} />
|
||||
<button type="submit" class="self-end">save</button>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
end
|
|
@ -1,25 +0,0 @@
|
|||
defmodule Web.UserLoginLive do
|
||||
@moduledoc false
|
||||
use Web, :live_view
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
form = to_form(%{}, as: "user")
|
||||
{:ok, assign(socket, form: form), temporary_assigns: [form: form], layout: false}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<header>sign in</header>
|
||||
|
||||
<.form for={@form} id="login_form" action={~p"/admin/users/log_in"} phx-update="ignore">
|
||||
<.input field={@form[:username]} type="text" label="username" required />
|
||||
<.input field={@form[:password]} type="password" label="password" required />
|
||||
<.button phx-disable-with="signing in..." class="w-full" type="submit">
|
||||
sign in
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
|
@ -1,64 +0,0 @@
|
|||
defmodule Web.UserRegistrationLive do
|
||||
@moduledoc false
|
||||
use Web, :live_view
|
||||
|
||||
alias Core.Accounts
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
changeset = Accounts.change_user_registration(%Schema.User{})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(trigger_submit: false)
|
||||
|> assign_form(changeset)
|
||||
|
||||
{:ok, socket, temporary_assigns: [form: nil], layout: false}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
socket =
|
||||
case Accounts.register_user(user_params) do
|
||||
{:ok, user} ->
|
||||
changeset = Accounts.change_user_registration(user)
|
||||
socket |> assign(trigger_submit: true) |> assign_form(changeset)
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
assign_form(socket, changeset)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
changeset = Accounts.change_user_registration(%Schema.User{}, user_params)
|
||||
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<header class="text-center">finish installation</header>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
id="registration_form"
|
||||
phx-submit="save"
|
||||
phx-change="validate"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
action={~p"/admin/users/log_in?_action=registered"}
|
||||
method="post"
|
||||
>
|
||||
<.input field={@form[:username]} type="text" label="username" />
|
||||
<.input field={@form[:password]} type="password" label="password" />
|
||||
<.button type="submit">create administrator</.button>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||
form = to_form(changeset, as: "user")
|
||||
|
||||
assign(socket, form: form)
|
||||
end
|
||||
end
|
|
@ -1,156 +0,0 @@
|
|||
defmodule Web.UserSettingsLive do
|
||||
@moduledoc false
|
||||
use Web, :live_view
|
||||
|
||||
alias Core.Accounts
|
||||
|
||||
def render(assigns) do
|
||||
# ~H"""
|
||||
# <.header class="text-center">
|
||||
# Account Settings
|
||||
# <:subtitle>Manage your account username address and password settings</:subtitle>
|
||||
# </.header>
|
||||
|
||||
# <div class="space-y-12 divide-y">
|
||||
# <div>
|
||||
# <.simple_form
|
||||
# for={@username_form}
|
||||
# id="username_form"
|
||||
# phx-submit="update_username"
|
||||
# phx-change="validate_username"
|
||||
# >
|
||||
# <.input field={@username_form[:username]} type="text" label="Email" required />
|
||||
# <.input
|
||||
# field={@username_form[:current_password]}
|
||||
# name="current_password"
|
||||
# id="current_password_for_username"
|
||||
# type="password"
|
||||
# label="Current password"
|
||||
# value={@username_form_current_password}
|
||||
# required
|
||||
# />
|
||||
# <:actions>
|
||||
# <.button phx-disable-with="Changing...">Change Email</.button>
|
||||
# </:actions>
|
||||
# </.simple_form>
|
||||
# </div>
|
||||
# <div>
|
||||
# <.simple_form
|
||||
# for={@password_form}
|
||||
# id="password_form"
|
||||
# action={~p/admin/users/log_in?_action=password_updated"}
|
||||
# method="post"
|
||||
# phx-change="validate_password"
|
||||
# phx-submit="update_password"
|
||||
# phx-trigger-action={@trigger_submit}
|
||||
# >
|
||||
# <input
|
||||
# name={@password_form[:username].name}
|
||||
# type="hidden"
|
||||
# id="hidden_user_username"
|
||||
# value={@current_username}
|
||||
# />
|
||||
# <.input field={@password_form[:password]} type="password" label="New password" required />
|
||||
# <.input
|
||||
# field={@password_form[:password_confirmation]}
|
||||
# type="password"
|
||||
# label="Confirm new password"
|
||||
# />
|
||||
# <.input
|
||||
# field={@password_form[:current_password]}
|
||||
# name="current_password"
|
||||
# type="password"
|
||||
# label="Current password"
|
||||
# id="current_password_for_password"
|
||||
# value={@current_password}
|
||||
# required
|
||||
# />
|
||||
# <:actions>
|
||||
# <.button phx-disable-with="Changing...">Change Password</.button>
|
||||
# </:actions>
|
||||
# </.simple_form>
|
||||
# </div>
|
||||
# </div>
|
||||
# """
|
||||
~H"""
|
||||
<pre>UserSettingsLive</pre>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_user
|
||||
username_changeset = Accounts.change_user_username(user)
|
||||
password_changeset = Accounts.change_user_password(user)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:current_password, nil)
|
||||
|> assign(:username_form_current_password, nil)
|
||||
|> assign(:current_username, user.username)
|
||||
|> assign(:username_form, to_form(username_changeset))
|
||||
|> assign(:password_form, to_form(password_changeset))
|
||||
|> assign(:trigger_submit, false)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def handle_event("validate_username", params, socket) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
|
||||
username_form =
|
||||
socket.assigns.current_user
|
||||
|> Accounts.change_user_username(user_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, username_form: username_form, username_form_current_password: password)}
|
||||
end
|
||||
|
||||
def handle_event("update_username", params, socket) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
user = socket.assigns.current_user
|
||||
|
||||
case Accounts.update_user_username(user, password, user_params) do
|
||||
{:ok, updated_user} ->
|
||||
username_changeset = Accounts.change_user_username(updated_user)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Email updated")
|
||||
|> assign(username_form_current_password: nil, username_form: to_form(username_changeset))}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :username_form, to_form(Map.put(changeset, :action, :insert)))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_password", params, socket) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
|
||||
password_form =
|
||||
socket.assigns.current_user
|
||||
|> Accounts.change_user_password(user_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, password_form: password_form, current_password: password)}
|
||||
end
|
||||
|
||||
def handle_event("update_password", params, socket) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
user = socket.assigns.current_user
|
||||
|
||||
case Accounts.update_user_password(user, password, user_params) do
|
||||
{:ok, user} ->
|
||||
password_form =
|
||||
user
|
||||
|> Accounts.change_user_password(user_params)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, password_form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,7 +1,11 @@
|
|||
defmodule Web.Router do
|
||||
use Web, :router
|
||||
|
||||
import Web.UserAuth
|
||||
import Web.AdminAuth
|
||||
import Web.Globals
|
||||
|
||||
alias Web.AdminAuth
|
||||
alias Web.Globals
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
|
@ -10,37 +14,47 @@ defmodule Web.Router do
|
|||
plug :put_root_layout, html: {Web.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :fetch_current_user
|
||||
plug :assign_globals
|
||||
end
|
||||
|
||||
scope "/", Web do
|
||||
pipe_through [:browser, :redirect_if_user_is_authenticated]
|
||||
pipeline :supports_admin_action do
|
||||
plug :mount_admin
|
||||
end
|
||||
|
||||
live_session :redirect_if_user_is_authenticated,
|
||||
on_mount: [{Web.UserAuth, :redirect_if_user_is_authenticated}] do
|
||||
live "/admin/users/register", UserRegistrationLive, :new
|
||||
live "/admin/users/log_in", UserLoginLive, :new
|
||||
pipeline :requires_admin do
|
||||
plug :mount_admin
|
||||
plug :require_admin
|
||||
end
|
||||
|
||||
live_session :default, on_mount: [AdminAuth, Globals] do
|
||||
scope "/", Web do
|
||||
pipe_through :browser
|
||||
pipe_through :supports_admin_action
|
||||
|
||||
get "/", PageController, :home
|
||||
|
||||
get "/writing", PostController, :index
|
||||
get "/writing/:post_id", PostController, :show
|
||||
|
||||
get "/microblog", StatusController, :index
|
||||
get "/microblog/:status_id", StatusController, :show
|
||||
|
||||
live "/sign-in", AdminLoginLive
|
||||
post "/admin/session/create", AdminSessionController, :create
|
||||
get "/admin/session/destroy", AdminSessionController, :destroy
|
||||
end
|
||||
|
||||
post "/admin/users/log_in", UserSessionController, :create
|
||||
end
|
||||
scope "/admin", Web do
|
||||
pipe_through :browser
|
||||
pipe_through :requires_admin
|
||||
|
||||
scope "/admin", Web do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
live "/", AdminLive
|
||||
|
||||
live_session :require_authenticated_user, on_mount: [{Web.UserAuth, :ensure_authenticated}] do
|
||||
live "/users/settings", UserSettingsLive, :edit
|
||||
live "/posts/new", PostLive, :new
|
||||
live "/posts/:post_id", PostLive, :edit
|
||||
|
||||
live "/statuses/new", StatusLive, :new
|
||||
live "/statuses/:status_id", StatusLive, :edit
|
||||
end
|
||||
end
|
||||
|
||||
scope "/", Web do
|
||||
pipe_through [:browser, :require_setup]
|
||||
|
||||
get "/", PageController, :home
|
||||
|
||||
delete "/admin/users/log_out", UserSessionController, :delete
|
||||
|
||||
# live_session :current_user, on_mount: [{Web.UserAuth, :mount_current_user}] do
|
||||
# end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,241 +0,0 @@
|
|||
defmodule Web.UserAuth do
|
||||
@moduledoc false
|
||||
use Web, :verified_routes
|
||||
|
||||
import Phoenix.Controller
|
||||
import Plug.Conn
|
||||
|
||||
alias Core.Accounts
|
||||
|
||||
# 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 "_core_web_user_remember_me"
|
||||
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
|
||||
|
||||
@doc """
|
||||
Logs the user in.
|
||||
|
||||
It renews the session ID and clears the whole session
|
||||
to avoid fixation attacks. See the renew_session
|
||||
function to customize this behaviour.
|
||||
|
||||
It also sets a `:live_socket_id` key in the session,
|
||||
so LiveView sessions are identified and automatically
|
||||
disconnected on log out. The line can be safely removed
|
||||
if you are not using LiveView.
|
||||
"""
|
||||
def log_in_user(conn, user, params \\ %{}) do
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
user_return_to = get_session(conn, :user_return_to)
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> put_token_in_session(token)
|
||||
|> maybe_write_remember_me_cookie(token, params)
|
||||
|> redirect(to: user_return_to || signed_in_path(conn))
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
|
||||
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, _token, _params) do
|
||||
conn
|
||||
end
|
||||
|
||||
# This function renews the session ID and erases the whole
|
||||
# session to avoid fixation attacks. If there is any data
|
||||
# in the session you may want to preserve after log in/log out,
|
||||
# you must explicitly fetch the session data before clearing
|
||||
# and then immediately set it after clearing, for example:
|
||||
#
|
||||
# defp renew_session(conn) do
|
||||
# preferred_locale = get_session(conn, :preferred_locale)
|
||||
#
|
||||
# conn
|
||||
# |> configure_session(renew: true)
|
||||
# |> clear_session()
|
||||
# |> put_session(:preferred_locale, preferred_locale)
|
||||
# end
|
||||
#
|
||||
defp renew_session(conn) do
|
||||
delete_csrf_token()
|
||||
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user out.
|
||||
|
||||
It clears all session data for safety. See renew_session.
|
||||
"""
|
||||
def log_out_user(conn) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
user_token && Accounts.delete_user_session_token(user_token)
|
||||
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
Web.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates the user by looking into the session
|
||||
and remember me token.
|
||||
"""
|
||||
def fetch_current_user(conn, _opts) do
|
||||
{user_token, conn} = ensure_user_token(conn)
|
||||
user = user_token && Accounts.get_user_by_session_token(user_token)
|
||||
assign(conn, :current_user, user)
|
||||
end
|
||||
|
||||
defp ensure_user_token(conn) do
|
||||
if token = get_session(conn, :user_token) do
|
||||
{token, conn}
|
||||
else
|
||||
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
||||
|
||||
if token = conn.cookies[@remember_me_cookie] do
|
||||
{token, put_token_in_session(conn, token)}
|
||||
else
|
||||
{nil, conn}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles mounting and authenticating the current_user in LiveViews.
|
||||
|
||||
## `on_mount` arguments
|
||||
|
||||
* `:mount_current_user` - Assigns current_user
|
||||
to socket assigns based on user_token, or nil if
|
||||
there's no user_token or no matching user.
|
||||
|
||||
* `:ensure_authenticated` - Authenticates the user from the session,
|
||||
and assigns the current_user to socket assigns based
|
||||
on user_token.
|
||||
Redirects to login page if there's no logged user.
|
||||
|
||||
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
|
||||
Redirects to signed_in_path if there's a logged user.
|
||||
|
||||
## Examples
|
||||
|
||||
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
|
||||
the current_user:
|
||||
|
||||
defmodule Web.PageLive do
|
||||
use Web, :live_view
|
||||
|
||||
on_mount {Web.UserAuth, :mount_current_user}
|
||||
...
|
||||
end
|
||||
|
||||
Or use the `live_session` of your router to invoke the on_mount callback:
|
||||
|
||||
live_session :authenticated, on_mount: [{Web.UserAuth, :ensure_authenticated}] do
|
||||
live "/profile", ProfileLive, :index
|
||||
end
|
||||
"""
|
||||
def on_mount(:mount_current_user, _params, session, socket) do
|
||||
{:cont, mount_current_user(socket, session)}
|
||||
end
|
||||
|
||||
def on_mount(:ensure_authenticated, _params, session, socket) do
|
||||
socket = mount_current_user(socket, session)
|
||||
|
||||
if socket.assigns.current_user do
|
||||
{:cont, socket}
|
||||
else
|
||||
socket =
|
||||
socket
|
||||
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/admin/users/log_in")
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
|
||||
socket = mount_current_user(socket, session)
|
||||
|
||||
if socket.assigns.current_user do
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp mount_current_user(socket, session) do
|
||||
Phoenix.Component.assign_new(socket, :current_user, fn ->
|
||||
if user_token = session["user_token"] do
|
||||
Accounts.get_user_by_session_token(user_token)
|
||||
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: ~p"/admin/users/log_in")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
def require_setup(conn, _opts) do
|
||||
if Core.Accounts.has_registered_user?() do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: ~p"/admin/users/register")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp put_token_in_session(conn, token) do
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|
||||
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: ~p"/"
|
||||
end
|
4
mix.exs
4
mix.exs
|
@ -33,7 +33,6 @@ defmodule SlaonelyButSurely.MixProject do
|
|||
# Type `mix help deps` for examples and options.
|
||||
defp deps do
|
||||
[
|
||||
{:argon2_elixir, "~> 3.0"},
|
||||
# Default Phoenix dependencies
|
||||
{:phoenix, "~> 1.7.19"},
|
||||
{:phoenix_ecto, "~> 4.5"},
|
||||
|
@ -52,6 +51,9 @@ defmodule SlaonelyButSurely.MixProject do
|
|||
{:bandit, "~> 1.5"},
|
||||
|
||||
# Added dependencies
|
||||
{:argon2_elixir, "~> 4.1"},
|
||||
{:timex, "~> 3.7"},
|
||||
{:typed_struct, "~> 0.3.0"},
|
||||
{:boundary, "~> 0.10.4"},
|
||||
|
||||
# Added dev and/or test dependencies
|
||||
|
|
2
mix.lock
2
mix.lock
|
@ -1,5 +1,5 @@
|
|||
%{
|
||||
"argon2_elixir": {:hex, :argon2_elixir, "3.2.1", "f47740bf9f2a39ffef79ba48eb25dea2ee37bcc7eadf91d49615591d1a6fce1a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "a813b78217394530b5fcf4c8070feee43df03ffef938d044019169c766315690"},
|
||||
"argon2_elixir": {:hex, :argon2_elixir, "4.1.2", "1160a3ccd59b951175525882240651f5ed3303b75c616204713f8b31c76b37bd", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9222341e1b0d9aa5ca7e26a1c77bd1bd92d2314c92b57ca3e2c7ed847223b51d"},
|
||||
"bandit": {:hex, :bandit, "1.6.7", "42f30e37a1c89a2a12943c5dca76f731a2313e8a2e21c1a95dc8241893e922d1", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "551ba8ff5e4fc908cbeb8c9f0697775fb6813a96d9de5f7fe02e34e76fd7d184"},
|
||||
"boundary": {:hex, :boundary, "0.10.4", "5fec5d2736c12f9bfe1720c3a2bd8c48c3547c24d6002ebf8e087570afd5bd2f", [:mix], [], "hexpm", "8baf6f23987afdb1483033ed0bde75c9c703613c22ed58d5f23bf948f203247c"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
|
|
13
priv/repo/migrations/20250222164951_add_posts_table.exs
Normal file
13
priv/repo/migrations/20250222164951_add_posts_table.exs
Normal file
|
@ -0,0 +1,13 @@
|
|||
defmodule Core.Repo.Migrations.AddPostsTable do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:posts, primary_key: false) do
|
||||
add :id, :uuid, primary_key: true
|
||||
add :title, :text
|
||||
add :body, :text, null: false, default: ""
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
end
|
14
priv/repo/migrations/20250222201807_add_statuses_table.exs
Normal file
14
priv/repo/migrations/20250222201807_add_statuses_table.exs
Normal file
|
@ -0,0 +1,14 @@
|
|||
defmodule Core.Repo.Migrations.AddStatusesTable do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:statuses, primary_key: false) do
|
||||
add :id, :uuid, primary_key: true
|
||||
add :body, :text, null: false
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:statuses, [:inserted_at])
|
||||
end
|
||||
end
|
|
@ -1,27 +0,0 @@
|
|||
defmodule Core.Repo.Migrations.CreateUsersAuthTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:users, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :username, :text, null: false
|
||||
add :hashed_password, :text, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:users, [:username])
|
||||
|
||||
create table(:users_tokens, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :token, :binary, null: false
|
||||
add :context, :text, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
|
||||
create index(:users_tokens, [:user_id])
|
||||
create unique_index(:users_tokens, [:context, :token])
|
||||
end
|
||||
end
|
|
@ -36,30 +36,4 @@ defmodule Test.ConnCase do
|
|||
Test.DataCase.setup_sandbox(tags)
|
||||
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
||||
end
|
||||
|
||||
# @doc """
|
||||
# Setup helper that registers and logs in users.
|
||||
|
||||
# setup :register_and_log_in_user
|
||||
|
||||
# It stores an updated connection and a registered user in the
|
||||
# test context.
|
||||
# """
|
||||
# def register_and_log_in_user(%{conn: conn}) do
|
||||
# user = Core.AccountsFixtures.user_fixture()
|
||||
# %{conn: log_in_user(conn, user), user: user}
|
||||
# end
|
||||
|
||||
# @doc """
|
||||
# Logs the given `user` into the `conn`.
|
||||
|
||||
# It returns an updated `conn`.
|
||||
# """
|
||||
# def log_in_user(conn, user) do
|
||||
# token = Core.Accounts.generate_user_session_token(user)
|
||||
|
||||
# conn
|
||||
# |> Phoenix.ConnTest.init_test_session(%{})
|
||||
# |> Plug.Conn.put_session(:user_token, token)
|
||||
# end
|
||||
end
|
16
test/support/test/fixtures/accounts.ex
vendored
16
test/support/test/fixtures/accounts.ex
vendored
|
@ -1,16 +0,0 @@
|
|||
defmodule Test.Fixtures.Accounts do
|
||||
@moduledoc """
|
||||
This module defines test helpers for creating
|
||||
entities via the `Core.Accounts` context.
|
||||
"""
|
||||
|
||||
def unique_user_username, do: "user#{System.unique_integer()}"
|
||||
def valid_user_password, do: "hello world!"
|
||||
|
||||
def user(attrs \\ %{}) do
|
||||
Enum.into(attrs, %{
|
||||
username: unique_user_username(),
|
||||
password: valid_user_password()
|
||||
})
|
||||
end
|
||||
end
|
8
test/web/controllers/page_controller_test.exs
Normal file
8
test/web/controllers/page_controller_test.exs
Normal file
|
@ -0,0 +1,8 @@
|
|||
defmodule Test.Web.PageControllerTest do
|
||||
use Test.ConnCase
|
||||
|
||||
test "GET /", %{conn: conn} do
|
||||
conn = get(conn, ~p"/")
|
||||
assert html_response(conn, 200) =~ "sloanelybutsurely.com"
|
||||
end
|
||||
end
|
|
@ -1,273 +0,0 @@
|
|||
defmodule Test.Web.UserAuthTest do
|
||||
use Test.ConnCase, async: true
|
||||
|
||||
alias Core.Accounts
|
||||
alias Phoenix.LiveView
|
||||
alias Web.UserAuth
|
||||
|
||||
@remember_me_cookie "_core_web_user_remember_me"
|
||||
|
||||
setup %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> Map.replace!(:secret_key_base, Web.Endpoint.config(:secret_key_base))
|
||||
|> init_test_session(%{})
|
||||
|
||||
{:ok, user} = Accounts.register_user(Test.Fixtures.Accounts.user())
|
||||
|
||||
%{user: user, 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) == ~p"/"
|
||||
assert Accounts.get_user_by_session_token(token)
|
||||
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.generate_user_session_token(user)
|
||||
|
||||
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) == ~p"/"
|
||||
refute Accounts.get_user_by_session_token(user_token)
|
||||
end
|
||||
|
||||
test "broadcasts to the given live_socket_id", %{conn: conn} do
|
||||
live_socket_id = "users_sessions:abcdef-token"
|
||||
Web.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: ^live_socket_id}
|
||||
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) == ~p"/"
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_current_user/2" do
|
||||
test "authenticates user from session", %{conn: conn, user: user} do
|
||||
user_token = Accounts.generate_user_session_token(user)
|
||||
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 conn.assigns.current_user.id == user.id
|
||||
assert get_session(conn, :user_token) == user_token
|
||||
|
||||
assert get_session(conn, :live_socket_id) ==
|
||||
"users_sessions:#{Base.url_encode64(user_token)}"
|
||||
end
|
||||
|
||||
test "does not authenticate if data is missing", %{conn: conn, user: user} do
|
||||
_ = Accounts.generate_user_session_token(user)
|
||||
conn = UserAuth.fetch_current_user(conn, [])
|
||||
refute get_session(conn, :user_token)
|
||||
refute conn.assigns.current_user
|
||||
end
|
||||
end
|
||||
|
||||
describe "on_mount :mount_current_user" do
|
||||
test "assigns current_user based on a valid user_token", %{conn: conn, user: user} do
|
||||
user_token = Accounts.generate_user_session_token(user)
|
||||
session = conn |> put_session(:user_token, user_token) |> get_session()
|
||||
|
||||
{:cont, updated_socket} =
|
||||
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
|
||||
|
||||
assert updated_socket.assigns.current_user.id == user.id
|
||||
end
|
||||
|
||||
test "assigns nil to current_user assign if there isn't a valid user_token", %{conn: conn} do
|
||||
user_token = "invalid_token"
|
||||
session = conn |> put_session(:user_token, user_token) |> get_session()
|
||||
|
||||
{:cont, updated_socket} =
|
||||
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
|
||||
|
||||
assert updated_socket.assigns.current_user == nil
|
||||
end
|
||||
|
||||
test "assigns nil to current_user assign if there isn't a user_token", %{conn: conn} do
|
||||
session = get_session(conn)
|
||||
|
||||
{:cont, updated_socket} =
|
||||
UserAuth.on_mount(:mount_current_user, %{}, session, %LiveView.Socket{})
|
||||
|
||||
assert updated_socket.assigns.current_user == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "on_mount :ensure_authenticated" do
|
||||
test "authenticates current_user based on a valid user_token", %{conn: conn, user: user} do
|
||||
user_token = Accounts.generate_user_session_token(user)
|
||||
session = conn |> put_session(:user_token, user_token) |> get_session()
|
||||
|
||||
{:cont, updated_socket} =
|
||||
UserAuth.on_mount(:ensure_authenticated, %{}, session, %LiveView.Socket{})
|
||||
|
||||
assert updated_socket.assigns.current_user.id == user.id
|
||||
end
|
||||
|
||||
test "redirects to login page if there isn't a valid user_token", %{conn: conn} do
|
||||
user_token = "invalid_token"
|
||||
session = conn |> put_session(:user_token, user_token) |> get_session()
|
||||
|
||||
socket = %LiveView.Socket{
|
||||
endpoint: Web.Endpoint,
|
||||
assigns: %{__changed__: %{}, flash: %{}}
|
||||
}
|
||||
|
||||
{:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
|
||||
assert updated_socket.assigns.current_user == nil
|
||||
end
|
||||
|
||||
test "redirects to login page if there isn't a user_token", %{conn: conn} do
|
||||
session = get_session(conn)
|
||||
|
||||
socket = %LiveView.Socket{
|
||||
endpoint: Web.Endpoint,
|
||||
assigns: %{__changed__: %{}, flash: %{}}
|
||||
}
|
||||
|
||||
{:halt, updated_socket} = UserAuth.on_mount(:ensure_authenticated, %{}, session, socket)
|
||||
assert updated_socket.assigns.current_user == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "on_mount :redirect_if_user_is_authenticated" do
|
||||
test "redirects if there is an authenticated user ", %{conn: conn, user: user} do
|
||||
user_token = Accounts.generate_user_session_token(user)
|
||||
session = conn |> put_session(:user_token, user_token) |> get_session()
|
||||
|
||||
assert {:halt, _updated_socket} =
|
||||
UserAuth.on_mount(
|
||||
:redirect_if_user_is_authenticated,
|
||||
%{},
|
||||
session,
|
||||
%LiveView.Socket{}
|
||||
)
|
||||
end
|
||||
|
||||
test "doesn't redirect if there is no authenticated user", %{conn: conn} do
|
||||
session = get_session(conn)
|
||||
|
||||
assert {:cont, _updated_socket} =
|
||||
UserAuth.on_mount(
|
||||
:redirect_if_user_is_authenticated,
|
||||
%{},
|
||||
session,
|
||||
%LiveView.Socket{}
|
||||
)
|
||||
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) == ~p"/"
|
||||
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) == ~p"/admin/users/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
|
Loading…
Reference in a new issue