This commit is contained in:
parent
f12da8a359
commit
349cee0ce6
19 changed files with 300 additions and 100 deletions
|
|
@ -90,6 +90,8 @@ lib/
|
|||
│ ├── custom_field.ex # Custom field (definition) resource
|
||||
│ ├── custom_field_value.ex # Custom field value resource
|
||||
│ ├── 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.)
|
||||
│ ├── group.ex # Group 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).
|
||||
- **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`).
|
||||
- `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).
|
||||
|
|
@ -1292,7 +1296,7 @@ mix hex.outdated
|
|||
|
||||
**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):**
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_jo
|
|||
- **Implementation:**
|
||||
- **Sign-in** (`SignInLive`): Uses `use Phoenix.LiveView` (not `use MvWeb, :live_view`) so AshAuthentication’s 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 library’s 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 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -156,9 +156,9 @@
|
|||
|
||||
/* 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
|
||||
de‑emphasised vs body text but meet the minimum ratio. */
|
||||
[data-theme="light"] .label,
|
||||
[data-theme="dark"] .label {
|
||||
de‑emphasised vs body text but meet the minimum ratio. Match .label directly
|
||||
so the override applies even when data-theme is not yet set (e.g. initial load). */
|
||||
.label {
|
||||
color: color-mix(in oklab, var(--color-base-content) 85%, transparent);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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)
|
||||
config :esbuild,
|
||||
version: "0.17.11",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,11 @@ defmodule Mv.Accounts.User.Validations.RegistrationEnabled do
|
|||
{:error,
|
||||
field: :base,
|
||||
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
|
||||
|
|
|
|||
13
lib/membership/join_notifier.ex
Normal file
13
lib/membership/join_notifier.ex
Normal 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
|
||||
|
|
@ -16,13 +16,16 @@ defmodule Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken do
|
|||
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
|
||||
|
||||
if is_binary(token) and token != "" do
|
||||
hash = JoinRequest.hash_confirmation_token(token)
|
||||
expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour)
|
||||
now = DateTime.utc_now()
|
||||
expires_at = DateTime.add(now, @confirmation_validity_hours, :hour)
|
||||
|
||||
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_sent_at, DateTime.utc_now())
|
||||
|> Ash.Changeset.force_change_attribute(:confirmation_sent_at, now)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
|
|
|
|||
|
|
@ -32,9 +32,7 @@ defmodule Mv.Membership do
|
|||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.JoinRequest
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.Emails.JoinAlreadyMemberEmail
|
||||
alias MvWeb.Emails.JoinAlreadyPendingEmail
|
||||
alias MvWeb.Emails.JoinConfirmationEmail
|
||||
alias Mv.Membership.SettingsCache
|
||||
require Logger
|
||||
|
||||
admin do
|
||||
|
|
@ -118,10 +116,16 @@ defmodule Mv.Membership 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
|
||||
{: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"
|
||||
|
||||
Mv.Membership.Setting
|
||||
|
|
@ -162,9 +166,16 @@ defmodule Mv.Membership do
|
|||
|
||||
"""
|
||||
def update_settings(settings, attrs) do
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
case settings
|
||||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _updated} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -228,11 +239,18 @@ defmodule Mv.Membership do
|
|||
|
||||
"""
|
||||
def update_member_field_visibility(settings, visibility_config) do
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
case settings
|
||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -265,12 +283,19 @@ defmodule Mv.Membership do
|
|||
field: field,
|
||||
show_in_overview: show_in_overview
|
||||
) do
|
||||
settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
case settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -304,13 +329,20 @@ defmodule Mv.Membership do
|
|||
show_in_overview: show_in_overview,
|
||||
required: required
|
||||
) do
|
||||
settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.set_argument(:required, required)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
case settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.set_argument(:required, required)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|
||||
|> Ash.update(domain: __MODULE__) do
|
||||
{:ok, _} = result ->
|
||||
SettingsCache.invalidate()
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -427,12 +459,12 @@ defmodule Mv.Membership do
|
|||
|
||||
defp pending_join_request_with_email(_), do: nil
|
||||
|
||||
defp apply_anti_enumeration_delay do
|
||||
Process.sleep(100 + :rand.uniform(200))
|
||||
defp join_notifier do
|
||||
Application.get_env(:mv, :join_notifier, MvWeb.JoinNotifierImpl)
|
||||
end
|
||||
|
||||
defp send_already_member_and_return(email) do
|
||||
case JoinAlreadyMemberEmail.send(email) do
|
||||
case join_notifier().send_already_member(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -440,7 +472,7 @@ defmodule Mv.Membership do
|
|||
Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
apply_anti_enumeration_delay()
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, :notified_already_member}
|
||||
end
|
||||
|
||||
|
|
@ -461,7 +493,7 @@ defmodule Mv.Membership do
|
|||
})
|
||||
|> Ash.update(domain: __MODULE__, authorize?: false) do
|
||||
{:ok, _updated} ->
|
||||
case JoinConfirmationEmail.send(email, new_token, resend: true) do
|
||||
case join_notifier().send_confirmation(email, new_token, resend: true) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -469,7 +501,7 @@ defmodule Mv.Membership do
|
|||
Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
apply_anti_enumeration_delay()
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, :notified_already_pending}
|
||||
|
||||
{:error, _} ->
|
||||
|
|
@ -479,7 +511,7 @@ defmodule Mv.Membership do
|
|||
end
|
||||
|
||||
defp send_already_pending_and_return(email) do
|
||||
case JoinAlreadyPendingEmail.send(email) do
|
||||
case join_notifier().send_already_pending(email) do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -487,7 +519,7 @@ defmodule Mv.Membership do
|
|||
Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
apply_anti_enumeration_delay()
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, :notified_already_pending}
|
||||
end
|
||||
|
||||
|
|
@ -501,9 +533,9 @@ defmodule Mv.Membership do
|
|||
domain: __MODULE__
|
||||
) do
|
||||
{:ok, request} ->
|
||||
case JoinConfirmationEmail.send(request.email, token) do
|
||||
case join_notifier().send_confirmation(request.email, token, []) do
|
||||
{:ok, _email} ->
|
||||
apply_anti_enumeration_delay()
|
||||
# Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process.
|
||||
{:ok, request}
|
||||
|
||||
{:error, reason} ->
|
||||
|
|
|
|||
85
lib/membership/settings_cache.ex
Normal file
85
lib/membership/settings_cache.ex
Normal 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
|
||||
|
|
@ -6,6 +6,7 @@ defmodule Mv.Application do
|
|||
use Application
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
alias Mv.Membership.SettingsCache
|
||||
alias Mv.Repo
|
||||
alias Mv.Vereinfacht.SyncFlash
|
||||
alias MvWeb.Endpoint
|
||||
|
|
@ -16,20 +17,28 @@ defmodule Mv.Application do
|
|||
def start(_type, _args) do
|
||||
SyncFlash.create_table!()
|
||||
|
||||
children = [
|
||||
Telemetry,
|
||||
Repo,
|
||||
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
|
||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mv.PubSub},
|
||||
{AshAuthentication.Supervisor, otp_app: :my},
|
||||
SystemActor,
|
||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||
# {Mv.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
Endpoint
|
||||
]
|
||||
# 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,
|
||||
Repo
|
||||
] ++
|
||||
cache_children ++
|
||||
[
|
||||
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
|
||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mv.PubSub},
|
||||
{AshAuthentication.Supervisor, otp_app: :my},
|
||||
SystemActor,
|
||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||
# {Mv.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
Endpoint
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
|
|
|
|||
|
|
@ -17,16 +17,24 @@ defmodule MvWeb.Layouts do
|
|||
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
|
||||
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 :club_name, :string,
|
||||
default: nil,
|
||||
doc: "optional; if set, avoids get_settings() in the component"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def public_page(assigns) do
|
||||
club_name =
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
|
||||
_ -> "Mitgliederverwaltung"
|
||||
end
|
||||
assigns[:club_name] ||
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
|
||||
_ -> "Mitgliederverwaltung"
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :club_name, club_name)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,15 +48,8 @@ defmodule MvWeb.JoinConfirmController do
|
|||
end
|
||||
|
||||
defp assign_confirm_assigns(conn, result) do
|
||||
club_name =
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} -> settings.club_name || "Mitgliederverwaltung"
|
||||
_ -> "Mitgliederverwaltung"
|
||||
end
|
||||
|
||||
conn
|
||||
|> assign(:result, result)
|
||||
|> assign(:club_name, club_name)
|
||||
|> assign(:csrf_token, Plug.CSRFProtection.get_csrf_token())
|
||||
|> assign(:flash, conn.assigns[:flash] || conn.flash || %{})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,24 +1,4 @@
|
|||
<%!-- Public header (same structure as Layouts.app unauthenticated branch) --%>
|
||||
<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">
|
||||
<Layouts.public_page flash={@flash}>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="hero min-h-[60vh] bg-base-200 rounded-lg">
|
||||
<div class="hero-content flex-col items-start text-left">
|
||||
|
|
@ -62,4 +42,4 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layouts.public_page>
|
||||
|
|
|
|||
25
lib/mv_web/join_notifier_impl.ex
Normal file
25
lib/mv_web/join_notifier_impl.ex
Normal 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
|
||||
|
|
@ -12,12 +12,22 @@ defmodule MvWeb.JoinLive do
|
|||
# Honeypot field name (legitimate-sounding to avoid bot detection)
|
||||
@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
|
||||
def mount(_params, _session, socket) do
|
||||
allowlist = Membership.get_join_form_allowlist()
|
||||
join_fields = build_join_fields_with_labels(allowlist)
|
||||
client_ip = client_ip_from_socket(socket)
|
||||
|
||||
club_name =
|
||||
case Membership.get_settings() do
|
||||
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
|
||||
_ -> "Mitgliederverwaltung"
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:join_fields, join_fields)
|
||||
|
|
@ -25,6 +35,7 @@ defmodule MvWeb.JoinLive do
|
|||
|> assign(:rate_limit_error, nil)
|
||||
|> assign(:client_ip, client_ip)
|
||||
|> assign(:honeypot_field, @honeypot_field)
|
||||
|> assign(:club_name, club_name)
|
||||
|> assign(:form, to_form(initial_form_params(join_fields)))
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -33,7 +44,7 @@ defmodule MvWeb.JoinLive do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.public_page flash={@flash}>
|
||||
<Layouts.public_page flash={@flash} club_name={@club_name}>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="hero min-h-[60vh] bg-base-200 rounded-lg">
|
||||
<div class="hero-content flex-col items-start text-left">
|
||||
|
|
@ -149,7 +160,11 @@ defmodule MvWeb.JoinLive do
|
|||
{:ok, attrs} ->
|
||||
case Membership.submit_join_request(attrs, actor: nil) do
|
||||
{: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} ->
|
||||
{:noreply,
|
||||
|
|
@ -181,6 +196,16 @@ defmodule MvWeb.JoinLive do
|
|||
|> assign(:form, to_form(params, as: "join"))}
|
||||
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
|
||||
{:noreply,
|
||||
socket
|
||||
|
|
|
|||
|
|
@ -2897,7 +2897,6 @@ msgstr "Intervall auswählen"
|
|||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr "Sprache auswählen"
|
||||
|
|
@ -3892,3 +3891,8 @@ msgstr "Einstellung konnte nicht gespeichert werden."
|
|||
#, elixir-autogen, elixir-format
|
||||
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."
|
||||
|
||||
#: 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."
|
||||
|
|
|
|||
|
|
@ -2898,7 +2898,6 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
|
@ -3892,3 +3891,8 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "If disabled, users cannot sign up via /register; sign-in and the join form remain available."
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -2898,7 +2898,6 @@ msgstr ""
|
|||
|
||||
#: lib/mv_web/components/layouts.ex
|
||||
#: lib/mv_web/components/layouts/sidebar.ex
|
||||
#: lib/mv_web/controllers/join_confirm_html/confirm.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
|
@ -3892,3 +3891,8 @@ msgstr "Failed to update setting."
|
|||
#, elixir-autogen, elixir-format
|
||||
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."
|
||||
|
||||
#: 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 ""
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ defmodule MvWeb.JoinLiveTest do
|
|||
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".
|
||||
"""
|
||||
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 Ecto.Query
|
||||
|
||||
|
|
@ -53,6 +54,9 @@ defmodule MvWeb.JoinLiveTest do
|
|||
})
|
||||
|> render_submit()
|
||||
|
||||
# Anti-enumeration delay is applied in LiveView via send_after (100–300 ms); wait for success UI.
|
||||
Process.sleep(400)
|
||||
|
||||
assert count_join_requests() == count_before + 1
|
||||
assert view |> element("[data-testid='join-success-message']") |> has_element?()
|
||||
assert render(view) =~ "saved your details"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue