From 3c059a9098fb9d9aea694c880c9e472c617526de Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Tue, 20 Jan 2026 11:03:54 +0100 Subject: [PATCH 01/44] Basic Phoenix server --- .formatter.exs | 6 + .gitignore | 37 + AGENTS.md | 301 +++++ README.md | 18 + assets/css/app.css | 105 ++ assets/js/app.js | 83 ++ assets/tsconfig.json | 32 + assets/vendor/daisyui-theme.js | 124 ++ assets/vendor/daisyui.js | 1031 +++++++++++++++++ assets/vendor/heroicons.js | 43 + assets/vendor/topbar.js | 138 +++ config/config.exs | 65 ++ config/dev.exs | 88 ++ config/prod.exs | 21 + config/runtime.exs | 119 ++ config/test.exs | 37 + lib/beet_round_server.ex | 9 + lib/beet_round_server/application.ex | 34 + lib/beet_round_server/mailer.ex | 3 + lib/beet_round_server/repo.ex | 5 + lib/beet_round_server_web.ex | 114 ++ .../components/core_components.ex | 472 ++++++++ .../components/layouts.ex | 154 +++ .../components/layouts/root.html.heex | 36 + .../controllers/error_html.ex | 24 + .../controllers/error_json.ex | 21 + .../controllers/page_controller.ex | 7 + .../controllers/page_html.ex | 10 + .../controllers/page_html/home.html.heex | 202 ++++ lib/beet_round_server_web/endpoint.ex | 54 + lib/beet_round_server_web/gettext.ex | 25 + lib/beet_round_server_web/router.ex | 44 + lib/beet_round_server_web/telemetry.ex | 93 ++ mix.exs | 94 ++ mix.lock | 46 + priv/gettext/en/LC_MESSAGES/errors.po | 112 ++ priv/gettext/errors.pot | 109 ++ priv/repo/migrations/.formatter.exs | 4 + priv/repo/seeds.exs | 11 + priv/static/favicon.ico | Bin 0 -> 152 bytes priv/static/images/logo.svg | 6 + priv/static/robots.txt | 5 + .../controllers/error_html_test.exs | 14 + .../controllers/error_json_test.exs | 12 + .../controllers/page_controller_test.exs | 8 + test/support/conn_case.ex | 38 + test/support/data_case.ex | 58 + test/test_helper.exs | 2 + 48 files changed, 4074 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 assets/css/app.css create mode 100644 assets/js/app.js create mode 100644 assets/tsconfig.json create mode 100644 assets/vendor/daisyui-theme.js create mode 100644 assets/vendor/daisyui.js create mode 100644 assets/vendor/heroicons.js create mode 100644 assets/vendor/topbar.js create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/runtime.exs create mode 100644 config/test.exs create mode 100644 lib/beet_round_server.ex create mode 100644 lib/beet_round_server/application.ex create mode 100644 lib/beet_round_server/mailer.ex create mode 100644 lib/beet_round_server/repo.ex create mode 100644 lib/beet_round_server_web.ex create mode 100644 lib/beet_round_server_web/components/core_components.ex create mode 100644 lib/beet_round_server_web/components/layouts.ex create mode 100644 lib/beet_round_server_web/components/layouts/root.html.heex create mode 100644 lib/beet_round_server_web/controllers/error_html.ex create mode 100644 lib/beet_round_server_web/controllers/error_json.ex create mode 100644 lib/beet_round_server_web/controllers/page_controller.ex create mode 100644 lib/beet_round_server_web/controllers/page_html.ex create mode 100644 lib/beet_round_server_web/controllers/page_html/home.html.heex create mode 100644 lib/beet_round_server_web/endpoint.ex create mode 100644 lib/beet_round_server_web/gettext.ex create mode 100644 lib/beet_round_server_web/router.ex create mode 100644 lib/beet_round_server_web/telemetry.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 priv/gettext/en/LC_MESSAGES/errors.po create mode 100644 priv/gettext/errors.pot create mode 100644 priv/repo/migrations/.formatter.exs create mode 100644 priv/repo/seeds.exs create mode 100644 priv/static/favicon.ico create mode 100644 priv/static/images/logo.svg create mode 100644 priv/static/robots.txt create mode 100644 test/beet_round_server_web/controllers/error_html_test.exs create mode 100644 test/beet_round_server_web/controllers/error_json_test.exs create mode 100644 test/beet_round_server_web/controllers/page_controller_test.exs create mode 100644 test/support/conn_case.ex create mode 100644 test/support/data_case.ex create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..ef8840c --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,6 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd3c8c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +beet_round_server-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7952511 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,301 @@ +This is a web application written using the Phoenix web framework. + +## Project guidelines + +- Use `mix precommit` alias when you are done with all changes and fix any pending issues +- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps +### Phoenix v1.8 guidelines + +- **Always** begin your LiveView templates with `` which wraps all inner content +- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again +- Anytime you run into errors with no `current_scope` assign: + - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `` + - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed +- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module +- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar +- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will will save steps and prevent errors +- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your +custom classes must fully style the input + + + +## Elixir guidelines + +- Elixir lists **do not support index based access via the access syntax** + + **Never do this (invalid)**: + + i = 0 + mylist = ["blue", "green"] + mylist[i] + + Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: + + i = 0 + mylist = ["blue", "green"] + Enum.at(mylist, i) + +- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc + you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: + + # INVALID: we are rebinding inside the `if` and the result never gets assigned + if connected?(socket) do + socket = assign(socket, :val, val) + end + + # VALID: we rebind the result of the `if` to a new variable + socket = + if connected?(socket) do + assign(socket, :val, val) + end + +- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors +- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets +- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) +- Don't use `String.to_atom/1` on user input (memory leak risk) +- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards +- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` +- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option + +## Mix guidelines + +- Read the docs and options before using tasks (by using `mix help task_name`) +- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` +- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason + + +## Phoenix guidelines + +- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. + +- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: + + scope "/admin", AppWeb.Admin do + pipe_through :browser + + live "/users", UserLive, :index + end + + the UserLive route would point to the `AppWeb.Admin.UserLive` module + +- `Phoenix.View` no longer is needed or included with Phoenix, don't use it + + +## Ecto Guidelines + +- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` +- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` +- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` +- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed +- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields +- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct + + +## Phoenix HTML guidelines + +- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` +- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated +- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` +- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`) +- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) + +- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. + + **Never do this (invalid)**: + + <%= if condition do %> + ... + <% else if other_condition %> + ... + <% end %> + + Instead **always** do this: + + <%= cond do %> + <% condition -> %> + ... + <% condition2 -> %> + ... + <% true -> %> + ... + <% end %> + +- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
+
+      
+        let obj = {key: "val"}
+      
+
+  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
+
+- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
+
+      Text
+
+  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
+
+  and **never** do this, since it's invalid (note the missing `[` and `]`):
+
+       ...
+      => Raises compile syntax error on invalid HEEx attr syntax
+
+- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
+- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
+- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
+
+  **Always** do this:
+
+      
+ {@my_assign} + <%= if @some_block_condition do %> + {@another_assign} + <% end %> +
+ + and **Never** do this – the program will terminate with a syntax error: + + <%!-- THIS IS INVALID NEVER EVER DO THIS --%> +
+ {if @invalid_block_construct do} + {end} +
+ + +## Phoenix LiveView guidelines + +- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews +- **Avoid LiveComponent's** unless you have a strong, specific need for them +- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive` +- Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute +- **Never** write embedded ` + + + + {@inner_content} + + diff --git a/lib/beet_round_server_web/controllers/error_html.ex b/lib/beet_round_server_web/controllers/error_html.ex new file mode 100644 index 0000000..dbb1e95 --- /dev/null +++ b/lib/beet_round_server_web/controllers/error_html.ex @@ -0,0 +1,24 @@ +defmodule BeetRoundServerWeb.ErrorHTML do + @moduledoc """ + This module is invoked by your endpoint in case of errors on HTML requests. + + See config/config.exs. + """ + use BeetRoundServerWeb, :html + + # If you want to customize your error pages, + # uncomment the embed_templates/1 call below + # and add pages to the error directory: + # + # * lib/beet_round_server_web/controllers/error_html/404.html.heex + # * lib/beet_round_server_web/controllers/error_html/500.html.heex + # + # embed_templates "error_html/*" + + # The default is to render a plain text page based on + # the template name. For example, "404.html" becomes + # "Not Found". + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/beet_round_server_web/controllers/error_json.ex b/lib/beet_round_server_web/controllers/error_json.ex new file mode 100644 index 0000000..0c105aa --- /dev/null +++ b/lib/beet_round_server_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule BeetRoundServerWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/lib/beet_round_server_web/controllers/page_controller.ex b/lib/beet_round_server_web/controllers/page_controller.ex new file mode 100644 index 0000000..649bae0 --- /dev/null +++ b/lib/beet_round_server_web/controllers/page_controller.ex @@ -0,0 +1,7 @@ +defmodule BeetRoundServerWeb.PageController do + use BeetRoundServerWeb, :controller + + def home(conn, _params) do + render(conn, :home) + end +end diff --git a/lib/beet_round_server_web/controllers/page_html.ex b/lib/beet_round_server_web/controllers/page_html.ex new file mode 100644 index 0000000..7eecf03 --- /dev/null +++ b/lib/beet_round_server_web/controllers/page_html.ex @@ -0,0 +1,10 @@ +defmodule BeetRoundServerWeb.PageHTML do + @moduledoc """ + This module contains pages rendered by PageController. + + See the `page_html` directory for all templates available. + """ + use BeetRoundServerWeb, :html + + embed_templates "page_html/*" +end diff --git a/lib/beet_round_server_web/controllers/page_html/home.html.heex b/lib/beet_round_server_web/controllers/page_html/home.html.heex new file mode 100644 index 0000000..b107fd0 --- /dev/null +++ b/lib/beet_round_server_web/controllers/page_html/home.html.heex @@ -0,0 +1,202 @@ + + +
diff --git a/lib/beet_round_server_web/endpoint.ex b/lib/beet_round_server_web/endpoint.ex new file mode 100644 index 0000000..623e8a3 --- /dev/null +++ b/lib/beet_round_server_web/endpoint.ex @@ -0,0 +1,54 @@ +defmodule BeetRoundServerWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :beet_round_server + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_beet_round_server_key", + signing_salt: "/UzlZhzi", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # When code reloading is disabled (e.g., in production), + # the `gzip` option is enabled to serve compressed + # static files generated by running `phx.digest`. + plug Plug.Static, + at: "/", + from: :beet_round_server, + gzip: not code_reloading?, + only: BeetRoundServerWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :beet_round_server + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug BeetRoundServerWeb.Router +end diff --git a/lib/beet_round_server_web/gettext.ex b/lib/beet_round_server_web/gettext.ex new file mode 100644 index 0000000..88d1986 --- /dev/null +++ b/lib/beet_round_server_web/gettext.ex @@ -0,0 +1,25 @@ +defmodule BeetRoundServerWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations + that you can use in your application. To use this Gettext backend module, + call `use Gettext` and pass it as an option: + + use Gettext, backend: BeetRoundServerWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext.Backend, otp_app: :beet_round_server +end diff --git a/lib/beet_round_server_web/router.ex b/lib/beet_round_server_web/router.ex new file mode 100644 index 0000000..514cd9c --- /dev/null +++ b/lib/beet_round_server_web/router.ex @@ -0,0 +1,44 @@ +defmodule BeetRoundServerWeb.Router do + use BeetRoundServerWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {BeetRoundServerWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", BeetRoundServerWeb do + pipe_through :browser + + get "/", PageController, :home + end + + # Other scopes may use custom stacks. + # scope "/api", BeetRoundServerWeb do + # pipe_through :api + # end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:beet_round_server, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through :browser + + live_dashboard "/dashboard", metrics: BeetRoundServerWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + end +end diff --git a/lib/beet_round_server_web/telemetry.ex b/lib/beet_round_server_web/telemetry.ex new file mode 100644 index 0000000..9c192c8 --- /dev/null +++ b/lib/beet_round_server_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule BeetRoundServerWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("beet_round_server.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("beet_round_server.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("beet_round_server.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("beet_round_server.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("beet_round_server.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {BeetRoundServerWeb, :count_users, []} + ] + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..e0bc17d --- /dev/null +++ b/mix.exs @@ -0,0 +1,94 @@ +defmodule BeetRoundServer.MixProject do + use Mix.Project + + def project do + [ + app: :beet_round_server, + version: "0.1.0", + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + compilers: [:phoenix_live_view] ++ Mix.compilers(), + listeners: [Phoenix.CodeReloader] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {BeetRoundServer.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + def cli do + [ + preferred_envs: [precommit: :test] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.8.0"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.13"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.1.0"}, + {:lazy_html, ">= 0.1.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}, + {:heroicons, + github: "tailwindlabs/heroicons", + tag: "v2.2.0", + sparse: "optimized", + app: false, + compile: false, + depth: 1}, + {:swoosh, "~> 1.16"}, + {:req, "~> 0.5"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.26"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.2.0"}, + {:bandit, "~> 1.5"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind beet_round_server", "esbuild beet_round_server"], + "assets.deploy": [ + "tailwind beet_round_server --minify", + "esbuild beet_round_server --minify", + "phx.digest" + ], + precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..cde09b7 --- /dev/null +++ b/mix.lock @@ -0,0 +1,46 @@ +%{ + "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"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, + "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "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"}, + "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, + "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"}, + "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"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, + "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_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "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"}, + "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"}, + "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"}, + "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"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, +} diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..844c4f5 --- /dev/null +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,112 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot new file mode 100644 index 0000000..eef2de2 --- /dev/null +++ b/priv/gettext/errors.pot @@ -0,0 +1,109 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be %{count} byte(s)" +msgid_plural "should be %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} byte(s)" +msgid_plural "should be at least %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} byte(s)" +msgid_plural "should be at most %{count} byte(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..f19ee2c --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# BeetRoundServer.Repo.insert!(%BeetRoundServer.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y literal 0 HcmV?d00001 diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg new file mode 100644 index 0000000..9f26bab --- /dev/null +++ b/priv/static/images/logo.svg @@ -0,0 +1,6 @@ + diff --git a/priv/static/robots.txt b/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/test/beet_round_server_web/controllers/error_html_test.exs b/test/beet_round_server_web/controllers/error_html_test.exs new file mode 100644 index 0000000..95a0549 --- /dev/null +++ b/test/beet_round_server_web/controllers/error_html_test.exs @@ -0,0 +1,14 @@ +defmodule BeetRoundServerWeb.ErrorHTMLTest do + use BeetRoundServerWeb.ConnCase, async: true + + # Bring render_to_string/4 for testing custom views + import Phoenix.Template, only: [render_to_string: 4] + + test "renders 404.html" do + assert render_to_string(BeetRoundServerWeb.ErrorHTML, "404", "html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(BeetRoundServerWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" + end +end diff --git a/test/beet_round_server_web/controllers/error_json_test.exs b/test/beet_round_server_web/controllers/error_json_test.exs new file mode 100644 index 0000000..4924718 --- /dev/null +++ b/test/beet_round_server_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule BeetRoundServerWeb.ErrorJSONTest do + use BeetRoundServerWeb.ConnCase, async: true + + test "renders 404" do + assert BeetRoundServerWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert BeetRoundServerWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/test/beet_round_server_web/controllers/page_controller_test.exs b/test/beet_round_server_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..a7ab09a --- /dev/null +++ b/test/beet_round_server_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule BeetRoundServerWeb.PageControllerTest do + use BeetRoundServerWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, ~p"/") + assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..93556e9 --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,38 @@ +defmodule BeetRoundServerWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use BeetRoundServerWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint BeetRoundServerWeb.Endpoint + + use BeetRoundServerWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import BeetRoundServerWeb.ConnCase + end + end + + setup tags do + BeetRoundServer.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..e96ad7e --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,58 @@ +defmodule BeetRoundServer.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use BeetRoundServer.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + alias BeetRoundServer.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import BeetRoundServer.DataCase + end + end + + setup tags do + BeetRoundServer.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(BeetRoundServer.Repo, shared: not tags[:async]) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..42e2d36 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(BeetRoundServer.Repo, :manual) From 9ed6ad898f1fdf16aea390bc1e81a2f58691e875 Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Tue, 20 Jan 2026 14:45:43 +0100 Subject: [PATCH 02/44] After "mix phx.gen.auth Accounts User users". --- AGENTS.md | 301 ------------- config/config.exs | 13 + config/test.exs | 3 + lib/beet_round_server/accounts.ex | 297 +++++++++++++ lib/beet_round_server/accounts/scope.ex | 33 ++ lib/beet_round_server/accounts/user.ex | 134 ++++++ .../accounts/user_notifier.ex | 84 ++++ lib/beet_round_server/accounts/user_token.ex | 158 +++++++ .../components/layouts/root.html.heex | 20 + .../controllers/user_session_controller.ex | 67 +++ .../live/user_live/confirmation.ex | 94 +++++ .../live/user_live/login.ex | 131 ++++++ .../live/user_live/registration.ex | 88 ++++ .../live/user_live/settings.ex | 157 +++++++ lib/beet_round_server_web/router.ex | 31 ++ lib/beet_round_server_web/user_auth.ex | 287 +++++++++++++ mix.exs | 1 + mix.lock | 2 + ...0260120133604_create_users_auth_tables.exs | 32 ++ test/beet_round_server/accounts_test.exs | 397 ++++++++++++++++++ .../user_session_controller_test.exs | 147 +++++++ .../live/user_live/confirmation_test.exs | 118 ++++++ .../live/user_live/login_test.exs | 109 +++++ .../live/user_live/registration_test.exs | 82 ++++ .../live/user_live/settings_test.exs | 212 ++++++++++ test/beet_round_server_web/user_auth_test.exs | 390 +++++++++++++++++ test/support/conn_case.ex | 41 ++ test/support/fixtures/accounts_fixtures.ex | 89 ++++ 28 files changed, 3217 insertions(+), 301 deletions(-) delete mode 100644 AGENTS.md create mode 100644 lib/beet_round_server/accounts.ex create mode 100644 lib/beet_round_server/accounts/scope.ex create mode 100644 lib/beet_round_server/accounts/user.ex create mode 100644 lib/beet_round_server/accounts/user_notifier.ex create mode 100644 lib/beet_round_server/accounts/user_token.ex create mode 100644 lib/beet_round_server_web/controllers/user_session_controller.ex create mode 100644 lib/beet_round_server_web/live/user_live/confirmation.ex create mode 100644 lib/beet_round_server_web/live/user_live/login.ex create mode 100644 lib/beet_round_server_web/live/user_live/registration.ex create mode 100644 lib/beet_round_server_web/live/user_live/settings.ex create mode 100644 lib/beet_round_server_web/user_auth.ex create mode 100644 priv/repo/migrations/20260120133604_create_users_auth_tables.exs create mode 100644 test/beet_round_server/accounts_test.exs create mode 100644 test/beet_round_server_web/controllers/user_session_controller_test.exs create mode 100644 test/beet_round_server_web/live/user_live/confirmation_test.exs create mode 100644 test/beet_round_server_web/live/user_live/login_test.exs create mode 100644 test/beet_round_server_web/live/user_live/registration_test.exs create mode 100644 test/beet_round_server_web/live/user_live/settings_test.exs create mode 100644 test/beet_round_server_web/user_auth_test.exs create mode 100644 test/support/fixtures/accounts_fixtures.ex diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 7952511..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,301 +0,0 @@ -This is a web application written using the Phoenix web framework. - -## Project guidelines - -- Use `mix precommit` alias when you are done with all changes and fix any pending issues -- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps -### Phoenix v1.8 guidelines - -- **Always** begin your LiveView templates with `` which wraps all inner content -- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again -- Anytime you run into errors with no `current_scope` assign: - - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `` - - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed -- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module -- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar -- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will will save steps and prevent errors -- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your -custom classes must fully style the input - - - -## Elixir guidelines - -- Elixir lists **do not support index based access via the access syntax** - - **Never do this (invalid)**: - - i = 0 - mylist = ["blue", "green"] - mylist[i] - - Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: - - i = 0 - mylist = ["blue", "green"] - Enum.at(mylist, i) - -- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc - you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: - - # INVALID: we are rebinding inside the `if` and the result never gets assigned - if connected?(socket) do - socket = assign(socket, :val, val) - end - - # VALID: we rebind the result of the `if` to a new variable - socket = - if connected?(socket) do - assign(socket, :val, val) - end - -- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors -- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets -- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) -- Don't use `String.to_atom/1` on user input (memory leak risk) -- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards -- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` -- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option - -## Mix guidelines - -- Read the docs and options before using tasks (by using `mix help task_name`) -- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` -- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason - - -## Phoenix guidelines - -- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. - -- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: - - scope "/admin", AppWeb.Admin do - pipe_through :browser - - live "/users", UserLive, :index - end - - the UserLive route would point to the `AppWeb.Admin.UserLive` module - -- `Phoenix.View` no longer is needed or included with Phoenix, don't use it - - -## Ecto Guidelines - -- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` -- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` -- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` -- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed -- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields -- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct - - -## Phoenix HTML guidelines - -- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` -- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated -- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` -- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`) -- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) - -- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. - - **Never do this (invalid)**: - - <%= if condition do %> - ... - <% else if other_condition %> - ... - <% end %> - - Instead **always** do this: - - <%= cond do %> - <% condition -> %> - ... - <% condition2 -> %> - ... - <% true -> %> - ... - <% end %> - -- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
-
-      
-        let obj = {key: "val"}
-      
-
-  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
-
-- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
-
-      Text
-
-  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
-
-  and **never** do this, since it's invalid (note the missing `[` and `]`):
-
-       ...
-      => Raises compile syntax error on invalid HEEx attr syntax
-
-- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
-- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
-- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
-
-  **Always** do this:
-
-      
- {@my_assign} - <%= if @some_block_condition do %> - {@another_assign} - <% end %> -
- - and **Never** do this – the program will terminate with a syntax error: - - <%!-- THIS IS INVALID NEVER EVER DO THIS --%> -
- {if @invalid_block_construct do} - {end} -
- - -## Phoenix LiveView guidelines - -- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews -- **Avoid LiveComponent's** unless you have a strong, specific need for them -- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive` -- Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute -- **Never** write embedded ` + {@inner_content} diff --git a/lib/beet_round_server_web/controllers/user_session_controller.ex b/lib/beet_round_server_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..19ab0f1 --- /dev/null +++ b/lib/beet_round_server_web/controllers/user_session_controller.ex @@ -0,0 +1,67 @@ +defmodule BeetRoundServerWeb.UserSessionController do + use BeetRoundServerWeb, :controller + + alias BeetRoundServer.Accounts + alias BeetRoundServerWeb.UserAuth + + def create(conn, %{"_action" => "confirmed"} = params) do + create(conn, params, "User confirmed successfully.") + end + + def create(conn, params) do + create(conn, params, "Welcome back!") + end + + # magic link login + defp create(conn, %{"user" => %{"token" => token} = user_params}, info) do + case Accounts.login_user_by_magic_link(token) do + {:ok, {user, tokens_to_disconnect}} -> + UserAuth.disconnect_sessions(tokens_to_disconnect) + + conn + |> put_flash(:info, info) + |> UserAuth.log_in_user(user, user_params) + + _ -> + conn + |> put_flash(:error, "The link is invalid or it has expired.") + |> redirect(to: ~p"/users/log-in") + end + end + + # email + password login + defp create(conn, %{"user" => user_params}, info) do + %{"email" => email, "password" => password} = user_params + + if user = Accounts.get_user_by_email_and_password(email, password) do + conn + |> put_flash(:info, info) + |> UserAuth.log_in_user(user, user_params) + else + # In order to prevent user enumeration attacks, don't disclose whether the email is registered. + conn + |> put_flash(:error, "Invalid email or password") + |> put_flash(:email, String.slice(email, 0, 160)) + |> redirect(to: ~p"/users/log-in") + end + end + + def update_password(conn, %{"user" => user_params} = params) do + user = conn.assigns.current_scope.user + true = Accounts.sudo_mode?(user) + {:ok, {_user, expired_tokens}} = Accounts.update_user_password(user, user_params) + + # disconnect all existing LiveViews with old sessions + UserAuth.disconnect_sessions(expired_tokens) + + conn + |> put_session(:user_return_to, ~p"/users/settings") + |> create(params, "Password updated successfully!") + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/lib/beet_round_server_web/live/user_live/confirmation.ex b/lib/beet_round_server_web/live/user_live/confirmation.ex new file mode 100644 index 0000000..8c48a88 --- /dev/null +++ b/lib/beet_round_server_web/live/user_live/confirmation.ex @@ -0,0 +1,94 @@ +defmodule BeetRoundServerWeb.UserLive.Confirmation do + use BeetRoundServerWeb, :live_view + + alias BeetRoundServer.Accounts + + @impl true + def render(assigns) do + ~H""" + +
+
+ <.header>Welcome {@user.email} +
+ + <.form + :if={!@user.confirmed_at} + for={@form} + id="confirmation_form" + phx-mounted={JS.focus_first()} + phx-submit="submit" + action={~p"/users/log-in?_action=confirmed"} + phx-trigger-action={@trigger_submit} + > + + <.button + name={@form[:remember_me].name} + value="true" + phx-disable-with="Confirming..." + class="btn btn-primary w-full" + > + Confirm and stay logged in + + <.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2"> + Confirm and log in only this time + + + + <.form + :if={@user.confirmed_at} + for={@form} + id="login_form" + phx-submit="submit" + phx-mounted={JS.focus_first()} + action={~p"/users/log-in"} + phx-trigger-action={@trigger_submit} + > + + <%= if @current_scope do %> + <.button phx-disable-with="Logging in..." class="btn btn-primary w-full"> + Log in + + <% 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 phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2"> + Log me in only this time + + <% end %> + + +

+ Tip: If you prefer passwords, you can enable them in the user settings. +

+
+
+ """ + end + + @impl true + def mount(%{"token" => token}, _session, socket) do + if user = Accounts.get_user_by_magic_link_token(token) do + form = to_form(%{"token" => token}, as: "user") + + {:ok, assign(socket, user: user, form: form, trigger_submit: false), + temporary_assigns: [form: nil]} + else + {:ok, + socket + |> put_flash(:error, "Magic link is invalid or it has expired.") + |> push_navigate(to: ~p"/users/log-in")} + end + end + + @impl true + def handle_event("submit", %{"user" => params}, socket) do + {:noreply, assign(socket, form: to_form(params, as: "user"), trigger_submit: true)} + end +end diff --git a/lib/beet_round_server_web/live/user_live/login.ex b/lib/beet_round_server_web/live/user_live/login.ex new file mode 100644 index 0000000..ba19185 --- /dev/null +++ b/lib/beet_round_server_web/live/user_live/login.ex @@ -0,0 +1,131 @@ +defmodule BeetRoundServerWeb.UserLive.Login do + use BeetRoundServerWeb, :live_view + + alias BeetRoundServer.Accounts + + @impl true + def render(assigns) do + ~H""" + +
+
+ <.header> +

Log in

+ <: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 for an account now. + <% end %> + + +
+ +
+ <.icon name="hero-information-circle" class="size-6 shrink-0" /> +
+

You are running the local mail adapter.

+

+ To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page. +

+
+
+ + <.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 + + + +
or
+ + <.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 + + <.button class="btn btn-primary btn-soft w-full mt-2"> + Log in only this time + + +
+
+ """ + end + + @impl true + def mount(_params, _session, socket) do + email = + Phoenix.Flash.get(socket.assigns.flash, :email) || + get_in(socket.assigns, [:current_scope, Access.key(:user), Access.key(:email)]) + + form = to_form(%{"email" => email}, as: "user") + + {:ok, assign(socket, form: form, trigger_submit: false)} + end + + @impl true + def handle_event("submit_password", _params, socket) do + {:noreply, assign(socket, :trigger_submit, true)} + end + + def handle_event("submit_magic", %{"user" => %{"email" => email}}, socket) do + if user = Accounts.get_user_by_email(email) do + Accounts.deliver_login_instructions( + user, + &url(~p"/users/log-in/#{&1}") + ) + end + + info = + "If your email is in our system, you will receive instructions for logging in shortly." + + {:noreply, + socket + |> put_flash(:info, info) + |> push_navigate(to: ~p"/users/log-in")} + end + + defp local_mail_adapter? do + Application.get_env(:beet_round_server, BeetRoundServer.Mailer)[:adapter] == Swoosh.Adapters.Local + end +end diff --git a/lib/beet_round_server_web/live/user_live/registration.ex b/lib/beet_round_server_web/live/user_live/registration.ex new file mode 100644 index 0000000..44cf707 --- /dev/null +++ b/lib/beet_round_server_web/live/user_live/registration.ex @@ -0,0 +1,88 @@ +defmodule BeetRoundServerWeb.UserLive.Registration do + use BeetRoundServerWeb, :live_view + + alias BeetRoundServer.Accounts + alias BeetRoundServer.Accounts.User + + @impl true + def render(assigns) do + ~H""" + +
+
+ <.header> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline"> + Log in + + to your account now. + + +
+ + <.form for={@form} id="registration_form" phx-submit="save" phx-change="validate"> + <.input + field={@form[:email]} + type="email" + label="Email" + autocomplete="username" + required + phx-mounted={JS.focus()} + /> + + <.button phx-disable-with="Creating account..." class="btn btn-primary w-full"> + Create an account + + +
+
+ """ + end + + @impl true + def mount(_params, _session, %{assigns: %{current_scope: %{user: user}}} = socket) + when not is_nil(user) do + {:ok, redirect(socket, to: BeetRoundServerWeb.UserAuth.signed_in_path(socket))} + end + + def mount(_params, _session, socket) do + changeset = Accounts.change_user_email(%User{}, %{}, validate_unique: false) + + {:ok, assign_form(socket, changeset), temporary_assigns: [form: nil]} + end + + @impl true + def handle_event("save", %{"user" => user_params}, socket) do + case Accounts.register_user(user_params) do + {:ok, user} -> + {:ok, _} = + Accounts.deliver_login_instructions( + user, + &url(~p"/users/log-in/#{&1}") + ) + + {:noreply, + socket + |> put_flash( + :info, + "An email was sent to #{user.email}, please access it to confirm your account." + ) + |> push_navigate(to: ~p"/users/log-in")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = Accounts.change_user_email(%User{}, user_params, validate_unique: false) + {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))} + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + form = to_form(changeset, as: "user") + assign(socket, form: form) + end +end diff --git a/lib/beet_round_server_web/live/user_live/settings.ex b/lib/beet_round_server_web/live/user_live/settings.ex new file mode 100644 index 0000000..2e49a8d --- /dev/null +++ b/lib/beet_round_server_web/live/user_live/settings.ex @@ -0,0 +1,157 @@ +defmodule BeetRoundServerWeb.UserLive.Settings do + use BeetRoundServerWeb, :live_view + + on_mount {BeetRoundServerWeb.UserAuth, :require_sudo_mode} + + alias BeetRoundServer.Accounts + + @impl true + def render(assigns) do + ~H""" + +
+ <.header> + Account Settings + <:subtitle>Manage your account email address and password settings + +
+ + <.form for={@email_form} id="email_form" phx-submit="update_email" phx-change="validate_email"> + <.input + field={@email_form[:email]} + type="email" + label="Email" + autocomplete="username" + required + /> + <.button variant="primary" phx-disable-with="Changing...">Change Email + + +
+ + <.form + for={@password_form} + id="password_form" + action={~p"/users/update-password"} + method="post" + phx-change="validate_password" + phx-submit="update_password" + phx-trigger-action={@trigger_submit} + > + + <.input + field={@password_form[:password]} + type="password" + label="New password" + autocomplete="new-password" + required + /> + <.input + field={@password_form[:password_confirmation]} + type="password" + label="Confirm new password" + autocomplete="new-password" + /> + <.button variant="primary" phx-disable-with="Saving..."> + Save Password + + + + """ + end + + @impl true + def mount(%{"token" => token}, _session, socket) do + socket = + case Accounts.update_user_email(socket.assigns.current_scope.user, token) do + {:ok, _user} -> + put_flash(socket, :info, "Email changed successfully.") + + {:error, _} -> + put_flash(socket, :error, "Email change link is invalid or it has expired.") + end + + {:ok, push_navigate(socket, to: ~p"/users/settings")} + end + + def mount(_params, _session, socket) do + user = socket.assigns.current_scope.user + email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false) + password_changeset = Accounts.change_user_password(user, %{}, hash_password: false) + + socket = + socket + |> assign(:current_email, user.email) + |> assign(:email_form, to_form(email_changeset)) + |> assign(:password_form, to_form(password_changeset)) + |> assign(:trigger_submit, false) + + {:ok, socket} + end + + @impl true + def handle_event("validate_email", params, socket) do + %{"user" => user_params} = params + + email_form = + socket.assigns.current_scope.user + |> Accounts.change_user_email(user_params, validate_unique: false) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, email_form: email_form)} + end + + def handle_event("update_email", params, socket) do + %{"user" => user_params} = params + user = socket.assigns.current_scope.user + true = Accounts.sudo_mode?(user) + + case Accounts.change_user_email(user, user_params) do + %{valid?: true} = changeset -> + Accounts.deliver_user_update_email_instructions( + Ecto.Changeset.apply_action!(changeset, :insert), + user.email, + &url(~p"/users/settings/confirm-email/#{&1}") + ) + + info = "A link to confirm your email change has been sent to the new address." + {:noreply, socket |> put_flash(:info, info)} + + changeset -> + {:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))} + end + end + + def handle_event("validate_password", params, socket) do + %{"user" => user_params} = params + + password_form = + socket.assigns.current_scope.user + |> Accounts.change_user_password(user_params, hash_password: false) + |> Map.put(:action, :validate) + |> to_form() + + {:noreply, assign(socket, password_form: password_form)} + end + + def handle_event("update_password", params, socket) do + %{"user" => user_params} = params + user = socket.assigns.current_scope.user + true = Accounts.sudo_mode?(user) + + case Accounts.change_user_password(user, user_params) do + %{valid?: true} = changeset -> + {:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))} + + changeset -> + {:noreply, assign(socket, password_form: to_form(changeset, action: :insert))} + end + end +end diff --git a/lib/beet_round_server_web/router.ex b/lib/beet_round_server_web/router.ex index 514cd9c..0d56990 100644 --- a/lib/beet_round_server_web/router.ex +++ b/lib/beet_round_server_web/router.ex @@ -1,6 +1,8 @@ defmodule BeetRoundServerWeb.Router do use BeetRoundServerWeb, :router + import BeetRoundServerWeb.UserAuth + pipeline :browser do plug :accepts, ["html"] plug :fetch_session @@ -8,6 +10,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_user end pipeline :api do @@ -41,4 +44,32 @@ defmodule BeetRoundServerWeb.Router do forward "/mailbox", Plug.Swoosh.MailboxPreview end end + + ## Authentication routes + + scope "/", BeetRoundServerWeb do + pipe_through [:browser, :require_authenticated_user] + + live_session :require_authenticated_user, + on_mount: [{BeetRoundServerWeb.UserAuth, :require_authenticated}] do + live "/users/settings", UserLive.Settings, :edit + live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email + end + + post "/users/update-password", UserSessionController, :update_password + end + + scope "/", BeetRoundServerWeb do + pipe_through [:browser] + + live_session :current_user, + on_mount: [{BeetRoundServerWeb.UserAuth, :mount_current_scope}] do + live "/users/register", UserLive.Registration, :new + live "/users/log-in", UserLive.Login, :new + live "/users/log-in/:token", UserLive.Confirmation, :new + end + + post "/users/log-in", UserSessionController, :create + delete "/users/log-out", UserSessionController, :delete + end end diff --git a/lib/beet_round_server_web/user_auth.ex b/lib/beet_round_server_web/user_auth.ex new file mode 100644 index 0000000..343b204 --- /dev/null +++ b/lib/beet_round_server_web/user_auth.ex @@ -0,0 +1,287 @@ +defmodule BeetRoundServerWeb.UserAuth do + use BeetRoundServerWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias BeetRoundServer.Accounts + alias BeetRoundServer.Accounts.Scope + + # Make the remember me cookie valid for 14 days. This should match + # the session validity setting in UserToken. + @max_cookie_age_in_days 14 + @remember_me_cookie "_beet_round_server_web_user_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 user in. + + Redirects to the session's `:user_return_to` path + or falls back to the `signed_in_path/1`. + """ + def log_in_user(conn, user, params \\ %{}) do + user_return_to = get_session(conn, :user_return_to) + + conn + |> create_or_extend_session(user, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + 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 user 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_user(conn, _opts) do + with {token, conn} <- ensure_user_token(conn), + {user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do + conn + |> assign(:current_scope, Scope.for_user(user)) + |> maybe_reissue_user_session_token(user, token_inserted_at) + else + nil -> assign(conn, :current_scope, Scope.for_user(nil)) + end + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)} + else + nil + end + end + end + + # Reissue the session token if it is older than the configured reissue age. + defp maybe_reissue_user_session_token(conn, user, 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, user, %{}) + 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, user, params) do + token = Accounts.generate_user_session_token(user) + remember_me = get_session(conn, :user_remember_me) + + conn + |> renew_session(user) + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params, remember_me) + end + + # Do not renew session if the user is already logged in + # to prevent CSRF errors or data being lost in tabs that are still open + defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.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, _user) 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, _user) 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(:user_remember_me, true) + |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options) + end + + defp put_token_in_session(conn, token) do + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, user_session_topic(token)) + end + + @doc """ + Disconnects existing sockets for the given tokens. + """ + def disconnect_sessions(tokens) do + Enum.each(tokens, fn %{token: token} -> + BeetRoundServerWeb.Endpoint.broadcast(user_session_topic(token), "disconnect", %{}) + end) + end + + defp user_session_topic(token), do: "users_sessions:#{Base.url_encode64(token)}" + + @doc """ + Handles mounting and authenticating the current_scope in LiveViews. + + ## `on_mount` arguments + + * `:mount_current_scope` - Assigns current_scope + to socket assigns based on user_token, or nil if + there's no user_token or no matching user. + + * `:require_authenticated` - Authenticates the user from the session, + and assigns the current_scope to socket assigns based + on user_token. + Redirects to login page if there's no logged user. + + ## Examples + + Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate + the `current_scope`: + + defmodule BeetRoundServerWeb.PageLive do + use BeetRoundServerWeb, :live_view + + on_mount {BeetRoundServerWeb.UserAuth, :mount_current_scope} + ... + end + + Or use the `live_session` of your router to invoke the on_mount callback: + + live_session :authenticated, on_mount: [{BeetRoundServerWeb.UserAuth, :require_authenticated}] do + live "/profile", ProfileLive, :index + end + """ + def on_mount(:mount_current_scope, _params, session, socket) do + {:cont, mount_current_scope(socket, session)} + end + + def on_mount(:require_authenticated, _params, session, socket) do + socket = mount_current_scope(socket, session) + + if socket.assigns.current_scope && socket.assigns.current_scope.user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log-in") + + {:halt, socket} + end + end + + def on_mount(:require_sudo_mode, _params, session, socket) do + socket = mount_current_scope(socket, session) + + if Accounts.sudo_mode?(socket.assigns.current_scope.user, -10) do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must re-authenticate to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log-in") + + {:halt, socket} + end + end + + defp mount_current_scope(socket, session) do + Phoenix.Component.assign_new(socket, :current_scope, fn -> + {user, _} = + if user_token = session["user_token"] do + Accounts.get_user_by_session_token(user_token) + end || {nil, nil} + + Scope.for_user(user) + end) + end + + @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" + end + + def signed_in_path(_), do: ~p"/" + + @doc """ + Plug for routes that require the user to be authenticated. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns.current_scope && conn.assigns.current_scope.user do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log-in") + |> halt() + end + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn +end diff --git a/mix.exs b/mix.exs index e0bc17d..e33adc6 100644 --- a/mix.exs +++ b/mix.exs @@ -40,6 +40,7 @@ defmodule BeetRoundServer.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:bcrypt_elixir, "~> 3.0"}, {:phoenix, "~> 1.8.0"}, {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.13"}, diff --git a/mix.lock b/mix.lock index cde09b7..8a43846 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,8 @@ %{ "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"}, + "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"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, diff --git a/priv/repo/migrations/20260120133604_create_users_auth_tables.exs b/priv/repo/migrations/20260120133604_create_users_auth_tables.exs new file mode 100644 index 0000000..c6c531f --- /dev/null +++ b/priv/repo/migrations/20260120133604_create_users_auth_tables.exs @@ -0,0 +1,32 @@ +defmodule BeetRoundServer.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + create table(:users, 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(:users, [:email]) + + create table(:users_tokens, primary_key: false) do + add :id, :binary_id, primary_key: true + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + add :authenticated_at, :utc_datetime + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/test/beet_round_server/accounts_test.exs b/test/beet_round_server/accounts_test.exs new file mode 100644 index 0000000..3f0ac39 --- /dev/null +++ b/test/beet_round_server/accounts_test.exs @@ -0,0 +1,397 @@ +defmodule BeetRoundServer.AccountsTest do + use BeetRoundServer.DataCase + + alias BeetRoundServer.Accounts + + import BeetRoundServer.AccountsFixtures + alias BeetRoundServer.Accounts.{User, UserToken} + + describe "get_user_by_email/1" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email("unknown@example.com") + end + + test "returns the user if the email exists" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user_by_email(user.email) + end + end + + describe "get_user_by_email_and_password/2" do + test "does not return the user if the email does not exist" do + refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!") + end + + test "does not return the user if the password is not valid" do + user = user_fixture() |> set_password() + refute Accounts.get_user_by_email_and_password(user.email, "invalid") + end + + test "returns the user if the email and password are valid" do + %{id: id} = user = user_fixture() |> set_password() + + assert %User{id: ^id} = + Accounts.get_user_by_email_and_password(user.email, valid_user_password()) + end + end + + describe "get_user!/1" do + test "raises if id is invalid" do + assert_raise Ecto.NoResultsError, fn -> + Accounts.get_user!("11111111-1111-1111-1111-111111111111") + end + end + + test "returns the user with the given id" do + %{id: id} = user = user_fixture() + assert %User{id: ^id} = Accounts.get_user!(user.id) + end + end + + describe "register_user/1" do + test "requires email to be set" do + {:error, changeset} = Accounts.register_user(%{}) + + assert %{email: ["can't be blank"]} = errors_on(changeset) + end + + test "validates email when given" do + {:error, changeset} = Accounts.register_user(%{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} = Accounts.register_user(%{email: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email + end + + test "validates email uniqueness" do + %{email: email} = user_fixture() + {:error, changeset} = Accounts.register_user(%{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} = Accounts.register_user(%{email: String.upcase(email)}) + assert "has already been taken" in errors_on(changeset).email + end + + test "registers users without password" do + email = unique_user_email() + {:ok, user} = Accounts.register_user(valid_user_attributes(email: email)) + assert user.email == email + assert is_nil(user.hashed_password) + assert is_nil(user.confirmed_at) + assert is_nil(user.password) + end + end + + describe "sudo_mode?/2" do + test "validates the authenticated_at time" do + now = DateTime.utc_now() + + assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()}) + assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -19, :minute)}) + refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -21, :minute)}) + + # minute override + refute Accounts.sudo_mode?( + %User{authenticated_at: DateTime.add(now, -11, :minute)}, + -10 + ) + + # not authenticated + refute Accounts.sudo_mode?(%User{}) + end + end + + describe "change_user_email/3" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{}) + assert changeset.required == [:email] + end + end + + describe "deliver_user_update_email_instructions/3" do + setup do + %{user: user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(user, "current@example.com", url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "change:current@example.com" + end + end + + describe "update_user_email/2" do + setup do + user = unconfirmed_user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{user: user, token: token, email: email} + end + + test "updates the email with a valid token", %{user: user, token: token, email: email} do + assert {:ok, %{email: ^email}} = Accounts.update_user_email(user, token) + changed_user = Repo.get!(User, user.id) + assert changed_user.email != user.email + assert changed_user.email == email + refute Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email with invalid token", %{user: user} do + assert Accounts.update_user_email(user, "oops") == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if user email changed", %{user: user, token: token} do + assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + + test "does not update email if token expired", %{user: user, token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + + assert Accounts.update_user_email(user, token) == + {:error, :transaction_aborted} + + assert Repo.get!(User, user.id).email == user.email + assert Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "change_user_password/3" do + test "returns a user changeset" do + assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{}) + assert changeset.required == [:password] + end + + test "allows fields to be set" do + changeset = + Accounts.change_user_password( + %User{}, + %{ + "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_user_password/2" do + setup do + %{user: user_fixture()} + end + + test "validates password", %{user: user} do + {:error, changeset} = + Accounts.update_user_password(user, %{ + 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", %{user: user} do + too_long = String.duplicate("db", 100) + + {:error, changeset} = + Accounts.update_user_password(user, %{password: too_long}) + + assert "should be at most 72 character(s)" in errors_on(changeset).password + end + + test "updates the password", %{user: user} do + {:ok, {user, expired_tokens}} = + Accounts.update_user_password(user, %{ + password: "new valid password" + }) + + assert expired_tokens == [] + assert is_nil(user.password) + assert Accounts.get_user_by_email_and_password(user.email, "new valid password") + end + + test "deletes all tokens for the given user", %{user: user} do + _ = Accounts.generate_user_session_token(user) + + {:ok, {_, _}} = + Accounts.update_user_password(user, %{ + password: "new valid password" + }) + + refute Repo.get_by(UserToken, user_id: user.id) + end + end + + describe "generate_user_session_token/1" do + setup do + %{user: user_fixture()} + end + + test "generates a token", %{user: user} do + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.context == "session" + assert user_token.authenticated_at != nil + + # Creating the same token for another user should fail + assert_raise Ecto.ConstraintError, fn -> + Repo.insert!(%UserToken{ + token: user_token.token, + user_id: user_fixture().id, + context: "session" + }) + end + end + + test "duplicates the authenticated_at of given user in new token", %{user: user} do + user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)} + token = Accounts.generate_user_session_token(user) + assert user_token = Repo.get_by(UserToken, token: token) + assert user_token.authenticated_at == user.authenticated_at + assert DateTime.compare(user_token.inserted_at, user.authenticated_at) == :gt + end + end + + describe "get_user_by_session_token/1" do + setup do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + %{user: user, token: token} + end + + test "returns user by token", %{user: user, token: token} do + assert {session_user, token_inserted_at} = Accounts.get_user_by_session_token(token) + assert session_user.id == user.id + assert session_user.authenticated_at != nil + assert token_inserted_at != nil + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_session_token("oops") + end + + test "does not return user for expired token", %{token: token} do + dt = ~N[2020-01-01 00:00:00] + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: dt, authenticated_at: dt]) + refute Accounts.get_user_by_session_token(token) + end + end + + describe "get_user_by_magic_link_token/1" do + setup do + user = user_fixture() + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + %{user: user, token: encoded_token} + end + + test "returns user by token", %{user: user, token: token} do + assert session_user = Accounts.get_user_by_magic_link_token(token) + assert session_user.id == user.id + end + + test "does not return user for invalid token" do + refute Accounts.get_user_by_magic_link_token("oops") + end + + test "does not return user for expired token", %{token: token} do + {1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]]) + refute Accounts.get_user_by_magic_link_token(token) + end + end + + describe "login_user_by_magic_link/1" do + test "confirms user and expires tokens" do + user = unconfirmed_user_fixture() + refute user.confirmed_at + {encoded_token, hashed_token} = generate_user_magic_link_token(user) + + assert {:ok, {user, [%{token: ^hashed_token}]}} = + Accounts.login_user_by_magic_link(encoded_token) + + assert user.confirmed_at + end + + test "returns user and (deleted) token for confirmed user" do + user = user_fixture() + assert user.confirmed_at + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + assert {:ok, {^user, []}} = Accounts.login_user_by_magic_link(encoded_token) + # one time use only + assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token) + end + + test "raises when unconfirmed user has password set" do + user = unconfirmed_user_fixture() + {1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"]) + {encoded_token, _hashed_token} = generate_user_magic_link_token(user) + + assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn -> + Accounts.login_user_by_magic_link(encoded_token) + end + end + end + + describe "delete_user_session_token/1" do + test "deletes the token" do + user = user_fixture() + token = Accounts.generate_user_session_token(user) + assert Accounts.delete_user_session_token(token) == :ok + refute Accounts.get_user_by_session_token(token) + end + end + + describe "deliver_login_instructions/2" do + setup do + %{user: unconfirmed_user_fixture()} + end + + test "sends token through notification", %{user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, token} = Base.url_decode64(token, padding: false) + assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) + assert user_token.user_id == user.id + assert user_token.sent_to == user.email + assert user_token.context == "login" + end + end + + describe "inspect/2 for the User module" do + test "does not include password" do + refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" + end + end +end diff --git a/test/beet_round_server_web/controllers/user_session_controller_test.exs b/test/beet_round_server_web/controllers/user_session_controller_test.exs new file mode 100644 index 0000000..ee09e64 --- /dev/null +++ b/test/beet_round_server_web/controllers/user_session_controller_test.exs @@ -0,0 +1,147 @@ +defmodule BeetRoundServerWeb.UserSessionControllerTest do + use BeetRoundServerWeb.ConnCase, async: true + + import BeetRoundServer.AccountsFixtures + alias BeetRoundServer.Accounts + + setup do + %{unconfirmed_user: unconfirmed_user_fixture(), user: user_fixture()} + end + + describe "POST /users/log-in - email and password" do + test "logs the user in", %{conn: conn, user: user} do + user = set_password(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + assert get_session(conn, :user_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 =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log-out" + end + + test "logs the user in with remember me", %{conn: conn, user: user} do + user = set_password(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password(), + "remember_me" => "true" + } + }) + + assert conn.resp_cookies["_beet_round_server_web_user_remember_me"] + assert redirected_to(conn) == ~p"/" + end + + test "logs the user in with return to", %{conn: conn, user: user} do + user = set_password(user) + + conn = + conn + |> init_test_session(user_return_to: "/foo/bar") + |> post(~p"/users/log-in", %{ + "user" => %{ + "email" => user.email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/foo/bar" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!" + end + + test "redirects to login page with invalid credentials", %{conn: conn, user: user} do + conn = + post(conn, ~p"/users/log-in?mode=password", %{ + "user" => %{"email" => user.email, "password" => "invalid_password"} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + assert redirected_to(conn) == ~p"/users/log-in" + end + end + + describe "POST /users/log-in - magic link" do + test "logs the user in", %{conn: conn, user: user} do + {token, _hashed_token} = generate_user_magic_link_token(user) + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => token} + }) + + assert get_session(conn, :user_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 =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log-out" + end + + test "confirms unconfirmed user", %{conn: conn, unconfirmed_user: user} do + {token, _hashed_token} = generate_user_magic_link_token(user) + refute user.confirmed_at + + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => token}, + "_action" => "confirmed" + }) + + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully." + + assert Accounts.get_user!(user.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 =~ user.email + assert response =~ ~p"/users/settings" + assert response =~ ~p"/users/log-out" + end + + test "redirects to login page when magic link is invalid", %{conn: conn} do + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"token" => "invalid"} + }) + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "The link is invalid or it has expired." + + assert redirected_to(conn) == ~p"/users/log-in" + end + end + + describe "DELETE /users/log-out" do + test "logs the user out", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> delete(~p"/users/log-out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + + test "succeeds even if the user is not logged in", %{conn: conn} do + conn = delete(conn, ~p"/users/log-out") + assert redirected_to(conn) == ~p"/" + refute get_session(conn, :user_token) + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully" + end + end +end diff --git a/test/beet_round_server_web/live/user_live/confirmation_test.exs b/test/beet_round_server_web/live/user_live/confirmation_test.exs new file mode 100644 index 0000000..b4678df --- /dev/null +++ b/test/beet_round_server_web/live/user_live/confirmation_test.exs @@ -0,0 +1,118 @@ +defmodule BeetRoundServerWeb.UserLive.ConfirmationTest do + use BeetRoundServerWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import BeetRoundServer.AccountsFixtures + + alias BeetRoundServer.Accounts + + setup do + %{unconfirmed_user: unconfirmed_user_fixture(), confirmed_user: user_fixture()} + end + + describe "Confirm user" do + test "renders confirmation page for unconfirmed user", %{conn: conn, unconfirmed_user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, _lv, html} = live(conn, ~p"/users/log-in/#{token}") + assert html =~ "Confirm and stay logged in" + end + + test "renders login page for confirmed user", %{conn: conn, confirmed_user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, _lv, html} = live(conn, ~p"/users/log-in/#{token}") + refute html =~ "Confirm my account" + assert html =~ "Keep me logged in on this device" + end + + test "renders login page for already logged in user", %{conn: conn, confirmed_user: user} do + conn = log_in_user(conn, user) + + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, _lv, html} = live(conn, ~p"/users/log-in/#{token}") + refute html =~ "Confirm my account" + assert html =~ "Log in" + end + + test "confirms the given token once", %{conn: conn, unconfirmed_user: user} do + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, lv, _html} = live(conn, ~p"/users/log-in/#{token}") + + form = form(lv, "#confirmation_form", %{"user" => %{"token" => token}}) + render_submit(form) + + conn = follow_trigger_action(form, conn) + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "User confirmed successfully" + + assert Accounts.get_user!(user.id).confirmed_at + # we are logged in now + assert get_session(conn, :user_token) + assert redirected_to(conn) == ~p"/" + + # log out, new conn + conn = build_conn() + + {:ok, _lv, html} = + live(conn, ~p"/users/log-in/#{token}") + |> follow_redirect(conn, ~p"/users/log-in") + + assert html =~ "Magic link is invalid or it has expired" + end + + test "logs confirmed user in without changing confirmed_at", %{ + conn: conn, + confirmed_user: user + } do + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, lv, _html} = live(conn, ~p"/users/log-in/#{token}") + + form = form(lv, "#login_form", %{"user" => %{"token" => token}}) + render_submit(form) + + conn = follow_trigger_action(form, conn) + + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ + "Welcome back!" + + assert Accounts.get_user!(user.id).confirmed_at == user.confirmed_at + + # log out, new conn + conn = build_conn() + + {:ok, _lv, html} = + live(conn, ~p"/users/log-in/#{token}") + |> follow_redirect(conn, ~p"/users/log-in") + + assert html =~ "Magic link is invalid or it has expired" + end + + test "raises error for invalid token", %{conn: conn} do + {:ok, _lv, html} = + live(conn, ~p"/users/log-in/invalid-token") + |> follow_redirect(conn, ~p"/users/log-in") + + assert html =~ "Magic link is invalid or it has expired" + end + end +end diff --git a/test/beet_round_server_web/live/user_live/login_test.exs b/test/beet_round_server_web/live/user_live/login_test.exs new file mode 100644 index 0000000..877826c --- /dev/null +++ b/test/beet_round_server_web/live/user_live/login_test.exs @@ -0,0 +1,109 @@ +defmodule BeetRoundServerWeb.UserLive.LoginTest do + use BeetRoundServerWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import BeetRoundServer.AccountsFixtures + + describe "login page" do + test "renders login page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/users/log-in") + + assert html =~ "Log in" + assert html =~ "Register" + assert html =~ "Log in with email" + end + end + + describe "user login - magic link" do + test "sends magic link email when user exists", %{conn: conn} do + user = user_fixture() + + {:ok, lv, _html} = live(conn, ~p"/users/log-in") + + {:ok, _lv, html} = + form(lv, "#login_form_magic", user: %{email: user.email}) + |> render_submit() + |> follow_redirect(conn, ~p"/users/log-in") + + assert html =~ "If your email is in our system" + + assert BeetRoundServer.Repo.get_by!(BeetRoundServer.Accounts.UserToken, user_id: user.id).context == + "login" + end + + test "does not disclose if user is registered", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/log-in") + + {:ok, _lv, html} = + form(lv, "#login_form_magic", user: %{email: "idonotexist@example.com"}) + |> render_submit() + |> follow_redirect(conn, ~p"/users/log-in") + + assert html =~ "If your email is in our system" + end + end + + describe "user login - password" do + test "redirects if user logs in with valid credentials", %{conn: conn} do + user = user_fixture() |> set_password() + + {:ok, lv, _html} = live(conn, ~p"/users/log-in") + + form = + form(lv, "#login_form_password", + user: %{email: user.email, password: valid_user_password(), remember_me: true} + ) + + conn = submit_form(form, conn) + + assert redirected_to(conn) == ~p"/" + end + + test "redirects to login page with a flash error if credentials are invalid", %{ + conn: conn + } do + {:ok, lv, _html} = live(conn, ~p"/users/log-in") + + form = + form(lv, "#login_form_password", user: %{email: "test@email.com", password: "123456"}) + + render_submit(form, %{user: %{remember_me: true}}) + + conn = follow_trigger_action(form, conn) + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password" + assert redirected_to(conn) == ~p"/users/log-in" + end + end + + describe "login navigation" do + test "redirects to registration page when the Register button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/log-in") + + {:ok, _login_live, login_html} = + lv + |> element("main a", "Sign up") + |> render_click() + |> follow_redirect(conn, ~p"/users/register") + + assert login_html =~ "Register" + end + end + + describe "re-authentication (sudo mode)" do + setup %{conn: conn} do + user = user_fixture() + %{user: user, conn: log_in_user(conn, user)} + end + + test "shows login page with email filled in", %{conn: conn, user: user} do + {:ok, _lv, html} = live(conn, ~p"/users/log-in") + + assert html =~ "You need to reauthenticate" + refute html =~ "Register" + assert html =~ "Log in with email" + + assert html =~ + ~s( log_in_user(user_fixture()) + |> live(~p"/users/register") + |> follow_redirect(conn, ~p"/") + + assert {:ok, _conn} = result + end + + test "renders errors for invalid data", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + result = + lv + |> element("#registration_form") + |> render_change(user: %{"email" => "with spaces"}) + + assert result =~ "Register" + assert result =~ "must have the @ sign and no spaces" + end + end + + describe "register user" do + test "creates account but does not log in", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + email = unique_user_email() + form = form(lv, "#registration_form", user: valid_user_attributes(email: email)) + + {:ok, _lv, html} = + render_submit(form) + |> follow_redirect(conn, ~p"/users/log-in") + + assert html =~ + ~r/An email was sent to .*, please access it to confirm your account/ + end + + test "renders errors for duplicated email", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + user = user_fixture(%{email: "test@email.com"}) + + result = + lv + |> form("#registration_form", + user: %{"email" => user.email} + ) + |> render_submit() + + assert result =~ "has already been taken" + end + end + + describe "registration navigation" do + test "redirects to login page when the Log in button is clicked", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/register") + + {:ok, _login_live, login_html} = + lv + |> element("main a", "Log in") + |> render_click() + |> follow_redirect(conn, ~p"/users/log-in") + + assert login_html =~ "Log in" + end + end +end diff --git a/test/beet_round_server_web/live/user_live/settings_test.exs b/test/beet_round_server_web/live/user_live/settings_test.exs new file mode 100644 index 0000000..452ea39 --- /dev/null +++ b/test/beet_round_server_web/live/user_live/settings_test.exs @@ -0,0 +1,212 @@ +defmodule BeetRoundServerWeb.UserLive.SettingsTest do + use BeetRoundServerWeb.ConnCase, async: true + + alias BeetRoundServer.Accounts + import Phoenix.LiveViewTest + import BeetRoundServer.AccountsFixtures + + describe "Settings page" do + test "renders settings page", %{conn: conn} do + {:ok, _lv, html} = + conn + |> log_in_user(user_fixture()) + |> live(~p"/users/settings") + + assert html =~ "Change Email" + assert html =~ "Save Password" + end + + test "redirects if user is not logged in", %{conn: conn} do + assert {:error, redirect} = live(conn, ~p"/users/settings") + + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log-in" + assert %{"error" => "You must log in to access this page."} = flash + end + + test "redirects if user is not in sudo mode", %{conn: conn} do + {:ok, conn} = + conn + |> log_in_user(user_fixture(), + token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute) + ) + |> live(~p"/users/settings") + |> follow_redirect(conn, ~p"/users/log-in") + + assert conn.resp_body =~ "You must re-authenticate to access this page." + end + end + + describe "update email form" do + setup %{conn: conn} do + user = user_fixture() + %{conn: log_in_user(conn, user), user: user} + end + + test "updates the user email", %{conn: conn, user: user} do + new_email = unique_user_email() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "user" => %{"email" => new_email} + }) + |> render_submit() + + assert result =~ "A link to confirm your email" + assert Accounts.get_user_by_email(user.email) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#email_form") + |> render_change(%{ + "action" => "update_email", + "user" => %{"email" => "with spaces"} + }) + + assert result =~ "Change Email" + assert result =~ "must have the @ sign and no spaces" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#email_form", %{ + "user" => %{"email" => user.email} + }) + |> render_submit() + + assert result =~ "Change Email" + assert result =~ "did not change" + end + end + + describe "update password form" do + setup %{conn: conn} do + user = user_fixture() + %{conn: log_in_user(conn, user), user: user} + end + + test "updates the user password", %{conn: conn, user: user} do + new_password = valid_user_password() + + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + form = + form(lv, "#password_form", %{ + "user" => %{ + "email" => user.email, + "password" => new_password, + "password_confirmation" => new_password + } + }) + + render_submit(form) + + new_password_conn = follow_trigger_action(form, conn) + + assert redirected_to(new_password_conn) == ~p"/users/settings" + + assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) + + assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~ + "Password updated successfully" + + assert Accounts.get_user_by_email_and_password(user.email, new_password) + end + + test "renders errors with invalid data (phx-change)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> element("#password_form") + |> render_change(%{ + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + + assert result =~ "Save Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + + test "renders errors with invalid data (phx-submit)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/users/settings") + + result = + lv + |> form("#password_form", %{ + "user" => %{ + "password" => "too short", + "password_confirmation" => "does not match" + } + }) + |> render_submit() + + assert result =~ "Save Password" + assert result =~ "should be at least 12 character(s)" + assert result =~ "does not match password" + end + end + + describe "confirm email" do + setup %{conn: conn} do + user = user_fixture() + email = unique_user_email() + + token = + extract_user_token(fn url -> + Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url) + end) + + %{conn: log_in_user(conn, user), token: token, email: email, user: user} + end + + test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm-email/#{token}") + + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"info" => message} = flash + assert message == "Email changed successfully." + refute Accounts.get_user_by_email(user.email) + assert Accounts.get_user_by_email(email) + + # use confirm token again + {:error, redirect} = live(conn, ~p"/users/settings/confirm-email/#{token}") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + end + + test "does not update email with invalid token", %{conn: conn, user: user} do + {:error, redirect} = live(conn, ~p"/users/settings/confirm-email/oops") + assert {:live_redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/settings" + assert %{"error" => message} = flash + assert message == "Email change link is invalid or it has expired." + assert Accounts.get_user_by_email(user.email) + end + + test "redirects if user is not logged in", %{token: token} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/users/settings/confirm-email/#{token}") + assert {:redirect, %{to: path, flash: flash}} = redirect + assert path == ~p"/users/log-in" + assert %{"error" => message} = flash + assert message == "You must log in to access this page." + end + end +end diff --git a/test/beet_round_server_web/user_auth_test.exs b/test/beet_round_server_web/user_auth_test.exs new file mode 100644 index 0000000..1759c23 --- /dev/null +++ b/test/beet_round_server_web/user_auth_test.exs @@ -0,0 +1,390 @@ +defmodule BeetRoundServerWeb.UserAuthTest do + use BeetRoundServerWeb.ConnCase, async: true + + alias Phoenix.LiveView + alias BeetRoundServer.Accounts + alias BeetRoundServer.Accounts.Scope + alias BeetRoundServerWeb.UserAuth + + import BeetRoundServer.AccountsFixtures + + @remember_me_cookie "_beet_round_server_web_user_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(%{}) + + %{user: %{user_fixture() | authenticated_at: DateTime.utc_now(:second)}, conn: conn} + end + + describe "log_in_user/3" do + test "stores the user token in the session", %{conn: conn, user: user} do + conn = UserAuth.log_in_user(conn, user) + assert token = get_session(conn, :user_token) + assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" + assert redirected_to(conn) == ~p"/" + assert Accounts.get_user_by_session_token(token) + end + + test "clears everything previously stored in the session", %{conn: conn, user: user} do + conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) + refute get_session(conn, :to_be_removed) + end + + test "keeps session when re-authenticating", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> put_session(:to_be_removed, "value") + |> UserAuth.log_in_user(user) + + assert get_session(conn, :to_be_removed) + end + + test "clears session when user does not match when re-authenticating", %{ + conn: conn, + user: user + } do + other_user = user_fixture() + + conn = + conn + |> assign(:current_scope, Scope.for_user(other_user)) + |> put_session(:to_be_removed, "value") + |> UserAuth.log_in_user(user) + + refute get_session(conn, :to_be_removed) + end + + test "redirects to the configured path", %{conn: conn, user: user} do + conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) + assert redirected_to(conn) == "/hello" + end + + test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + assert get_session(conn, :user_remember_me) == true + + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == @remember_me_cookie_max_age + end + + test "redirects to settings when user is already logged in", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.log_in_user(user) + + assert redirected_to(conn) == ~p"/users/settings" + end + + test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do + conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] + assert get_session(conn, :user_remember_me) == true + + conn = + conn + |> recycle() + |> Map.replace!(:secret_key_base, BeetRoundServerWeb.Endpoint.config(:secret_key_base)) + |> fetch_cookies() + |> init_test_session(%{user_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 |> UserAuth.log_in_user(user, %{}) + assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] + assert signed_token != get_session(conn, :user_token) + assert max_age == @remember_me_cookie_max_age + assert get_session(conn, :user_remember_me) == true + end + end + + describe "logout_user/1" do + test "erases session and cookies", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + |> fetch_cookies() + |> UserAuth.log_out_user() + + refute get_session(conn, :user_token) + refute conn.cookies[@remember_me_cookie] + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + refute Accounts.get_user_by_session_token(user_token) + end + + test "broadcasts to the given live_socket_id", %{conn: conn} do + live_socket_id = "users_sessions:abcdef-token" + BeetRoundServerWeb.Endpoint.subscribe(live_socket_id) + + conn + |> put_session(:live_socket_id, live_socket_id) + |> UserAuth.log_out_user() + + assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id} + end + + test "works even if user is already logged out", %{conn: conn} do + conn = conn |> fetch_cookies() |> UserAuth.log_out_user() + refute get_session(conn, :user_token) + assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] + assert redirected_to(conn) == ~p"/" + end + end + + describe "fetch_current_scope_for_user/2" do + test "authenticates user from session", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + + conn = + conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert get_session(conn, :user_token) == user_token + end + + test "authenticates user from cookies", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + user_token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + conn = + conn + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert get_session(conn, :user_token) == user_token + assert get_session(conn, :user_remember_me) + + assert get_session(conn, :live_socket_id) == + "users_sessions:#{Base.url_encode64(user_token)}" + end + + test "does not authenticate if data is missing", %{conn: conn, user: user} do + _ = Accounts.generate_user_session_token(user) + conn = UserAuth.fetch_current_scope_for_user(conn, []) + refute get_session(conn, :user_token) + refute conn.assigns.current_scope + end + + test "reissues a new token after a few days and refreshes cookie", %{conn: conn, user: user} do + logged_in_conn = + conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) + + token = logged_in_conn.cookies[@remember_me_cookie] + %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] + + offset_user_token(token, -10, :day) + {user, _} = Accounts.get_user_by_session_token(token) + + conn = + conn + |> put_session(:user_token, token) + |> put_session(:user_remember_me, true) + |> put_req_cookie(@remember_me_cookie, signed_token) + |> UserAuth.fetch_current_scope_for_user([]) + + assert conn.assigns.current_scope.user.id == user.id + assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at + assert new_token = get_session(conn, :user_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 "on_mount :mount_current_scope" do + setup %{conn: conn} do + %{conn: UserAuth.fetch_current_scope_for_user(conn, [])} + end + + test "assigns current_scope based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_scope, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_scope.user.id == user.id + end + + test "assigns nil to current_scope assign if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_scope, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_scope == nil + end + + test "assigns nil to current_scope assign if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:mount_current_scope, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_scope == nil + end + end + + describe "on_mount :require_authenticated" do + test "authenticates current_scope based on a valid user_token", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + {:cont, updated_socket} = + UserAuth.on_mount(:require_authenticated, %{}, session, %LiveView.Socket{}) + + assert updated_socket.assigns.current_scope.user.id == user.id + end + + test "redirects to login page if there isn't a valid user_token", %{conn: conn} do + user_token = "invalid_token" + session = conn |> put_session(:user_token, user_token) |> get_session() + + socket = %LiveView.Socket{ + endpoint: BeetRoundServerWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:require_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_scope == nil + end + + test "redirects to login page if there isn't a user_token", %{conn: conn} do + session = conn |> get_session() + + socket = %LiveView.Socket{ + endpoint: BeetRoundServerWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + {:halt, updated_socket} = UserAuth.on_mount(:require_authenticated, %{}, session, socket) + assert updated_socket.assigns.current_scope == nil + end + end + + describe "on_mount :require_sudo_mode" do + test "allows users that have authenticated in the last 10 minutes", %{conn: conn, user: user} do + user_token = Accounts.generate_user_session_token(user) + session = conn |> put_session(:user_token, user_token) |> get_session() + + socket = %LiveView.Socket{ + endpoint: BeetRoundServerWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + assert {:cont, _updated_socket} = + UserAuth.on_mount(:require_sudo_mode, %{}, session, socket) + end + + test "redirects when authentication is too old", %{conn: conn, user: user} do + eleven_minutes_ago = DateTime.utc_now(:second) |> DateTime.add(-11, :minute) + user = %{user | authenticated_at: eleven_minutes_ago} + user_token = Accounts.generate_user_session_token(user) + {user, token_inserted_at} = Accounts.get_user_by_session_token(user_token) + assert DateTime.compare(token_inserted_at, user.authenticated_at) == :gt + session = conn |> put_session(:user_token, user_token) |> get_session() + + socket = %LiveView.Socket{ + endpoint: BeetRoundServerWeb.Endpoint, + assigns: %{__changed__: %{}, flash: %{}} + } + + assert {:halt, _updated_socket} = + UserAuth.on_mount(:require_sudo_mode, %{}, session, socket) + end + end + + describe "require_authenticated_user/2" do + setup %{conn: conn} do + %{conn: UserAuth.fetch_current_scope_for_user(conn, [])} + end + + test "redirects if user is not authenticated", %{conn: conn} do + conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) + assert conn.halted + + assert redirected_to(conn) == ~p"/users/log-in" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You must log in to access this page." + end + + test "stores the path to redirect to on GET", %{conn: conn} do + halted_conn = + %{conn | path_info: ["foo"], query_string: ""} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar=baz"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" + + halted_conn = + %{conn | path_info: ["foo"], query_string: "bar", method: "POST"} + |> fetch_flash() + |> UserAuth.require_authenticated_user([]) + + assert halted_conn.halted + refute get_session(halted_conn, :user_return_to) + end + + test "does not redirect if user is authenticated", %{conn: conn, user: user} do + conn = + conn + |> assign(:current_scope, Scope.for_user(user)) + |> UserAuth.require_authenticated_user([]) + + refute conn.halted + refute conn.status + end + end + + describe "disconnect_sessions/1" do + test "broadcasts disconnect messages for each token" do + tokens = [%{token: "token1"}, %{token: "token2"}] + + for %{token: token} <- tokens do + BeetRoundServerWeb.Endpoint.subscribe("users_sessions:#{Base.url_encode64(token)}") + end + + UserAuth.disconnect_sessions(tokens) + + assert_receive %Phoenix.Socket.Broadcast{ + event: "disconnect", + topic: "users_sessions:dG9rZW4x" + } + + assert_receive %Phoenix.Socket.Broadcast{ + event: "disconnect", + topic: "users_sessions:dG9rZW4y" + } + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 93556e9..ca8b0a5 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,4 +35,45 @@ defmodule BeetRoundServerWeb.ConnCase do BeetRoundServer.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end + + @doc """ + Setup helper that registers and logs in users. + + setup :register_and_log_in_user + + It stores an updated connection and a registered user in the + test context. + """ + def register_and_log_in_user(%{conn: conn} = context) do + user = BeetRoundServer.AccountsFixtures.user_fixture() + scope = BeetRoundServer.Accounts.Scope.for_user(user) + + opts = + context + |> Map.take([:token_authenticated_at]) + |> Enum.into([]) + + %{conn: log_in_user(conn, user, opts), user: user, scope: scope} + end + + @doc """ + Logs the given `user` into the `conn`. + + It returns an updated `conn`. + """ + def log_in_user(conn, user, opts \\ []) do + token = BeetRoundServer.Accounts.generate_user_session_token(user) + + maybe_set_token_authenticated_at(token, opts[:token_authenticated_at]) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(:user_token, token) + end + + defp maybe_set_token_authenticated_at(_token, nil), do: nil + + defp maybe_set_token_authenticated_at(token, authenticated_at) do + BeetRoundServer.AccountsFixtures.override_token_authenticated_at(token, authenticated_at) + end end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex new file mode 100644 index 0000000..41d7940 --- /dev/null +++ b/test/support/fixtures/accounts_fixtures.ex @@ -0,0 +1,89 @@ +defmodule BeetRoundServer.AccountsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `BeetRoundServer.Accounts` context. + """ + + import Ecto.Query + + alias BeetRoundServer.Accounts + alias BeetRoundServer.Accounts.Scope + + def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def valid_user_password, do: "hello world!" + + def valid_user_attributes(attrs \\ %{}) do + Enum.into(attrs, %{ + email: unique_user_email() + }) + end + + def unconfirmed_user_fixture(attrs \\ %{}) do + {:ok, user} = + attrs + |> valid_user_attributes() + |> Accounts.register_user() + + user + end + + def user_fixture(attrs \\ %{}) do + user = unconfirmed_user_fixture(attrs) + + token = + extract_user_token(fn url -> + Accounts.deliver_login_instructions(user, url) + end) + + {:ok, {user, _expired_tokens}} = + Accounts.login_user_by_magic_link(token) + + user + end + + def user_scope_fixture do + user = user_fixture() + user_scope_fixture(user) + end + + def user_scope_fixture(user) do + Scope.for_user(user) + end + + def set_password(user) do + {:ok, {user, _expired_tokens}} = + Accounts.update_user_password(user, %{password: valid_user_password()}) + + user + end + + def extract_user_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 Accounts.UserToken, + where: t.token == ^token + ), + set: [authenticated_at: authenticated_at] + ) + end + + def generate_user_magic_link_token(user) do + {encoded_token, user_token} = Accounts.UserToken.build_email_token(user, "login") + BeetRoundServer.Repo.insert!(user_token) + {encoded_token, user_token.token} + end + + def offset_user_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 Accounts.UserToken, where: ut.token == ^token), + set: [inserted_at: dt, authenticated_at: dt] + ) + end +end From 5f966d485a878e3ec07ebc3aa55c9a275b407bbb Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Wed, 21 Jan 2026 17:57:28 +0100 Subject: [PATCH 03/44] Added timeout to flash messages. --- .../components/core_components.ex | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lib/beet_round_server_web/components/core_components.ex b/lib/beet_round_server_web/components/core_components.ex index 6597199..d5a095b 100644 --- a/lib/beet_round_server_web/components/core_components.ex +++ b/lib/beet_round_server_web/components/core_components.ex @@ -55,6 +55,8 @@ defmodule BeetRoundServerWeb.CoreComponents do :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)} id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} + phx-hook=".FlashTimeout" + kind={@kind} role="alert" class="toast toast-top toast-end z-50" {@rest} @@ -75,6 +77,28 @@ defmodule BeetRoundServerWeb.CoreComponents do <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
+ """ end From 14f04befd009f34ae2a3b432d48947939b319616 Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Wed, 11 Feb 2026 10:57:27 +0100 Subject: [PATCH 04/44] After "mix phx.gen.json BiddingRounds BiddingRound bidding_rounds round_number:integer running:boolean --no-scope". --- lib/beet_round_server/bidding_rounds.ex | 104 ++++++++++++++++++ .../bidding_rounds/bidding_round.ex | 20 ++++ .../controllers/bidding_round_controller.ex | 43 ++++++++ .../controllers/bidding_round_json.ex | 25 +++++ .../controllers/changeset_json.ex | 25 +++++ .../controllers/fallback_controller.ex | 24 ++++ lib/beet_round_server_web/router.ex | 8 +- .../20260122104608_create_bidding_rounds.exs | 13 +++ .../beet_round_server/bidding_rounds_test.exs | 61 ++++++++++ .../bidding_round_controller_test.exs | 88 +++++++++++++++ .../fixtures/bidding_rounds_fixtures.ex | 21 ++++ 11 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 lib/beet_round_server/bidding_rounds.ex create mode 100644 lib/beet_round_server/bidding_rounds/bidding_round.ex create mode 100644 lib/beet_round_server_web/controllers/bidding_round_controller.ex create mode 100644 lib/beet_round_server_web/controllers/bidding_round_json.ex create mode 100644 lib/beet_round_server_web/controllers/changeset_json.ex create mode 100644 lib/beet_round_server_web/controllers/fallback_controller.ex create mode 100644 priv/repo/migrations/20260122104608_create_bidding_rounds.exs create mode 100644 test/beet_round_server/bidding_rounds_test.exs create mode 100644 test/beet_round_server_web/controllers/bidding_round_controller_test.exs create mode 100644 test/support/fixtures/bidding_rounds_fixtures.ex diff --git a/lib/beet_round_server/bidding_rounds.ex b/lib/beet_round_server/bidding_rounds.ex new file mode 100644 index 0000000..b1613ce --- /dev/null +++ b/lib/beet_round_server/bidding_rounds.ex @@ -0,0 +1,104 @@ +defmodule BeetRoundServer.BiddingRounds do + @moduledoc """ + The BiddingRounds context. + """ + + import Ecto.Query, warn: false + alias BeetRoundServer.Repo + + alias BeetRoundServer.BiddingRounds.BiddingRound + + @doc """ + Returns the list of bidding_rounds. + + ## Examples + + iex> list_bidding_rounds() + [%BiddingRound{}, ...] + + """ + def list_bidding_rounds do + Repo.all(BiddingRound) + end + + @doc """ + Gets a single bidding_round. + + Raises `Ecto.NoResultsError` if the Bidding round does not exist. + + ## Examples + + iex> get_bidding_round!(123) + %BiddingRound{} + + iex> get_bidding_round!(456) + ** (Ecto.NoResultsError) + + """ + def get_bidding_round!(id), do: Repo.get!(BiddingRound, id) + + @doc """ + Creates a bidding_round. + + ## Examples + + iex> create_bidding_round(%{field: value}) + {:ok, %BiddingRound{}} + + iex> create_bidding_round(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_bidding_round(attrs) do + %BiddingRound{} + |> BiddingRound.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a bidding_round. + + ## Examples + + iex> update_bidding_round(bidding_round, %{field: new_value}) + {:ok, %BiddingRound{}} + + iex> update_bidding_round(bidding_round, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_bidding_round(%BiddingRound{} = bidding_round, attrs) do + bidding_round + |> BiddingRound.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a bidding_round. + + ## Examples + + iex> delete_bidding_round(bidding_round) + {:ok, %BiddingRound{}} + + iex> delete_bidding_round(bidding_round) + {:error, %Ecto.Changeset{}} + + """ + def delete_bidding_round(%BiddingRound{} = bidding_round) do + Repo.delete(bidding_round) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking bidding_round changes. + + ## Examples + + iex> change_bidding_round(bidding_round) + %Ecto.Changeset{data: %BiddingRound{}} + + """ + def change_bidding_round(%BiddingRound{} = bidding_round, attrs \\ %{}) do + BiddingRound.changeset(bidding_round, attrs) + end +end diff --git a/lib/beet_round_server/bidding_rounds/bidding_round.ex b/lib/beet_round_server/bidding_rounds/bidding_round.ex new file mode 100644 index 0000000..155dbd8 --- /dev/null +++ b/lib/beet_round_server/bidding_rounds/bidding_round.ex @@ -0,0 +1,20 @@ +defmodule BeetRoundServer.BiddingRounds.BiddingRound do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "bidding_rounds" do + field :round_number, :integer + field :running, :boolean, default: false + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(bidding_round, attrs) do + bidding_round + |> cast(attrs, [:round_number, :running]) + |> validate_required([:round_number, :running]) + end +end diff --git a/lib/beet_round_server_web/controllers/bidding_round_controller.ex b/lib/beet_round_server_web/controllers/bidding_round_controller.ex new file mode 100644 index 0000000..0b884bb --- /dev/null +++ b/lib/beet_round_server_web/controllers/bidding_round_controller.ex @@ -0,0 +1,43 @@ +defmodule BeetRoundServerWeb.BiddingRoundController do + use BeetRoundServerWeb, :controller + + alias BeetRoundServer.BiddingRounds + alias BeetRoundServer.BiddingRounds.BiddingRound + + action_fallback BeetRoundServerWeb.FallbackController + + 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 show(conn, %{"id" => id}) do + bidding_round = BiddingRounds.get_bidding_round!(id) + render(conn, :show, bidding_round: bidding_round) + end + + 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 + render(conn, :show, bidding_round: bidding_round) + end + end + + def delete(conn, %{"id" => id}) do + bidding_round = BiddingRounds.get_bidding_round!(id) + + with {:ok, %BiddingRound{}} <- BiddingRounds.delete_bidding_round(bidding_round) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/beet_round_server_web/controllers/bidding_round_json.ex b/lib/beet_round_server_web/controllers/bidding_round_json.ex new file mode 100644 index 0000000..9960607 --- /dev/null +++ b/lib/beet_round_server_web/controllers/bidding_round_json.ex @@ -0,0 +1,25 @@ +defmodule BeetRoundServerWeb.BiddingRoundJSON do + alias BeetRoundServer.BiddingRounds.BiddingRound + + @doc """ + Renders a list of bidding_rounds. + """ + def index(%{bidding_rounds: bidding_rounds}) do + %{data: for(bidding_round <- bidding_rounds, do: data(bidding_round))} + end + + @doc """ + Renders a single bidding_round. + """ + def show(%{bidding_round: bidding_round}) do + %{data: data(bidding_round)} + end + + defp data(%BiddingRound{} = bidding_round) do + %{ + id: bidding_round.id, + round_number: bidding_round.round_number, + running: bidding_round.running + } + end +end diff --git a/lib/beet_round_server_web/controllers/changeset_json.ex b/lib/beet_round_server_web/controllers/changeset_json.ex new file mode 100644 index 0000000..d5e85d9 --- /dev/null +++ b/lib/beet_round_server_web/controllers/changeset_json.ex @@ -0,0 +1,25 @@ +defmodule BeetRoundServerWeb.ChangesetJSON do + @doc """ + Renders changeset errors. + """ + def error(%{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 + + 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 diff --git a/lib/beet_round_server_web/controllers/fallback_controller.ex b/lib/beet_round_server_web/controllers/fallback_controller.ex new file mode 100644 index 0000000..eb929e9 --- /dev/null +++ b/lib/beet_round_server_web/controllers/fallback_controller.ex @@ -0,0 +1,24 @@ +defmodule BeetRoundServerWeb.FallbackController do + @moduledoc """ + Translates controller action results into valid `Plug.Conn` responses. + + See `Phoenix.Controller.action_fallback/1` for more details. + """ + use BeetRoundServerWeb, :controller + + # This clause handles errors returned by Ecto's insert/update/delete. + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(json: BeetRoundServerWeb.ChangesetJSON) + |> render(:error, changeset: changeset) + end + + # This clause is an example of how to handle resources that cannot be found. + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(html: BeetRoundServerWeb.ErrorHTML, json: BeetRoundServerWeb.ErrorJSON) + |> render(:"404") + end +end diff --git a/lib/beet_round_server_web/router.ex b/lib/beet_round_server_web/router.ex index 0d56990..f4f8e61 100644 --- a/lib/beet_round_server_web/router.ex +++ b/lib/beet_round_server_web/router.ex @@ -24,9 +24,11 @@ defmodule BeetRoundServerWeb.Router do end # Other scopes may use custom stacks. - # scope "/api", BeetRoundServerWeb do - # pipe_through :api - # end + scope "/api", BeetRoundServerWeb do + pipe_through :api + + resources "/bidding_rounds", BiddingRoundController, except: [:new, :edit] + end # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:beet_round_server, :dev_routes) do diff --git a/priv/repo/migrations/20260122104608_create_bidding_rounds.exs b/priv/repo/migrations/20260122104608_create_bidding_rounds.exs new file mode 100644 index 0000000..c139b0a --- /dev/null +++ b/priv/repo/migrations/20260122104608_create_bidding_rounds.exs @@ -0,0 +1,13 @@ +defmodule BeetRoundServer.Repo.Migrations.CreateBiddingRounds do + use Ecto.Migration + + def change do + create table(:bidding_rounds, primary_key: false) do + add :id, :binary_id, primary_key: true + add :round_number, :integer + add :running, :boolean, default: false, null: false + + timestamps(type: :utc_datetime) + end + end +end diff --git a/test/beet_round_server/bidding_rounds_test.exs b/test/beet_round_server/bidding_rounds_test.exs new file mode 100644 index 0000000..602b0b1 --- /dev/null +++ b/test/beet_round_server/bidding_rounds_test.exs @@ -0,0 +1,61 @@ +defmodule BeetRoundServer.BiddingRoundsTest do + use BeetRoundServer.DataCase + + alias BeetRoundServer.BiddingRounds + + describe "bidding_rounds" do + alias BeetRoundServer.BiddingRounds.BiddingRound + + import BeetRoundServer.BiddingRoundsFixtures + + @invalid_attrs %{running: nil, round_number: nil} + + test "list_bidding_rounds/0 returns all bidding_rounds" do + bidding_round = bidding_round_fixture() + assert BiddingRounds.list_bidding_rounds() == [bidding_round] + end + + test "get_bidding_round!/1 returns the bidding_round with given id" do + bidding_round = bidding_round_fixture() + assert BiddingRounds.get_bidding_round!(bidding_round.id) == bidding_round + end + + test "create_bidding_round/1 with valid data creates a bidding_round" do + valid_attrs = %{running: true, round_number: 42} + + assert {:ok, %BiddingRound{} = bidding_round} = BiddingRounds.create_bidding_round(valid_attrs) + assert bidding_round.running == true + assert bidding_round.round_number == 42 + end + + test "create_bidding_round/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = BiddingRounds.create_bidding_round(@invalid_attrs) + end + + 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} + + assert {:ok, %BiddingRound{} = bidding_round} = BiddingRounds.update_bidding_round(bidding_round, update_attrs) + assert bidding_round.running == 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 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 + end + + test "change_bidding_round/1 returns a bidding_round changeset" do + bidding_round = bidding_round_fixture() + assert %Ecto.Changeset{} = BiddingRounds.change_bidding_round(bidding_round) + end + end +end diff --git a/test/beet_round_server_web/controllers/bidding_round_controller_test.exs b/test/beet_round_server_web/controllers/bidding_round_controller_test.exs new file mode 100644 index 0000000..317af7d --- /dev/null +++ b/test/beet_round_server_web/controllers/bidding_round_controller_test.exs @@ -0,0 +1,88 @@ +defmodule BeetRoundServerWeb.BiddingRoundControllerTest do + use BeetRoundServerWeb.ConnCase + + import BeetRoundServer.BiddingRoundsFixtures + alias BeetRoundServer.BiddingRounds.BiddingRound + + @create_attrs %{ + running: true, + round_number: 42 + } + @update_attrs %{ + running: false, + round_number: 43 + } + @invalid_attrs %{running: nil, round_number: nil} + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all bidding_rounds", %{conn: conn} do + conn = get(conn, ~p"/api/bidding_rounds") + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create bidding_round" do + test "renders bidding_round when data is valid", %{conn: conn} do + conn = post(conn, ~p"/api/bidding_rounds", bidding_round: @create_attrs) + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get(conn, ~p"/api/bidding_rounds/#{id}") + + assert %{ + "id" => ^id, + "round_number" => 42, + "running" => true + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/api/bidding_rounds", bidding_round: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + 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 + conn = put(conn, ~p"/api/bidding_rounds/#{bidding_round}", bidding_round: @update_attrs) + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get(conn, ~p"/api/bidding_rounds/#{id}") + + assert %{ + "id" => ^id, + "round_number" => 43, + "running" => false + } = json_response(conn, 200)["data"] + end + + test "renders errors when data is invalid", %{conn: conn, bidding_round: bidding_round} do + conn = put(conn, ~p"/api/bidding_rounds/#{bidding_round}", bidding_round: @invalid_attrs) + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete bidding_round" do + setup [:create_bidding_round] + + test "deletes chosen bidding_round", %{conn: conn, bidding_round: bidding_round} do + conn = delete(conn, ~p"/api/bidding_rounds/#{bidding_round}") + assert response(conn, 204) + + assert_error_sent 404, fn -> + get(conn, ~p"/api/bidding_rounds/#{bidding_round}") + end + end + end + + defp create_bidding_round(_) do + bidding_round = bidding_round_fixture() + + %{bidding_round: bidding_round} + end +end diff --git a/test/support/fixtures/bidding_rounds_fixtures.ex b/test/support/fixtures/bidding_rounds_fixtures.ex new file mode 100644 index 0000000..1dcb09f --- /dev/null +++ b/test/support/fixtures/bidding_rounds_fixtures.ex @@ -0,0 +1,21 @@ +defmodule BeetRoundServer.BiddingRoundsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `BeetRoundServer.BiddingRounds` context. + """ + + @doc """ + Generate a bidding_round. + """ + def bidding_round_fixture(attrs \\ %{}) do + {:ok, bidding_round} = + attrs + |> Enum.into(%{ + round_number: 42, + running: true + }) + |> BeetRoundServer.BiddingRounds.create_bidding_round() + + bidding_round + end +end From 8ef1e76788258de555ecf15fac42dff61f56ecaf Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Wed, 11 Feb 2026 11:00:13 +0100 Subject: [PATCH 05/44] Setting title to BeetRound and changing the text of the landing page to a concise description of BeetRound. --- .../components/layouts.ex | 2 +- .../components/layouts/root.html.heex | 2 +- .../controllers/page_html/home.html.heex | 186 ++---------------- priv/static/favicon.ico | Bin 152 -> 15406 bytes priv/static/images/BeetRound.png | Bin 0 -> 35852 bytes priv/static/robots.txt | 4 +- 6 files changed, 24 insertions(+), 170 deletions(-) create mode 100644 priv/static/images/BeetRound.png diff --git a/lib/beet_round_server_web/components/layouts.ex b/lib/beet_round_server_web/components/layouts.ex index 82f4b48..1faadab 100644 --- a/lib/beet_round_server_web/components/layouts.ex +++ b/lib/beet_round_server_web/components/layouts.ex @@ -38,7 +38,7 @@ defmodule BeetRoundServerWeb.Layouts do
diff --git a/lib/beet_round_server_web/components/layouts/root.html.heex b/lib/beet_round_server_web/components/layouts/root.html.heex index cf9179b..a2dc234 100644 --- a/lib/beet_round_server_web/components/layouts/root.html.heex +++ b/lib/beet_round_server_web/components/layouts/root.html.heex @@ -33,23 +33,11 @@ {@inner_content} diff --git a/lib/beet_round_server_web/controllers/page_html/home.html.heex b/lib/beet_round_server_web/controllers/page_html/home.html.heex index 02abc2d..e348454 100644 --- a/lib/beet_round_server_web/controllers/page_html/home.html.heex +++ b/lib/beet_round_server_web/controllers/page_html/home.html.heex @@ -11,7 +11,6 @@ v{Application.spec(:beet_round_server, :vsn)} -

From a47931f40ebfa5df3cd9cf274eb860be4bed6777 Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Thu, 19 Feb 2026 20:58:34 +0100 Subject: [PATCH 38/44] /api/invite triggers sending a mail invite to the given user. --- lib/beet_round_server/accounts/user_email.ex | 13 ++++++++ .../controllers/user_controller.ex | 31 +++++++++++++++++++ .../controllers/user_json.ex | 4 +++ lib/beet_round_server_web/router.ex | 2 ++ .../templates/emails/invite/invite.html.eex | 23 ++++++++++++++ mix.exs | 1 + mix.lock | 2 ++ 7 files changed, 76 insertions(+) create mode 100644 lib/beet_round_server/accounts/user_email.ex create mode 100644 lib/beet_round_server_web/templates/emails/invite/invite.html.eex diff --git a/lib/beet_round_server/accounts/user_email.ex b/lib/beet_round_server/accounts/user_email.ex new file mode 100644 index 0000000..c08e2f7 --- /dev/null +++ b/lib/beet_round_server/accounts/user_email.ex @@ -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 diff --git a/lib/beet_round_server_web/controllers/user_controller.ex b/lib/beet_round_server_web/controllers/user_controller.ex index bea6ae3..6d7f6a1 100644 --- a/lib/beet_round_server_web/controllers/user_controller.ex +++ b/lib/beet_round_server_web/controllers/user_controller.ex @@ -37,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) diff --git a/lib/beet_round_server_web/controllers/user_json.ex b/lib/beet_round_server_web/controllers/user_json.ex index b0beab8..0c0c0fd 100644 --- a/lib/beet_round_server_web/controllers/user_json.ex +++ b/lib/beet_round_server_web/controllers/user_json.ex @@ -25,6 +25,10 @@ defmodule BeetRoundServerWeb.UserJSON do %{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, diff --git a/lib/beet_round_server_web/router.ex b/lib/beet_round_server_web/router.ex index c5f1aa2..9430531 100644 --- a/lib/beet_round_server_web/router.ex +++ b/lib/beet_round_server_web/router.ex @@ -37,6 +37,8 @@ defmodule BeetRoundServerWeb.Router do get "/biddings_of_round/:round_number", BiddingController, :biddings_of_round get "/biddings_of_highest_round", BiddingController, :biddings_of_highest_round + post "/invite", UserController, :invite + resources "/users", UserController, except: [:new, :edit] end diff --git a/lib/beet_round_server_web/templates/emails/invite/invite.html.eex b/lib/beet_round_server_web/templates/emails/invite/invite.html.eex new file mode 100644 index 0000000..3773e16 --- /dev/null +++ b/lib/beet_round_server_web/templates/emails/invite/invite.html.eex @@ -0,0 +1,23 @@ +

Bietrunde 26/27 - Das Grüne Zebra

+

Digitales Bieten

+

Hallo <%= @name %>,

+

du bist für die Bietrunde am Sonntag, 22.02. angemeldet.

+

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.

+ +

persönlicher Bietlink

+ +

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.

+ +

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.

+ +

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.

+ +

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.

+ +

Außerdem ist es natürlich auch möglich ganz klassisch das Gebot auf einen Zettel zu schreiben.

+ +

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!

+
+ +

Viele Grüße und bis Sonntag!

+

Eure Zebras

diff --git a/mix.exs b/mix.exs index e33adc6..3f2024d 100644 --- a/mix.exs +++ b/mix.exs @@ -60,6 +60,7 @@ defmodule BeetRoundServer.MixProject do compile: false, depth: 1}, {:swoosh, "~> 1.16"}, + {:phoenix_swoosh, "~> 1.2.1"}, {:req, "~> 0.5"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index 0e7b62e..eae5642 100644 --- a/mix.lock +++ b/mix.lock @@ -31,7 +31,9 @@ "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.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"}, From 53d19a3a184d093be42ba4fbaed25cc7c03c87ff Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Fri, 20 Feb 2026 13:09:55 +0100 Subject: [PATCH 39/44] After "mix phx.gen.auth Admins Admin admins" with added working register and login path. --- config/config.exs | 13 + lib/beet_round_server/admins.ex | 328 +++++++++++++++ lib/beet_round_server/admins/admin.ex | 134 ++++++ .../admins/admin_notifier.ex | 84 ++++ lib/beet_round_server/admins/admin_token.ex | 195 +++++++++ lib/beet_round_server/admins/scope.ex | 33 ++ lib/beet_round_server_web/admin_auth.ex | 219 ++++++++++ .../controllers/admin_controller.ex | 56 +++ .../controllers/admin_json.ex | 47 +++ .../admin_registration_controller.ex | 32 ++ .../controllers/admin_registration_html.ex | 5 + .../admin_registration_html/new.html.heex | 31 ++ .../controllers/admin_session_controller.ex | 88 ++++ .../controllers/admin_session_html.ex | 9 + .../admin_session_html/confirm.html.heex | 59 +++ .../admin_session_html/new.html.heex | 70 +++ .../controllers/admin_settings_controller.ex | 77 ++++ .../controllers/admin_settings_html.ex | 5 + .../admin_settings_html/edit.html.heex | 40 ++ lib/beet_round_server_web/router.ex | 35 ++ ...260220064646_create_admins_auth_tables.exs | 32 ++ test/beet_round_server/admins_test.exs | 397 ++++++++++++++++++ .../beet_round_server_web/admin_auth_test.exs | 293 +++++++++++++ .../admin_registration_controller_test.exs | 50 +++ .../admin_session_controller_test.exs | 220 ++++++++++ .../admin_settings_controller_test.exs | 148 +++++++ test/support/conn_case.ex | 41 ++ test/support/fixtures/admins_fixtures.ex | 89 ++++ 28 files changed, 2830 insertions(+) create mode 100644 lib/beet_round_server/admins.ex create mode 100644 lib/beet_round_server/admins/admin.ex create mode 100644 lib/beet_round_server/admins/admin_notifier.ex create mode 100644 lib/beet_round_server/admins/admin_token.ex create mode 100644 lib/beet_round_server/admins/scope.ex create mode 100644 lib/beet_round_server_web/admin_auth.ex create mode 100644 lib/beet_round_server_web/controllers/admin_controller.ex create mode 100644 lib/beet_round_server_web/controllers/admin_json.ex create mode 100644 lib/beet_round_server_web/controllers/admin_registration_controller.ex create mode 100644 lib/beet_round_server_web/controllers/admin_registration_html.ex create mode 100644 lib/beet_round_server_web/controllers/admin_registration_html/new.html.heex create mode 100644 lib/beet_round_server_web/controllers/admin_session_controller.ex create mode 100644 lib/beet_round_server_web/controllers/admin_session_html.ex create mode 100644 lib/beet_round_server_web/controllers/admin_session_html/confirm.html.heex create mode 100644 lib/beet_round_server_web/controllers/admin_session_html/new.html.heex create mode 100644 lib/beet_round_server_web/controllers/admin_settings_controller.ex create mode 100644 lib/beet_round_server_web/controllers/admin_settings_html.ex create mode 100644 lib/beet_round_server_web/controllers/admin_settings_html/edit.html.heex create mode 100644 priv/repo/migrations/20260220064646_create_admins_auth_tables.exs create mode 100644 test/beet_round_server/admins_test.exs create mode 100644 test/beet_round_server_web/admin_auth_test.exs create mode 100644 test/beet_round_server_web/controllers/admin_registration_controller_test.exs create mode 100644 test/beet_round_server_web/controllers/admin_session_controller_test.exs create mode 100644 test/beet_round_server_web/controllers/admin_settings_controller_test.exs create mode 100644 test/support/fixtures/admins_fixtures.ex diff --git a/config/config.exs b/config/config.exs index a2252b2..0c80d7b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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, diff --git a/lib/beet_round_server/admins.ex b/lib/beet_round_server/admins.ex new file mode 100644 index 0000000..5defa4f --- /dev/null +++ b/lib/beet_round_server/admins.ex @@ -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 diff --git a/lib/beet_round_server/admins/admin.ex b/lib/beet_round_server/admins/admin.ex new file mode 100644 index 0000000..dfd8f53 --- /dev/null +++ b/lib/beet_round_server/admins/admin.ex @@ -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 diff --git a/lib/beet_round_server/admins/admin_notifier.ex b/lib/beet_round_server/admins/admin_notifier.ex new file mode 100644 index 0000000..9c168d1 --- /dev/null +++ b/lib/beet_round_server/admins/admin_notifier.ex @@ -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 diff --git a/lib/beet_round_server/admins/admin_token.ex b/lib/beet_round_server/admins/admin_token.ex new file mode 100644 index 0000000..41597f9 --- /dev/null +++ b/lib/beet_round_server/admins/admin_token.ex @@ -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 diff --git a/lib/beet_round_server/admins/scope.ex b/lib/beet_round_server/admins/scope.ex new file mode 100644 index 0000000..4b8b7ca --- /dev/null +++ b/lib/beet_round_server/admins/scope.ex @@ -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 diff --git a/lib/beet_round_server_web/admin_auth.ex b/lib/beet_round_server_web/admin_auth.ex new file mode 100644 index 0000000..f18a433 --- /dev/null +++ b/lib/beet_round_server_web/admin_auth.ex @@ -0,0 +1,219 @@ +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 + + # 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 diff --git a/lib/beet_round_server_web/controllers/admin_controller.ex b/lib/beet_round_server_web/controllers/admin_controller.ex new file mode 100644 index 0000000..752ae5f --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_controller.ex @@ -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 diff --git a/lib/beet_round_server_web/controllers/admin_json.ex b/lib/beet_round_server_web/controllers/admin_json.ex new file mode 100644 index 0000000..8810f79 --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_json.ex @@ -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 diff --git a/lib/beet_round_server_web/controllers/admin_registration_controller.ex b/lib/beet_round_server_web/controllers/admin_registration_controller.ex new file mode 100644 index 0000000..42d2b5c --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_registration_controller.ex @@ -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 diff --git a/lib/beet_round_server_web/controllers/admin_registration_html.ex b/lib/beet_round_server_web/controllers/admin_registration_html.ex new file mode 100644 index 0000000..74d07d4 --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_registration_html.ex @@ -0,0 +1,5 @@ +defmodule BeetRoundServerWeb.AdminRegistrationHTML do + use BeetRoundServerWeb, :html + + embed_templates "admin_registration_html/*" +end diff --git a/lib/beet_round_server_web/controllers/admin_registration_html/new.html.heex b/lib/beet_round_server_web/controllers/admin_registration_html/new.html.heex new file mode 100644 index 0000000..3882b41 --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_registration_html/new.html.heex @@ -0,0 +1,31 @@ + +
+
+ <.header> + Register for an account + <:subtitle> + Already registered? + <.link navigate={~p"/admins/log-in"} class="font-semibold text-brand hover:underline"> + Log in + + to your account now. + + +
+ + <.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 + + +
+
diff --git a/lib/beet_round_server_web/controllers/admin_session_controller.ex b/lib/beet_round_server_web/controllers/admin_session_controller.ex new file mode 100644 index 0000000..ecd1efc --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_session_controller.ex @@ -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 diff --git a/lib/beet_round_server_web/controllers/admin_session_html.ex b/lib/beet_round_server_web/controllers/admin_session_html.ex new file mode 100644 index 0000000..855e875 --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_session_html.ex @@ -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 diff --git a/lib/beet_round_server_web/controllers/admin_session_html/confirm.html.heex b/lib/beet_round_server_web/controllers/admin_session_html/confirm.html.heex new file mode 100644 index 0000000..95edef9 --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_session_html/confirm.html.heex @@ -0,0 +1,59 @@ + +
+
+ <.header>Welcome {@admin.email} +
+ + <.form + :if={!@admin.confirmed_at} + for={@form} + id="confirmation_form" + action={~p"/admins/log-in?_action=confirmed"} + phx-mounted={JS.focus_first()} + > + + <.button + name={@form[:remember_me].name} + value="true" + phx-disable-with="Confirming..." + class="btn btn-primary w-full" + > + Confirm and stay logged in + + <.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2"> + Confirm and log in only this time + + + + <.form + :if={@admin.confirmed_at} + for={@form} + id="login_form" + action={~p"/admins/log-in"} + phx-mounted={JS.focus_first()} + > + + <%= if @current_scope do %> + <.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full"> + Log in + + <% 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 phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2"> + Log me in only this time + + <% end %> + + +

+ Tip: If you prefer passwords, you can enable them in the admin settings. +

+
+
diff --git a/lib/beet_round_server_web/controllers/admin_session_html/new.html.heex b/lib/beet_round_server_web/controllers/admin_session_html/new.html.heex new file mode 100644 index 0000000..379a1d1 --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_session_html/new.html.heex @@ -0,0 +1,70 @@ + +
+
+ <.header> +

Log in

+ <: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 for an account now. + <% end %> + + +
+ +
+ <.icon name="hero-information-circle" class="size-6 shrink-0" /> +
+

You are running the local mail adapter.

+

+ To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page. +

+
+
+ + <.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 + + + +
or
+ + <.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 + + <.button class="btn btn-primary btn-soft w-full mt-2"> + Log in only this time + + +
+
diff --git a/lib/beet_round_server_web/controllers/admin_settings_controller.ex b/lib/beet_round_server_web/controllers/admin_settings_controller.ex new file mode 100644 index 0000000..ba2d433 --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_settings_controller.ex @@ -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 diff --git a/lib/beet_round_server_web/controllers/admin_settings_html.ex b/lib/beet_round_server_web/controllers/admin_settings_html.ex new file mode 100644 index 0000000..76043f4 --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_settings_html.ex @@ -0,0 +1,5 @@ +defmodule BeetRoundServerWeb.AdminSettingsHTML do + use BeetRoundServerWeb, :html + + embed_templates "admin_settings_html/*" +end diff --git a/lib/beet_round_server_web/controllers/admin_settings_html/edit.html.heex b/lib/beet_round_server_web/controllers/admin_settings_html/edit.html.heex new file mode 100644 index 0000000..2386264 --- /dev/null +++ b/lib/beet_round_server_web/controllers/admin_settings_html/edit.html.heex @@ -0,0 +1,40 @@ + +
+ <.header> + Account Settings + <:subtitle>Manage your account email address and password settings + +
+ + <.form :let={f} for={@email_changeset} action={~p"/admins/settings"} id="update_email"> + + + <.input field={f[:email]} type="email" label="Email" autocomplete="email" required /> + + <.button variant="primary" phx-disable-with="Changing...">Change Email + + +
+ + <.form :let={f} for={@password_changeset} action={~p"/admins/settings"} id="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 + + + diff --git a/lib/beet_round_server_web/router.ex b/lib/beet_round_server_web/router.ex index 9430531..697d00c 100644 --- a/lib/beet_round_server_web/router.ex +++ b/lib/beet_round_server_web/router.ex @@ -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 @@ -23,6 +26,12 @@ defmodule BeetRoundServerWeb.Router do get "/", PageController, :home end + scope "/api", BeetRoundServerWeb do + pipe_through :api + post "/log_in", AdminController, :log_in + post "/admin_create", AdminController, :create + end + # Other scopes may use custom stacks. scope "/api", BeetRoundServerWeb do pipe_through :api @@ -93,4 +102,30 @@ defmodule BeetRoundServerWeb.Router do get "/log_in/:token", UserSessionController, :login end + + ## Authentication routes + + scope "/", BeetRoundServerWeb do + pipe_through [:browser, :redirect_if_admin_is_authenticated] + + get "/admins/register", AdminRegistrationController, :new + post "/admins/register", AdminRegistrationController, :create + end + + scope "/", BeetRoundServerWeb do + pipe_through [:browser, :require_authenticated_admin] + + get "/admins/settings", AdminSettingsController, :edit + put "/admins/settings", AdminSettingsController, :update + get "/admins/settings/confirm-email/:token", AdminSettingsController, :confirm_email + end + + scope "/", BeetRoundServerWeb do + pipe_through [:browser] + + get "/admins/log-in", AdminSessionController, :new + get "/admins/log-in/:token", AdminSessionController, :confirm + post "/admins/log-in", AdminSessionController, :create + delete "/admins/log-out", AdminSessionController, :delete + end end diff --git a/priv/repo/migrations/20260220064646_create_admins_auth_tables.exs b/priv/repo/migrations/20260220064646_create_admins_auth_tables.exs new file mode 100644 index 0000000..2c4e7ec --- /dev/null +++ b/priv/repo/migrations/20260220064646_create_admins_auth_tables.exs @@ -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 diff --git a/test/beet_round_server/admins_test.exs b/test/beet_round_server/admins_test.exs new file mode 100644 index 0000000..2177a18 --- /dev/null +++ b/test/beet_round_server/admins_test.exs @@ -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 diff --git a/test/beet_round_server_web/admin_auth_test.exs b/test/beet_round_server_web/admin_auth_test.exs new file mode 100644 index 0000000..7037f0a --- /dev/null +++ b/test/beet_round_server_web/admin_auth_test.exs @@ -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 diff --git a/test/beet_round_server_web/controllers/admin_registration_controller_test.exs b/test/beet_round_server_web/controllers/admin_registration_controller_test.exs new file mode 100644 index 0000000..a41bec7 --- /dev/null +++ b/test/beet_round_server_web/controllers/admin_registration_controller_test.exs @@ -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 diff --git a/test/beet_round_server_web/controllers/admin_session_controller_test.exs b/test/beet_round_server_web/controllers/admin_session_controller_test.exs new file mode 100644 index 0000000..4a5ba2b --- /dev/null +++ b/test/beet_round_server_web/controllers/admin_session_controller_test.exs @@ -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( + 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 diff --git a/test/beet_round_server_web/controllers/admin_settings_controller_test.exs b/test/beet_round_server_web/controllers/admin_settings_controller_test.exs new file mode 100644 index 0000000..d086f16 --- /dev/null +++ b/test/beet_round_server_web/controllers/admin_settings_controller_test.exs @@ -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 diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index ca8b0a5..044852a 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -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 diff --git a/test/support/fixtures/admins_fixtures.ex b/test/support/fixtures/admins_fixtures.ex new file mode 100644 index 0000000..217425d --- /dev/null +++ b/test/support/fixtures/admins_fixtures.ex @@ -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 From 38652c504db0c8e55dbbe0f2ed424549ca0478fc Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Fri, 20 Feb 2026 13:19:12 +0100 Subject: [PATCH 40/44] Removed browser routes for admin stuff. --- lib/beet_round_server_web/router.ex | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/lib/beet_round_server_web/router.ex b/lib/beet_round_server_web/router.ex index 697d00c..46d1cc4 100644 --- a/lib/beet_round_server_web/router.ex +++ b/lib/beet_round_server_web/router.ex @@ -102,30 +102,4 @@ defmodule BeetRoundServerWeb.Router do get "/log_in/:token", UserSessionController, :login end - - ## Authentication routes - - scope "/", BeetRoundServerWeb do - pipe_through [:browser, :redirect_if_admin_is_authenticated] - - get "/admins/register", AdminRegistrationController, :new - post "/admins/register", AdminRegistrationController, :create - end - - scope "/", BeetRoundServerWeb do - pipe_through [:browser, :require_authenticated_admin] - - get "/admins/settings", AdminSettingsController, :edit - put "/admins/settings", AdminSettingsController, :update - get "/admins/settings/confirm-email/:token", AdminSettingsController, :confirm_email - end - - scope "/", BeetRoundServerWeb do - pipe_through [:browser] - - get "/admins/log-in", AdminSessionController, :new - get "/admins/log-in/:token", AdminSessionController, :confirm - post "/admins/log-in", AdminSessionController, :create - delete "/admins/log-out", AdminSessionController, :delete - end end From 35dbb79ccd92650ceede82271f448c9decd4a924 Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Fri, 20 Feb 2026 16:22:19 +0100 Subject: [PATCH 41/44] Restricting the API access to logged in admins. Only admin log in is publicly accessible. --- lib/beet_round_server_web/admin_auth.ex | 12 ++++++++++++ lib/beet_round_server_web/router.ex | 12 +++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/beet_round_server_web/admin_auth.ex b/lib/beet_round_server_web/admin_auth.ex index f18a433..2f2e2aa 100644 --- a/lib/beet_round_server_web/admin_auth.ex +++ b/lib/beet_round_server_web/admin_auth.ex @@ -89,6 +89,18 @@ defmodule BeetRoundServerWeb.AdminAuth do 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) diff --git a/lib/beet_round_server_web/router.ex b/lib/beet_round_server_web/router.ex index 46d1cc4..856aca8 100644 --- a/lib/beet_round_server_web/router.ex +++ b/lib/beet_round_server_web/router.ex @@ -20,21 +20,27 @@ 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 + ### API ### scope "/api", BeetRoundServerWeb do pipe_through :api + post "/log_in", AdminController, :log_in - post "/admin_create", AdminController, :create + # post "/admin_create", AdminController, :create end - # Other scopes may use custom stacks. + ### protected API ### scope "/api", BeetRoundServerWeb do - pipe_through :api + pipe_through [:api, :admin] get "/", DefaultApiController, :index From 4f38eb36f193acaaed8639a7ae14529f6f4c4fdc Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Sat, 21 Feb 2026 11:27:46 +0100 Subject: [PATCH 42/44] Disabled login and logout for users. --- .../components/layouts/root.html.heex | 5 -- .../live/user_live/login.ex | 76 +------------------ 2 files changed, 3 insertions(+), 78 deletions(-) diff --git a/lib/beet_round_server_web/components/layouts/root.html.heex b/lib/beet_round_server_web/components/layouts/root.html.heex index a2dc234..734b797 100644 --- a/lib/beet_round_server_web/components/layouts/root.html.heex +++ b/lib/beet_round_server_web/components/layouts/root.html.heex @@ -32,11 +32,6 @@ {@inner_content} diff --git a/lib/beet_round_server_web/live/user_live/login.ex b/lib/beet_round_server_web/live/user_live/login.ex index ba19185..a655002 100644 --- a/lib/beet_round_server_web/live/user_live/login.ex +++ b/lib/beet_round_server_web/live/user_live/login.ex @@ -12,81 +12,10 @@ defmodule BeetRoundServerWeb.UserLive.Login do <.header>

Log in

<: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 for an account now. - <% end %> + Bitte nutze deinen persönlichen Link der dir per Mail zugesendet wurde um dich anzumelden.
- -
- <.icon name="hero-information-circle" class="size-6 shrink-0" /> -
-

You are running the local mail adapter.

-

- To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page. -

-
-
- - <.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 - - - -
or
- - <.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 - - <.button class="btn btn-primary btn-soft w-full mt-2"> - Log in only this time - -
""" @@ -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 From 0b364f19c27954dbbd63a579bdf2803207730794 Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Sat, 21 Feb 2026 11:32:16 +0100 Subject: [PATCH 43/44] Added uberspace deployment info to README.md --- README.md | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a1b613a..51ea43c 100644 --- a/README.md +++ b/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= + +export DATABASE_URL=ecto://beetround_admin:@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.de", + MAIL_NAME="", + MAIL_PW="", + PHX_HOST="beetround.example.com", + MIX_ENV=prod, + PORT=4005, + DATABASE_URL="ecto://beetround_admin:@localhost/beetround_server", + SECRET_KEY_BASE= +``` + +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 From 41e32e3ff1ead542d83a66326644185d0bcf5414 Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Sat, 21 Feb 2026 11:55:23 +0100 Subject: [PATCH 44/44] Configured for release on prod environment. --- README.md | 2 +- config/config.exs | 5 +++-- config/prod.exs | 4 +++- config/runtime.exs | 16 ++++++++++++++++ mix.exs | 3 ++- mix.lock | 2 ++ 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 51ea43c..cf2d19f 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ autorestart=true startsecs=60 environment = MAIL_RELAY=".uberspace.de", - MAIL_NAME="", + MAIL_ADDRESS="", MAIL_PW="", PHX_HOST="beetround.example.com", MIX_ENV=prod, diff --git a/config/config.exs b/config/config.exs index 0c80d7b..28130a2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -39,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 # diff --git a/config/prod.exs b/config/prod.exs index c1eb2aa..19426fa 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -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 diff --git a/config/runtime.exs b/config/runtime.exs index ecd3e35..0aaaf0f 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 diff --git a/mix.exs b/mix.exs index 3f2024d..43f4fac 100644 --- a/mix.exs +++ b/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, @@ -61,6 +61,7 @@ defmodule BeetRoundServer.MixProject do 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"}, diff --git a/mix.lock b/mix.lock index eae5642..ba1707b 100644 --- a/mix.lock +++ b/mix.lock @@ -14,6 +14,7 @@ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "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"}, @@ -37,6 +38,7 @@ "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.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"},