Compare commits

..

24 Commits

Author SHA1 Message Date
2186a7509c Merge branch 'release/0.7.1' 2026-02-21 11:55:49 +01:00
41e32e3ff1 Configured for release on prod environment. 2026-02-21 11:55:23 +01:00
0b364f19c2 Added uberspace deployment info to README.md 2026-02-21 11:35:42 +01:00
4f38eb36f1 Disabled login and logout for users. 2026-02-21 11:27:46 +01:00
35dbb79ccd Restricting the API access to logged in admins. Only admin log in is publicly accessible. 2026-02-20 16:22:19 +01:00
38652c504d Removed browser routes for admin stuff. 2026-02-20 13:19:12 +01:00
53d19a3a18 After "mix phx.gen.auth Admins Admin admins" with added working register and login path. 2026-02-20 13:09:55 +01:00
a47931f40e /api/invite triggers sending a mail invite to the given user. 2026-02-19 20:58:34 +01:00
6b31c6023f Only show the "log out" option in the top menu bar & moving the theme toggle there. 2026-02-19 16:20:35 +01:00
24eeacc425 Moving the "Gebot abgeben" button down & only show it if a bidding round is running. 2026-02-19 16:18:30 +01:00
15e21d34e8 Updated mix dependencies. ("mix deps.update --all") 2026-02-19 16:00:55 +01:00
60aa513a11 The server is accessible from the local network (in dev environment). 2026-02-19 15:58:53 +01:00
b8f8cec29b "Cancel" -> "Zurück" 2026-02-19 13:08:13 +01:00
55c128e25e Login per email token. With logout of previous account if already logged in. 2026-02-18 15:01:31 +01:00
02b473bbf1 Added a Accounts.delete_user(id) function. 2026-02-18 10:56:47 +01:00
e0b244bd4e When adding a user via API creating a session token and sending it with the response. 2026-02-18 10:56:16 +01:00
6c41f69723 Creating user via API give correct responses if the user already exists or the mail address is malformed. 2026-02-18 10:47:37 +01:00
601e08220d Using drop down menus for depot wishes in bidding form. (Using old depot entries for now.) 2026-02-18 09:59:44 +01:00
1a4a05ff18 Only showing the current bidding to the user. 2026-02-14 15:30:38 +01:00
f6d5f8d2ca Translated "new bidding" form. 2026-02-14 14:52:43 +01:00
dbaa6d136a Using German error string for "can't be blank" changeset error. 2026-02-14 14:51:24 +01:00
b7ca842cc3 Hiding the bidding round input field. 2026-02-14 14:47:35 +01:00
2519560b03 A new bidding can only placed in the new form if a bidding round is currently running. The bidding round is set automatically when opening the form. 2026-02-14 14:38:31 +01:00
d6f2d8c1f6 Started to translate user facing strings into German. 2026-02-14 12:41:27 +01:00
49 changed files with 3304 additions and 176 deletions

122
README.md
View File

@ -2,17 +2,123 @@
To start your Phoenix server:
* Run `mix setup` to install and setup dependencies
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
- Run `mix setup` to install and setup dependencies
- Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
## Learn more
# Deployment on new uberspace asteroid
* Official website: https://www.phoenixframework.org/
* Guides: https://hexdocs.pm/phoenix/overview.html
* Docs: https://hexdocs.pm/phoenix
* Forum: https://elixirforum.com/c/phoenix-forum
* Source: https://github.com/phoenixframework/phoenix
## Initial deployment
### Add subdomain
uberspace web domain add beetround.example.com
### Init database
Follow guide to initialize postgresql database:
https://lab.uberspace.de/guide_postgresql/
#### Configure database
createuser beetround_admin -P
createdb --encoding=UTF8 --owner=beetround_admin --template=template0 beetround_server
## Configure Elixir/Phoenix
uberspace tools version use erlang 27
## Build & run BeetRound
cd ~/
mkdir develop
git clone https://git.working-copy.org/bent/BeetRoundServer.git
cd develop
export MIX_ENV=prod
mix deps.get
mix phx.gen.secret
export SECRET_KEY_BASE=<SECRET_KEY>
export DATABASE_URL=ecto://beetround_admin:<DB_PASSWORD>@localhost/beetround_server
mix assets.deploy #throws "'mix tailwind beet_round_server --minify' exited with 1" error
Workaround: copy assets from develop machine
mix compile
PHX_HOST=beetround.example.com PORT=4005 mix ecto.migrate
### Create webbackend
uberspace web backend set beetround.example.com --http --port 4005
#### Test backend
PHX_HOST=beetround.example.com PORT=4005 mix phx.server
#### Create mix release
mix release
### Create service
nvim ~/etc/services.d/beetround_server.ini
```
[program:beetround_server]
command=%(ENV_HOME)s/develop/BeetRoundServer/_build/prod/rel/beet_round_server/bin/beet_round_server
directory=%(ENV_HOME)s/develop/BeetRoundServer
autostart=true
autorestart=true
startsecs=60
environment =
MAIL_RELAY="<UBERSPACE_ASTEROID>.uberspace.de",
MAIL_ADDRESS="<MAIL_ADDRESS>",
MAIL_PW="<MAIL_PASSWORD>",
PHX_HOST="beetround.example.com",
MIX_ENV=prod,
PORT=4005,
DATABASE_URL="ecto://beetround_admin:<DB_PASSWORD>@localhost/beetround_server",
SECRET_KEY_BASE=<SECRET_KEY>
```
supervisorctl reread
supervisorctl update
supervisorctl status
## Updates (TODO old content. needs to be adjusted/checked)
Steps on develop environment:
- create new release version (with git flow)
- push main branch to repo
Steps on server:
- cd develop/SplitPot/
- pull main branch
- mix deps.get --only prod
- MIX_ENV=prod mix assets.deploy
- export BUILD_DATE="DD.MM.YYYY"
- MIX_ENV=prod mix release
- supervisorctl stop splitpot_server
- DB migrations
- export DATABASE_URL=...
- export SECRET_BASE_KEY=...
- MIX_ENV=prod mix ecto.migrate
- supervisorctl start splitpot_server

View File

@ -7,6 +7,19 @@
# General application configuration
import Config
config :beet_round_server, :scopes,
admin: [
default: false,
module: BeetRoundServer.Admins.Scope,
assign_key: :current_scope,
access_path: [:admin, :id],
schema_key: :admin_id,
schema_type: :binary_id,
schema_table: :admins,
test_data_fixture: BeetRoundServer.AdminsFixtures,
test_setup_helper: :register_and_log_in_admin
]
config :beet_round_server, :scopes,
user: [
default: true,
@ -26,14 +39,15 @@ config :beet_round_server,
# Configures the endpoint
config :beet_round_server, BeetRoundServerWeb.Endpoint,
url: [host: "localhost"],
url: [host: "https://beetround.example.com"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: BeetRoundServerWeb.ErrorHTML, json: BeetRoundServerWeb.ErrorJSON],
layout: false
],
pubsub_server: BeetRoundServer.PubSub,
live_view: [signing_salt: "4HDgM4VC"]
live_view: [signing_salt: "4HDgM4VC"],
server: true
# Configures the mailer
#

View File

@ -19,7 +19,7 @@ config :beet_round_server, BeetRoundServer.Repo,
config :beet_round_server, BeetRoundServerWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")],
check_origin: false,
code_reloader: true,
debug_errors: false,

View File

@ -6,7 +6,9 @@ import Config
# which you should run after static files are built and
# before starting your production server.
config :beet_round_server, BeetRoundServerWeb.Endpoint,
cache_static_manifest: "priv/static/cache_manifest.json"
cache_static_manifest: "priv/static/cache_manifest.json",
url: [host: "https://beetround.example.com"],
check_origin: ["https://beetround.example.com"]
# Configures Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req

View File

@ -116,4 +116,20 @@ if config_env() == :prod do
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
mail_relay = System.get_env("MAIL_RELAY") || "example.com"
mail_address = System.get_env("MAIL_ADDRESS") || "info@example.com"
mail_pw = System.get_env("MAIL_PW") || ""
config :beet_round_server, BeetRoundServer.Mailer,
adapter: Swoosh.Adapters.SMTP,
relay: mail_relay,
username: mail_address,
password: mail_pw,
ssl: false,
ssl_opts: [verify: :verify_none],
tls_options: [verify: :verify_none],
tls: :always,
auth: :always,
port: 587,
retries: 2
end

View File

@ -57,6 +57,13 @@ defmodule BeetRoundServer.Accounts do
if User.valid_password?(user, password), do: user
end
def get_user_by_email_token(token) do
{:ok, query} =
UserToken.verify_email_token_query(token, "session")
Repo.one(query)
end
@doc """
Gets a single user.
@ -180,6 +187,13 @@ defmodule BeetRoundServer.Accounts do
|> update_user_and_delete_all_tokens()
end
def create_email_token(%User{} = user) do
{encoded_token, user_token} = UserToken.build_email_token(user, "session")
Repo.insert!(user_token)
encoded_token
end
## Session
@doc """
@ -294,6 +308,11 @@ defmodule BeetRoundServer.Accounts do
:ok
end
def delete_user(id) do
user = get_user!(id)
Repo.delete(user)
end
## Token helper
defp update_user_and_delete_all_tokens(changeset) do

View File

@ -0,0 +1,13 @@
defmodule BeetRoundServer.UserEmail do
use Phoenix.Swoosh,
template_root: "lib/beet_round_server_web/templates/emails",
template_path: "invite"
def invite(user) do
new()
|> to({user.name, user.email})
|> from({"Das Grüne Zebra e.V.", "bietrunde@das-gruene-zebra.de"})
|> subject("Bietrunde 26/27 - Digitales Bieten")
|> render_body("invite.html", %{name: user.name, invite_link: user.access_url})
end
end

View File

@ -125,6 +125,27 @@ defmodule BeetRoundServer.Accounts.UserToken do
end
end
def verify_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
days = days_for_context(context)
query =
from token in by_token_and_context_query(hashed_token, context),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
select: user
{:ok, query}
:error ->
:error
end
end
defp days_for_context("session"), do: @session_validity_in_days
@doc """
Checks if the token is valid and returns its underlying lookup query.

View File

@ -0,0 +1,328 @@
defmodule BeetRoundServer.Admins do
@moduledoc """
The Admins context.
"""
import Ecto.Query, warn: false
alias BeetRoundServer.Repo
alias BeetRoundServer.Admins.{Admin, AdminToken, AdminNotifier}
## Database getters
@doc """
Gets a admin by email.
## Examples
iex> get_admin_by_email("foo@example.com")
%Admin{}
iex> get_admin_by_email("unknown@example.com")
nil
"""
def get_admin_by_email(email) when is_binary(email) do
Repo.get_by(Admin, email: email)
end
@doc """
Gets a admin by email and password.
## Examples
iex> get_admin_by_email_and_password("foo@example.com", "correct_password")
%Admin{}
iex> get_admin_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
def get_admin_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
admin = Repo.get_by(Admin, email: email)
if Admin.valid_password?(admin, password), do: admin
end
@doc """
Gets a single admin.
Raises `Ecto.NoResultsError` if the Admin does not exist.
## Examples
iex> get_admin!(123)
%Admin{}
iex> get_admin!(456)
** (Ecto.NoResultsError)
"""
def get_admin!(id), do: Repo.get!(Admin, id)
## Admin registration
@doc """
Registers a admin.
## Examples
iex> register_admin(%{field: value})
{:ok, %Admin{}}
iex> register_admin(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_admin(attrs) do
%Admin{}
|> Admin.email_changeset(attrs)
|> Admin.password_changeset(attrs)
|> Repo.insert()
end
## Settings
@doc """
Checks whether the admin is in sudo mode.
The admin is in sudo mode when the last authentication was done no further
than 20 minutes ago. The limit can be given as second argument in minutes.
"""
def sudo_mode?(admin, minutes \\ -20)
def sudo_mode?(%Admin{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
end
def sudo_mode?(_admin, _minutes), do: false
@doc """
Returns an `%Ecto.Changeset{}` for changing the admin email.
See `BeetRoundServer.Admins.Admin.email_changeset/3` for a list of supported options.
## Examples
iex> change_admin_email(admin)
%Ecto.Changeset{data: %Admin{}}
"""
def change_admin_email(admin, attrs \\ %{}, opts \\ []) do
Admin.email_changeset(admin, attrs, opts)
end
@doc """
Updates the admin email using the given token.
If the token matches, the admin email is updated and the token is deleted.
"""
def update_admin_email(admin, token) do
context = "change:#{admin.email}"
Repo.transact(fn ->
with {:ok, query} <- AdminToken.verify_change_email_token_query(token, context),
%AdminToken{sent_to: email} <- Repo.one(query),
{:ok, admin} <- Repo.update(Admin.email_changeset(admin, %{email: email})),
{_count, _result} <-
Repo.delete_all(from(AdminToken, where: [admin_id: ^admin.id, context: ^context])) do
{:ok, admin}
else
_ -> {:error, :transaction_aborted}
end
end)
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the admin password.
See `BeetRoundServer.Admins.Admin.password_changeset/3` for a list of supported options.
## Examples
iex> change_admin_password(admin)
%Ecto.Changeset{data: %Admin{}}
"""
def change_admin_password(admin, attrs \\ %{}, opts \\ []) do
Admin.password_changeset(admin, attrs, opts)
end
@doc """
Updates the admin password.
Returns a tuple with the updated admin, as well as a list of expired tokens.
## Examples
iex> update_admin_password(admin, %{password: ...})
{:ok, {%Admin{}, [...]}}
iex> update_admin_password(admin, %{password: "too short"})
{:error, %Ecto.Changeset{}}
"""
def update_admin_password(admin, attrs) do
admin
|> Admin.password_changeset(attrs)
|> update_admin_and_delete_all_tokens()
end
## Session
@doc """
Generates a session token.
"""
def generate_admin_session_token(admin) do
{token, admin_token} = AdminToken.build_session_token(admin)
Repo.insert!(admin_token)
token
end
@doc """
Gets the admin with the given signed token.
If the token is valid `{admin, token_inserted_at}` is returned, otherwise `nil` is returned.
"""
def get_admin_by_session_token(token) do
{:ok, query} = AdminToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Gets the admin with the given magic link token.
"""
def get_admin_by_magic_link_token(token) do
with {:ok, query} <- AdminToken.verify_magic_link_token_query(token),
{admin, _token} <- Repo.one(query) do
admin
else
_ -> nil
end
end
@doc """
Logs the admin in by magic link.
There are three cases to consider:
1. The admin has already confirmed their email. They are logged in
and the magic link is expired.
2. The admin has not confirmed their email and no password is set.
In this case, the admin gets confirmed, logged in, and all tokens -
including session ones - are expired. In theory, no other tokens
exist but we delete all of them for best security practices.
3. The admin has not confirmed their email but a password is set.
This cannot happen in the default implementation but may be the
source of security pitfalls. See the "Mixing magic link and password registration" section of
`mix help phx.gen.auth`.
"""
def login_admin_by_magic_link(token) do
{:ok, query} = AdminToken.verify_magic_link_token_query(token)
case Repo.one(query) do
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
{%Admin{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
raise """
magic link log in is not allowed for unconfirmed users with a password set!
This cannot happen with the default implementation, which indicates that you
might have adapted the code to a different use case. Please make sure to read the
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
"""
{%Admin{confirmed_at: nil} = admin, _token} ->
admin
|> Admin.confirm_changeset()
|> update_admin_and_delete_all_tokens()
{admin, token} ->
Repo.delete!(token)
{:ok, {admin, []}}
nil ->
{:error, :not_found}
end
end
@doc ~S"""
Delivers the update email instructions to the given admin.
## Examples
iex> deliver_admin_update_email_instructions(admin, current_email, &url(~p"/admins/settings/confirm-email/#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
def deliver_admin_update_email_instructions(
%Admin{} = admin,
current_email,
update_email_url_fun
)
when is_function(update_email_url_fun, 1) do
{encoded_token, admin_token} = AdminToken.build_email_token(admin, "change:#{current_email}")
Repo.insert!(admin_token)
AdminNotifier.deliver_update_email_instructions(admin, update_email_url_fun.(encoded_token))
end
@doc """
Delivers the magic link login instructions to the given admin.
"""
def deliver_login_instructions(%Admin{} = admin, magic_link_url_fun)
when is_function(magic_link_url_fun, 1) do
{encoded_token, admin_token} = AdminToken.build_email_token(admin, "login")
Repo.insert!(admin_token)
AdminNotifier.deliver_login_instructions(admin, magic_link_url_fun.(encoded_token))
end
@doc """
Deletes the signed token with the given context.
"""
def delete_admin_session_token(token) do
Repo.delete_all(from(AdminToken, where: [token: ^token, context: "session"]))
:ok
end
@doc """
Creates a new api token for an admin.
The token returned must be saved somewhere safe.
This token cannot be recovered from the database.
"""
def create_admin_api_token(admin) do
{encoded_token, admin_token} = AdminToken.build_email_token(admin, "api-token")
Repo.insert!(admin_token)
encoded_token
end
@doc """
Fetches the admin by API token.
"""
def fetch_admin_by_api_token(token) do
with {:ok, query} <- AdminToken.verify_email_token_query(token, "api-token"),
%Admin{} = admin <- Repo.one(query) do
{:ok, admin}
else
_ -> :error
end
end
## Token helper
defp update_admin_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, admin} <- Repo.update(changeset) do
tokens_to_expire = Repo.all_by(AdminToken, admin_id: admin.id)
Repo.delete_all(
from(t in AdminToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id))
)
{:ok, {admin, tokens_to_expire}}
end
end)
end
end

View File

@ -0,0 +1,134 @@
defmodule BeetRoundServer.Admins.Admin do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "admins" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
timestamps(type: :utc_datetime)
end
@doc """
A admin changeset for registering or changing the email.
It requires the email to change otherwise an error is added.
## Options
* `:validate_unique` - Set to false if you don't want to validate the
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
def email_changeset(admin, attrs, opts \\ []) do
admin
|> cast(attrs, [:email])
|> validate_email(opts)
end
defp validate_email(changeset, opts) do
changeset =
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160)
if Keyword.get(opts, :validate_unique, true) do
changeset
|> unsafe_validate_unique(:email, BeetRoundServer.Repo)
|> unique_constraint(:email)
|> validate_email_changed()
else
changeset
end
end
defp validate_email_changed(changeset) do
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
add_error(changeset, :email, "did not change")
else
changeset
end
end
@doc """
A admin changeset for changing the password.
It is important to validate the length of the password, as long passwords may
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`.
"""
def password_changeset(admin, attrs, opts \\ []) do
admin
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(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
# If using Bcrypt, then further validate it is at most 72 bytes long
|> validate_length(:password, max: 72, count: :bytes)
# 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, Bcrypt.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
end
@doc """
Confirms the account by setting `confirmed_at`.
"""
def confirm_changeset(admin) do
now = DateTime.utc_now(:second)
change(admin, confirmed_at: now)
end
@doc """
Verifies the password.
If there is no admin or the admin doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%BeetRoundServer.Admins.Admin{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Bcrypt.no_user_verify()
false
end
end

View File

@ -0,0 +1,84 @@
defmodule BeetRoundServer.Admins.AdminNotifier do
import Swoosh.Email
alias BeetRoundServer.Mailer
alias BeetRoundServer.Admins.Admin
# Delivers the email using the application mailer.
defp deliver(recipient, subject, body) do
email =
new()
|> to(recipient)
|> from({"BeetRoundServer", "contact@example.com"})
|> subject(subject)
|> text_body(body)
with {:ok, _metadata} <- Mailer.deliver(email) do
{:ok, email}
end
end
@doc """
Deliver instructions to update a admin email.
"""
def deliver_update_email_instructions(admin, url) do
deliver(admin.email, "Update email instructions", """
==============================
Hi #{admin.email},
You can change your email by visiting the URL below:
#{url}
If you didn't request this change, please ignore this.
==============================
""")
end
@doc """
Deliver instructions to log in with a magic link.
"""
def deliver_login_instructions(admin, url) do
case admin do
%Admin{confirmed_at: nil} -> deliver_confirmation_instructions(admin, url)
_ -> deliver_magic_link_instructions(admin, url)
end
end
defp deliver_magic_link_instructions(admin, url) do
deliver(admin.email, "Log in instructions", """
==============================
Hi #{admin.email},
You can log into your account by visiting the URL below:
#{url}
If you didn't request this email, please ignore this.
==============================
""")
end
defp deliver_confirmation_instructions(admin, url) do
deliver(admin.email, "Confirmation instructions", """
==============================
Hi #{admin.email},
You can confirm your account by visiting the URL below:
#{url}
If you didn't create an account with us, please ignore this.
==============================
""")
end
end

View File

@ -0,0 +1,195 @@
defmodule BeetRoundServer.Admins.AdminToken do
use Ecto.Schema
import Ecto.Query
alias BeetRoundServer.Admins.AdminToken
@hash_algorithm :sha256
@rand_size 32
# It is very important to keep the magic link token expiry short,
# since someone with access to the email may take over the account.
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 14
@api_validity_in_days 30
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "admins_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
field :authenticated_at, :utc_datetime
belongs_to :admin, BeetRoundServer.Admins.Admin
timestamps(type: :utc_datetime, updated_at: false)
end
@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's 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 admin
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(admin) do
token = :crypto.strong_rand_bytes(@rand_size)
dt = admin.authenticated_at || DateTime.utc_now(:second)
{token,
%AdminToken{token: token, context: "session", admin_id: admin.id, authenticated_at: dt}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the admin found by the token, if any, along with the token's creation time.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def verify_session_token_query(token) do
query =
from token in by_token_and_context_query(token, "session"),
join: admin in assoc(token, :admin),
where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: {%{admin | authenticated_at: token.authenticated_at}, token.inserted_at}
{:ok, query}
end
@doc """
Builds a token and its hash to be delivered to the admin's email.
The non-hashed token is sent to the admin email while the
hashed part is stored in the database. The original token cannot be reconstructed,
which means anyone with read-only access to the database cannot directly use
the token in the application to gain access. Furthermore, if the admin changes
their email in the system, the tokens sent to the previous email are no longer
valid.
Users can easily adapt the existing code to provide other types of delivery methods,
for example, by phone numbers.
"""
def build_email_token(admin, context) do
build_hashed_token(admin, context, admin.email)
end
defp build_hashed_token(admin, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
%AdminToken{
token: hashed_token,
context: context,
sent_to: sent_to,
admin_id: admin.id
}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
If found, the query returns a tuple of the form `{admin, token}`.
The given token is valid if it matches its hashed counterpart in the
database. This function also checks if the token is being used within
15 minutes. The context of a magic link token is always "login".
"""
def verify_magic_link_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "login"),
join: admin in assoc(token, :admin),
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
where: token.sent_to == admin.email,
select: {admin, token}
{:ok, query}
:error ->
:error
end
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the admin_token found by the token, if any.
This is used to validate requests to change the admin
email.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (after @change_email_validity_in_days).
The context must always start with "change:".
"""
def verify_change_email_token_query(token, "change:" <> _ = context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, context),
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
{:ok, query}
:error ->
:error
end
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the admin found by the token, if any.
The given token is valid if it matches its hashed counterpart in the
database and the user email has not changed. This function also checks
if the token is being used within a certain period, depending on the
context. The default contexts supported by this function are either
"confirm", for account confirmation emails, and "reset_password",
for resetting the password. For verifying requests to change the email,
see `verify_change_email_token_query/2`.
"""
def verify_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
days = days_for_context(context)
query =
from token in by_token_and_context_query(hashed_token, context),
join: admin in assoc(token, :admin),
where: token.inserted_at > ago(^days, "day") and token.sent_to == admin.email,
select: admin
{:ok, query}
:error ->
:error
end
end
defp days_for_context("api-token"), do: @api_validity_in_days
defp by_token_and_context_query(token, context) do
from AdminToken, where: [token: ^token, context: ^context]
end
end

View File

@ -0,0 +1,33 @@
defmodule BeetRoundServer.Admins.Scope do
@moduledoc """
Defines the scope of the caller to be used throughout the app.
The `BeetRoundServer.Admins.Scope` allows public interfaces to receive
information about the caller, such as if the call is initiated from an
end-user, and if so, which user. Additionally, such a scope can carry fields
such as "super user" or other privileges for use as authorization, or to
ensure specific code paths can only be access for a given scope.
It is useful for logging as well as for scoping pubsub subscriptions and
broadcasts when a caller subscribes to an interface or performs a particular
action.
Feel free to extend the fields on this struct to fit the needs of
growing application requirements.
"""
alias BeetRoundServer.Admins.Admin
defstruct admin: nil
@doc """
Creates a scope for the given admin.
Returns nil if no admin is given.
"""
def for_admin(%Admin{} = admin) do
%__MODULE__{admin: admin}
end
def for_admin(nil), do: nil
end

View File

@ -0,0 +1,231 @@
defmodule BeetRoundServerWeb.AdminAuth do
use BeetRoundServerWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias BeetRoundServer.Admins
alias BeetRoundServer.Admins.Scope
# Make the remember me cookie valid for 14 days. This should match
# the session validity setting in AdminToken.
@max_cookie_age_in_days 14
@remember_me_cookie "_beet_round_server_web_admin_remember_me"
@remember_me_options [
sign: true,
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
same_site: "Lax"
]
# How old the session token should be before a new one is issued. When a request is made
# with a session token older than this value, then a new session token will be created
# and the session and remember-me cookies (if set) will be updated with the new token.
# Lowering this value will result in more tokens being created by active users. Increasing
# it will result in less time before a session token expires for a user to get issued a new
# token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
# the reissuing of tokens completely.
@session_reissue_age_in_days 7
@doc """
Logs the admin in.
Redirects to the session's `:admin_return_to` path
or falls back to the `signed_in_path/1`.
"""
def log_in_admin(conn, admin, params \\ %{}) do
admin_return_to = get_session(conn, :admin_return_to)
conn
|> create_or_extend_session(admin, params)
|> redirect(to: admin_return_to || signed_in_path(conn))
end
@doc """
Logs the admin out.
It clears all session data for safety. See renew_session.
"""
def log_out_admin(conn) do
admin_token = get_session(conn, :admin_token)
admin_token && Admins.delete_admin_session_token(admin_token)
if live_socket_id = get_session(conn, :live_socket_id) do
BeetRoundServerWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session(nil)
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
end
@doc """
Authenticates the admin by looking into the session and remember me token.
Will reissue the session token if it is older than the configured age.
"""
def fetch_current_scope_for_admin(conn, _opts) do
with {token, conn} <- ensure_admin_token(conn),
{admin, token_inserted_at} <- Admins.get_admin_by_session_token(token) do
conn
|> assign(:current_scope, Scope.for_admin(admin))
|> maybe_reissue_admin_session_token(admin, token_inserted_at)
else
nil -> assign(conn, :current_scope, Scope.for_admin(nil))
end
end
defp ensure_admin_token(conn) do
if token = get_session(conn, :admin_token) do
{token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if token = conn.cookies[@remember_me_cookie] do
{token, conn |> put_token_in_session(token) |> put_session(:admin_remember_me, true)}
else
nil
end
end
end
def fetch_api_admin(conn, _opts) do
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
{:ok, admin} <- Admins.fetch_admin_by_api_token(token) do
assign(conn, :current_admin, admin)
else
_ ->
conn
|> send_resp(:unauthorized, "No access for you!")
|> halt()
end
end
# Reissue the session token if it is older than the configured reissue age.
defp maybe_reissue_admin_session_token(conn, admin, token_inserted_at) do
token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
if token_age >= @session_reissue_age_in_days do
create_or_extend_session(conn, admin, %{})
else
conn
end
end
# This function is the one responsible for creating session tokens
# and storing them safely in the session and cookies. It may be called
# either when logging in, during sudo mode, or to renew a session which
# will soon expire.
#
# When the session is created, rather than extended, the renew_session
# function will clear the session to avoid fixation attacks. See the
# renew_session function to customize this behaviour.
defp create_or_extend_session(conn, admin, params) do
token = Admins.generate_admin_session_token(admin)
remember_me = get_session(conn, :admin_remember_me)
conn
|> renew_session(admin)
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params, remember_me)
end
# Do not renew session if the admin is already logged in
# to prevent CSRF errors or data being lost in tabs that are still open
defp renew_session(conn, admin) when conn.assigns.current_scope.admin.id == admin.id 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, _admin) do
# delete_csrf_token()
# 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, _admin) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, token, _params, true),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
defp write_remember_me_cookie(conn, token) do
conn
|> put_session(:admin_remember_me, true)
|> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
end
defp put_token_in_session(conn, token) do
put_session(conn, :admin_token, token)
end
@doc """
Plug for routes that require sudo mode.
"""
def require_sudo_mode(conn, _opts) do
if Admins.sudo_mode?(conn.assigns.current_scope.admin, -10) do
conn
else
conn
|> put_flash(:error, "You must re-authenticate to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/admins/log-in")
|> halt()
end
end
@doc """
Plug for routes that require the admin to not be authenticated.
"""
def redirect_if_admin_is_authenticated(conn, _opts) do
if conn.assigns.current_scope do
conn
|> redirect(to: signed_in_path(conn))
|> halt()
else
conn
end
end
defp signed_in_path(_conn), do: ~p"/"
@doc """
Plug for routes that require the admin to be authenticated.
"""
def require_authenticated_admin(conn, _opts) do
if conn.assigns.current_scope && conn.assigns.current_scope.admin do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/admins/log-in")
|> halt()
end
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :admin_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
end

View File

@ -49,13 +49,6 @@ defmodule BeetRoundServerWeb.Layouts do
</span>
</a>
</div>
<div class="flex-none">
<ul class="flex flex-column px-1 space-x-4 items-center">
<li>
<.theme_toggle />
</li>
</ul>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">

View File

@ -32,24 +32,7 @@
</head>
<body>
<ul class="menu menu-horizontal w-full relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
<%= if @current_scope do %>
<li>
{@current_scope.user.email}
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
<Layouts.theme_toggle />
</ul>
{@inner_content}
</body>

View File

@ -0,0 +1,56 @@
defmodule BeetRoundServerWeb.AdminController do
use BeetRoundServerWeb, :controller
alias BeetRoundServer.Admins
alias BeetRoundServer.Admins.Admin
action_fallback BeetRoundServerWeb.FallbackController
def create(conn, %{"admin" => admin_params}) do
with {:ok, %Admin{} = admin} <- Admins.register_admin(admin_params) do
conn
|> put_status(:created)
|> render(:show, admin: admin)
else
{:error, _changeset} ->
existingAdmin = Admins.get_admin_by_email(admin_params["email"])
if existingAdmin == nil do
conn
|> put_status(:bad_request)
|> render(:error, %{error: "Admin could not be created!", admin: admin_params})
else
admin = %{
mail: existingAdmin.email,
id: existingAdmin.id
}
conn
|> put_status(:conflict)
|> render(:error, %{error: "Admin already exists!", admin: admin})
end
end
end
def show(conn, %{"id" => id}) do
admin = Admins.get_admin!(id)
render(conn, :show, admin: admin)
end
def log_in(conn, %{"admin" => admin_params}) do
case Admins.get_admin_by_email_and_password(admin_params["email"], admin_params["password"]) do
nil ->
IO.puts("Admin couldn't be found!")
conn
|> put_status(:forbidden)
|> render(:error, %{error: "Invalid email or password!", admin: admin_params})
admin ->
encoded_token = Admins.create_admin_api_token(admin)
updated_admin = Map.put(admin, :token, encoded_token)
render(conn, :token, admin: updated_admin)
end
end
end

View File

@ -0,0 +1,47 @@
defmodule BeetRoundServerWeb.AdminJSON do
alias BeetRoundServer.Admins.Admin
@doc """
Renders a list of admins.
"""
def index(%{admins: admins}) do
%{data: for(admin <- admins, do: data(admin))}
end
@doc """
Renders a single admin.
"""
def show(%{admin: admin}) do
%{
data: data(admin)
}
end
def token(%{admin: admin}) do
%{
data: %{
id: admin.id,
email: admin.email,
token: admin.token
}
}
end
def mail_status(%{status: status}) do
%{data: status}
end
def error(%{error: error, admin: admin}) do
%{
error: error,
admin: admin
}
end
defp data(%Admin{} = admin) do
%{
id: admin.id,
email: admin.email
}
end
end

View File

@ -0,0 +1,32 @@
defmodule BeetRoundServerWeb.AdminRegistrationController do
use BeetRoundServerWeb, :controller
alias BeetRoundServer.Admins
alias BeetRoundServer.Admins.Admin
def new(conn, _params) do
changeset = Admins.change_admin_email(%Admin{})
render(conn, :new, changeset: changeset)
end
def create(conn, %{"admin" => admin_params}) do
case Admins.register_admin(admin_params) do
{:ok, admin} ->
{:ok, _} =
Admins.deliver_login_instructions(
admin,
&url(~p"/admins/log-in/#{&1}")
)
conn
|> put_flash(
:info,
"An email was sent to #{admin.email}, please access it to confirm your account."
)
|> redirect(to: ~p"/admins/log-in")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
end

View File

@ -0,0 +1,5 @@
defmodule BeetRoundServerWeb.AdminRegistrationHTML do
use BeetRoundServerWeb, :html
embed_templates "admin_registration_html/*"
end

View File

@ -0,0 +1,31 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Register for an account
<:subtitle>
Already registered?
<.link navigate={~p"/admins/log-in"} class="font-semibold text-brand hover:underline">
Log in
</.link>
to your account now.
</:subtitle>
</.header>
</div>
<.form :let={f} for={@changeset} action={~p"/admins/register"}>
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
Create an account
</.button>
</.form>
</div>
</Layouts.app>

View File

@ -0,0 +1,88 @@
defmodule BeetRoundServerWeb.AdminSessionController do
use BeetRoundServerWeb, :controller
alias BeetRoundServer.Admins
alias BeetRoundServerWeb.AdminAuth
def new(conn, _params) do
email = get_in(conn.assigns, [:current_scope, Access.key(:admin), Access.key(:email)])
form = Phoenix.Component.to_form(%{"email" => email}, as: "admin")
render(conn, :new, form: form)
end
# magic link login
def create(conn, %{"admin" => %{"token" => token} = admin_params} = params) do
info =
case params do
%{"_action" => "confirmed"} -> "Admin confirmed successfully."
_ -> "Welcome back!"
end
case Admins.login_admin_by_magic_link(token) do
{:ok, {admin, _expired_tokens}} ->
conn
|> put_flash(:info, info)
|> AdminAuth.log_in_admin(admin, admin_params)
{:error, :not_found} ->
conn
|> put_flash(:error, "The link is invalid or it has expired.")
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "admin"))
end
end
# email + password login
def create(conn, %{"admin" => %{"email" => email, "password" => password} = admin_params}) do
if admin = Admins.get_admin_by_email_and_password(email, password) do
conn
|> put_flash(:info, "Welcome back!")
|> AdminAuth.log_in_admin(admin, admin_params)
else
form = Phoenix.Component.to_form(admin_params, as: "admin")
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
conn
|> put_flash(:error, "Invalid email or password")
|> render(:new, form: form)
end
end
# magic link request
def create(conn, %{"admin" => %{"email" => email}}) do
if admin = Admins.get_admin_by_email(email) do
Admins.deliver_login_instructions(
admin,
&url(~p"/admins/log-in/#{&1}")
)
end
info =
"If your email is in our system, you will receive instructions for logging in shortly."
conn
|> put_flash(:info, info)
|> redirect(to: ~p"/admins/log-in")
end
def confirm(conn, %{"token" => token}) do
if admin = Admins.get_admin_by_magic_link_token(token) do
form = Phoenix.Component.to_form(%{"token" => token}, as: "admin")
conn
|> assign(:admin, admin)
|> assign(:form, form)
|> render(:confirm)
else
conn
|> put_flash(:error, "Magic link is invalid or it has expired.")
|> redirect(to: ~p"/admins/log-in")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> AdminAuth.log_out_admin()
end
end

View File

@ -0,0 +1,9 @@
defmodule BeetRoundServerWeb.AdminSessionHTML do
use BeetRoundServerWeb, :html
embed_templates "admin_session_html/*"
defp local_mail_adapter? do
Application.get_env(:beet_round_server, BeetRoundServer.Mailer)[:adapter] == Swoosh.Adapters.Local
end
end

View File

@ -0,0 +1,59 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>Welcome {@admin.email}</.header>
</div>
<.form
:if={!@admin.confirmed_at}
for={@form}
id="confirmation_form"
action={~p"/admins/log-in?_action=confirmed"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Confirming..."
class="btn btn-primary w-full"
>
Confirm and stay logged in
</.button>
<.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
Confirm and log in only this time
</.button>
</.form>
<.form
:if={@admin.confirmed_at}
for={@form}
id="login_form"
action={~p"/admins/log-in"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<%= if @current_scope do %>
<.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
Log in
</.button>
<% else %>
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Logging in..."
class="btn btn-primary w-full"
>
Keep me logged in on this device
</.button>
<.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
Log me in only this time
</.button>
<% end %>
</.form>
<p :if={!@admin.confirmed_at} class="alert alert-outline mt-8">
Tip: If you prefer passwords, you can enable them in the admin settings.
</p>
</div>
</Layouts.app>

View File

@ -0,0 +1,70 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm space-y-4">
<div class="text-center">
<.header>
<p>Log in</p>
<:subtitle>
<%= if @current_scope do %>
You need to reauthenticate to perform sensitive actions on your account.
<% else %>
Don't have an account? <.link
navigate={~p"/admins/register"}
class="font-semibold text-brand hover:underline"
phx-no-format
>Sign up</.link> for an account now.
<% end %>
</:subtitle>
</.header>
</div>
<div :if={local_mail_adapter?()} class="alert alert-info">
<.icon name="hero-information-circle" class="size-6 shrink-0" />
<div>
<p>You are running the local mail adapter.</p>
<p>
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
</p>
</div>
</div>
<.form :let={f} for={@form} as={:admin} id="login_form_magic" action={~p"/admins/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.button class="btn btn-primary w-full">
Log in with email <span aria-hidden="true">→</span>
</.button>
</.form>
<div class="divider">or</div>
<.form :let={f} for={@form} as={:admin} id="login_form_password" action={~p"/admins/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
/>
<.input
field={f[:password]}
type="password"
label="Password"
autocomplete="current-password"
/>
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true">→</span>
</.button>
<.button class="btn btn-primary btn-soft w-full mt-2">
Log in only this time
</.button>
</.form>
</div>
</Layouts.app>

View File

@ -0,0 +1,77 @@
defmodule BeetRoundServerWeb.AdminSettingsController do
use BeetRoundServerWeb, :controller
alias BeetRoundServer.Admins
alias BeetRoundServerWeb.AdminAuth
import BeetRoundServerWeb.AdminAuth, only: [require_sudo_mode: 2]
plug :require_sudo_mode
plug :assign_email_and_password_changesets
def edit(conn, _params) do
render(conn, :edit)
end
def update(conn, %{"action" => "update_email"} = params) do
%{"admin" => admin_params} = params
admin = conn.assigns.current_scope.admin
case Admins.change_admin_email(admin, admin_params) do
%{valid?: true} = changeset ->
Admins.deliver_admin_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
admin.email,
&url(~p"/admins/settings/confirm-email/#{&1}")
)
conn
|> put_flash(
:info,
"A link to confirm your email change has been sent to the new address."
)
|> redirect(to: ~p"/admins/settings")
changeset ->
render(conn, :edit, email_changeset: %{changeset | action: :insert})
end
end
def update(conn, %{"action" => "update_password"} = params) do
%{"admin" => admin_params} = params
admin = conn.assigns.current_scope.admin
case Admins.update_admin_password(admin, admin_params) do
{:ok, {admin, _}} ->
conn
|> put_flash(:info, "Password updated successfully.")
|> put_session(:admin_return_to, ~p"/admins/settings")
|> AdminAuth.log_in_admin(admin)
{:error, changeset} ->
render(conn, :edit, password_changeset: changeset)
end
end
def confirm_email(conn, %{"token" => token}) do
case Admins.update_admin_email(conn.assigns.current_scope.admin, token) do
{:ok, _admin} ->
conn
|> put_flash(:info, "Email changed successfully.")
|> redirect(to: ~p"/admins/settings")
{:error, _} ->
conn
|> put_flash(:error, "Email change link is invalid or it has expired.")
|> redirect(to: ~p"/admins/settings")
end
end
defp assign_email_and_password_changesets(conn, _opts) do
admin = conn.assigns.current_scope.admin
conn
|> assign(:email_changeset, Admins.change_admin_email(admin))
|> assign(:password_changeset, Admins.change_admin_password(admin))
end
end

View File

@ -0,0 +1,5 @@
defmodule BeetRoundServerWeb.AdminSettingsHTML do
use BeetRoundServerWeb, :html
embed_templates "admin_settings_html/*"
end

View File

@ -0,0 +1,40 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="text-center">
<.header>
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
</div>
<.form :let={f} for={@email_changeset} action={~p"/admins/settings"} id="update_email">
<input type="hidden" name="action" value="update_email" />
<.input field={f[:email]} type="email" label="Email" autocomplete="email" required />
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
</.form>
<div class="divider" />
<.form :let={f} for={@password_changeset} action={~p"/admins/settings"} id="update_password">
<input type="hidden" name="action" value="update_password" />
<.input
field={f[:password]}
type="password"
label="New password"
autocomplete="new-password"
required
/>
<.input
field={f[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
required
/>
<.button variant="primary" phx-disable-with="Changing...">
Save Password
</.button>
</.form>
</Layouts.app>

View File

@ -11,7 +11,6 @@
v{Application.spec(:beet_round_server, :vsn)}
</small>
</h1>
<Layouts.theme_toggle />
</div>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">

View File

@ -12,11 +12,23 @@ defmodule BeetRoundServerWeb.UserController do
end
def create(conn, %{"user" => user_params}) do
with {:ok, %User{} = user} <- Accounts.register_user(user_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/users/#{user}")
|> render(:show, user: user)
case Accounts.register_user(user_params) do
{:ok, %User{} = user} ->
send_created_response(conn, user)
{:error, changeset} ->
with %User{} = user <- Accounts.get_user_by_email(user_params["email"]) do
send_already_reported_response(conn, user)
else
_ ->
send_not_acceptable(conn, changeset)
end
_ ->
conn
|> put_status(:bad_request)
|> put_resp_header("location", ~p"/api/users")
|> render(:show, changeset: "Bad request")
end
end
@ -25,6 +37,37 @@ defmodule BeetRoundServerWeb.UserController do
render(conn, :show, user: user)
end
def invite(conn, %{"user" => user_params}) do
case Accounts.get_user!(user_params["user_id"]) do
nil ->
IO.puts("User couldn't be found! Reason:")
user ->
user_params = Map.put(user_params, "email", user.email)
email =
BeetRoundServer.UserEmail.invite(%{
name: user_params["name"],
email: user_params["email"],
access_url: user_params["access_url"]
})
case BeetRoundServer.Mailer.deliver(email) do
{:ok, data} ->
IO.puts("Mail sent successfully.")
IO.inspect(data)
render(conn, :mail_status, %{status: "Mail sent successfully."})
{:error, error} ->
IO.puts("Mail error:")
IO.inspect(error)
render(conn, :show, %User{})
# render(conn, :error, error: error, user: user_params)
end
end
end
# def update(conn, %{"id" => id, "user" => user_params}) do
# user = Accounts.get_user!(id)
@ -40,4 +83,30 @@ defmodule BeetRoundServerWeb.UserController do
# send_resp(conn, :no_content, "")
# end
# end
defp send_created_response(conn, %User{} = user) do
encoded_token = Accounts.create_email_token(user)
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/users/#{user}")
|> render(:show, %{user: user, token: encoded_token})
end
defp send_already_reported_response(conn, %User{} = user) do
encoded_token = Accounts.create_email_token(user)
IO.puts("encoded_token for user: " <> user.email)
IO.inspect(encoded_token)
conn
|> put_status(:already_reported)
|> render(:show, %{user: user, token: encoded_token})
end
defp send_not_acceptable(conn, changeset) do
conn
|> put_status(:not_acceptable)
|> put_resp_header("location", ~p"/api/users")
|> render(:show, changeset: changeset)
end
end

View File

@ -9,16 +9,45 @@ defmodule BeetRoundServerWeb.UserJSON do
end
@doc """
Renders a single user.
Renders a single user with token.
"""
def show(%{user: user, token: encoded_token}) do
%{data: %{email: user.email, id: user.id, token: encoded_token}}
end
def show(%{user: user}) do
%{data: data(user)}
end
def show(%{changeset: changeset}) do
# When encoded, the changeset returns its errors
# as a JSON object. So we just pass it forward.
%{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
end
def mail_status(%{status: status}) do
%{data: status}
end
defp data(%User{} = user) do
%{
id: user.id,
email: user.email
}
end
defp translate_error({msg, opts}) do
# You can make use of gettext to translate error messages by
# uncommenting and adjusting the following code:
# if count = opts[:count] do
# Gettext.dngettext(BeetRoundServerWeb.Gettext, "errors", msg, msg, count, opts)
# else
# Gettext.dgettext(BeetRoundServerWeb.Gettext, "errors", msg, opts)
# end
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
end

View File

@ -12,6 +12,23 @@ defmodule BeetRoundServerWeb.UserSessionController do
create(conn, params, "Welcome back!")
end
def login(conn, %{"token" => token}) do
IO.puts("Login via token:")
IO.inspect(token)
UserAuth.log_out_user_without_redirect(conn)
if user = Accounts.get_user_by_email_token(token) do
conn
|> put_flash(:info, "Login successful!")
|> UserAuth.log_in_without_creating_cookie(user)
else
conn
|> put_flash(:error, "Invalid token")
|> redirect(to: ~p"/")
end
end
# magic link login
defp create(conn, %{"user" => %{"token" => token} = user_params}, info) do
case Accounts.login_user_by_magic_link(token) do

View File

@ -3,6 +3,7 @@ defmodule BeetRoundServerWeb.BiddingLive.Form do
alias BeetRoundServer.Biddings
alias BeetRoundServer.Biddings.Bidding
alias BeetRoundServer.BiddingRounds.BiddingRoundFacade
@impl true
def render(assigns) do
@ -10,28 +11,73 @@ defmodule BeetRoundServerWeb.BiddingLive.Form do
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
{@page_title}
<:subtitle>Use this form to manage bidding records in your database.</:subtitle>
<:subtitle>Bitte gib hier den Betrag ein, den Du monatlich bezahlen willst.</:subtitle>
</.header>
<.form for={@form} id="bidding-form" phx-change="validate" phx-submit="save">
<.input field={@form[:bidding_round]} type="number" label="Bidding round" />
<.input field={@form[:amount]} type="number" label="Amount" />
<.input field={@form[:depot_wish_one]} type="text" label="Depot wish one" />
<.input field={@form[:depot_wish_two]} type="text" label="Depot wish two" />
<%= if @bidding.bidding_round == 0 do %>
<p><b>Keine Bietrunde aktiv.</b></p>
<footer>
<.button phx-disable-with="Saving..." variant="primary">Save Bidding</.button>
<.button navigate={return_path(@current_scope, @return_to, @bidding)}>Cancel</.button>
<.button navigate={return_path(@current_scope, @return_to, @bidding)}>Zurück</.button>
</footer>
</.form>
<% else %>
<.form for={@form} id="bidding-form" phx-change="validate" phx-submit="save">
<.input field={@form[:amount]} type="number" label="Betrag" />
<.input
field={@form[:depot_wish_one]}
type="select"
label="Depot Wunsch 1"
options={[
{"", ""},
{"KlimaWerkStadt (1)", "KlimaWerkStadt"},
{"Puramila (2)", "Puramila"},
{"Eine Welt Aktion (3)", "Eine Welt Aktion"},
{"Hof Buntentor (4)", "Hof Buntentor"},
{"Mädchen-Kulturhaus (5)", "Mädchen-Kulturhaus"},
{"neues Depot im Viertel (6)", "neues Depot im Viertel"},
{"Creative Hub (7)", "Creative Hub"},
{"Klimazone (8)", "Klimazone"},
{"Garage Walle (9)", "Garage Walle"},
{"Hof Riede (A)", "Hof Riede"},
{"Thedinghausen (B)", "Thedinghausen"}
]}
/>
<.input
field={@form[:depot_wish_two]}
type="select"
label="Depot Wunsch 2"
options={[
{"", ""},
{"KlimaWerkStadt (1)", "KlimaWerkStadt"},
{"Puramila (2)", "Puramila"},
{"Eine Welt Aktion (3)", "Eine Welt Aktion"},
{"Hof Buntentor (4)", "Hof Buntentor"},
{"Mädchen-Kulturhaus (5)", "Mädchen-Kulturhaus"},
{"neues Depot im Viertel (6)", "neues Depot im Viertel"},
{"Creative Hub (7)", "Creative Hub"},
{"Klimazone (8)", "Klimazone"},
{"Garage Walle (9)", "Garage Walle"},
{"Hof Riede (A)", "Hof Riede"},
{"Thedinghausen (B)", "Thedinghausen"}
]}
/>
<.input field={@form[:bidding_round]} type="number" readonly hidden />
<footer>
<.button phx-disable-with="Bearbeitung..." variant="primary">Gebot abgeben</.button>
<.button navigate={return_path(@current_scope, @return_to, @bidding)}>Abbrechen</.button>
</footer>
</.form>
<% end %>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
# current_round = BiddingRoundFacade.get_current_round()
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
# |> assign(bidding_round: current_round)
|> apply_action(socket.assigns.live_action, params)}
end
@ -42,23 +88,34 @@ defmodule BeetRoundServerWeb.BiddingLive.Form do
bidding = Biddings.get_bidding!(socket.assigns.current_scope, id)
socket
|> assign(:page_title, "Edit Bidding")
|> assign(:page_title, "Gebot bearbeiten")
|> assign(:bidding, bidding)
|> assign(:form, to_form(Biddings.change_bidding(socket.assigns.current_scope, bidding)))
end
defp apply_action(socket, :new, _params) do
bidding = %Bidding{user_id: socket.assigns.current_scope.user.id}
current_round = BiddingRoundFacade.get_current_round()
bidding = %Bidding{
user_id: socket.assigns.current_scope.user.id,
bidding_round: current_round
}
socket
|> assign(:page_title, "New Bidding")
|> assign(:page_title, "Neues Gebot")
|> assign(:bidding, bidding)
|> assign(:form, to_form(Biddings.change_bidding(socket.assigns.current_scope, bidding)))
end
@impl true
def handle_event("validate", %{"bidding" => bidding_params}, socket) do
changeset = Biddings.change_bidding(socket.assigns.current_scope, socket.assigns.bidding, bidding_params)
changeset =
Biddings.change_bidding(
socket.assigns.current_scope,
socket.assigns.bidding,
bidding_params
)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
@ -67,11 +124,15 @@ defmodule BeetRoundServerWeb.BiddingLive.Form do
end
defp save_bidding(socket, :edit, bidding_params) do
case Biddings.update_bidding(socket.assigns.current_scope, socket.assigns.bidding, bidding_params) do
case Biddings.update_bidding(
socket.assigns.current_scope,
socket.assigns.bidding,
bidding_params
) do
{:ok, bidding} ->
{:noreply,
socket
|> put_flash(:info, "Bidding updated successfully")
|> put_flash(:info, "Gebot erfolgreich bearbeitet")
|> push_navigate(
to: return_path(socket.assigns.current_scope, socket.assigns.return_to, bidding)
)}
@ -86,7 +147,7 @@ defmodule BeetRoundServerWeb.BiddingLive.Form do
{:ok, bidding} ->
{:noreply,
socket
|> put_flash(:info, "Bidding created successfully")
|> put_flash(:info, "Gebot erfolgreich abgegeben")
|> push_navigate(
to: return_path(socket.assigns.current_scope, socket.assigns.return_to, bidding)
)}

View File

@ -9,44 +9,33 @@ defmodule BeetRoundServerWeb.BiddingLive.Index do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Listing Biddings
<:actions>
<.button variant="primary" navigate={~p"/biddings/new"}>
<.icon name="hero-plus" /> New Bidding
</.button>
</:actions>
{@current_scope.user.email}
</.header>
<%= if @bidding_round == 0 do %>
<p>Keine Bietrunde aktiv.</p>
<p>Keine Bietrunde aktiv. Aktuell kein Bieten möglich!</p>
<% else %>
<p>Aktive Bietrunde: {@bidding_round}</p>
<p>Aktive Bietrunde: {@bidding_round} - Es kann geboten werden!</p>
<div align="right">
<.button variant="primary" navigate={~p"/biddings/new"}>
<.icon name="hero-plus" /> Neues Gebot
</.button>
</div>
<% end %>
<.table
id="biddings"
rows={@streams.biddings}
row_click={fn {_id, bidding} -> JS.navigate(~p"/biddings/#{bidding}") end}
>
<:col :let={{_id, bidding}} label="Bidding round">{bidding.bidding_round}</:col>
<:col :let={{_id, bidding}} label="Amount">{bidding.amount}</:col>
<:col :let={{_id, bidding}} label="Depot wish one">{bidding.depot_wish_one}</:col>
<:col :let={{_id, bidding}} label="Depot wish two">{bidding.depot_wish_two}</:col>
<:action :let={{_id, bidding}}>
<div class="sr-only">
<.link navigate={~p"/biddings/#{bidding}"}>Show</.link>
</div>
<.link navigate={~p"/biddings/#{bidding}/edit"}>Edit</.link>
</:action>
<:action :let={{id, bidding}}>
<.link
phx-click={JS.push("delete", value: %{id: bidding.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
<br />
<%= if @current_bidding do %>
<p><b>Aktuelles Gebot:</b></p>
<.list>
<:item title="Bietrunde">{@current_bidding.bidding_round}</:item>
<:item title="monatl. Betrag">{@current_bidding.amount}</:item>
<:item title="Depot Wunsch 1">{@current_bidding.depot_wish_one}</:item>
<:item title="Depot Wunsch 2">{@current_bidding.depot_wish_two}</:item>
</.list>
<% else %>
<p>Noch kein Gebot abgegeben</p>
<% end %>
</Layouts.app>
"""
end
@ -58,11 +47,13 @@ defmodule BeetRoundServerWeb.BiddingLive.Index do
end
current_round = BiddingRoundFacade.get_current_round()
current_bidding = Biddings.get_most_recent_bidding(socket.assigns.current_scope)
{:ok,
socket
|> assign(:page_title, "Listing Biddings")
|> assign(:page_title, "Aktuelles Gebot")
|> assign(bidding_round: current_round)
|> assign(current_bidding: current_bidding)
|> stream(:biddings, list_biddings(socket.assigns.current_scope))}
end

View File

@ -12,81 +12,10 @@ defmodule BeetRoundServerWeb.UserLive.Login do
<.header>
<p>Log in</p>
<:subtitle>
<%= if @current_scope do %>
You need to reauthenticate to perform sensitive actions on your account.
<% else %>
Don't have an account? <.link
navigate={~p"/users/register"}
class="font-semibold text-brand hover:underline"
phx-no-format
>Sign up</.link> for an account now.
<% end %>
Bitte nutze deinen persönlichen Link der dir per Mail zugesendet wurde um dich anzumelden.
</:subtitle>
</.header>
</div>
<div :if={local_mail_adapter?()} class="alert alert-info">
<.icon name="hero-information-circle" class="size-6 shrink-0" />
<div>
<p>You are running the local mail adapter.</p>
<p>
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
</p>
</div>
</div>
<.form
:let={f}
for={@form}
id="login_form_magic"
action={~p"/users/log-in"}
phx-submit="submit_magic"
>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.button class="btn btn-primary w-full">
Log in with email <span aria-hidden="true">→</span>
</.button>
</.form>
<div class="divider">or</div>
<.form
:let={f}
for={@form}
id="login_form_password"
action={~p"/users/log-in"}
phx-submit="submit_password"
phx-trigger-action={@trigger_submit}
>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
/>
<.input
field={@form[:password]}
type="password"
label="Password"
autocomplete="current-password"
/>
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true">→</span>
</.button>
<.button class="btn btn-primary btn-soft w-full mt-2">
Log in only this time
</.button>
</.form>
</div>
</Layouts.app>
"""
@ -126,6 +55,7 @@ defmodule BeetRoundServerWeb.UserLive.Login do
end
defp local_mail_adapter? do
Application.get_env(:beet_round_server, BeetRoundServer.Mailer)[:adapter] == Swoosh.Adapters.Local
Application.get_env(:beet_round_server, BeetRoundServer.Mailer)[:adapter] ==
Swoosh.Adapters.Local
end
end

View File

@ -1,6 +1,8 @@
defmodule BeetRoundServerWeb.Router do
use BeetRoundServerWeb, :router
import BeetRoundServerWeb.AdminAuth
import BeetRoundServerWeb.UserAuth
pipeline :browser do
@ -10,6 +12,7 @@ defmodule BeetRoundServerWeb.Router do
plug :put_root_layout, html: {BeetRoundServerWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_admin
plug :fetch_current_scope_for_user
end
@ -17,16 +20,28 @@ defmodule BeetRoundServerWeb.Router do
plug :accepts, ["json"]
end
pipeline :admin do
plug :fetch_api_admin
end
scope "/", BeetRoundServerWeb do
pipe_through :browser
get "/", PageController, :home
end
# Other scopes may use custom stacks.
### API ###
scope "/api", BeetRoundServerWeb do
pipe_through :api
post "/log_in", AdminController, :log_in
# post "/admin_create", AdminController, :create
end
### protected API ###
scope "/api", BeetRoundServerWeb do
pipe_through [:api, :admin]
get "/", DefaultApiController, :index
get "/bidding_rounds/get_current", BiddingRoundController, :get_highest
@ -37,6 +52,8 @@ defmodule BeetRoundServerWeb.Router do
get "/biddings_of_round/:round_number", BiddingController, :biddings_of_round
get "/biddings_of_highest_round", BiddingController, :biddings_of_highest_round
post "/invite", UserController, :invite
resources "/users", UserController, except: [:new, :edit]
end
@ -88,5 +105,7 @@ defmodule BeetRoundServerWeb.Router do
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
get "/log_in/:token", UserSessionController, :login
end
end

View File

@ -0,0 +1,23 @@
<h1>Bietrunde 26/27 - Das Grüne Zebra</h1>
<h2>Digitales Bieten</h2>
<p>Hallo <%= @name %>,</p>
<p>du bist für die Bietrunde am Sonntag, 22.02. angemeldet.</p>
<p>Du kannst während der Bietrunde über den folgenden Link dein Gebot digital abgeben, wodurch das Auszählen der gebotenen Beträge deutlich beschleunigt wird.</p>
<p> <a href="<%= @invite_link %>">persönlicher Bietlink</a></p>
<p>Es handelt sich um einen personalisierten Link, der an deine Mitgliedschaft gekoppelt ist. Du wirst auf der sich öffnenden Website automatisch eingeloggt und kannst dort, sobald wir den Bietvorgang starten, dein Gebot und deine Depotwünsche eingeben.</p>
<p>Solltest du nicht persönlich an der Bietrunde teilnehmen, sondern jemandem eine Vollmacht erteilt haben, kannst du die Mail einfach weiterleiten und die betreffende Person kann sich über den Link an deiner Stelle einloggen.</p>
<p>Solltest du nicht nur für dich sondern auch noch per Vollmacht für jemand anderes bieten, ist das auch möglich: du brauchst nur den personalisierten Link der Person, für die du die Vollmacht hast. Beim Klick auf die einzelnen Links wirst du ggf. aus einem anderen Account ausgeloggt.</p>
<p>Wir können im Zweifelsfall auch vor Ort einen QR-Code generieren, falls der personalisierte Link nicht vorliegt, doch auf einem anderen Handy ist oder die Katze das Handy aufgegessen hat.</p>
<p>Außerdem ist es natürlich auch möglich ganz klassisch das Gebot auf einen Zettel zu schreiben.</p>
<p>Falls du online an der Bietrunde teilnehmen willst, solltest du bereits den Link zur Zoom-Veranstaltung per Mail bekommen. Sollte das nicht passieren, melde dich noch mal bei uns!</p>
<br/>
<p>Viele Grüße und bis Sonntag!</p>
<p>Eure Zebras</p>

View File

@ -40,6 +40,16 @@ defmodule BeetRoundServerWeb.UserAuth do
|> redirect(to: user_return_to || signed_in_path(conn))
end
def log_in_without_creating_cookie(conn, user) do
token = Accounts.generate_user_session_token(user)
user_return_to = get_session(conn, :user_return_to)
conn
|> renew_session(user)
|> put_token_in_session(token)
|> redirect(to: user_return_to || signed_in_path(conn))
end
@doc """
Logs the user out.
@ -59,6 +69,19 @@ defmodule BeetRoundServerWeb.UserAuth do
|> redirect(to: ~p"/")
end
def log_out_user_without_redirect(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
BeetRoundServerWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session(nil)
|> delete_resp_cookie(@remember_me_cookie)
end
@doc """
Authenticates the user by looking into the session and remember me token.
@ -259,7 +282,7 @@ defmodule BeetRoundServerWeb.UserAuth do
@doc "Returns the path to redirect to after log in."
# the user was already logged in, redirect to settings
def signed_in_path(%Plug.Conn{assigns: %{current_scope: %Scope{user: %Accounts.User{}}}}) do
~p"/users/settings"
~p"/biddings"
end
def signed_in_path(_), do: ~p"/biddings"

View File

@ -4,7 +4,7 @@ defmodule BeetRoundServer.MixProject do
def project do
[
app: :beet_round_server,
version: "0.1.0",
version: "0.7.1",
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
@ -60,6 +60,8 @@ defmodule BeetRoundServer.MixProject do
compile: false,
depth: 1},
{:swoosh, "~> 1.16"},
{:phoenix_swoosh, "~> 1.2.1"},
{:gen_smtp, "~> 1.1"},
{:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},

View File

@ -1,5 +1,5 @@
%{
"bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [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", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [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", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
@ -12,14 +12,15 @@
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"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.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
@ -29,14 +30,17 @@
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [: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]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.20", "4f20850ee700b309b21906a0e510af1b916b454b4f810fb8581ada016eb42dfc", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [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]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c16abd605a21f778165cb0079946351ef20ef84eb1ef467a862fb9a173b1d27d"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.24", "1a000a048d5971b61a9efe29a3c4144ca955afd42224998d841c5011a5354838", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [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]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0c724e6c65f197841cac49d73be4e0f9b93a7711eaa52d2d4d1b9f859c329267"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [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", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"swoosh": {:hex, :swoosh, "1.20.1", "0f570fc03a87d71b4d74d64f33f57d6c3d69a6f0a6b3ae73f682acce1fdc5a7b", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {: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]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {: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]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "08292f83045f57296398a8640bbd49cd44fe23eb8146351b14c8efdcee474454"},
"swoosh": {:hex, :swoosh, "1.22.0", "0d65a95f89aedb5011af13295742294e309b4b4aaca556858d81e3b372b58abc", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {: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]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {: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]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c01ced23d8786d1ee1a03e4c16574290b2ccd6267beb8c81d081c4a34574ef6e"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},

View File

@ -12,7 +12,7 @@ msgstr ""
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
msgstr "Muss ausgefüllt werden!"
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"

View File

@ -0,0 +1,32 @@
defmodule BeetRoundServer.Repo.Migrations.CreateAdminsAuthTables do
use Ecto.Migration
def change do
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
create table(:admins, primary_key: false) do
add :id, :binary_id, primary_key: true
add :email, :citext, null: false
add :hashed_password, :string
add :confirmed_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create unique_index(:admins, [:email])
create table(:admins_tokens, primary_key: false) do
add :id, :binary_id, primary_key: true
add :admin_id, references(:admins, type: :binary_id, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
add :authenticated_at, :utc_datetime
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:admins_tokens, [:admin_id])
create unique_index(:admins_tokens, [:context, :token])
end
end

View File

@ -0,0 +1,397 @@
defmodule BeetRoundServer.AdminsTest do
use BeetRoundServer.DataCase
alias BeetRoundServer.Admins
import BeetRoundServer.AdminsFixtures
alias BeetRoundServer.Admins.{Admin, AdminToken}
describe "get_admin_by_email/1" do
test "does not return the admin if the email does not exist" do
refute Admins.get_admin_by_email("unknown@example.com")
end
test "returns the admin if the email exists" do
%{id: id} = admin = admin_fixture()
assert %Admin{id: ^id} = Admins.get_admin_by_email(admin.email)
end
end
describe "get_admin_by_email_and_password/2" do
test "does not return the admin if the email does not exist" do
refute Admins.get_admin_by_email_and_password("unknown@example.com", "hello world!")
end
test "does not return the admin if the password is not valid" do
admin = admin_fixture() |> set_password()
refute Admins.get_admin_by_email_and_password(admin.email, "invalid")
end
test "returns the admin if the email and password are valid" do
%{id: id} = admin = admin_fixture() |> set_password()
assert %Admin{id: ^id} =
Admins.get_admin_by_email_and_password(admin.email, valid_admin_password())
end
end
describe "get_admin!/1" do
test "raises if id is invalid" do
assert_raise Ecto.NoResultsError, fn ->
Admins.get_admin!("11111111-1111-1111-1111-111111111111")
end
end
test "returns the admin with the given id" do
%{id: id} = admin = admin_fixture()
assert %Admin{id: ^id} = Admins.get_admin!(admin.id)
end
end
describe "register_admin/1" do
test "requires email to be set" do
{:error, changeset} = Admins.register_admin(%{})
assert %{email: ["can't be blank"]} = errors_on(changeset)
end
test "validates email when given" do
{:error, changeset} = Admins.register_admin(%{email: "not valid"})
assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
end
test "validates maximum values for email for security" do
too_long = String.duplicate("db", 100)
{:error, changeset} = Admins.register_admin(%{email: too_long})
assert "should be at most 160 character(s)" in errors_on(changeset).email
end
test "validates email uniqueness" do
%{email: email} = admin_fixture()
{:error, changeset} = Admins.register_admin(%{email: email})
assert "has already been taken" in errors_on(changeset).email
# Now try with the uppercased email too, to check that email case is ignored.
{:error, changeset} = Admins.register_admin(%{email: String.upcase(email)})
assert "has already been taken" in errors_on(changeset).email
end
test "registers admins without password" do
email = unique_admin_email()
{:ok, admin} = Admins.register_admin(valid_admin_attributes(email: email))
assert admin.email == email
assert is_nil(admin.hashed_password)
assert is_nil(admin.confirmed_at)
assert is_nil(admin.password)
end
end
describe "sudo_mode?/2" do
test "validates the authenticated_at time" do
now = DateTime.utc_now()
assert Admins.sudo_mode?(%Admin{authenticated_at: DateTime.utc_now()})
assert Admins.sudo_mode?(%Admin{authenticated_at: DateTime.add(now, -19, :minute)})
refute Admins.sudo_mode?(%Admin{authenticated_at: DateTime.add(now, -21, :minute)})
# minute override
refute Admins.sudo_mode?(
%Admin{authenticated_at: DateTime.add(now, -11, :minute)},
-10
)
# not authenticated
refute Admins.sudo_mode?(%Admin{})
end
end
describe "change_admin_email/3" do
test "returns a admin changeset" do
assert %Ecto.Changeset{} = changeset = Admins.change_admin_email(%Admin{})
assert changeset.required == [:email]
end
end
describe "deliver_admin_update_email_instructions/3" do
setup do
%{admin: admin_fixture()}
end
test "sends token through notification", %{admin: admin} do
token =
extract_admin_token(fn url ->
Admins.deliver_admin_update_email_instructions(admin, "current@example.com", url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert admin_token = Repo.get_by(AdminToken, token: :crypto.hash(:sha256, token))
assert admin_token.admin_id == admin.id
assert admin_token.sent_to == admin.email
assert admin_token.context == "change:current@example.com"
end
end
describe "update_admin_email/2" do
setup do
admin = unconfirmed_admin_fixture()
email = unique_admin_email()
token =
extract_admin_token(fn url ->
Admins.deliver_admin_update_email_instructions(%{admin | email: email}, admin.email, url)
end)
%{admin: admin, token: token, email: email}
end
test "updates the email with a valid token", %{admin: admin, token: token, email: email} do
assert {:ok, %{email: ^email}} = Admins.update_admin_email(admin, token)
changed_admin = Repo.get!(Admin, admin.id)
assert changed_admin.email != admin.email
assert changed_admin.email == email
refute Repo.get_by(AdminToken, admin_id: admin.id)
end
test "does not update email with invalid token", %{admin: admin} do
assert Admins.update_admin_email(admin, "oops") ==
{:error, :transaction_aborted}
assert Repo.get!(Admin, admin.id).email == admin.email
assert Repo.get_by(AdminToken, admin_id: admin.id)
end
test "does not update email if admin email changed", %{admin: admin, token: token} do
assert Admins.update_admin_email(%{admin | email: "current@example.com"}, token) ==
{:error, :transaction_aborted}
assert Repo.get!(Admin, admin.id).email == admin.email
assert Repo.get_by(AdminToken, admin_id: admin.id)
end
test "does not update email if token expired", %{admin: admin, token: token} do
{1, nil} = Repo.update_all(AdminToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
assert Admins.update_admin_email(admin, token) ==
{:error, :transaction_aborted}
assert Repo.get!(Admin, admin.id).email == admin.email
assert Repo.get_by(AdminToken, admin_id: admin.id)
end
end
describe "change_admin_password/3" do
test "returns a admin changeset" do
assert %Ecto.Changeset{} = changeset = Admins.change_admin_password(%Admin{})
assert changeset.required == [:password]
end
test "allows fields to be set" do
changeset =
Admins.change_admin_password(
%Admin{},
%{
"password" => "new valid password"
},
hash_password: false
)
assert changeset.valid?
assert get_change(changeset, :password) == "new valid password"
assert is_nil(get_change(changeset, :hashed_password))
end
end
describe "update_admin_password/2" do
setup do
%{admin: admin_fixture()}
end
test "validates password", %{admin: admin} do
{:error, changeset} =
Admins.update_admin_password(admin, %{
password: "not valid",
password_confirmation: "another"
})
assert %{
password: ["should be at least 12 character(s)"],
password_confirmation: ["does not match password"]
} = errors_on(changeset)
end
test "validates maximum values for password for security", %{admin: admin} do
too_long = String.duplicate("db", 100)
{:error, changeset} =
Admins.update_admin_password(admin, %{password: too_long})
assert "should be at most 72 character(s)" in errors_on(changeset).password
end
test "updates the password", %{admin: admin} do
{:ok, {admin, expired_tokens}} =
Admins.update_admin_password(admin, %{
password: "new valid password"
})
assert expired_tokens == []
assert is_nil(admin.password)
assert Admins.get_admin_by_email_and_password(admin.email, "new valid password")
end
test "deletes all tokens for the given admin", %{admin: admin} do
_ = Admins.generate_admin_session_token(admin)
{:ok, {_, _}} =
Admins.update_admin_password(admin, %{
password: "new valid password"
})
refute Repo.get_by(AdminToken, admin_id: admin.id)
end
end
describe "generate_admin_session_token/1" do
setup do
%{admin: admin_fixture()}
end
test "generates a token", %{admin: admin} do
token = Admins.generate_admin_session_token(admin)
assert admin_token = Repo.get_by(AdminToken, token: token)
assert admin_token.context == "session"
assert admin_token.authenticated_at != nil
# Creating the same token for another admin should fail
assert_raise Ecto.ConstraintError, fn ->
Repo.insert!(%AdminToken{
token: admin_token.token,
admin_id: admin_fixture().id,
context: "session"
})
end
end
test "duplicates the authenticated_at of given admin in new token", %{admin: admin} do
admin = %{admin | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)}
token = Admins.generate_admin_session_token(admin)
assert admin_token = Repo.get_by(AdminToken, token: token)
assert admin_token.authenticated_at == admin.authenticated_at
assert DateTime.compare(admin_token.inserted_at, admin.authenticated_at) == :gt
end
end
describe "get_admin_by_session_token/1" do
setup do
admin = admin_fixture()
token = Admins.generate_admin_session_token(admin)
%{admin: admin, token: token}
end
test "returns admin by token", %{admin: admin, token: token} do
assert {session_admin, token_inserted_at} = Admins.get_admin_by_session_token(token)
assert session_admin.id == admin.id
assert session_admin.authenticated_at != nil
assert token_inserted_at != nil
end
test "does not return admin for invalid token" do
refute Admins.get_admin_by_session_token("oops")
end
test "does not return admin for expired token", %{token: token} do
dt = ~N[2020-01-01 00:00:00]
{1, nil} = Repo.update_all(AdminToken, set: [inserted_at: dt, authenticated_at: dt])
refute Admins.get_admin_by_session_token(token)
end
end
describe "get_admin_by_magic_link_token/1" do
setup do
admin = admin_fixture()
{encoded_token, _hashed_token} = generate_admin_magic_link_token(admin)
%{admin: admin, token: encoded_token}
end
test "returns admin by token", %{admin: admin, token: token} do
assert session_admin = Admins.get_admin_by_magic_link_token(token)
assert session_admin.id == admin.id
end
test "does not return admin for invalid token" do
refute Admins.get_admin_by_magic_link_token("oops")
end
test "does not return admin for expired token", %{token: token} do
{1, nil} = Repo.update_all(AdminToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
refute Admins.get_admin_by_magic_link_token(token)
end
end
describe "login_admin_by_magic_link/1" do
test "confirms admin and expires tokens" do
admin = unconfirmed_admin_fixture()
refute admin.confirmed_at
{encoded_token, hashed_token} = generate_admin_magic_link_token(admin)
assert {:ok, {admin, [%{token: ^hashed_token}]}} =
Admins.login_admin_by_magic_link(encoded_token)
assert admin.confirmed_at
end
test "returns admin and (deleted) token for confirmed admin" do
admin = admin_fixture()
assert admin.confirmed_at
{encoded_token, _hashed_token} = generate_admin_magic_link_token(admin)
assert {:ok, {^admin, []}} = Admins.login_admin_by_magic_link(encoded_token)
# one time use only
assert {:error, :not_found} = Admins.login_admin_by_magic_link(encoded_token)
end
test "raises when unconfirmed admin has password set" do
admin = unconfirmed_admin_fixture()
{1, nil} = Repo.update_all(Admin, set: [hashed_password: "hashed"])
{encoded_token, _hashed_token} = generate_admin_magic_link_token(admin)
assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn ->
Admins.login_admin_by_magic_link(encoded_token)
end
end
end
describe "delete_admin_session_token/1" do
test "deletes the token" do
admin = admin_fixture()
token = Admins.generate_admin_session_token(admin)
assert Admins.delete_admin_session_token(token) == :ok
refute Admins.get_admin_by_session_token(token)
end
end
describe "deliver_login_instructions/2" do
setup do
%{admin: unconfirmed_admin_fixture()}
end
test "sends token through notification", %{admin: admin} do
token =
extract_admin_token(fn url ->
Admins.deliver_login_instructions(admin, url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert admin_token = Repo.get_by(AdminToken, token: :crypto.hash(:sha256, token))
assert admin_token.admin_id == admin.id
assert admin_token.sent_to == admin.email
assert admin_token.context == "login"
end
end
describe "inspect/2 for the Admin module" do
test "does not include password" do
refute inspect(%Admin{password: "123456"}) =~ "password: \"123456\""
end
end
end

View File

@ -0,0 +1,293 @@
defmodule BeetRoundServerWeb.AdminAuthTest do
use BeetRoundServerWeb.ConnCase, async: true
alias BeetRoundServer.Admins
alias BeetRoundServer.Admins.Scope
alias BeetRoundServerWeb.AdminAuth
import BeetRoundServer.AdminsFixtures
@remember_me_cookie "_beet_round_server_web_admin_remember_me"
@remember_me_cookie_max_age 60 * 60 * 24 * 14
setup %{conn: conn} do
conn =
conn
|> Map.replace!(:secret_key_base, BeetRoundServerWeb.Endpoint.config(:secret_key_base))
|> init_test_session(%{})
%{admin: %{admin_fixture() | authenticated_at: DateTime.utc_now(:second)}, conn: conn}
end
describe "log_in_admin/3" do
test "stores the admin token in the session", %{conn: conn, admin: admin} do
conn = AdminAuth.log_in_admin(conn, admin)
assert token = get_session(conn, :admin_token)
assert redirected_to(conn) == ~p"/"
assert Admins.get_admin_by_session_token(token)
end
test "clears everything previously stored in the session", %{conn: conn, admin: admin} do
conn = conn |> put_session(:to_be_removed, "value") |> AdminAuth.log_in_admin(admin)
refute get_session(conn, :to_be_removed)
end
test "keeps session when re-authenticating", %{conn: conn, admin: admin} do
conn =
conn
|> assign(:current_scope, Scope.for_admin(admin))
|> put_session(:to_be_removed, "value")
|> AdminAuth.log_in_admin(admin)
assert get_session(conn, :to_be_removed)
end
test "clears session when admin does not match when re-authenticating", %{
conn: conn,
admin: admin
} do
other_admin = admin_fixture()
conn =
conn
|> assign(:current_scope, Scope.for_admin(other_admin))
|> put_session(:to_be_removed, "value")
|> AdminAuth.log_in_admin(admin)
refute get_session(conn, :to_be_removed)
end
test "redirects to the configured path", %{conn: conn, admin: admin} do
conn = conn |> put_session(:admin_return_to, "/hello") |> AdminAuth.log_in_admin(admin)
assert redirected_to(conn) == "/hello"
end
test "writes a cookie if remember_me is configured", %{conn: conn, admin: admin} do
conn = conn |> fetch_cookies() |> AdminAuth.log_in_admin(admin, %{"remember_me" => "true"})
assert get_session(conn, :admin_token) == conn.cookies[@remember_me_cookie]
assert get_session(conn, :admin_remember_me) == true
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert signed_token != get_session(conn, :admin_token)
assert max_age == @remember_me_cookie_max_age
end
test "writes a cookie if remember_me was set in previous session", %{conn: conn, admin: admin} do
conn = conn |> fetch_cookies() |> AdminAuth.log_in_admin(admin, %{"remember_me" => "true"})
assert get_session(conn, :admin_token) == conn.cookies[@remember_me_cookie]
assert get_session(conn, :admin_remember_me) == true
conn =
conn
|> recycle()
|> Map.replace!(:secret_key_base, BeetRoundServerWeb.Endpoint.config(:secret_key_base))
|> fetch_cookies()
|> init_test_session(%{admin_remember_me: true})
# the conn is already logged in and has the remember_me cookie set,
# now we log in again and even without explicitly setting remember_me,
# the cookie should be set again
conn = conn |> AdminAuth.log_in_admin(admin, %{})
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert signed_token != get_session(conn, :admin_token)
assert max_age == @remember_me_cookie_max_age
assert get_session(conn, :admin_remember_me) == true
end
end
describe "logout_admin/1" do
test "erases session and cookies", %{conn: conn, admin: admin} do
admin_token = Admins.generate_admin_session_token(admin)
conn =
conn
|> put_session(:admin_token, admin_token)
|> put_req_cookie(@remember_me_cookie, admin_token)
|> fetch_cookies()
|> AdminAuth.log_out_admin()
refute get_session(conn, :admin_token)
refute conn.cookies[@remember_me_cookie]
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
refute Admins.get_admin_by_session_token(admin_token)
end
test "works even if admin is already logged out", %{conn: conn} do
conn = conn |> fetch_cookies() |> AdminAuth.log_out_admin()
refute get_session(conn, :admin_token)
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
end
end
describe "fetch_current_scope_for_admin/2" do
test "authenticates admin from session", %{conn: conn, admin: admin} do
admin_token = Admins.generate_admin_session_token(admin)
conn =
conn |> put_session(:admin_token, admin_token) |> AdminAuth.fetch_current_scope_for_admin([])
assert conn.assigns.current_scope.admin.id == admin.id
assert conn.assigns.current_scope.admin.authenticated_at == admin.authenticated_at
assert get_session(conn, :admin_token) == admin_token
end
test "authenticates admin from cookies", %{conn: conn, admin: admin} do
logged_in_conn =
conn |> fetch_cookies() |> AdminAuth.log_in_admin(admin, %{"remember_me" => "true"})
admin_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)
|> AdminAuth.fetch_current_scope_for_admin([])
assert conn.assigns.current_scope.admin.id == admin.id
assert conn.assigns.current_scope.admin.authenticated_at == admin.authenticated_at
assert get_session(conn, :admin_token) == admin_token
assert get_session(conn, :admin_remember_me)
end
test "does not authenticate if data is missing", %{conn: conn, admin: admin} do
_ = Admins.generate_admin_session_token(admin)
conn = AdminAuth.fetch_current_scope_for_admin(conn, [])
refute get_session(conn, :admin_token)
refute conn.assigns.current_scope
end
test "reissues a new token after a few days and refreshes cookie", %{conn: conn, admin: admin} do
logged_in_conn =
conn |> fetch_cookies() |> AdminAuth.log_in_admin(admin, %{"remember_me" => "true"})
token = logged_in_conn.cookies[@remember_me_cookie]
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
offset_admin_token(token, -10, :day)
{admin, _} = Admins.get_admin_by_session_token(token)
conn =
conn
|> put_session(:admin_token, token)
|> put_session(:admin_remember_me, true)
|> put_req_cookie(@remember_me_cookie, signed_token)
|> AdminAuth.fetch_current_scope_for_admin([])
assert conn.assigns.current_scope.admin.id == admin.id
assert conn.assigns.current_scope.admin.authenticated_at == admin.authenticated_at
assert new_token = get_session(conn, :admin_token)
assert new_token != token
assert %{value: new_signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert new_signed_token != signed_token
assert max_age == @remember_me_cookie_max_age
end
end
describe "require_sudo_mode/2" do
test "allows admins that have authenticated in the last 10 minutes", %{conn: conn, admin: admin} do
conn =
conn
|> fetch_flash()
|> assign(:current_scope, Scope.for_admin(admin))
|> AdminAuth.require_sudo_mode([])
refute conn.halted
refute conn.status
end
test "redirects when authentication is too old", %{conn: conn, admin: admin} do
eleven_minutes_ago = DateTime.utc_now(:second) |> DateTime.add(-11, :minute)
admin = %{admin | authenticated_at: eleven_minutes_ago}
admin_token = Admins.generate_admin_session_token(admin)
{admin, token_inserted_at} = Admins.get_admin_by_session_token(admin_token)
assert DateTime.compare(token_inserted_at, admin.authenticated_at) == :gt
conn =
conn
|> fetch_flash()
|> assign(:current_scope, Scope.for_admin(admin))
|> AdminAuth.require_sudo_mode([])
assert redirected_to(conn) == ~p"/admins/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"You must re-authenticate to access this page."
end
end
describe "redirect_if_admin_is_authenticated/2" do
setup %{conn: conn} do
%{conn: AdminAuth.fetch_current_scope_for_admin(conn, [])}
end
test "redirects if admin is authenticated", %{conn: conn, admin: admin} do
conn =
conn
|> assign(:current_scope, Scope.for_admin(admin))
|> AdminAuth.redirect_if_admin_is_authenticated([])
assert conn.halted
assert redirected_to(conn) == ~p"/"
end
test "does not redirect if admin is not authenticated", %{conn: conn} do
conn = AdminAuth.redirect_if_admin_is_authenticated(conn, [])
refute conn.halted
refute conn.status
end
end
describe "require_authenticated_admin/2" do
setup %{conn: conn} do
%{conn: AdminAuth.fetch_current_scope_for_admin(conn, [])}
end
test "redirects if admin is not authenticated", %{conn: conn} do
conn = conn |> fetch_flash() |> AdminAuth.require_authenticated_admin([])
assert conn.halted
assert redirected_to(conn) == ~p"/admins/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()
|> AdminAuth.require_authenticated_admin([])
assert halted_conn.halted
assert get_session(halted_conn, :admin_return_to) == "/foo"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar=baz"}
|> fetch_flash()
|> AdminAuth.require_authenticated_admin([])
assert halted_conn.halted
assert get_session(halted_conn, :admin_return_to) == "/foo?bar=baz"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
|> fetch_flash()
|> AdminAuth.require_authenticated_admin([])
assert halted_conn.halted
refute get_session(halted_conn, :admin_return_to)
end
test "does not redirect if admin is authenticated", %{conn: conn, admin: admin} do
conn =
conn
|> assign(:current_scope, Scope.for_admin(admin))
|> AdminAuth.require_authenticated_admin([])
refute conn.halted
refute conn.status
end
end
end

View File

@ -0,0 +1,50 @@
defmodule BeetRoundServerWeb.AdminRegistrationControllerTest do
use BeetRoundServerWeb.ConnCase, async: true
import BeetRoundServer.AdminsFixtures
describe "GET /admins/register" do
test "renders registration page", %{conn: conn} do
conn = get(conn, ~p"/admins/register")
response = html_response(conn, 200)
assert response =~ "Register"
assert response =~ ~p"/admins/log-in"
assert response =~ ~p"/admins/register"
end
test "redirects if already logged in", %{conn: conn} do
conn = conn |> log_in_admin(admin_fixture()) |> get(~p"/admins/register")
assert redirected_to(conn) == ~p"/"
end
end
describe "POST /admins/register" do
@tag :capture_log
test "creates account but does not log in", %{conn: conn} do
email = unique_admin_email()
conn =
post(conn, ~p"/admins/register", %{
"admin" => valid_admin_attributes(email: email)
})
refute get_session(conn, :admin_token)
assert redirected_to(conn) == ~p"/admins/log-in"
assert conn.assigns.flash["info"] =~
~r/An email was sent to .*, please access it to confirm your account/
end
test "render errors for invalid data", %{conn: conn} do
conn =
post(conn, ~p"/admins/register", %{
"admin" => %{"email" => "with spaces"}
})
response = html_response(conn, 200)
assert response =~ "Register"
assert response =~ "must have the @ sign and no spaces"
end
end
end

View File

@ -0,0 +1,220 @@
defmodule BeetRoundServerWeb.AdminSessionControllerTest do
use BeetRoundServerWeb.ConnCase, async: true
import BeetRoundServer.AdminsFixtures
alias BeetRoundServer.Admins
setup do
%{unconfirmed_admin: unconfirmed_admin_fixture(), admin: admin_fixture()}
end
describe "GET /admins/log-in" do
test "renders login page", %{conn: conn} do
conn = get(conn, ~p"/admins/log-in")
response = html_response(conn, 200)
assert response =~ "Log in"
assert response =~ ~p"/admins/register"
assert response =~ "Log in with email"
end
test "renders login page with email filled in (sudo mode)", %{conn: conn, admin: admin} do
html =
conn
|> log_in_admin(admin)
|> get(~p"/admins/log-in")
|> html_response(200)
assert html =~ "You need to reauthenticate"
refute html =~ "Register"
assert html =~ "Log in with email"
assert html =~
~s(<input type="email" name="admin[email]" id="login_form_magic_email" value="#{admin.email}")
end
test "renders login page (email + password)", %{conn: conn} do
conn = get(conn, ~p"/admins/log-in?mode=password")
response = html_response(conn, 200)
assert response =~ "Log in"
assert response =~ ~p"/admins/register"
assert response =~ "Log in with email"
end
end
describe "GET /admins/log-in/:token" do
test "renders confirmation page for unconfirmed admin", %{conn: conn, unconfirmed_admin: admin} do
token =
extract_admin_token(fn url ->
Admins.deliver_login_instructions(admin, url)
end)
conn = get(conn, ~p"/admins/log-in/#{token}")
assert html_response(conn, 200) =~ "Confirm and stay logged in"
end
test "renders login page for confirmed admin", %{conn: conn, admin: admin} do
token =
extract_admin_token(fn url ->
Admins.deliver_login_instructions(admin, url)
end)
conn = get(conn, ~p"/admins/log-in/#{token}")
html = html_response(conn, 200)
refute html =~ "Confirm my account"
assert html =~ "Log in"
end
test "raises error for invalid token", %{conn: conn} do
conn = get(conn, ~p"/admins/log-in/invalid-token")
assert redirected_to(conn) == ~p"/admins/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"Magic link is invalid or it has expired."
end
end
describe "POST /admins/log-in - email and password" do
test "logs the admin in", %{conn: conn, admin: admin} do
admin = set_password(admin)
conn =
post(conn, ~p"/admins/log-in", %{
"admin" => %{"email" => admin.email, "password" => valid_admin_password()}
})
assert get_session(conn, :admin_token)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu
conn = get(conn, ~p"/")
response = html_response(conn, 200)
assert response =~ admin.email
assert response =~ ~p"/admins/settings"
assert response =~ ~p"/admins/log-out"
end
test "logs the admin in with remember me", %{conn: conn, admin: admin} do
admin = set_password(admin)
conn =
post(conn, ~p"/admins/log-in", %{
"admin" => %{
"email" => admin.email,
"password" => valid_admin_password(),
"remember_me" => "true"
}
})
assert conn.resp_cookies["_beet_round_server_web_admin_remember_me"]
assert redirected_to(conn) == ~p"/"
end
test "logs the admin in with return to", %{conn: conn, admin: admin} do
admin = set_password(admin)
conn =
conn
|> init_test_session(admin_return_to: "/foo/bar")
|> post(~p"/admins/log-in", %{
"admin" => %{
"email" => admin.email,
"password" => valid_admin_password()
}
})
assert redirected_to(conn) == "/foo/bar"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!"
end
test "emits error message with invalid credentials", %{conn: conn, admin: admin} do
conn =
post(conn, ~p"/admins/log-in?mode=password", %{
"admin" => %{"email" => admin.email, "password" => "invalid_password"}
})
response = html_response(conn, 200)
assert response =~ "Log in"
assert response =~ "Invalid email or password"
end
end
describe "POST /admins/log-in - magic link" do
test "sends magic link email when admin exists", %{conn: conn, admin: admin} do
conn =
post(conn, ~p"/admins/log-in", %{
"admin" => %{"email" => admin.email}
})
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
assert BeetRoundServer.Repo.get_by!(Admins.AdminToken, admin_id: admin.id).context == "login"
end
test "logs the admin in", %{conn: conn, admin: admin} do
{token, _hashed_token} = generate_admin_magic_link_token(admin)
conn =
post(conn, ~p"/admins/log-in", %{
"admin" => %{"token" => token}
})
assert get_session(conn, :admin_token)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu
conn = get(conn, ~p"/")
response = html_response(conn, 200)
assert response =~ admin.email
assert response =~ ~p"/admins/settings"
assert response =~ ~p"/admins/log-out"
end
test "confirms unconfirmed admin", %{conn: conn, unconfirmed_admin: admin} do
{token, _hashed_token} = generate_admin_magic_link_token(admin)
refute admin.confirmed_at
conn =
post(conn, ~p"/admins/log-in", %{
"admin" => %{"token" => token},
"_action" => "confirmed"
})
assert get_session(conn, :admin_token)
assert redirected_to(conn) == ~p"/"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Admin confirmed successfully."
assert Admins.get_admin!(admin.id).confirmed_at
# Now do a logged in request and assert on the menu
conn = get(conn, ~p"/")
response = html_response(conn, 200)
assert response =~ admin.email
assert response =~ ~p"/admins/settings"
assert response =~ ~p"/admins/log-out"
end
test "emits error message when magic link is invalid", %{conn: conn} do
conn =
post(conn, ~p"/admins/log-in", %{
"admin" => %{"token" => "invalid"}
})
assert html_response(conn, 200) =~ "The link is invalid or it has expired."
end
end
describe "DELETE /admins/log-out" do
test "logs the admin out", %{conn: conn, admin: admin} do
conn = conn |> log_in_admin(admin) |> delete(~p"/admins/log-out")
assert redirected_to(conn) == ~p"/"
refute get_session(conn, :admin_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end
test "succeeds even if the admin is not logged in", %{conn: conn} do
conn = delete(conn, ~p"/admins/log-out")
assert redirected_to(conn) == ~p"/"
refute get_session(conn, :admin_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end
end
end

View File

@ -0,0 +1,148 @@
defmodule BeetRoundServerWeb.AdminSettingsControllerTest do
use BeetRoundServerWeb.ConnCase, async: true
alias BeetRoundServer.Admins
import BeetRoundServer.AdminsFixtures
setup :register_and_log_in_admin
describe "GET /admins/settings" do
test "renders settings page", %{conn: conn} do
conn = get(conn, ~p"/admins/settings")
response = html_response(conn, 200)
assert response =~ "Settings"
end
test "redirects if admin is not logged in" do
conn = build_conn()
conn = get(conn, ~p"/admins/settings")
assert redirected_to(conn) == ~p"/admins/log-in"
end
@tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute)
test "redirects if admin is not in sudo mode", %{conn: conn} do
conn = get(conn, ~p"/admins/settings")
assert redirected_to(conn) == ~p"/admins/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"You must re-authenticate to access this page."
end
end
describe "PUT /admins/settings (change password form)" do
test "updates the admin password and resets tokens", %{conn: conn, admin: admin} do
new_password_conn =
put(conn, ~p"/admins/settings", %{
"action" => "update_password",
"admin" => %{
"password" => "new valid password",
"password_confirmation" => "new valid password"
}
})
assert redirected_to(new_password_conn) == ~p"/admins/settings"
assert get_session(new_password_conn, :admin_token) != get_session(conn, :admin_token)
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
"Password updated successfully"
assert Admins.get_admin_by_email_and_password(admin.email, "new valid password")
end
test "does not update password on invalid data", %{conn: conn} do
old_password_conn =
put(conn, ~p"/admins/settings", %{
"action" => "update_password",
"admin" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
response = html_response(old_password_conn, 200)
assert response =~ "Settings"
assert response =~ "should be at least 12 character(s)"
assert response =~ "does not match password"
assert get_session(old_password_conn, :admin_token) == get_session(conn, :admin_token)
end
end
describe "PUT /admins/settings (change email form)" do
@tag :capture_log
test "updates the admin email", %{conn: conn, admin: admin} do
conn =
put(conn, ~p"/admins/settings", %{
"action" => "update_email",
"admin" => %{"email" => unique_admin_email()}
})
assert redirected_to(conn) == ~p"/admins/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"A link to confirm your email"
assert Admins.get_admin_by_email(admin.email)
end
test "does not update email on invalid data", %{conn: conn} do
conn =
put(conn, ~p"/admins/settings", %{
"action" => "update_email",
"admin" => %{"email" => "with spaces"}
})
response = html_response(conn, 200)
assert response =~ "Settings"
assert response =~ "must have the @ sign and no spaces"
end
end
describe "GET /admins/settings/confirm-email/:token" do
setup %{admin: admin} do
email = unique_admin_email()
token =
extract_admin_token(fn url ->
Admins.deliver_admin_update_email_instructions(%{admin | email: email}, admin.email, url)
end)
%{token: token, email: email}
end
test "updates the admin email once", %{conn: conn, admin: admin, token: token, email: email} do
conn = get(conn, ~p"/admins/settings/confirm-email/#{token}")
assert redirected_to(conn) == ~p"/admins/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"Email changed successfully"
refute Admins.get_admin_by_email(admin.email)
assert Admins.get_admin_by_email(email)
conn = get(conn, ~p"/admins/settings/confirm-email/#{token}")
assert redirected_to(conn) == ~p"/admins/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, admin: admin} do
conn = get(conn, ~p"/admins/settings/confirm-email/oops")
assert redirected_to(conn) == ~p"/admins/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Email change link is invalid or it has expired"
assert Admins.get_admin_by_email(admin.email)
end
test "redirects if admin is not logged in", %{token: token} do
conn = build_conn()
conn = get(conn, ~p"/admins/settings/confirm-email/#{token}")
assert redirected_to(conn) == ~p"/admins/log-in"
end
end
end

View File

@ -76,4 +76,45 @@ defmodule BeetRoundServerWeb.ConnCase do
defp maybe_set_token_authenticated_at(token, authenticated_at) do
BeetRoundServer.AccountsFixtures.override_token_authenticated_at(token, authenticated_at)
end
@doc """
Setup helper that registers and logs in admins.
setup :register_and_log_in_admin
It stores an updated connection and a registered admin in the
test context.
"""
def register_and_log_in_admin(%{conn: conn} = context) do
admin = BeetRoundServer.AdminsFixtures.admin_fixture()
scope = BeetRoundServer.Admins.Scope.for_admin(admin)
opts =
context
|> Map.take([:token_authenticated_at])
|> Enum.into([])
%{conn: log_in_admin(conn, admin, opts), admin: admin, scope: scope}
end
@doc """
Logs the given `admin` into the `conn`.
It returns an updated `conn`.
"""
def log_in_admin(conn, admin, opts \\ []) do
token = BeetRoundServer.Admins.generate_admin_session_token(admin)
maybe_set_token_authenticated_at(token, opts[:token_authenticated_at])
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:admin_token, token)
end
defp maybe_set_token_authenticated_at(_token, nil), do: nil
defp maybe_set_token_authenticated_at(token, authenticated_at) do
BeetRoundServer.AdminsFixtures.override_token_authenticated_at(token, authenticated_at)
end
end

View File

@ -0,0 +1,89 @@
defmodule BeetRoundServer.AdminsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `BeetRoundServer.Admins` context.
"""
import Ecto.Query
alias BeetRoundServer.Admins
alias BeetRoundServer.Admins.Scope
def unique_admin_email, do: "admin#{System.unique_integer()}@example.com"
def valid_admin_password, do: "hello world!"
def valid_admin_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
email: unique_admin_email()
})
end
def unconfirmed_admin_fixture(attrs \\ %{}) do
{:ok, admin} =
attrs
|> valid_admin_attributes()
|> Admins.register_admin()
admin
end
def admin_fixture(attrs \\ %{}) do
admin = unconfirmed_admin_fixture(attrs)
token =
extract_admin_token(fn url ->
Admins.deliver_login_instructions(admin, url)
end)
{:ok, {admin, _expired_tokens}} =
Admins.login_admin_by_magic_link(token)
admin
end
def admin_scope_fixture do
admin = admin_fixture()
admin_scope_fixture(admin)
end
def admin_scope_fixture(admin) do
Scope.for_admin(admin)
end
def set_password(admin) do
{:ok, {admin, _expired_tokens}} =
Admins.update_admin_password(admin, %{password: valid_admin_password()})
admin
end
def extract_admin_token(fun) do
{:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
[_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
token
end
def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do
BeetRoundServer.Repo.update_all(
from(t in Admins.AdminToken,
where: t.token == ^token
),
set: [authenticated_at: authenticated_at]
)
end
def generate_admin_magic_link_token(admin) do
{encoded_token, admin_token} = Admins.AdminToken.build_email_token(admin, "login")
BeetRoundServer.Repo.insert!(admin_token)
{encoded_token, admin_token.token}
end
def offset_admin_token(token, amount_to_add, unit) do
dt = DateTime.add(DateTime.utc_now(:second), amount_to_add, unit)
BeetRoundServer.Repo.update_all(
from(ut in Admins.AdminToken, where: ut.token == ^token),
set: [inserted_at: dt, authenticated_at: dt]
)
end
end