Finalize join request feature #472
19 changed files with 300 additions and 100 deletions
|
|
@ -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):**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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`).
|
- **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** (`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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
de‑emphasised vs body text but meet the minimum ratio. */
|
de‑emphasised 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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} ->
|
||||||
|
|
|
||||||
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
|
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,20 +17,28 @@ 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).
|
||||||
Telemetry,
|
cache_children =
|
||||||
Repo,
|
if Application.get_env(:mv, :environment) == :test, do: [], else: [SettingsCache]
|
||||||
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
|
|
||||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
children =
|
||||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
[
|
||||||
{Phoenix.PubSub, name: Mv.PubSub},
|
Telemetry,
|
||||||
{AshAuthentication.Supervisor, otp_app: :my},
|
Repo
|
||||||
SystemActor,
|
] ++
|
||||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
cache_children ++
|
||||||
# {Mv.Worker, arg},
|
[
|
||||||
# Start to serve requests, typically the last entry
|
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
|
||||||
Endpoint
|
{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
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
# for other strategies and supported options
|
# 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,
|
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 =
|
||||||
case Mv.Membership.get_settings() do
|
assigns[:club_name] ||
|
||||||
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
|
case Mv.Membership.get_settings() do
|
||||||
_ -> "Mitgliederverwaltung"
|
{:ok, s} -> s.club_name || "Mitgliederverwaltung"
|
||||||
end
|
_ -> "Mitgliederverwaltung"
|
||||||
|
end
|
||||||
|
|
||||||
assigns = assign(assigns, :club_name, club_name)
|
assigns = assign(assigns, :club_name, club_name)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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 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
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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 (100–300 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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue