refactor: review remarks
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-03-13 17:55:17 +01:00
parent f12da8a359
commit 349cee0ce6
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
19 changed files with 300 additions and 100 deletions

View file

@ -90,6 +90,8 @@ lib/
│ ├── custom_field.ex # Custom field (definition) resource │ ├── custom_field.ex # Custom field (definition) resource
│ ├── custom_field_value.ex # Custom field value resource │ ├── custom_field_value.ex # Custom field value resource
│ ├── setting.ex # Global settings (singleton resource; incl. join form config) │ ├── setting.ex # Global settings (singleton resource; incl. join form config)
│ ├── settings_cache.ex # Process cache for get_settings (TTL; invalidate on update; not started in test)
│ ├── join_notifier.ex # Behaviour for join emails (confirmation, already member, already pending)
│ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.) │ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.)
│ ├── group.ex # Group resource │ ├── group.ex # Group resource
│ ├── member_group.ex # MemberGroup join table resource │ ├── member_group.ex # MemberGroup join table resource
@ -1275,6 +1277,8 @@ mix hex.outdated
- SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). ENV takes priority (same pattern as OIDC/Vereinfacht). - SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). ENV takes priority (same pattern as OIDC/Vereinfacht).
- **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`. - **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`.
- **Settings cache:** `Mv.Membership.get_settings/0` uses `Mv.Membership.SettingsCache` when the cache process is running (not in test). Cache has a short TTL and is invalidated on every settings update. This avoids repeated DB reads on hot paths (e.g. `RegistrationEnabled` validation, `Layouts.public_page`). In test, the cache is not started so all callers use `get_settings_uncached/0` in the test process (Ecto Sandbox).
- **Join emails (domain → web):** The domain calls `Mv.Membership.JoinNotifier` (config `:join_notifier`, default `MvWeb.JoinNotifierImpl`) for sending join confirmation, already-member, and already-pending emails. This keeps the domain independent of the web layer; tests can override the notifier.
- Sender identity is also configurable via ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`) or Settings (`smtp_from_name`, `smtp_from_email`). - Sender identity is also configurable via ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`) or Settings (`smtp_from_name`, `smtp_from_email`).
- `SMTP_PASSWORD_FILE`: path to a file containing the password (Docker Secrets / Kubernetes secrets pattern); overridden by `SMTP_PASSWORD` when both are set. - `SMTP_PASSWORD_FILE`: path to a file containing the password (Docker Secrets / Kubernetes secrets pattern); overridden by `SMTP_PASSWORD` when both are set.
- `SMTP_SSL` values: `tls` (default, port 587), `ssl` (port 465), `none` (port 25). - `SMTP_SSL` values: `tls` (default, port 587), `ssl` (port 465), `none` (port 25).
@ -1292,7 +1296,7 @@ mix hex.outdated
**Join confirmation email:** **Join confirmation email:**
- `MvWeb.Emails.JoinConfirmationEmail` uses `Mailer.deliver(email, Mailer.smtp_config())` so it uses the same SMTP configuration as the test mail (Settings or boot ENV). On delivery failure, `Mv.Membership.submit_join_request/2` returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI. - Join emails are sent via `Mv.Membership.JoinNotifier` (default impl: `MvWeb.JoinNotifierImpl` calling `JoinConfirmationEmail`, etc.). `MvWeb.Emails.JoinConfirmationEmail` uses `Mailer.deliver(email, Mailer.smtp_config())` so it uses the same SMTP configuration as the test mail (Settings or boot ENV). On delivery failure, `Mv.Membership.submit_join_request/2` returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI.
**Unified layout (transactional emails):** **Unified layout (transactional emails):**

View file

@ -89,7 +89,7 @@ Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_jo
- **Implementation:** - **Implementation:**
- **Sign-in** (`SignInLive`): Uses `use Phoenix.LiveView` (not `use MvWeb, :live_view`) so AshAuthentications sign_in_route live_session on_mount chain is not mixed with LiveHelpers hooks. Renders `<Layouts.public_page flash={@flash}>` with the SignIn component inside a hero. Displays a locale-aware `<h1>` title (“Anmelden” / “Registrieren”) above the AshAuthentication component (the librarys Banner is hidden via `show_banner: false`). - **Sign-in** (`SignInLive`): Uses `use Phoenix.LiveView` (not `use MvWeb, :live_view`) so AshAuthentications sign_in_route live_session on_mount chain is not mixed with LiveHelpers hooks. Renders `<Layouts.public_page flash={@flash}>` with the SignIn component inside a hero. Displays a locale-aware `<h1>` title (“Anmelden” / “Registrieren”) above the AshAuthentication component (the librarys Banner is hidden via `show_banner: false`).
- **Join** (`JoinLive`): Uses `use MvWeb, :live_view` and wraps content in `<Layouts.public_page flash={@flash}>` with a hero for the form. - **Join** (`JoinLive`): Uses `use MvWeb, :live_view` and wraps content in `<Layouts.public_page flash={@flash}>` with a hero for the form.
- **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that repeats the same header markup and a hero block for the result (no component call from controller templates). - **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that wraps content in `<Layouts.public_page flash={@flash}>` and a hero block for the result, so the confirm page shares the same header and chrome as Join and Sign-in.
## 3) Typography (system) ## 3) Typography (system)

View file

@ -156,9 +156,9 @@
/* WCAG 2.2 AA (4.5:1 for normal text): Form labels. DaisyUI .label uses 60% opacity, /* WCAG 2.2 AA (4.5:1 for normal text): Form labels. DaisyUI .label uses 60% opacity,
which fails contrast. Override to 85% of base-content so labels stay slightly which fails contrast. Override to 85% of base-content so labels stay slightly
deemphasised vs body text but meet the minimum ratio. */ deemphasised vs body text but meet the minimum ratio. Match .label directly
[data-theme="light"] .label, so the override applies even when data-theme is not yet set (e.g. initial load). */
[data-theme="dark"] .label { .label {
color: color-mix(in oklab, var(--color-base-content) 85%, transparent); color: color-mix(in oklab, var(--color-base-content) 85%, transparent);
} }

View file

@ -104,6 +104,9 @@ config :mv, :mail_from, {"Mila", "noreply@example.com"}
# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP. # Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP.
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10 config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10
# Join emails: notifier implementation (domain → web abstraction). Override in test to inject a mock.
config :mv, :join_notifier, MvWeb.JoinNotifierImpl
# Configure esbuild (the version is required) # Configure esbuild (the version is required)
config :esbuild, config :esbuild,
version: "0.17.11", version: "0.17.11",

View file

@ -21,7 +21,11 @@ defmodule Mv.Accounts.User.Validations.RegistrationEnabled do
{:error, {:error,
field: :base, field: :base,
message: message:
"Registration is disabled. Please use the join form or contact an administrator."} Gettext.dgettext(
MvWeb.Gettext,
"default",
"Registration is disabled. Please use the join form or contact an administrator."
)}
end end
end end
end end

View file

@ -0,0 +1,13 @@
defmodule Mv.Membership.JoinNotifier do
@moduledoc """
Behaviour for sending join-related emails (confirmation, already member, already pending).
The domain calls this module instead of MvWeb.Emails directly, so the domain layer
does not depend on the web layer. The default implementation is set in config
(`config :mv, :join_notifier, MvWeb.JoinNotifierImpl`). Tests can override with a mock.
"""
@callback send_confirmation(email :: String.t(), token :: String.t(), opts :: keyword()) ::
{:ok, term()} | {:error, term()}
@callback send_already_member(email :: String.t()) :: {:ok, term()} | {:error, term()}
@callback send_already_pending(email :: String.t()) :: {:ok, term()} | {:error, term()}
end

View file

@ -16,13 +16,16 @@ defmodule Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken do
token = Ash.Changeset.get_argument(changeset, :confirmation_token) token = Ash.Changeset.get_argument(changeset, :confirmation_token)
if is_binary(token) and token != "" do if is_binary(token) and token != "" do
hash = JoinRequest.hash_confirmation_token(token) now = DateTime.utc_now()
expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour) expires_at = DateTime.add(now, @confirmation_validity_hours, :hour)
changeset changeset
|> Ash.Changeset.force_change_attribute(:confirmation_token_hash, hash) |> Ash.Changeset.force_change_attribute(
:confirmation_token_hash,
JoinRequest.hash_confirmation_token(token)
)
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at) |> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|> Ash.Changeset.force_change_attribute(:confirmation_sent_at, DateTime.utc_now()) |> Ash.Changeset.force_change_attribute(:confirmation_sent_at, now)
else else
changeset changeset
end end

View file

@ -32,9 +32,7 @@ defmodule Mv.Membership do
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
alias Mv.Membership.JoinRequest alias Mv.Membership.JoinRequest
alias Mv.Membership.Member alias Mv.Membership.Member
alias MvWeb.Emails.JoinAlreadyMemberEmail alias Mv.Membership.SettingsCache
alias MvWeb.Emails.JoinAlreadyPendingEmail
alias MvWeb.Emails.JoinConfirmationEmail
require Logger require Logger
admin do admin do
@ -118,10 +116,16 @@ defmodule Mv.Membership do
""" """
def get_settings do def get_settings do
# Try to get the first (and only) settings record case Process.whereis(SettingsCache) do
nil -> get_settings_uncached()
_pid -> SettingsCache.get()
end
end
@doc false
def get_settings_uncached do
case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do
{:ok, nil} -> {:ok, nil} ->
# No settings exist - create as fallback (should normally be created via seed script)
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
Mv.Membership.Setting Mv.Membership.Setting
@ -162,9 +166,16 @@ defmodule Mv.Membership do
""" """
def update_settings(settings, attrs) do def update_settings(settings, attrs) do
settings case settings
|> Ash.Changeset.for_update(:update, attrs) |> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__) do
{:ok, _updated} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end end
@doc """ @doc """
@ -228,11 +239,18 @@ defmodule Mv.Membership do
""" """
def update_member_field_visibility(settings, visibility_config) do def update_member_field_visibility(settings, visibility_config) do
settings case settings
|> Ash.Changeset.for_update(:update_member_field_visibility, %{ |> Ash.Changeset.for_update(:update_member_field_visibility, %{
member_field_visibility: visibility_config member_field_visibility: visibility_config
}) })
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end end
@doc """ @doc """
@ -265,12 +283,19 @@ defmodule Mv.Membership do
field: field, field: field,
show_in_overview: show_in_overview show_in_overview: show_in_overview
) do ) do
settings case settings
|> Ash.Changeset.new() |> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field) |> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{}) |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end end
@doc """ @doc """
@ -304,13 +329,20 @@ defmodule Mv.Membership do
show_in_overview: show_in_overview, show_in_overview: show_in_overview,
required: required required: required
) do ) do
settings case settings
|> Ash.Changeset.new() |> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:field, field) |> Ash.Changeset.set_argument(:field, field)
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|> Ash.Changeset.set_argument(:required, required) |> Ash.Changeset.set_argument(:required, required)
|> Ash.Changeset.for_update(:update_single_member_field, %{}) |> Ash.Changeset.for_update(:update_single_member_field, %{})
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__) do
{:ok, _} = result ->
SettingsCache.invalidate()
result
error ->
error
end
end end
@doc """ @doc """
@ -427,12 +459,12 @@ defmodule Mv.Membership do
defp pending_join_request_with_email(_), do: nil defp pending_join_request_with_email(_), do: nil
defp apply_anti_enumeration_delay do defp join_notifier do
Process.sleep(100 + :rand.uniform(200)) Application.get_env(:mv, :join_notifier, MvWeb.JoinNotifierImpl)
end end
defp send_already_member_and_return(email) do defp send_already_member_and_return(email) do
case JoinAlreadyMemberEmail.send(email) do case join_notifier().send_already_member(email) do
{:ok, _} -> {:ok, _} ->
:ok :ok
@ -440,7 +472,7 @@ defmodule Mv.Membership do
Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}") Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
end end
apply_anti_enumeration_delay() # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_member} {:ok, :notified_already_member}
end end
@ -461,7 +493,7 @@ defmodule Mv.Membership do
}) })
|> Ash.update(domain: __MODULE__, authorize?: false) do |> Ash.update(domain: __MODULE__, authorize?: false) do
{:ok, _updated} -> {:ok, _updated} ->
case JoinConfirmationEmail.send(email, new_token, resend: true) do case join_notifier().send_confirmation(email, new_token, resend: true) do
{:ok, _} -> {:ok, _} ->
:ok :ok
@ -469,7 +501,7 @@ defmodule Mv.Membership do
Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}") Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
end end
apply_anti_enumeration_delay() # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_pending} {:ok, :notified_already_pending}
{:error, _} -> {:error, _} ->
@ -479,7 +511,7 @@ defmodule Mv.Membership do
end end
defp send_already_pending_and_return(email) do defp send_already_pending_and_return(email) do
case JoinAlreadyPendingEmail.send(email) do case join_notifier().send_already_pending(email) do
{:ok, _} -> {:ok, _} ->
:ok :ok
@ -487,7 +519,7 @@ defmodule Mv.Membership do
Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}") Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
end end
apply_anti_enumeration_delay() # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, :notified_already_pending} {:ok, :notified_already_pending}
end end
@ -501,9 +533,9 @@ defmodule Mv.Membership do
domain: __MODULE__ domain: __MODULE__
) do ) do
{:ok, request} -> {:ok, request} ->
case JoinConfirmationEmail.send(request.email, token) do case join_notifier().send_confirmation(request.email, token, []) do
{:ok, _email} -> {:ok, _email} ->
apply_anti_enumeration_delay() # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
{:ok, request} {:ok, request}
{:error, reason} -> {:error, reason} ->

View file

@ -0,0 +1,85 @@
defmodule Mv.Membership.SettingsCache do
@moduledoc """
Process-based cache for global settings to avoid repeated DB reads on hot paths
(e.g. RegistrationEnabled validation, Layouts.public_page, Plugs).
Uses a short TTL (default 60 seconds). Cache is invalidated on every settings
update so that changes take effect quickly. If no settings process exists
(e.g. in tests), get/1 falls back to direct read.
"""
use GenServer
@default_ttl_seconds 60
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Returns cached settings or fetches and caches them. Uses TTL; invalidate on update.
"""
def get do
case Process.whereis(__MODULE__) do
nil ->
# No cache process (e.g. test) read directly
do_fetch()
_pid ->
GenServer.call(__MODULE__, :get, 10_000)
end
end
@doc """
Invalidates the cache so the next get/0 will refetch from the database.
Call after update_settings and any other path that mutates settings.
"""
def invalidate do
case Process.whereis(__MODULE__) do
nil -> :ok
_pid -> GenServer.cast(__MODULE__, :invalidate)
end
end
@impl true
def init(opts) do
ttl = Keyword.get(opts, :ttl_seconds, @default_ttl_seconds)
state = %{ttl_seconds: ttl, cached: nil, expires_at: nil}
{:ok, state}
end
@impl true
def handle_call(:get, _from, state) do
now = System.monotonic_time(:second)
expired? = state.expires_at == nil or state.expires_at <= now
{result, new_state} =
if expired? do
fetch_and_cache(now, state)
else
{{:ok, state.cached}, state}
end
{:reply, result, new_state}
end
defp fetch_and_cache(now, state) do
case do_fetch() do
{:ok, settings} = ok ->
expires = now + state.ttl_seconds
{ok, %{state | cached: settings, expires_at: expires}}
err ->
result = if state.cached, do: {:ok, state.cached}, else: err
{result, state}
end
end
@impl true
def handle_cast(:invalidate, state) do
{:noreply, %{state | cached: nil, expires_at: nil}}
end
defp do_fetch do
Mv.Membership.get_settings_uncached()
end
end

View file

@ -6,6 +6,7 @@ defmodule Mv.Application do
use Application use Application
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
alias Mv.Membership.SettingsCache
alias Mv.Repo alias Mv.Repo
alias Mv.Vereinfacht.SyncFlash alias Mv.Vereinfacht.SyncFlash
alias MvWeb.Endpoint alias MvWeb.Endpoint
@ -16,9 +17,17 @@ defmodule Mv.Application do
def start(_type, _args) do def start(_type, _args) do
SyncFlash.create_table!() SyncFlash.create_table!()
children = [ # SettingsCache not started in test so get_settings runs in the test process (Ecto Sandbox).
cache_children =
if Application.get_env(:mv, :environment) == :test, do: [], else: [SettingsCache]
children =
[
Telemetry, Telemetry,
Repo, Repo
] ++
cache_children ++
[
{JoinRateLimit, [clean_period: :timer.minutes(1)]}, {JoinRateLimit, [clean_period: :timer.minutes(1)]},
{Task.Supervisor, name: Mv.TaskSupervisor}, {Task.Supervisor, name: Mv.TaskSupervisor},
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},

View file

@ -17,12 +17,20 @@ defmodule MvWeb.Layouts do
Renders the public (unauthenticated) page layout: header with logo + "Mitgliederverwaltung" left, Renders the public (unauthenticated) page layout: header with logo + "Mitgliederverwaltung" left,
club name centered, language selector right; plus main content and flash group. Use for sign-in, join, and join-confirm pages so they club name centered, language selector right; plus main content and flash group. Use for sign-in, join, and join-confirm pages so they
share the same chrome without the sidebar or authenticated layout logic. share the same chrome without the sidebar or authenticated layout logic.
Pass optional `:club_name` from the parent (e.g. LiveView mount) to avoid a settings read in the component.
""" """
attr :flash, :map, required: true, doc: "the map of flash messages" attr :flash, :map, required: true, doc: "the map of flash messages"
attr :club_name, :string,
default: nil,
doc: "optional; if set, avoids get_settings() in the component"
slot :inner_block, required: true slot :inner_block, required: true
def public_page(assigns) do def public_page(assigns) do
club_name = club_name =
assigns[:club_name] ||
case Mv.Membership.get_settings() do case Mv.Membership.get_settings() do
{:ok, s} -> s.club_name || "Mitgliederverwaltung" {:ok, s} -> s.club_name || "Mitgliederverwaltung"
_ -> "Mitgliederverwaltung" _ -> "Mitgliederverwaltung"

View file

@ -48,15 +48,8 @@ defmodule MvWeb.JoinConfirmController do
end end
defp assign_confirm_assigns(conn, result) do defp assign_confirm_assigns(conn, result) do
club_name =
case Mv.Membership.get_settings() do
{:ok, settings} -> settings.club_name || "Mitgliederverwaltung"
_ -> "Mitgliederverwaltung"
end
conn conn
|> assign(:result, result) |> assign(:result, result)
|> assign(:club_name, club_name) |> assign(:flash, conn.assigns[:flash] || conn.flash || %{})
|> assign(:csrf_token, Plug.CSRFProtection.get_csrf_token())
end end
end end

View file

@ -1,24 +1,4 @@
<%!-- Public header (same structure as Layouts.app unauthenticated branch) --%> <Layouts.public_page flash={@flash}>
<header class="flex items-center gap-3 p-4 border-b border-base-300 bg-base-100">
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<span class="menu-label text-lg font-bold truncate flex-1">
{@club_name}
</span>
<form method="post" action={~p"/set_locale"} class="shrink-0">
<input type="hidden" name="_csrf_token" value={@csrf_token} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
</select>
</form>
</header>
<main class="px-4 py-8 sm:px-6">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="hero min-h-[60vh] bg-base-200 rounded-lg"> <div class="hero min-h-[60vh] bg-base-200 rounded-lg">
<div class="hero-content flex-col items-start text-left"> <div class="hero-content flex-col items-start text-left">
@ -62,4 +42,4 @@
</div> </div>
</div> </div>
</div> </div>
</main> </Layouts.public_page>

View file

@ -0,0 +1,25 @@
defmodule MvWeb.JoinNotifierImpl do
@moduledoc """
Default implementation of Mv.Membership.JoinNotifier that delegates to MvWeb.Emails.
"""
@behaviour Mv.Membership.JoinNotifier
alias MvWeb.Emails.JoinAlreadyMemberEmail
alias MvWeb.Emails.JoinAlreadyPendingEmail
alias MvWeb.Emails.JoinConfirmationEmail
@impl true
def send_confirmation(email, token, opts \\ []) do
JoinConfirmationEmail.send(email, token, opts)
end
@impl true
def send_already_member(email) do
JoinAlreadyMemberEmail.send(email)
end
@impl true
def send_already_pending(email) do
JoinAlreadyPendingEmail.send(email)
end
end

View file

@ -12,12 +12,22 @@ defmodule MvWeb.JoinLive do
# Honeypot field name (legitimate-sounding to avoid bot detection) # Honeypot field name (legitimate-sounding to avoid bot detection)
@honeypot_field "website" @honeypot_field "website"
# Anti-enumeration: delay before showing success (ms). Applied in LiveView so the process is not blocked.
@anti_enumeration_delay_ms_min 100
@anti_enumeration_delay_ms_rand 200
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
allowlist = Membership.get_join_form_allowlist() allowlist = Membership.get_join_form_allowlist()
join_fields = build_join_fields_with_labels(allowlist) join_fields = build_join_fields_with_labels(allowlist)
client_ip = client_ip_from_socket(socket) client_ip = client_ip_from_socket(socket)
club_name =
case Membership.get_settings() do
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
_ -> "Mitgliederverwaltung"
end
socket = socket =
socket socket
|> assign(:join_fields, join_fields) |> assign(:join_fields, join_fields)
@ -25,6 +35,7 @@ defmodule MvWeb.JoinLive do
|> assign(:rate_limit_error, nil) |> assign(:rate_limit_error, nil)
|> assign(:client_ip, client_ip) |> assign(:client_ip, client_ip)
|> assign(:honeypot_field, @honeypot_field) |> assign(:honeypot_field, @honeypot_field)
|> assign(:club_name, club_name)
|> assign(:form, to_form(initial_form_params(join_fields))) |> assign(:form, to_form(initial_form_params(join_fields)))
{:ok, socket} {:ok, socket}
@ -33,7 +44,7 @@ defmodule MvWeb.JoinLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.public_page flash={@flash}> <Layouts.public_page flash={@flash} club_name={@club_name}>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="hero min-h-[60vh] bg-base-200 rounded-lg"> <div class="hero min-h-[60vh] bg-base-200 rounded-lg">
<div class="hero-content flex-col items-start text-left"> <div class="hero-content flex-col items-start text-left">
@ -149,7 +160,11 @@ defmodule MvWeb.JoinLive do
{:ok, attrs} -> {:ok, attrs} ->
case Membership.submit_join_request(attrs, actor: nil) do case Membership.submit_join_request(attrs, actor: nil) do
{:ok, _} -> {:ok, _} ->
{:noreply, assign(socket, :submitted, true)} delay_ms =
@anti_enumeration_delay_ms_min + :rand.uniform(@anti_enumeration_delay_ms_rand)
Process.send_after(self(), :show_join_success, delay_ms)
{:noreply, socket}
{:error, :email_delivery_failed} -> {:error, :email_delivery_failed} ->
{:noreply, {:noreply,
@ -181,6 +196,16 @@ defmodule MvWeb.JoinLive do
|> assign(:form, to_form(params, as: "join"))} |> assign(:form, to_form(params, as: "join"))}
end end
@impl true
def handle_info(:show_join_success, socket) do
{:noreply, assign(socket, :submitted, true)}
end
# Swoosh (e.g. in test) may send {:email, email} to the LiveView process; ignore.
def handle_info(_msg, socket) do
{:noreply, socket}
end
defp rate_limited_reply(socket, params) do defp rate_limited_reply(socket, params) do
{:noreply, {:noreply,
socket socket

View file

@ -2897,7 +2897,6 @@ msgstr "Intervall auswählen"
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select language" msgid "Select language"
msgstr "Sprache auswählen" msgstr "Sprache auswählen"
@ -3892,3 +3891,8 @@ msgstr "Einstellung konnte nicht gespeichert werden."
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmelden; Anmeldung und Beitrittsformular bleiben verfügbar." msgstr "Wenn deaktiviert, können sich Nutzer*innen nicht über /register anmelden; Anmeldung und Beitrittsformular bleiben verfügbar."
#: lib/accounts/user/validations/registration_enabled.ex
#, elixir-autogen, elixir-format
msgid "Registration is disabled. Please use the join form or contact an administrator."
msgstr "Die Registrierung ist deaktiviert. Bitte nutze das Beitrittsformular oder wende dich an eine*n Administrator*in."

View file

@ -2898,7 +2898,6 @@ msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Select language" msgid "Select language"
msgstr "" msgstr ""
@ -3892,3 +3891,8 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr "" msgstr ""
#: lib/accounts/user/validations/registration_enabled.ex
#, elixir-autogen, elixir-format
msgid "Registration is disabled. Please use the join form or contact an administrator."
msgstr ""

View file

@ -2898,7 +2898,6 @@ msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Select language" msgid "Select language"
msgstr "" msgstr ""
@ -3892,3 +3891,8 @@ msgstr "Failed to update setting."
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available." msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
msgstr "If disabled, users cannot sign up via /register; sign-in and the join form remain available." msgstr "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
#: lib/accounts/user/validations/registration_enabled.ex
#, elixir-autogen, elixir-format
msgid "Registration is disabled. Please use the join form or contact an administrator."
msgstr ""

View file

@ -9,7 +9,8 @@ defmodule MvWeb.JoinLiveTest do
Honeypot: form param `"website"` (legit-sounding name per best practice; not "honeypot"). Honeypot: form param `"website"` (legit-sounding name per best practice; not "honeypot").
Field is hidden via CSS class in app.css (off-screen, no inline styles), type="text". Field is hidden via CSS class in app.css (off-screen, no inline styles), type="text".
""" """
use MvWeb.ConnCase, async: true # async: false so LiveView and test share sandbox (submit creates JoinRequest in LiveView process).
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import Ecto.Query import Ecto.Query
@ -53,6 +54,9 @@ defmodule MvWeb.JoinLiveTest do
}) })
|> render_submit() |> render_submit()
# Anti-enumeration delay is applied in LiveView via send_after (100300 ms); wait for success UI.
Process.sleep(400)
assert count_join_requests() == count_before + 1 assert count_join_requests() == count_before + 1
assert view |> element("[data-testid='join-success-message']") |> has_element?() assert view |> element("[data-testid='join-success-message']") |> has_element?()
assert render(view) =~ "saved your details" assert render(view) =~ "saved your details"