Compare commits
34 Commits
c994ea171e
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a30fda9a4 | |||
| b2ef39df76 | |||
| 8c3e7b3ee8 | |||
| 2186a7509c | |||
| 41e32e3ff1 | |||
| 0b364f19c2 | |||
| 4f38eb36f1 | |||
| 35dbb79ccd | |||
| 38652c504d | |||
| 53d19a3a18 | |||
| a47931f40e | |||
| 6b31c6023f | |||
| 24eeacc425 | |||
| 15e21d34e8 | |||
| 60aa513a11 | |||
| b8f8cec29b | |||
| 55c128e25e | |||
| 02b473bbf1 | |||
| e0b244bd4e | |||
| 6c41f69723 | |||
| 601e08220d | |||
| 1a4a05ff18 | |||
| f6d5f8d2ca | |||
| dbaa6d136a | |||
| b7ca842cc3 | |||
| 2519560b03 | |||
| d6f2d8c1f6 | |||
| 1c02f28d25 | |||
| ebdb919245 | |||
| 6eea9d2fba | |||
| 04c79d341c | |||
| e01042130e | |||
| 647da6c9d7 | |||
| 9709dbabe2 |
122
README.md
122
README.md
@ -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
|
||||
|
||||
@ -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
|
||||
#
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
13
lib/beet_round_server/accounts/user_email.ex
Normal file
13
lib/beet_round_server/accounts/user_email.ex
Normal 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
|
||||
@ -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.
|
||||
|
||||
|
||||
328
lib/beet_round_server/admins.ex
Normal file
328
lib/beet_round_server/admins.ex
Normal 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
|
||||
134
lib/beet_round_server/admins/admin.ex
Normal file
134
lib/beet_round_server/admins/admin.ex
Normal 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
|
||||
84
lib/beet_round_server/admins/admin_notifier.ex
Normal file
84
lib/beet_round_server/admins/admin_notifier.ex
Normal 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
|
||||
195
lib/beet_round_server/admins/admin_token.ex
Normal file
195
lib/beet_round_server/admins/admin_token.ex
Normal 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
|
||||
33
lib/beet_round_server/admins/scope.ex
Normal file
33
lib/beet_round_server/admins/scope.ex
Normal 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
|
||||
@ -37,6 +37,27 @@ defmodule BeetRoundServer.BiddingRounds do
|
||||
"""
|
||||
def get_bidding_round!(id), do: Repo.get!(BiddingRound, id)
|
||||
|
||||
def get_highest_bidding_round!() do
|
||||
query =
|
||||
Ecto.Query.from(bidding_round in BiddingRound,
|
||||
order_by: [desc: bidding_round.round_number],
|
||||
limit: 1
|
||||
)
|
||||
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
def get_bidding_round_by_number!(round_number) do
|
||||
query =
|
||||
Ecto.Query.from(bidding_round in BiddingRound,
|
||||
where: bidding_round.round_number == ^round_number,
|
||||
order_by: [desc: bidding_round.inserted_at],
|
||||
limit: 1
|
||||
)
|
||||
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a bidding_round.
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ defmodule BeetRoundServer.BiddingRounds.BiddingRound do
|
||||
@foreign_key_type :binary_id
|
||||
schema "bidding_rounds" do
|
||||
field :round_number, :integer
|
||||
field :running, :boolean, default: false
|
||||
field :stopped, :boolean, default: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
@ -14,7 +14,8 @@ defmodule BeetRoundServer.BiddingRounds.BiddingRound do
|
||||
@doc false
|
||||
def changeset(bidding_round, attrs) do
|
||||
bidding_round
|
||||
|> cast(attrs, [:round_number, :running])
|
||||
|> validate_required([:round_number, :running])
|
||||
|> cast(attrs, [:round_number, :stopped])
|
||||
|> validate_required([:round_number, :stopped])
|
||||
|> unique_constraint(:round_number)
|
||||
end
|
||||
end
|
||||
|
||||
118
lib/beet_round_server/bidding_rounds/bidding_round_facade.ex
Normal file
118
lib/beet_round_server/bidding_rounds/bidding_round_facade.ex
Normal file
@ -0,0 +1,118 @@
|
||||
defmodule BeetRoundServer.BiddingRounds.BiddingRoundFacade do
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRound
|
||||
alias BeetRoundServer.BiddingRounds
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRoundServer
|
||||
|
||||
def restart_if_necessary() do
|
||||
last_round = get_highest_bidding_round()
|
||||
|
||||
if last_round.stopped == false do
|
||||
IO.puts("There is a last round, that wasn't stopped. Should be running...")
|
||||
|
||||
if !isAlive() do
|
||||
IO.puts("...but it isn't. Restarting last round...")
|
||||
restart_hightest_round()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_highest_bidding_round() do
|
||||
last_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
if last_round != nil do
|
||||
last_round
|
||||
else
|
||||
%BiddingRound{round_number: 0, stopped: true, id: "00000000-0000-0000-0000-000000000000"}
|
||||
end
|
||||
end
|
||||
|
||||
def get_current_round do
|
||||
restart_if_necessary()
|
||||
|
||||
if GenServer.whereis(CurrentRoundServer) == nil do
|
||||
IO.puts("CurrentRoundServer isn't alive. Returning 0...")
|
||||
# %BiddingRound{round_number: 0, stopped: true, id: "00000000-0000-0000-0000-000000000000"}
|
||||
0
|
||||
else
|
||||
GenServer.call(CurrentRoundServer, :val)
|
||||
end
|
||||
end
|
||||
|
||||
def start_new_round() do
|
||||
if isAlive() do
|
||||
IO.puts("CurrentRoundServer is alive! Please stop the server before starting a new round")
|
||||
{:error, "A current round is running! Please stop it, before starting a new round."}
|
||||
else
|
||||
IO.puts("CurrentRoundServer isn't alive. Starting instance...")
|
||||
|
||||
last_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
cond do
|
||||
last_round == nil ->
|
||||
IO.puts("No bidding round found. Starting first round...")
|
||||
|
||||
round_number = 1
|
||||
|
||||
BiddingRoundServer.start(round_number)
|
||||
BiddingRounds.create_bidding_round(%{round_number: round_number})
|
||||
|
||||
last_round.stopped == false ->
|
||||
IO.puts("Last bidding round not stopped. Restarting round...")
|
||||
|
||||
BiddingRoundServer.start(last_round.round_number)
|
||||
|
||||
true ->
|
||||
IO.puts("Last bidding round has stopped. Starting a new round...")
|
||||
|
||||
round_number = last_round.round_number + 1
|
||||
|
||||
BiddingRoundServer.start(round_number)
|
||||
BiddingRounds.create_bidding_round(%{round_number: round_number})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def restart_hightest_round() do
|
||||
IO.puts("Restarting hightest round...")
|
||||
|
||||
if isAlive() do
|
||||
IO.puts("Server is alive. Nothing to do...")
|
||||
IO.puts(["Current round: ", GenServer.call(CurrentRoundServer, :val)])
|
||||
else
|
||||
IO.puts("Server isn't alive. Trying to restart last round.")
|
||||
|
||||
last_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
cond do
|
||||
last_round == nil ->
|
||||
IO.puts("No bidding round found! Can't restart round...")
|
||||
{:error, "No bidding round found! Nothing to restart."}
|
||||
|
||||
true ->
|
||||
IO.puts("Last bidding round found. Restarting...")
|
||||
|
||||
BiddingRoundServer.start(last_round.round_number)
|
||||
BiddingRounds.update_bidding_round(last_round, %{stopped: false})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def stop_current_round() do
|
||||
IO.puts("Stopping current round...")
|
||||
|
||||
if isAlive() do
|
||||
IO.puts("Server is alive. Shutting down and writing to DB...")
|
||||
current_round_number = GenServer.call(CurrentRoundServer, :val)
|
||||
GenServer.stop(CurrentRoundServer)
|
||||
|
||||
current_round = BiddingRounds.get_bidding_round_by_number!(current_round_number)
|
||||
BiddingRounds.update_bidding_round(current_round, %{stopped: true})
|
||||
else
|
||||
IO.puts("Server isn't alive. Nothing to shut down.")
|
||||
end
|
||||
end
|
||||
|
||||
def isAlive() do
|
||||
GenServer.whereis(CurrentRoundServer) != nil
|
||||
end
|
||||
end
|
||||
39
lib/beet_round_server/bidding_rounds/bidding_round_server.ex
Normal file
39
lib/beet_round_server/bidding_rounds/bidding_round_server.ex
Normal file
@ -0,0 +1,39 @@
|
||||
defmodule BeetRoundServer.BiddingRounds.BiddingRoundServer do
|
||||
use GenServer
|
||||
def inc(pid), do: GenServer.cast(pid, :inc)
|
||||
def dec(pid), do: GenServer.cast(pid, :dec)
|
||||
|
||||
def val(pid) do
|
||||
GenServer.call(pid, :val)
|
||||
end
|
||||
|
||||
def stop(pid) do
|
||||
GenServer.stop(pid)
|
||||
end
|
||||
|
||||
def start(initial_val) do
|
||||
GenServer.start(__MODULE__, initial_val, name: CurrentRoundServer)
|
||||
end
|
||||
|
||||
def init(initial_val) do
|
||||
{:ok, initial_val}
|
||||
end
|
||||
|
||||
def terminate(_reason, val) do
|
||||
IO.puts("Stopping bidding round:")
|
||||
IO.puts(val)
|
||||
:ok
|
||||
end
|
||||
|
||||
def handle_cast(:inc, val) do
|
||||
{:noreply, val + 1}
|
||||
end
|
||||
|
||||
def handle_cast(:dec, val) do
|
||||
{:noreply, val - 1}
|
||||
end
|
||||
|
||||
def handle_call(:val, _from, val) do
|
||||
{:reply, val, val}
|
||||
end
|
||||
end
|
||||
@ -44,6 +44,10 @@ defmodule BeetRoundServer.Biddings do
|
||||
Repo.all_by(Bidding, user_id: scope.user.id)
|
||||
end
|
||||
|
||||
def list_biddings() do
|
||||
Repo.all(Bidding)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single bidding.
|
||||
|
||||
@ -62,6 +66,26 @@ defmodule BeetRoundServer.Biddings do
|
||||
Repo.get_by!(Bidding, id: id, user_id: scope.user.id)
|
||||
end
|
||||
|
||||
def biddings_of_round(round_number) do
|
||||
Repo.all(
|
||||
from(bidding in Bidding,
|
||||
where: bidding.bidding_round == ^round_number,
|
||||
order_by: [asc: bidding.inserted_at]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def get_most_recent_bidding(%Scope{} = scope) do
|
||||
query =
|
||||
Ecto.Query.from(bidding in Bidding,
|
||||
where: bidding.user_id == ^scope.user.id,
|
||||
order_by: [desc: bidding.inserted_at],
|
||||
limit: 1
|
||||
)
|
||||
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a bidding.
|
||||
|
||||
|
||||
231
lib/beet_round_server_web/admin_auth.ex
Normal file
231
lib/beet_round_server_web/admin_auth.ex
Normal 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
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
56
lib/beet_round_server_web/controllers/admin_controller.ex
Normal file
56
lib/beet_round_server_web/controllers/admin_controller.ex
Normal 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
|
||||
47
lib/beet_round_server_web/controllers/admin_json.ex
Normal file
47
lib/beet_round_server_web/controllers/admin_json.ex
Normal 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
|
||||
@ -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
|
||||
@ -0,0 +1,5 @@
|
||||
defmodule BeetRoundServerWeb.AdminRegistrationHTML do
|
||||
use BeetRoundServerWeb, :html
|
||||
|
||||
embed_templates "admin_registration_html/*"
|
||||
end
|
||||
@ -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>
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
|
||||
@ -0,0 +1,5 @@
|
||||
defmodule BeetRoundServerWeb.AdminSettingsHTML do
|
||||
use BeetRoundServerWeb, :html
|
||||
|
||||
embed_templates "admin_settings_html/*"
|
||||
end
|
||||
@ -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>
|
||||
59
lib/beet_round_server_web/controllers/bidding_controller.ex
Normal file
59
lib/beet_round_server_web/controllers/bidding_controller.ex
Normal file
@ -0,0 +1,59 @@
|
||||
defmodule BeetRoundServerWeb.BiddingController do
|
||||
use BeetRoundServerWeb, :controller
|
||||
|
||||
alias BeetRoundServer.Biddings
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRoundFacade
|
||||
# alias BeetRoundServer.Biddings.Bidding
|
||||
|
||||
action_fallback BeetRoundServerWeb.FallbackController
|
||||
|
||||
def index(conn, _params) do
|
||||
biddings = Biddings.list_biddings()
|
||||
IO.puts("biddings:")
|
||||
IO.inspect(biddings)
|
||||
render(conn, :index, biddings: biddings)
|
||||
end
|
||||
|
||||
def biddings_of_round(conn, %{"round_number" => round_number}) do
|
||||
biddings = Biddings.biddings_of_round(round_number)
|
||||
render(conn, :index, biddings: biddings)
|
||||
end
|
||||
|
||||
def biddings_of_highest_round(conn, _params) do
|
||||
round = BiddingRoundFacade.get_highest_bidding_round()
|
||||
IO.puts("Highest round number:")
|
||||
IO.puts(round.round_number)
|
||||
biddings = Biddings.biddings_of_round(round.round_number)
|
||||
render(conn, :index, biddings: biddings)
|
||||
end
|
||||
|
||||
# def create(conn, %{"bidding" => bidding_params}) do
|
||||
# with {:ok, %Bidding{} = bidding} <- Biddings.create_bidding(bidding_params) do
|
||||
# conn
|
||||
# |> put_status(:created)
|
||||
# |> put_resp_header("location", ~p"/api/biddings/#{bidding}")
|
||||
# |> render(:show, bidding: bidding)
|
||||
# end
|
||||
# end
|
||||
|
||||
# def show(conn, %{"id" => id}) do
|
||||
# bidding = Biddings.get_bidding!(id)
|
||||
# render(conn, :show, bidding: bidding)
|
||||
# end
|
||||
|
||||
# def update(conn, %{"id" => id, "bidding" => bidding_params}) do
|
||||
# bidding = Biddings.get_bidding!(id)
|
||||
|
||||
# with {:ok, %Bidding{} = bidding} <- Biddings.update_bidding(bidding, bidding_params) do
|
||||
# render(conn, :show, bidding: bidding)
|
||||
# end
|
||||
# end
|
||||
|
||||
# def delete(conn, %{"id" => id}) do
|
||||
# bidding = Biddings.get_bidding!(id)
|
||||
|
||||
# with {:ok, %Bidding{}} <- Biddings.delete_bidding(bidding) do
|
||||
# send_resp(conn, :no_content, "")
|
||||
# end
|
||||
# end
|
||||
end
|
||||
28
lib/beet_round_server_web/controllers/bidding_json.ex
Normal file
28
lib/beet_round_server_web/controllers/bidding_json.ex
Normal file
@ -0,0 +1,28 @@
|
||||
defmodule BeetRoundServerWeb.BiddingJSON do
|
||||
alias BeetRoundServer.Biddings.Bidding
|
||||
|
||||
@doc """
|
||||
Renders a list of biddings.
|
||||
"""
|
||||
def index(%{biddings: biddings}) do
|
||||
%{data: for(bidding <- biddings, do: data(bidding))}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a single bidding.
|
||||
"""
|
||||
def show(%{bidding: bidding}) do
|
||||
%{data: data(bidding)}
|
||||
end
|
||||
|
||||
defp data(%Bidding{} = bidding) do
|
||||
%{
|
||||
user_id: bidding.user_id,
|
||||
id: bidding.id,
|
||||
bidding_round: bidding.bidding_round,
|
||||
amount: bidding.amount,
|
||||
depot_wish_one: bidding.depot_wish_one,
|
||||
depot_wish_two: bidding.depot_wish_two
|
||||
}
|
||||
end
|
||||
end
|
||||
@ -3,22 +3,62 @@ defmodule BeetRoundServerWeb.BiddingRoundController do
|
||||
|
||||
alias BeetRoundServer.BiddingRounds
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRound
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRoundFacade
|
||||
|
||||
action_fallback BeetRoundServerWeb.FallbackController
|
||||
|
||||
def get_highest(conn, _params) do
|
||||
BiddingRoundFacade.restart_if_necessary()
|
||||
|
||||
last_round = BiddingRoundFacade.get_highest_bidding_round()
|
||||
|
||||
conn
|
||||
|> render(:show, bidding_round: last_round)
|
||||
end
|
||||
|
||||
def start_new(conn, _params) do
|
||||
BiddingRoundFacade.start_new_round()
|
||||
|
||||
current_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> render(:show, bidding_round: current_round)
|
||||
end
|
||||
|
||||
def restart(conn, _params) do
|
||||
BiddingRoundFacade.restart_hightest_round()
|
||||
|
||||
current_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> render(:show, bidding_round: current_round)
|
||||
end
|
||||
|
||||
def stop(conn, _params) do
|
||||
BiddingRoundFacade.stop_current_round()
|
||||
|
||||
stopped_round = BiddingRounds.get_highest_bidding_round!()
|
||||
|
||||
conn
|
||||
|> render(:show, bidding_round: stopped_round)
|
||||
end
|
||||
|
||||
def index(conn, _params) do
|
||||
bidding_rounds = BiddingRounds.list_bidding_rounds()
|
||||
render(conn, :index, bidding_rounds: bidding_rounds)
|
||||
end
|
||||
|
||||
def create(conn, %{"bidding_round" => bidding_round_params}) do
|
||||
with {:ok, %BiddingRound{} = bidding_round} <- BiddingRounds.create_bidding_round(bidding_round_params) do
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> put_resp_header("location", ~p"/api/bidding_rounds/#{bidding_round}")
|
||||
|> render(:show, bidding_round: bidding_round)
|
||||
end
|
||||
end
|
||||
# def create(conn, %{"bidding_round" => bidding_round_params}) do
|
||||
# with {:ok, %BiddingRound{} = bidding_round} <-
|
||||
# BiddingRounds.create_bidding_round(bidding_round_params) do
|
||||
# conn
|
||||
# |> put_status(:created)
|
||||
# |> put_resp_header("location", ~p"/api/bidding_rounds/#{bidding_round}")
|
||||
# |> render(:show, bidding_round: bidding_round)
|
||||
# end
|
||||
# end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
bidding_round = BiddingRounds.get_bidding_round!(id)
|
||||
@ -28,7 +68,8 @@ defmodule BeetRoundServerWeb.BiddingRoundController do
|
||||
def update(conn, %{"id" => id, "bidding_round" => bidding_round_params}) do
|
||||
bidding_round = BiddingRounds.get_bidding_round!(id)
|
||||
|
||||
with {:ok, %BiddingRound{} = bidding_round} <- BiddingRounds.update_bidding_round(bidding_round, bidding_round_params) do
|
||||
with {:ok, %BiddingRound{} = bidding_round} <-
|
||||
BiddingRounds.update_bidding_round(bidding_round, bidding_round_params) do
|
||||
render(conn, :show, bidding_round: bidding_round)
|
||||
end
|
||||
end
|
||||
|
||||
@ -19,7 +19,7 @@ defmodule BeetRoundServerWeb.BiddingRoundJSON do
|
||||
%{
|
||||
id: bidding_round.id,
|
||||
round_number: bidding_round.round_number,
|
||||
running: bidding_round.running
|
||||
stopped: bidding_round.stopped
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -3,35 +3,93 @@ defmodule BeetRoundServerWeb.BiddingLive.Form do
|
||||
|
||||
alias BeetRoundServer.Biddings
|
||||
alias BeetRoundServer.Biddings.Bidding
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRoundFacade
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>Use this form to manage bidding records in your database.</:subtitle>
|
||||
{@page_title} ({@current_scope.user.email})
|
||||
<: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">
|
||||
<p>
|
||||
Wenn du für einen halben Anteil bietest, orientiere dich bitte an einen Richtwert von 56 €.
|
||||
</p>
|
||||
<br />
|
||||
<p><b>Bietrunde: {@bidding.bidding_round}</b></p>
|
||||
<%= if @bidding.bidding_round == 1 do %>
|
||||
<.input field={@form[:amount]} type="number" label="Betrag/Monat" />
|
||||
<.input
|
||||
field={@form[:depot_wish_one]}
|
||||
type="select"
|
||||
label="Depot Wunsch 1"
|
||||
options={[
|
||||
{"", ""},
|
||||
{"Puramila (1)", "Puramila"},
|
||||
{"Eine Welt Aktion (2)", "Eine Welt Aktion"},
|
||||
{"KlimaWerkStadt (3)", "KlimaWerkStadt"},
|
||||
{"Buntentorsteinweg 231, Abholschrank (4)", "Buntentorsteinweg 231, Abholschrank"},
|
||||
{"Klimazone (5)", "Klimazone"},
|
||||
{"Hof von bude e.V., Abholschrank(6)", "Hof von bude e.V., Abholschrank"},
|
||||
{"Lagerhaus, Abholschrank (7)", "Lagerhaus, Abholschrank"},
|
||||
{"KARL, Abholschrank (8)", "KARL, Abholschrank"},
|
||||
{"Hof Riede (A)", "Hof Riede"},
|
||||
{"Thedinghausen (B)", "Thedinghausen"},
|
||||
{"Achim (Planung ab April) (C)", "Achim"}
|
||||
]}
|
||||
/>
|
||||
<.input
|
||||
field={@form[:depot_wish_two]}
|
||||
type="select"
|
||||
label="Depot Wunsch 2"
|
||||
options={[
|
||||
{"", ""},
|
||||
{"Puramila (1)", "Puramila"},
|
||||
{"Eine Welt Aktion (2)", "Eine Welt Aktion"},
|
||||
{"KlimaWerkStadt (3)", "KlimaWerkStadt"},
|
||||
{"Buntentorsteinweg 231, Abholschrank (4)", "Buntentorsteinweg 231, Abholschrank"},
|
||||
{"Klimazone (5)", "Klimazone"},
|
||||
{"Hof von bude e.V., Abholschrank(6)", "Hof von bude e.V., Abholschrank"},
|
||||
{"Lagerhaus, Abholschrank (7)", "Lagerhaus, Abholschrank"},
|
||||
{"KARL, Abholschrank (8)", "KARL, Abholschrank"},
|
||||
{"Hof Riede (A)", "Hof Riede"},
|
||||
{"Thedinghausen (B)", "Thedinghausen"},
|
||||
{"Achim (Planung ab April) (C)", "Achim"}
|
||||
]}
|
||||
/>
|
||||
<.input field={@form[:bidding_round]} type="hidden" readonly />
|
||||
<% else %>
|
||||
<.input field={@form[:amount]} type="number" label="Betrag/Monat" />
|
||||
<.input field={@form[:depot_wish_one]} type="hidden" readonly />
|
||||
<.input field={@form[:depot_wish_two]} type="hidden" readonly />
|
||||
<.input field={@form[:bidding_round]} type="hidden" readonly />
|
||||
<% end %>
|
||||
<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 +100,52 @@ 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()
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "New Bidding")
|
||||
|> assign(:bidding, bidding)
|
||||
|> assign(:form, to_form(Biddings.change_bidding(socket.assigns.current_scope, bidding)))
|
||||
current_bidding = Biddings.get_most_recent_bidding(socket.assigns.current_scope)
|
||||
|
||||
case current_bidding do
|
||||
nil ->
|
||||
bidding = %Bidding{
|
||||
user_id: socket.assigns.current_scope.user.id,
|
||||
bidding_round: current_round
|
||||
}
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Neues Gebot")
|
||||
|> assign(:bidding, bidding)
|
||||
|> assign(:form, to_form(Biddings.change_bidding(socket.assigns.current_scope, bidding)))
|
||||
|
||||
%Bidding{} ->
|
||||
bidding = %Bidding{
|
||||
user_id: socket.assigns.current_scope.user.id,
|
||||
bidding_round: current_round,
|
||||
depot_wish_one: current_bidding.depot_wish_one,
|
||||
depot_wish_two: current_bidding.depot_wish_two
|
||||
}
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Neues Gebot")
|
||||
|> assign(:bidding, bidding)
|
||||
|> assign(:form, to_form(Biddings.change_bidding(socket.assigns.current_scope, bidding)))
|
||||
end
|
||||
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 +154,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 +177,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)
|
||||
)}
|
||||
|
||||
@ -2,44 +2,40 @@ defmodule BeetRoundServerWeb.BiddingLive.Index do
|
||||
use BeetRoundServerWeb, :live_view
|
||||
|
||||
alias BeetRoundServer.Biddings
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRoundFacade
|
||||
|
||||
@impl true
|
||||
def render(assigns) 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>
|
||||
|
||||
<.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>
|
||||
<%= if @bidding_round == 0 do %>
|
||||
<p>Keine Bietrunde aktiv. Aktuell kein Bieten möglich!</p>
|
||||
<% else %>
|
||||
<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 %>
|
||||
|
||||
<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
|
||||
@ -50,9 +46,14 @@ defmodule BeetRoundServerWeb.BiddingLive.Index do
|
||||
Biddings.subscribe_biddings(socket.assigns.current_scope)
|
||||
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
|
||||
|
||||
@ -67,7 +68,8 @@ defmodule BeetRoundServerWeb.BiddingLive.Index do
|
||||
@impl true
|
||||
def handle_info({type, %BeetRoundServer.Biddings.Bidding{}}, socket)
|
||||
when type in [:created, :updated, :deleted] do
|
||||
{:noreply, stream(socket, :biddings, list_biddings(socket.assigns.current_scope), reset: true)}
|
||||
{:noreply,
|
||||
stream(socket, :biddings, list_biddings(socket.assigns.current_scope), reset: true)}
|
||||
end
|
||||
|
||||
defp list_biddings(current_scope) do
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,19 +20,40 @@ 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
|
||||
|
||||
resources "/bidding_rounds", BiddingRoundController, except: [:new, :edit]
|
||||
get "/bidding_rounds/get_current", BiddingRoundController, :get_highest
|
||||
get "/bidding_rounds/start_new", BiddingRoundController, :start_new
|
||||
get "/bidding_rounds/restart", BiddingRoundController, :restart
|
||||
get "/bidding_rounds/stop", BiddingRoundController, :stop
|
||||
|
||||
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
|
||||
|
||||
@ -81,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
|
||||
|
||||
@ -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>
|
||||
@ -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"
|
||||
|
||||
4
mix.exs
4
mix.exs
@ -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"},
|
||||
|
||||
14
mix.lock
14
mix.lock
@ -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"},
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
defmodule BeetRoundServer.Repo.Migrations.BiddingRoundStatusStoppedInsteadOfRunning do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:bidding_rounds) do
|
||||
add :stopped, :boolean, default: false, null: false
|
||||
remove :running
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -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
|
||||
397
test/beet_round_server/admins_test.exs
Normal file
397
test/beet_round_server/admins_test.exs
Normal 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
|
||||
@ -8,7 +8,7 @@ defmodule BeetRoundServer.BiddingRoundsTest do
|
||||
|
||||
import BeetRoundServer.BiddingRoundsFixtures
|
||||
|
||||
@invalid_attrs %{running: nil, round_number: nil}
|
||||
@invalid_attrs %{stopped: nil, round_number: nil}
|
||||
|
||||
test "list_bidding_rounds/0 returns all bidding_rounds" do
|
||||
bidding_round = bidding_round_fixture()
|
||||
@ -21,10 +21,12 @@ defmodule BeetRoundServer.BiddingRoundsTest do
|
||||
end
|
||||
|
||||
test "create_bidding_round/1 with valid data creates a bidding_round" do
|
||||
valid_attrs = %{running: true, round_number: 42}
|
||||
valid_attrs = %{stopped: true, round_number: 42}
|
||||
|
||||
assert {:ok, %BiddingRound{} = bidding_round} = BiddingRounds.create_bidding_round(valid_attrs)
|
||||
assert bidding_round.running == true
|
||||
assert {:ok, %BiddingRound{} = bidding_round} =
|
||||
BiddingRounds.create_bidding_round(valid_attrs)
|
||||
|
||||
assert bidding_round.stopped == true
|
||||
assert bidding_round.round_number == 42
|
||||
end
|
||||
|
||||
@ -34,23 +36,31 @@ defmodule BeetRoundServer.BiddingRoundsTest do
|
||||
|
||||
test "update_bidding_round/2 with valid data updates the bidding_round" do
|
||||
bidding_round = bidding_round_fixture()
|
||||
update_attrs = %{running: false, round_number: 43}
|
||||
update_attrs = %{stopped: false, round_number: 43}
|
||||
|
||||
assert {:ok, %BiddingRound{} = bidding_round} = BiddingRounds.update_bidding_round(bidding_round, update_attrs)
|
||||
assert bidding_round.running == false
|
||||
assert {:ok, %BiddingRound{} = bidding_round} =
|
||||
BiddingRounds.update_bidding_round(bidding_round, update_attrs)
|
||||
|
||||
assert bidding_round.stopped == false
|
||||
assert bidding_round.round_number == 43
|
||||
end
|
||||
|
||||
test "update_bidding_round/2 with invalid data returns error changeset" do
|
||||
bidding_round = bidding_round_fixture()
|
||||
assert {:error, %Ecto.Changeset{}} = BiddingRounds.update_bidding_round(bidding_round, @invalid_attrs)
|
||||
|
||||
assert {:error, %Ecto.Changeset{}} =
|
||||
BiddingRounds.update_bidding_round(bidding_round, @invalid_attrs)
|
||||
|
||||
assert bidding_round == BiddingRounds.get_bidding_round!(bidding_round.id)
|
||||
end
|
||||
|
||||
test "delete_bidding_round/1 deletes the bidding_round" do
|
||||
bidding_round = bidding_round_fixture()
|
||||
assert {:ok, %BiddingRound{}} = BiddingRounds.delete_bidding_round(bidding_round)
|
||||
assert_raise Ecto.NoResultsError, fn -> BiddingRounds.get_bidding_round!(bidding_round.id) end
|
||||
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
BiddingRounds.get_bidding_round!(bidding_round.id)
|
||||
end
|
||||
end
|
||||
|
||||
test "change_bidding_round/1 returns a bidding_round changeset" do
|
||||
|
||||
293
test/beet_round_server_web/admin_auth_test.exs
Normal file
293
test/beet_round_server_web/admin_auth_test.exs
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -0,0 +1,98 @@
|
||||
defmodule BeetRoundServerWeb.BiddingControllerTest do
|
||||
use BeetRoundServerWeb.ConnCase
|
||||
|
||||
import BeetRoundServer.BiddingsFixtures
|
||||
alias BeetRoundServer.Biddings.Bidding
|
||||
import BeetRoundServer.AccountsFixtures, only: [user_scope_fixture: 0]
|
||||
|
||||
@create_attrs %{
|
||||
amount: 42,
|
||||
bidding_round: 42,
|
||||
depot_wish_one: "some depot_wish_one",
|
||||
depot_wish_two: "some depot_wish_two"
|
||||
}
|
||||
@update_attrs %{
|
||||
amount: 43,
|
||||
bidding_round: 43,
|
||||
depot_wish_one: "some updated depot_wish_one",
|
||||
depot_wish_two: "some updated depot_wish_two"
|
||||
}
|
||||
@invalid_attrs %{amount: nil, bidding_round: nil, depot_wish_one: nil, depot_wish_two: nil}
|
||||
|
||||
setup %{conn: conn} do
|
||||
{:ok, conn: put_req_header(conn, "accept", "application/json")}
|
||||
end
|
||||
|
||||
describe "index" do
|
||||
test "lists all biddings", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/biddings")
|
||||
assert json_response(conn, 200)["data"] == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "create bidding" do
|
||||
test "renders bidding when data is valid", %{conn: conn} do
|
||||
conn = post(conn, ~p"/api/biddings", bidding: @create_attrs)
|
||||
assert %{"id" => id} = json_response(conn, 201)["data"]
|
||||
|
||||
conn = get(conn, ~p"/api/biddings/#{id}")
|
||||
|
||||
assert %{
|
||||
"id" => ^id,
|
||||
"amount" => 42,
|
||||
"bidding_round" => 42,
|
||||
"depot_wish_one" => "some depot_wish_one",
|
||||
"depot_wish_two" => "some depot_wish_two"
|
||||
} = json_response(conn, 200)["data"]
|
||||
end
|
||||
|
||||
test "renders errors when data is invalid", %{conn: conn} do
|
||||
conn = post(conn, ~p"/api/biddings", bidding: @invalid_attrs)
|
||||
assert json_response(conn, 422)["errors"] != %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "update bidding" do
|
||||
setup [:create_bidding]
|
||||
|
||||
test "renders bidding when data is valid", %{conn: conn, bidding: %Bidding{id: id} = bidding} do
|
||||
conn = put(conn, ~p"/api/biddings/#{bidding}", bidding: @update_attrs)
|
||||
assert %{"id" => ^id} = json_response(conn, 200)["data"]
|
||||
|
||||
conn = get(conn, ~p"/api/biddings/#{id}")
|
||||
|
||||
assert %{
|
||||
"id" => ^id,
|
||||
"amount" => 43,
|
||||
"bidding_round" => 43,
|
||||
"depot_wish_one" => "some updated depot_wish_one",
|
||||
"depot_wish_two" => "some updated depot_wish_two"
|
||||
} = json_response(conn, 200)["data"]
|
||||
end
|
||||
|
||||
test "renders errors when data is invalid", %{conn: conn, bidding: bidding} do
|
||||
conn = put(conn, ~p"/api/biddings/#{bidding}", bidding: @invalid_attrs)
|
||||
assert json_response(conn, 422)["errors"] != %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete bidding" do
|
||||
setup [:create_bidding]
|
||||
|
||||
test "deletes chosen bidding", %{conn: conn, bidding: bidding} do
|
||||
conn = delete(conn, ~p"/api/biddings/#{bidding}")
|
||||
assert response(conn, 204)
|
||||
|
||||
assert_error_sent 404, fn ->
|
||||
get(conn, ~p"/api/biddings/#{bidding}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp create_bidding(_) do
|
||||
scope = user_scope_fixture()
|
||||
bidding = bidding_fixture(scope)
|
||||
|
||||
%{bidding: bidding}
|
||||
end
|
||||
end
|
||||
@ -5,14 +5,14 @@ defmodule BeetRoundServerWeb.BiddingRoundControllerTest do
|
||||
alias BeetRoundServer.BiddingRounds.BiddingRound
|
||||
|
||||
@create_attrs %{
|
||||
running: true,
|
||||
stopped: true,
|
||||
round_number: 42
|
||||
}
|
||||
@update_attrs %{
|
||||
running: false,
|
||||
stopped: false,
|
||||
round_number: 43
|
||||
}
|
||||
@invalid_attrs %{running: nil, round_number: nil}
|
||||
@invalid_attrs %{stopped: nil, round_number: nil}
|
||||
|
||||
setup %{conn: conn} do
|
||||
{:ok, conn: put_req_header(conn, "accept", "application/json")}
|
||||
@ -35,7 +35,7 @@ defmodule BeetRoundServerWeb.BiddingRoundControllerTest do
|
||||
assert %{
|
||||
"id" => ^id,
|
||||
"round_number" => 42,
|
||||
"running" => true
|
||||
"stopped" => true
|
||||
} = json_response(conn, 200)["data"]
|
||||
end
|
||||
|
||||
@ -48,7 +48,10 @@ defmodule BeetRoundServerWeb.BiddingRoundControllerTest do
|
||||
describe "update bidding_round" do
|
||||
setup [:create_bidding_round]
|
||||
|
||||
test "renders bidding_round when data is valid", %{conn: conn, bidding_round: %BiddingRound{id: id} = bidding_round} do
|
||||
test "renders bidding_round when data is valid", %{
|
||||
conn: conn,
|
||||
bidding_round: %BiddingRound{id: id} = bidding_round
|
||||
} do
|
||||
conn = put(conn, ~p"/api/bidding_rounds/#{bidding_round}", bidding_round: @update_attrs)
|
||||
assert %{"id" => ^id} = json_response(conn, 200)["data"]
|
||||
|
||||
@ -57,7 +60,7 @@ defmodule BeetRoundServerWeb.BiddingRoundControllerTest do
|
||||
assert %{
|
||||
"id" => ^id,
|
||||
"round_number" => 43,
|
||||
"running" => false
|
||||
"stopped" => false
|
||||
} = json_response(conn, 200)["data"]
|
||||
end
|
||||
|
||||
|
||||
@ -3,6 +3,6 @@ defmodule BeetRoundServerWeb.PageControllerTest do
|
||||
|
||||
test "GET /", %{conn: conn} do
|
||||
conn = get(conn, ~p"/")
|
||||
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
|
||||
assert html_response(conn, 200) =~ "BeetRound · Das grüne Zebra"
|
||||
end
|
||||
end
|
||||
|
||||
@ -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
|
||||
|
||||
89
test/support/fixtures/admins_fixtures.ex
Normal file
89
test/support/fixtures/admins_fixtures.ex
Normal 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
|
||||
@ -12,7 +12,7 @@ defmodule BeetRoundServer.BiddingRoundsFixtures do
|
||||
attrs
|
||||
|> Enum.into(%{
|
||||
round_number: 42,
|
||||
running: true
|
||||
stopped: false
|
||||
})
|
||||
|> BeetRoundServer.BiddingRounds.create_bidding_round()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user