diff --git a/.env.example b/.env.example index e24b118..d63e019 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ ASSOCIATION_NAME="Sportsclub XYZ" # Optional: Admin user (created/updated on container start via Release.seed_admin) # In production, set these so the first admin can log in. Change password without redeploy: # bin/mv eval "Mv.Release.seed_admin()" (with new ADMIN_PASSWORD or ADMIN_PASSWORD_FILE) +# FORCE_SEEDS=true re-runs bootstrap seeds even when admin user exists (e.g. after changing roles/custom fields). # ADMIN_EMAIL=admin@example.com # ADMIN_PASSWORD=secure-password # ADMIN_PASSWORD_FILE=/run/secrets/admin_password @@ -41,3 +42,15 @@ ASSOCIATION_NAME="Sportsclub XYZ" # VEREINFACHT_API_KEY=your-api-key # VEREINFACHT_CLUB_ID=2 # VEREINFACHT_APP_URL=https://app.verein.visuel.dev + +# Optional: Mail / SMTP (transactional emails). If set, overrides Settings UI. +# Export current UI settings to .env: mix mv.export_smtp_to_env +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USERNAME=user +# SMTP_PASSWORD=secret +# SMTP_PASSWORD_FILE=/run/secrets/smtp_password +# SMTP_SSL=tls +# SMTP_VERIFY_PEER=false +# MAIL_FROM_EMAIL=noreply@example.com +# MAIL_FROM_NAME=Mila diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c23c01..b94ce50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.1.1] - 2026-03-16 + +### Added +- **FORCE_SEEDS** – Environment variable. When set to `"true"`, bootstrap (and optionally dev) seeds are run even when the admin user already exists, so you can re-apply changed seed data (e.g. new roles or custom fields) without deleting the admin user. +- **Improved OIDC-only mode** – Admin can enable “Only OIDC sign-in” in settings; when enabled, direct registration is disabled and sign-in page redirects to OIDC when configured. +- **Success toast auto-dismiss** – Success flash messages (e.g. “Settings saved”) hide automatically after 5 seconds instead of requiring the user to close them. + +### Changed +- **Seeds run only when needed** – Bootstrap and dev seeds are skipped on application start when the admin user already exists (`Mv.Release.bootstrap_seeds_applied?/0`). This avoids duplicate data and speeds up startup in dev and production after the first run. Set `FORCE_SEEDS=true` to override and re-run. +- **Unauthenticated access** – Users who are not logged in are redirected to sign-in without showing a “no permission” message; the message is only shown to logged-in users who lack access. + +### Fixed +- **SMTP configuration** – Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly. + +## [1.1.0] - 2026-03-13 + +### Added +- **Browser timezone for datetime display** – Date/time values (e.g. join request submitted at, approved at, rejected at) are shown in the user’s local timezone. +- **Registration toggle** – New global setting to disable direct registration (`/register`). When disabled, visitors are redirected to sign-in and the register link is hidden; join form remains available. +- **Configurable SMTP in global settings** – SMTP host, port, user, password, and TLS options configurable via Admin → Global Settings. Test-email action to verify delivery. Join confirmation and other transactional emails use this configuration. +- **Theme and language selector on unauthenticated pages** – Sign-in and join pages now offer theme (light/dark) and locale (e.g. German/English) controls in the header. +- **Duplicate-email handling for join form** – If an applicant’s email is already a member or already has a pending join request, the system sends a clarifying email (already-member or already-pending) and shows the same success message (anti-enumeration). +- **Reviewed-by display for join requests** – Approval UI shows who reviewed a request via a dedicated display field, without loading the User record. +- **Improved field order and seeds for join request approval** – Approval screen field order improved; seed data updated for join-form and approval flows. +- **Tests for SMTP mailer configuration** – Tests for SMTP config and for join confirmation email delivery failure (domain and LiveView). + +### Changed +- **SMTP settings layout** – SMTP options reordered and grouped in global settings for clearer configuration. +- **Join confirmation mail** – Uses configurable SMTP from settings; on delivery failure the join form shows an error and no success message. +- **i18n** – Gettext catalogs updated for new and changed strings. + +### Fixed +- **Login page translation** – Corrected translation/locale handling on the sign-in page. + +--- + +## [1.0.0] and earlier ### Added - **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 4aa7566..f84c5ad 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -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 @@ -128,6 +130,8 @@ lib/ │ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields) │ ├── application.ex # OTP application │ ├── mailer.ex # Email mailer +│ ├── smtp/ +│ │ └── config_builder.ex # SMTP adapter opts (TLS/sockopts); used by runtime.exs and Mailer │ ├── release.ex # Release tasks │ ├── repo.ex # Database repository │ ├── secrets.ex # Secret management @@ -280,13 +284,13 @@ end ### 1.2.1 Database Seeds -Seeds are split into **bootstrap** and **dev**: +Seeds are split into **bootstrap** and **dev**. They run on every start (e.g. `just run`, Docker entrypoint) but **exit early** if already applied so startup stays fast and no duplicate data is created. -- **`priv/repo/seeds.exs`** – Entrypoint. Runs `seeds_bootstrap.exs` always; runs `seeds_dev.exs` only when `Mix.env()` is `:dev` or `:test`. +- **`priv/repo/seeds.exs`** – Entrypoint. If the admin user (ADMIN_EMAIL or default) already exists, skips entirely (unless `FORCE_SEEDS=true`); otherwise runs `seeds_bootstrap.exs` and, in dev/test, `seeds_dev.exs`. - **`priv/repo/seeds_bootstrap.exs`** – Creates only data required for system startup: membership fee types, custom fields, roles, admin user, system user, global settings (including default membership fee type). No members, no groups. Used in all environments (dev, test, prod). - **`priv/repo/seeds_dev.exs`** – Creates 20 sample members, groups, and optional custom field values. Run only in dev and test. -In production, running `mix run priv/repo/seeds.exs` executes only the bootstrap part (no dev seeds). +In production, running `mix run priv/repo/seeds.exs` (or `Mv.Release.run_seeds/0`) executes only the bootstrap part when not yet applied (no dev seeds unless `RUN_DEV_SEEDS=true`). The “already applied” check uses `Mv.Release.bootstrap_seeds_applied?/0` (admin user exists). Set `FORCE_SEEDS=true` to re-run seeds even when already applied. ### 1.3 Domain-Driven Design @@ -1267,7 +1271,34 @@ mix hex.outdated **Mailer and from address:** - `Mv.Mailer` (Swoosh) and `Mv.Mailer.mail_from/0` return the configured sender `{name, email}`. -- Config: `config :mv, :mail_from, {"Mila", "noreply@example.com"}` in config.exs. In production, runtime.exs overrides from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). +- Sender identity priority: `MAIL_FROM_NAME`/`MAIL_FROM_EMAIL` ENV > Settings `smtp_from_name`/`smtp_from_email` > hardcoded defaults `{"Mila", "noreply@example.com"}`. +- Access via `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`. +- **Important:** On most SMTP servers the sender email must be the same address as `smtp_username` or an alias owned by that account (e.g. Postfix strict relay). Misconfiguration causes a 553 error. + +**SMTP configuration:** + +- 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). +- When `SMTP_HOST` ENV is present at boot, `runtime.exs` configures `Swoosh.Adapters.SMTP` automatically. +- When SMTP is configured only via Settings, `Mv.Mailer.smtp_config/0` builds the adapter config per-send. +- In test environment, `Swoosh.Adapters.Test` is used regardless of SMTP config. +- **TLS in OTP 27:** Verify mode defaults to `verify_none` for self-signed/internal certs. Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) in prod when using public SMTP (Gmail, Mailgun). Config key `:smtp_verify_peer` is set in `runtime.exs` and read by `Mv.Mailer.smtp_config/0`. +- **Test email:** `Mv.Mailer.send_test_email(to_email)` sends a transactional test email; returns `{:ok, email}` or `{:error, classified_reason}`. Classified errors: `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}`. Each shows a specific message in the UI. +- **Production warning:** When SMTP is not configured in production, a warning is shown in the Settings UI. Use `Application.get_env(:mv, :environment, :dev)` (or assign in mount) for environment checks in LiveView/templates; do not use `Mix.env()` at runtime (it is not available in releases). +- Access config values via `Mv.Config.smtp_host/0`, `smtp_port/0`, `smtp_username/0`, `smtp_password/0`, `smtp_ssl/0`, `smtp_configured?/0`. + +**AshAuthentication senders:** + +- `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Errors are logged via `Logger.error` and not re-raised so they never crash the caller process. + +**Join confirmation email:** + +- 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):** @@ -1287,7 +1318,11 @@ new() |> put_view(MvWeb.EmailsView) |> put_layout({MvWeb.EmailLayoutView, "layout.html"}) |> render_body("template_name.html", %{assigns}) -|> Mailer.deliver!() + +case Mailer.deliver(email) do + {:ok, _} -> :ok + {:error, reason} -> Logger.error("Email delivery failed: #{inspect(reason)}") +end ``` ### 3.12 Internationalization: Gettext @@ -1315,13 +1350,16 @@ dgettext("auth", "Sign in with email") **Extract and Merge:** ```bash -# Extract new translatable strings -mix gettext.extract +# Extract new translatable strings and merge into existing .po files (recommended) +mix gettext.extract --merge -# Merge into existing translations +# Alternative: extract only, then merge separately +mix gettext.extract mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete ``` +**Gettext merge workflow:** Prefer `mix gettext.extract --merge` so the `.pot` template is regenerated from source and merged into all locale `.po` files in one step. Edit only the `msgstr` values in `.po` files for translations; do not manually change source references, entry order, or the `.pot` file structure. If Git merge conflicts appear in `.po` or `.pot` files, resolve by removing conflict markers (keeping both sides where appropriate), then run `mix gettext.extract --merge`. If the `.pot` file is corrupted, delete it and run `mix gettext.extract --merge` to regenerate it from source. + ### 3.13 Task Runner: Just **Common Commands:** diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md index 92f7a90..0ad562e 100644 --- a/DESIGN_GUIDELINES.md +++ b/DESIGN_GUIDELINES.md @@ -76,6 +76,21 @@ For LiveViews that render an edit or new form (e.g. member, group, role, user, c If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`). +### 2.3 Public / unauthenticated pages (Join, Sign-in, Join Confirm) + +Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_join/:token`) use a unified layout via the **`Layouts.public_page`** component: + +- **Component:** `Layouts.public_page` renders: + - **Header:** Logo + "Mitgliederverwaltung" (left) | Club name centered via absolute positioning | Language selector + theme swap (sun/moon, DaisyUI swap with rotate) (right) + - Main content slot, Flash group. No sidebar, no authenticated-layout logic. +- **Content:** DaisyUI **hero** section (`hero`, `hero-content`) for the main message or form, so all public pages share the same visual structure. The hero is constrained in width (`max-w-4xl mx-auto`) and content is left-aligned (`hero-content flex-col items-start text-left`). +- **Locale handling:** The language selector uses `Gettext.get_locale(MvWeb.Gettext)` (backend-specific) to correctly reflect the active locale. `SignInLive` sets both `Gettext.put_locale(MvWeb.Gettext, locale)` and `Gettext.put_locale(locale)` to keep global and backend locales in sync. +- **Translations for AshAuthentication components:** AshAuthentication’s `_gettext` mechanism translates button labels (e.g. “Sign in” → “Anmelden”, “Register” → “Registrieren”) at runtime via `gettext_fn: {MvWeb.Gettext, "auth"}`. Components that do NOT use `_gettext` (e.g. `HorizontalRule`) receive static German overrides via **`MvWeb.AuthOverridesDE`**, which is prepended to the overrides list in `SignInLive` when the locale is `"de"`. +- **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 `` with the SignIn component inside a hero. Displays a locale-aware `

` 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 `` with a hero for the form. + - **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that wraps content in `` 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) Use these standard roles: @@ -83,16 +98,18 @@ Use these standard roles: | Role | Use | Class | |---|---|---| | Page title (H1) | main page title | `text-xl font-semibold leading-8` | -| Subtitle | helper under title | `text-sm text-base-content/70` | +| Subtitle | helper under title | `text-sm text-base-content/85` | | Section title (H2) | section headings | `text-lg font-semibold` | -| Helper text | under inputs | `text-sm text-base-content/70` | -| Fine print | small hints | `text-xs text-base-content/60` | -| Empty state | no data | `text-base-content/60 italic` | +| Helper text | under inputs | `text-sm text-base-content/85` | +| Fine print | small hints | `text-xs text-base-content/80` | +| Empty state | no data | `text-base-content/80 italic` | | Destructive text | danger | `text-error` | **MUST:** Page titles via `<.header>`. **MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later). +**Form labels (WCAG 2.2 AA):** DaisyUI `.label` defaults to 60% opacity and fails contrast. We override it in `app.css` to 85% of `base-content` so labels stay slightly de‑emphasised vs body text but meet the 4.5:1 minimum. Use `class="label"` and `` as usual; no extra classes needed. + --- ## 4) States: Loading, Empty, Error (mandatory consistency) @@ -204,6 +221,11 @@ If these cannot be met, use `secondary`/`outline` instead of `ghost`. - **MUST:** Required fields are marked consistently (UI indicator + accessible text). - **SHOULD:** If required-ness is configurable via settings, display it consistently in the form. +### 6.4 Form layout (settings / long forms) +- **SHOULD:** On wide viewports, use a responsive grid so related fields share a row and reduce scrolling (e.g. `grid grid-cols-1 lg:grid-cols-2` or `lg:grid-cols-[2fr_5rem_1fr]` for mixed widths). +- **SHOULD:** Limit the main content width for readability (e.g. Settings page uses `max-w-4xl mx-auto px-4` around the content area below the header). +- **Example:** SMTP settings use three rows on large screens (Host, Port, TLS/SSL | Username, Password | Sender email, Sender name) without subsection labels. + --- ## 7) Lists, Search & Filters (mandatory UX consistency) diff --git a/Justfile b/Justfile index f3ad5a3..d2c51e5 100644 --- a/Justfile +++ b/Justfile @@ -10,6 +10,7 @@ install-dependencies: mix deps.get migrate-database: + mix compile mix ash.setup reset-database: diff --git a/assets/css/app.css b/assets/css/app.css index e3c6e83..d7f873c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -154,6 +154,14 @@ background-color: var(--color-base-100); } +/* 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. 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); +} + /* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background. Theme tokens *-content are often too light on * backgrounds in light theme, and badge-soft uses variant as text on a light tint (low contrast). We override diff --git a/assets/js/app.js b/assets/js/app.js index ee423eb..4c7e3c5 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -25,6 +25,14 @@ import Sortable from "../vendor/sortable" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +function getBrowserTimezone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || null + } catch (_e) { + return null + } +} + // Hooks for LiveView components let Hooks = {} @@ -105,6 +113,25 @@ Hooks.FocusRestore = { } } +// FlashAutoDismiss: after a delay, clear the flash so the toast hides without user clicking X (e.g. success toasts) +Hooks.FlashAutoDismiss = { + mounted() { + const ms = this.el.dataset.autoClearMs + if (!ms) return + const delay = parseInt(ms, 10) + if (delay > 0) { + this.timer = setTimeout(() => { + const key = this.el.dataset.clearFlashKey || "success" + this.pushEvent("lv:clear-flash", {key}) + }, delay) + } + }, + + destroyed() { + if (this.timer) clearTimeout(this.timer) + } +} + // TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex) Hooks.TabListKeydown = { mounted() { @@ -312,7 +339,10 @@ Hooks.SidebarState = { let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken}, + params: { + _csrf_token: csrfToken, + timezone: getBrowserTimezone() + }, hooks: Hooks }) diff --git a/config/config.exs b/config/config.exs index ab55f2a..7bb4f61 100644 --- a/config/config.exs +++ b/config/config.exs @@ -46,11 +46,18 @@ config :spark, ] ] +# IANA timezone database for DateTime.shift_zone (browser timezone display) +config :elixir, :time_zone_database, Tz.TimeZoneDatabase + config :mv, ecto_repos: [Mv.Repo], generators: [timestamp_type: :utc_datetime], ash_domains: [Mv.Membership, Mv.Accounts, Mv.MembershipFees, Mv.Authorization] +# Environment (dev/test/prod). Use this instead of Mix.env() at runtime; Mix.env() is +# not available in releases. Set once at compile time via config_env(). +config :mv, :environment, config_env() + # CSV Import configuration config :mv, csv_import: [ @@ -89,6 +96,10 @@ config :mv, MvWeb.Endpoint, # at the `config/runtime.exs`. config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local +# SMTP TLS verification: false = allow self-signed/internal certs; true = verify_peer (use for public SMTP). +# Overridden in runtime.exs from SMTP_VERIFY_PEER when SMTP is configured via ENV in prod. +config :mv, :smtp_verify_peer, false + # Default mail "from" address for transactional emails (join confirmation, # user confirmation, password reset). Override in config/runtime.exs from ENV. config :mv, :mail_from, {"Mila", "noreply@example.com"} @@ -96,6 +107,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", diff --git a/config/runtime.exs b/config/runtime.exs index b8570d8..6a434fa 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -223,19 +223,52 @@ if config_env() == :prod do {System.get_env("MAIL_FROM_NAME", "Mila"), System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")} - # In production you may need to configure the mailer to use a different adapter. - # Also, you may need to configure the Swoosh API client of your choice if you - # are not using SMTP. Here is an example of the configuration: - # - # config :mv, Mv.Mailer, - # adapter: Swoosh.Adapters.Mailgun, - # api_key: System.get_env("MAILGUN_API_KEY"), - # domain: System.get_env("MAILGUN_DOMAIN") - # - # For this example you need include a HTTP client required by Swoosh API client. - # Swoosh supports Hackney, Req and Finch out of the box: - # - # config :swoosh, :api_client, Swoosh.ApiClient.Hackney - # - # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. + # SMTP configuration from environment variables (overrides base adapter in prod). + # When SMTP_HOST is set, configure Swoosh to use the SMTP adapter at boot time. + # If SMTP is configured only via Settings (Admin UI), the mailer builds the config + # per-send at runtime using Mv.Mailer.smtp_config/0 (which uses the same Mv.Smtp.ConfigBuilder). + smtp_host_env = System.get_env("SMTP_HOST") + + if smtp_host_env && String.trim(smtp_host_env) != "" do + smtp_port_env = + case System.get_env("SMTP_PORT") do + nil -> 587 + v -> String.to_integer(String.trim(v)) + end + + smtp_password_env = + case System.get_env("SMTP_PASSWORD") do + nil -> + case System.get_env("SMTP_PASSWORD_FILE") do + nil -> nil + path -> path |> File.read!() |> String.trim() + end + + v -> + v + end + + smtp_ssl_mode = System.get_env("SMTP_SSL", "tls") + + # SMTP_VERIFY_PEER: set to true/1/yes to enable TLS certificate verification (recommended + # for public SMTP like Gmail/Mailgun). Default false for self-signed/internal certs. + smtp_verify_peer = + (System.get_env("SMTP_VERIFY_PEER", "false") |> String.downcase()) in ~w(true 1 yes) + + config :mv, :smtp_verify_peer, smtp_verify_peer + + verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none + + smtp_opts = + Mv.Smtp.ConfigBuilder.build_opts( + host: String.trim(smtp_host_env), + port: smtp_port_env, + username: System.get_env("SMTP_USERNAME"), + password: smtp_password_env, + ssl_mode: smtp_ssl_mode, + verify_mode: verify_mode + ) + + config :mv, Mv.Mailer, smtp_opts + end end diff --git a/config/test.exs b/config/test.exs index 84ccd70..ef54982 100644 --- a/config/test.exs +++ b/config/test.exs @@ -58,3 +58,7 @@ config :mv, :sql_sandbox, true # Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute) config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2 + +# Ash: silence "after_transaction hooks in surrounding transaction" warning when using +# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected). +config :ash, warn_on_transaction_hooks?: false diff --git a/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md index 5e26c85..5413f91 100644 --- a/docs/admin-bootstrap-and-oidc-role-sync.md +++ b/docs/admin-bootstrap-and-oidc-role-sync.md @@ -2,7 +2,7 @@ ## Overview -- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (bootstrap seeds; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`. +- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (skips if admin user already exists unless `FORCE_SEEDS=true`; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`. - **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in. ## Admin Bootstrap (Part A) @@ -10,13 +10,14 @@ ### Environment Variables - `RUN_DEV_SEEDS` – If set to `"true"`, `run_seeds/0` also runs dev seeds (members, groups, sample data). Otherwise only bootstrap seeds run. +- `FORCE_SEEDS` – If set to `"true"`, seeds are run even when the admin user already exists (e.g. after changing bootstrap data such as roles or custom fields). Otherwise seeds are skipped when bootstrap was already applied. - `ADMIN_EMAIL` – Email of the admin user to create/update. If unset, seed_admin/0 does nothing. - `ADMIN_PASSWORD` – Password for the admin user. If unset (and no file), no new user is created; if a user with ADMIN_EMAIL already exists (e.g. OIDC-only), their role is set to Admin (no password change). - `ADMIN_PASSWORD_FILE` – Path to a file containing the password (e.g. Docker secret). ### Release Tasks -- `Mv.Release.run_seeds/0` – Runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Idempotent. +- `Mv.Release.run_seeds/0` – If the admin user already exists (bootstrap already applied), skips unless `FORCE_SEEDS=true`; otherwise runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Safe to call on every start. - `Mv.Release.seed_admin/0` – Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both email and password are set: creates or updates the user with the Admin role. If only ADMIN_EMAIL is set: sets the Admin role on an existing user with that email (for OIDC-only admins); does not create a user. Idempotent. ### Entrypoint @@ -38,6 +39,7 @@ ### Sign-in page (OIDC-only mode) - `OIDC_ONLY` (or Settings → OIDC → "Only OIDC sign-in") – When set to true/1/yes and OIDC is configured, the sign-in page shows only the Single Sign-On button (password login is hidden). ENV takes precedence over Settings. +- **Redirect loop fix:** After an OIDC failure (e.g. provider down), the app redirects to `/sign-in?oidc_failed=1`. The plug `OidcOnlySignInRedirect` does not redirect that request back to OIDC, so the sign-in page is shown with the error (no endless redirect). ### Sync Logic diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index a6297ba..6d8e523 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -806,7 +806,7 @@ end - **Senders migrated:** `SendNewUserConfirmationEmail`, `SendPasswordResetEmail` use layout + `Mv.Mailer.mail_from/0`. - **Cleanup:** Mix task `mix join_requests.cleanup_expired` hard-deletes JoinRequests in `pending_confirmation` with expired `confirmation_token_expires_at` (authorize?: false). For cron/Oban. - **Gettext:** New email strings in default domain; German translations in de/LC_MESSAGES/default.po; English msgstr filled for email-related strings. -- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/1` and returns `{:ok, email}` \| `{:error, reason}`; domain logs delivery errors but still returns `{:ok, request}` so the user sees success. Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders. +- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/2` with `Mailer.smtp_config/0` (same config as test mail). On delivery failure the domain returns `{:error, :email_delivery_failed}` (logged via `Logger.error`), and the JoinLive shows an error message (no success UI). Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders. - Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` – all pass. **Subtask 3 – Admin: Join form settings (done):** diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 9c8c835..2ec15a5 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -36,10 +36,10 @@ **Closed Issues:** - ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13) +- ✅ [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen — fixed via `MvWeb.AuthOverridesDE` locale-specific module (2026-03-13) +- ✅ [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen — fixed locale selector bug with `Gettext.get_locale(MvWeb.Gettext)` (2026-03-13) -**Open Issues:** -- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low) -- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low) +**Open Issues:** (none remaining for Authentication UI) **Current State:** - ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345) @@ -49,6 +49,11 @@ - ✅ **Page-level authorization** - LiveView page access control - ✅ **System role protection** - Critical roles cannot be deleted +**Planned: OIDC-only mode (TDD, tests first):** +- Admin Settings: When OIDC-only is enabled, disable "Allow direct registration" toggle and show hint (tests in `GlobalSettingsLiveTest`). +- Backend: Reject password sign-in and `register_with_password` when OIDC-only (tests in `AuthControllerTest`, `Accounts`). +- GET `/sign-in` redirect to OIDC when OIDC-only and OIDC configured (tests in `AuthControllerTest`). Implementation to follow after tests. + **Missing Features:** - ❌ Password reset flow - ❌ Email verification @@ -270,6 +275,9 @@ **Open Issues:** - [#186](https://git.local-it.org/local-it/mitgliederverwaltung/issues/186) - Create Architecture docs in Repo (S, Low priority) +**Implemented Features:** +- ✅ **SMTP configuration** – Configure mail server via ENV (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) and Admin Settings (UI), with ENV taking priority. Test email from Settings SMTP section. Production warning when SMTP is not configured. See [`docs/smtp-configuration-concept.md`](smtp-configuration-concept.md). + **Missing Features:** - ❌ Email templates configuration - ❌ System health dashboard @@ -287,6 +295,7 @@ - ✅ Swoosh mailer integration - ✅ Email confirmation (via AshAuthentication) - ✅ Password reset emails (via AshAuthentication) +- ✅ **SMTP configuration** via ENV and Admin Settings (see Admin Panel section) - ⚠️ No member communication features **Missing Features:** diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 8083a7b..8e6c615 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -93,6 +93,7 @@ - **Placement:** Own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten" (club data). - **Join form enabled:** Checkbox (e.g. `join_form_enabled`). When set, the public `/join` page is active and the following config applies. +- **Copyable join link:** When the join form is enabled, a copyable full URL to the `/join` page is shown below the checkbox (above the field list), with a short hint so admins can share it with applicants. - **Field selection:** From **all existing** member fields (from `Mv.Constants.member_fields()`) and **custom fields**, the admin selects which fields appear on the join form. Stored as a list/set of field identifiers (no separate table); display in settings as a simple list, e.g. **badges with X to remove** (similar to the groups overview). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a **separate subtask**. - **Technically required fields:** The only field that must always be required for the join flow is **email**. All other fields can be optional or marked as required per admin choice; implementation should support a "required" flag per selected join-form field. - **Other:** Which entry paths are enabled, approval workflow (who can approve) – to be detailed in Step 2 and later specs. @@ -115,7 +116,7 @@ Implementation spec for Subtask 5. #### Route and pages - **List:** **`/join_requests`** – list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit. -- **Detail:** **`/join_requests/:id`** – single join request with all data (typed fields + `form_data`), actions Approve / Reject. +- **Detail:** **`/join_requests/:id`** – single join request. **Two blocks:** (1) **Applicant data** – all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review** – submitted_at, status, and when decided: approved_at/rejected_at, reviewed by. Actions Approve / Reject when status is `submitted`. #### Backend (JoinRequest) @@ -195,7 +196,7 @@ Implementation spec for Subtask 5. - **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron). - **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it. - **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plug’s `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page. -- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`). +- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User) for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`). - **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is **left for later**. - **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug). - **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**. diff --git a/docs/settings-authentication-mockup.txt b/docs/settings-authentication-mockup.txt new file mode 100644 index 0000000..00f64c4 --- /dev/null +++ b/docs/settings-authentication-mockup.txt @@ -0,0 +1,44 @@ +# Settings page – Authentication section (ASCII mockup) + +Structure after renaming "OIDC" to "Authentication" and adding the registration toggle. +Subsections use their own headings (h3) inside the main "Authentication" form_section. + ++------------------------------------------------------------------+ +| Settings | +| Manage global settings for the association. | ++------------------------------------------------------------------+ + ++-- Club Settings -------------------------------------------------+ +| Association Name: [________________] [Save Name] | ++------------------------------------------------------------------+ + ++-- Join Form -----------------------------------------------------+ +| ... (unchanged) | ++------------------------------------------------------------------+ + ++-- SMTP / E-Mail -------------------------------------------------+ +| ... | ++------------------------------------------------------------------+ + ++-- Accounting-Software (Vereinfacht) Integration -----------------+ +| ... | ++------------------------------------------------------------------+ + ++-- Authentication ------------------------------------------------+ <-- main section (renamed from "OIDC (Single Sign-On)") +| | +| Direct registration | <-- subsection heading (h3) +| [x] Allow direct registration (/register) | +| If disabled, users cannot sign up via /register; sign-in | +| and the join form remain available. | +| | +| OIDC (Single Sign-On) | <-- subsection heading (h3) +| (Some values are set via environment variables...) | +| Client ID: [________________] | +| Base URL: [________________] | +| Redirect URI: [________________] | +| Client Secret: [________________] (set) | +| Admin group name: [________________] | +| Groups claim: [________________] | +| [ ] Only OIDC sign-in (hide password login) | +| [Save OIDC Settings] | ++------------------------------------------------------------------+ diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md new file mode 100644 index 0000000..13b0d17 --- /dev/null +++ b/docs/smtp-configuration-concept.md @@ -0,0 +1,133 @@ +# SMTP Configuration – Concept + +**Status:** Implemented +**Last updated:** 2026-03-12 + +--- + +## 1. Goal + +Enable configurable SMTP for sending transactional emails (join confirmation, user confirmation, password reset). Configuration via **environment variables** and **Admin Settings** (database), with the same precedence pattern as OIDC and Vereinfacht: **ENV overrides Settings**. Include a **test email** action in Settings (button + recipient field) with clear success/error feedback. + +--- + +## 2. Scope + +- **In scope:** SMTP server configuration (host, port, credentials, TLS/SSL), sender identity (from-name, from-email), test email from Settings UI, warning when SMTP is not configured in production, specific error messages per failure category, graceful delivery errors in AshAuthentication senders. +- **Out of scope:** Separate adapters per email type; retry queues. + +--- + +## 3. Configuration Sources + +| Source | Priority | Use case | +|----------|----------|-----------------------------------| +| ENV | 1 | Production, Docker, 12-factor | +| Settings | 2 | Admin UI, dev without ENV | + +When an ENV variable is set, the corresponding Settings field is read-only in the UI (with hint "Set by environment"). + +--- + +## 4. SMTP Parameters + +| Parameter | ENV | Settings attribute | Notes | +|----------------|------------------------|---------------------|---------------------------------------------| +| Host | `SMTP_HOST` | `smtp_host` | e.g. `smtp.example.com` | +| Port | `SMTP_PORT` | `smtp_port` | Default 587 (TLS), 465 (SSL), 25 (plain) | +| Username | `SMTP_USERNAME` | `smtp_username` | Optional if no auth | +| Password | `SMTP_PASSWORD` | `smtp_password` | Sensitive, not shown when set | +| Password file | `SMTP_PASSWORD_FILE` | — | Docker/Secrets: path to file with password | +| TLS/SSL | `SMTP_SSL` | `smtp_ssl` | `tls` / `ssl` / `none` (default: tls) | +| Sender name | `MAIL_FROM_NAME` | `smtp_from_name` | Display name in "From" header (default: Mila)| +| Sender email | `MAIL_FROM_EMAIL` | `smtp_from_email` | Address in "From" header; must match SMTP user on most servers | + +**Important:** On most SMTP servers (e.g. Postfix with strict relay policies) the sender email (`smtp_from_email`) must be the same address as `smtp_username` or an alias that is owned by that account. + +**Settings UI:** The form uses three rows on wide viewports: host, port, TLS/SSL | username, password | sender email, sender name. Content width is limited by the global settings wrapper (see `DESIGN_GUIDELINES.md` §6.4). + +--- + +## 5. Password from File + +Support **SMTP_PASSWORD_FILE** (path to file containing the password), same pattern as `OIDC_CLIENT_SECRET_FILE` in `runtime.exs`. Read once at runtime; `SMTP_PASSWORD` ENV overrides file if both are set. + +--- + +## 6. Behaviour When SMTP Is Not Configured + +- **Dev/Test:** Keep current adapters (`Swoosh.Adapters.Local`, `Swoosh.Adapters.Test`). No change. +- **Production:** If neither ENV nor Settings provide SMTP (no host): + - Show a warning in the Settings UI. + - Delivery attempts silently fall back to the Local adapter (no crash). + +--- + +## 7. Test Email (Settings UI) + +- **Location:** SMTP / E-Mail section in Global Settings. +- **Elements:** Input for recipient, submit button inside a `phx-submit` form. +- **Behaviour:** Sends one email using current SMTP config and `mail_from/0`. Returns `{:ok, _}` or `{:error, classified_reason}`. +- **Error categories:** `:sender_rejected`, `:auth_failed`, `:recipient_rejected`, `:tls_failed`, `:connection_failed`, `{:smtp_error, message}` — each shows a specific human-readable message in the UI. +- **Permission:** Reuses existing Settings page authorization (admin). + +--- + +## 8. Sender Identity (`mail_from`) + +`Mv.Mailer.mail_from/0` returns `{name, email}`. Priority: +1. `MAIL_FROM_NAME` / `MAIL_FROM_EMAIL` ENV variables +2. `smtp_from_name` / `smtp_from_email` in Settings (DB) +3. Hardcoded defaults: `{"Mila", "noreply@example.com"}` + +Provided by `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`. + +--- + +## 9. Join Confirmation Email + +`MvWeb.Emails.JoinConfirmationEmail` uses the same SMTP configuration as the test email: `Mailer.deliver(email, Mailer.smtp_config())`. This ensures Settings-based SMTP is used when not configured via ENV at boot. On delivery failure the domain returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI. + +--- + +## 10. AshAuthentication Senders + +Both `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Delivery failures are logged (`Logger.error`) and not re-raised, so they never crash the caller process. AshAuthentication ignores the return value of `send/3`. + +--- + +## 11. TLS / SSL in OTP 27 + +OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates. + +By default, TLS certificate verification is relaxed (`verify_none`) so self-signed or internal SMTP servers work. For public SMTP providers (Gmail, Mailgun, etc.) you can enable verification: + +- **ENV (prod):** Set `SMTP_VERIFY_PEER=true` (or `1`/`yes`) when configuring SMTP via environment variables in `config/runtime.exs`. This sets `config :mv, :smtp_verify_peer` and is used for both boot-time and per-send config. +- **Default:** `false` (verify_none) for backward compatibility and internal/self-signed certs. + +Verify mode is set in `tls_options` for port 587 (STARTTLS). For port 465 (implicit SSL), the initial connection is `ssl:connect`, so we also pass `sockopts: [verify: verify_mode]` so the SSL handshake uses the same mode. For 587 we must not pass `verify` in sockopts—gen_tcp is used first and rejects it (ArgumentError). The logic lives in `Mv.Smtp.ConfigBuilder.build_opts/1` (single source of truth), used by `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only). + +--- + +## 12. Summary Checklist + +- [x] ENV: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`. +- [x] ENV: `MAIL_FROM_NAME`, `MAIL_FROM_EMAIL` for sender identity. +- [x] Settings: attributes and UI for host, port, username, password, TLS/SSL, from-name, from-email. +- [x] Password from file: `SMTP_PASSWORD_FILE` supported in `runtime.exs`. +- [x] Mailer: Swoosh SMTP adapter configured from merged ENV + Settings when SMTP is configured. +- [x] Per-request SMTP config via `Mv.Mailer.smtp_config/0` for Settings-only scenarios. +- [x] TLS certificate validation relaxed for OTP 27 (tls_options for 587; sockopts with verify only for 465). +- [x] Prod warning: clear message in Settings when SMTP is not configured. +- [x] Test email: form with recipient field, translatable content, classified success/error messages. +- [x] Join confirmation email: uses `Mailer.smtp_config/0` (same as test mail); on failure returns `{:error, :email_delivery_failed}`, error shown in JoinLive, logged for admin. +- [x] AshAuthentication senders: graceful error handling (no crash on delivery failure). +- [x] Gettext for all new UI strings, translated to German. +- [x] Docs and code guidelines updated. + +--- + +## 13. Follow-up / Future Work + +- **SMTP password at-rest encryption:** The `smtp_password` attribute is currently stored in plaintext in the `settings` table. It is excluded from default reads (same pattern as `oidc_client_secret`); both are read only via explicit select when needed. For production systems at-rest encryption (e.g. with [Cloak](https://hexdocs.pm/cloak)) should be considered and tracked as a follow-up issue. +- **Error classification:** SMTP error categorization currently uses substring matching on server messages (e.g. "535", "authentication"). A more robust approach would be to pattern-match on `gen_smtp` error tuples first where possible, and fall back to string analysis only when needed. Server wording varies; consider extending patterns as new providers are used. diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 6b9cd1e..0127796 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -362,6 +362,12 @@ defmodule Mv.Accounts.User do # Authorization Policies # Order matters: Most specific policies first, then general permission check policies do + # When OIDC-only is active, password sign-in is forbidden (SSO only). + policy action(:sign_in_with_password) do + forbid_if Mv.Authorization.Checks.OidcOnlyActive + authorize_if always() + end + # AshAuthentication bypass (registration/login without actor) bypass AshAuthentication.Checks.AshAuthenticationInteraction do description "Allow AshAuthentication internal operations (registration, login)" @@ -405,6 +411,14 @@ defmodule Mv.Accounts.User do where: [action_is([:register_with_password, :admin_set_password])], message: "must have length of at least 8" + # Block direct registration when disabled in global settings + validate {Mv.Accounts.User.Validations.RegistrationEnabled, []}, + where: [action_is(:register_with_password)] + + # Block password registration when OIDC-only mode is active + validate {Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration, []}, + where: [action_is(:register_with_password)] + # Email uniqueness check for all actions that change the email attribute # Validates that user email is not already used by another (unlinked) member validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember diff --git a/lib/accounts/user/validations/oidc_only_blocks_password_registration.ex b/lib/accounts/user/validations/oidc_only_blocks_password_registration.ex new file mode 100644 index 0000000..e4d9a35 --- /dev/null +++ b/lib/accounts/user/validations/oidc_only_blocks_password_registration.ex @@ -0,0 +1,27 @@ +defmodule Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration do + @moduledoc """ + Validation that blocks direct registration (register_with_password) when + OIDC-only mode is active. In OIDC-only mode, sign-in and registration are + only allowed via OIDC (SSO). + """ + use Ash.Resource.Validation + + @impl true + def init(opts), do: {:ok, opts} + + @impl true + def validate(_changeset, _opts, _context) do + if Mv.Config.oidc_only?() do + {:error, + field: :base, + message: + Gettext.dgettext( + MvWeb.Gettext, + "default", + "Registration with password is disabled when only OIDC sign-in is active." + )} + else + :ok + end + end +end diff --git a/lib/accounts/user/validations/registration_enabled.ex b/lib/accounts/user/validations/registration_enabled.ex new file mode 100644 index 0000000..f2342b7 --- /dev/null +++ b/lib/accounts/user/validations/registration_enabled.ex @@ -0,0 +1,31 @@ +defmodule Mv.Accounts.User.Validations.RegistrationEnabled do + @moduledoc """ + Validation that blocks direct registration (register_with_password) when + registration is disabled in global settings. Used so that even direct API/form + submissions cannot register when the setting is off. + """ + use Ash.Resource.Validation + + alias Mv.Membership + + @impl true + def init(opts), do: {:ok, opts} + + @impl true + def validate(_changeset, _opts, _context) do + case Membership.get_settings() do + {:ok, %{registration_enabled: true}} -> + :ok + + _ -> + {:error, + field: :base, + message: + Gettext.dgettext( + MvWeb.Gettext, + "default", + "Registration is disabled. Please use the join form or contact an administrator." + )} + end + end +end diff --git a/lib/membership/join_notifier.ex b/lib/membership/join_notifier.ex new file mode 100644 index 0000000..daec4c1 --- /dev/null +++ b/lib/membership/join_notifier.ex @@ -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 diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex index 05a9e8d..94907e2 100644 --- a/lib/membership/join_request.ex +++ b/lib/membership/join_request.ex @@ -77,6 +77,17 @@ defmodule Mv.Membership.JoinRequest do change Mv.Membership.JoinRequest.Changes.RejectRequest end + + # Internal: resend confirmation (new token) when user submits form again with same email. + # Called from domain with authorize?: false; not exposed to public. + update :regenerate_confirmation_token do + description "Set new confirmation token and expiry (resend flow)" + require_atomic? false + + argument :confirmation_token, :string, allow_nil?: false + + change Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken + end end policies do @@ -175,6 +186,11 @@ defmodule Mv.Membership.JoinRequest do attribute :approved_at, :utc_datetime_usec attribute :rejected_at, :utc_datetime_usec attribute :reviewed_by_user_id, :uuid + + attribute :reviewed_by_display, :string do + description "Denormalized reviewer display (e.g. email) for UI without loading User" + end + attribute :source, :string create_timestamp :inserted_at diff --git a/lib/membership/join_request/changes/approve_request.ex b/lib/membership/join_request/changes/approve_request.ex index 24716f6..b86ca5d 100644 --- a/lib/membership/join_request/changes/approve_request.ex +++ b/lib/membership/join_request/changes/approve_request.ex @@ -16,11 +16,13 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do if current_status == :submitted do reviewed_by_id = Helpers.actor_id(context.actor) + reviewed_by_display = Helpers.actor_email(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :approved) |> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now()) |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) + |> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display) else Ash.Changeset.add_error(changeset, field: :status, diff --git a/lib/membership/join_request/changes/helpers.ex b/lib/membership/join_request/changes/helpers.ex index ee09b75..9bb0697 100644 --- a/lib/membership/join_request/changes/helpers.ex +++ b/lib/membership/join_request/changes/helpers.ex @@ -16,4 +16,24 @@ defmodule Mv.Membership.JoinRequest.Changes.Helpers do end def actor_id(_), do: nil + + @doc """ + Extracts the actor's email for display (e.g. reviewed_by_display). + + Supports both atom and string keys for compatibility with different actor representations. + """ + @spec actor_email(term()) :: String.t() | nil + def actor_email(nil), do: nil + + def actor_email(actor) when is_map(actor) do + raw = Map.get(actor, :email) || Map.get(actor, "email") + if is_nil(raw), do: nil, else: actor_email_string(raw) + end + + def actor_email(_), do: nil + + defp actor_email_string(raw) do + s = raw |> to_string() |> String.trim() + if s == "", do: nil, else: s + end end diff --git a/lib/membership/join_request/changes/regenerate_confirmation_token.ex b/lib/membership/join_request/changes/regenerate_confirmation_token.ex new file mode 100644 index 0000000..c8055d2 --- /dev/null +++ b/lib/membership/join_request/changes/regenerate_confirmation_token.ex @@ -0,0 +1,33 @@ +defmodule Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken do + @moduledoc """ + Sets a new confirmation token hash and expiry on an existing join request (resend flow). + + Used when the user submits the join form again with the same email while a request + is still pending_confirmation. Internal use only (domain calls with authorize?: false). + """ + use Ash.Resource.Change + + alias Mv.Membership.JoinRequest + + @confirmation_validity_hours 24 + + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() + def change(changeset, _opts, _context) do + token = Ash.Changeset.get_argument(changeset, :confirmation_token) + + if is_binary(token) and token != "" do + now = DateTime.utc_now() + expires_at = DateTime.add(now, @confirmation_validity_hours, :hour) + + changeset + |> 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, now) + else + changeset + end + end +end diff --git a/lib/membership/join_request/changes/reject_request.ex b/lib/membership/join_request/changes/reject_request.ex index 2c33a77..1b9fe1a 100644 --- a/lib/membership/join_request/changes/reject_request.ex +++ b/lib/membership/join_request/changes/reject_request.ex @@ -15,11 +15,13 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do if current_status == :submitted do reviewed_by_id = Helpers.actor_id(context.actor) + reviewed_by_display = Helpers.actor_email(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :rejected) |> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now()) |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) + |> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display) else Ash.Changeset.add_error(changeset, field: :status, diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 2f18f90..7fa35dc 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -29,8 +29,10 @@ defmodule Mv.Membership do require Ash.Query import Ash.Expr alias Ash.Error.Query.NotFound, as: NotFoundError + alias Mv.Helpers.SystemActor alias Mv.Membership.JoinRequest - alias MvWeb.Emails.JoinConfirmationEmail + alias Mv.Membership.Member + alias Mv.Membership.SettingsCache require Logger admin do @@ -114,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 @@ -158,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 """ @@ -224,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 """ @@ -261,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 """ @@ -300,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 """ @@ -364,15 +400,131 @@ defmodule Mv.Membership do - `:actor` - Must be nil for public submit (policy allows only unauthenticated). ## Returns - - `{:ok, request}` - Created JoinRequest in status pending_confirmation + - `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent + - `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created) + - `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only + - `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged) - `{:error, error}` - Validation or authorization error """ def submit_join_request(attrs, opts \\ []) do actor = Keyword.get(opts, :actor) - token = Map.get(attrs, :confirmation_token) || generate_confirmation_token() + email = normalize_submit_email(attrs) - # Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken - # hashes it before persist. Only the hash is stored; the raw token is sent in the email link. + pending = + if email != nil and email != "", do: pending_join_request_with_email(email), else: nil + + cond do + email != nil and email != "" and member_exists_with_email?(email) -> + send_already_member_and_return(email) + + pending != nil -> + handle_already_pending(email, pending) + + true -> + do_create_join_request(attrs, actor) + end + end + + defp normalize_submit_email(attrs) do + raw = attrs["email"] || attrs[:email] + if is_binary(raw), do: String.trim(raw), else: nil + end + + defp member_exists_with_email?(email) when is_binary(email) do + system_actor = SystemActor.get_system_actor() + opts = [actor: system_actor, domain: __MODULE__] + + case Ash.get(Member, %{email: email}, opts) do + {:ok, _member} -> true + _ -> false + end + end + + defp member_exists_with_email?(_), do: false + + defp pending_join_request_with_email(email) when is_binary(email) do + system_actor = SystemActor.get_system_actor() + + query = + JoinRequest + |> Ash.Query.filter(expr(email == ^email and status in [:pending_confirmation, :submitted])) + |> Ash.Query.sort(inserted_at: :desc) + |> Ash.Query.limit(1) + + case Ash.read_one(query, actor: system_actor, domain: __MODULE__) do + {:ok, request} -> request + _ -> nil + end + end + + defp pending_join_request_with_email(_), do: nil + + defp join_notifier do + Application.get_env(:mv, :join_notifier, MvWeb.JoinNotifierImpl) + end + + defp send_already_member_and_return(email) do + case join_notifier().send_already_member(email) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}") + end + + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. + {:ok, :notified_already_member} + end + + defp handle_already_pending(email, existing) do + if existing.status == :pending_confirmation do + resend_confirmation_to_pending(email, existing) + else + send_already_pending_and_return(email) + end + end + + defp resend_confirmation_to_pending(email, request) do + new_token = generate_confirmation_token() + + case request + |> Ash.Changeset.for_update(:regenerate_confirmation_token, %{ + confirmation_token: new_token + }) + |> Ash.update(domain: __MODULE__, authorize?: false) do + {:ok, _updated} -> + case join_notifier().send_confirmation(email, new_token, resend: true) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}") + end + + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. + {:ok, :notified_already_pending} + + {:error, _} -> + # Fallback: do not create duplicate; send generic pending email + send_already_pending_and_return(email) + end + end + + defp send_already_pending_and_return(email) do + case join_notifier().send_already_pending(email) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}") + end + + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. + {:ok, :notified_already_pending} + end + + defp do_create_join_request(attrs, actor) do + token = Map.get(attrs, :confirmation_token) || generate_confirmation_token() attrs_with_token = Map.put(attrs, :confirmation_token, token) case Ash.create(JoinRequest, attrs_with_token, @@ -381,8 +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} -> + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. {:ok, request} {:error, reason} -> @@ -390,8 +543,7 @@ defmodule Mv.Membership do "Join confirmation email failed for #{request.email}: #{inspect(reason)}" ) - # Request was created; return success so the user sees the confirmation message - {:ok, request} + {:error, :email_delivery_failed} end error -> diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index bc2b1e7..83c5c8b 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -15,6 +15,7 @@ defmodule Mv.Membership.Setting do (e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional. - `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true) - `default_membership_fee_type_id` - Default membership fee type for new members (optional) + - `registration_enabled` - Whether direct registration via /register is allowed (default: true) - `join_form_enabled` - Whether the public /join page is active (default: false) - `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is either a member field name string (e.g. "email") or a custom field UUID. Email is always @@ -56,14 +57,20 @@ defmodule Mv.Membership.Setting do # Update membership fee settings {:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false}) """ + # primary_read_warning?: false — We use a custom read prepare that selects only public + # attributes and explicitly excludes smtp_password. Ash warns when the primary read does + # not load all attributes; we intentionally omit the password for security. use Ash.Resource, domain: Mv.Membership, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + primary_read_warning?: false # Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation) @uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i @valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + alias Ash.Resource.Info, as: ResourceInfo + postgres do table "settings" repo Mv.Repo @@ -73,8 +80,27 @@ defmodule Mv.Membership.Setting do description "Global application settings (singleton resource)" end + # Attributes excluded from the default read (sensitive data). Same pattern as smtp_password: + # read only via explicit select when needed; never loaded into default get_settings(). + @excluded_from_read [:smtp_password, :oidc_client_secret] + actions do - defaults [:read] + read :read do + primary? true + + # Exclude sensitive attributes (e.g. smtp_password) from default reads. Config reads + # them via explicit select when needed. Uses all attribute names minus excluded so + # the list stays correct when new attributes are added to the resource. + prepare fn query, _context -> + select_attrs = + __MODULE__ + |> ResourceInfo.attribute_names() + |> MapSet.to_list() + |> Kernel.--(@excluded_from_read) + + Ash.Query.select(query, select_attrs) + end + end # Internal create action - not exposed via code interface # Used only as fallback in get_settings/0 if settings don't exist @@ -97,6 +123,14 @@ defmodule Mv.Membership.Setting do :oidc_admin_group_name, :oidc_groups_claim, :oidc_only, + :smtp_host, + :smtp_port, + :smtp_username, + :smtp_password, + :smtp_ssl, + :smtp_from_name, + :smtp_from_email, + :registration_enabled, :join_form_enabled, :join_form_field_ids, :join_form_field_required @@ -126,6 +160,14 @@ defmodule Mv.Membership.Setting do :oidc_admin_group_name, :oidc_groups_claim, :oidc_only, + :smtp_host, + :smtp_port, + :smtp_username, + :smtp_password, + :smtp_ssl, + :smtp_from_name, + :smtp_from_email, + :registration_enabled, :join_form_enabled, :join_form_field_ids, :join_form_field_required @@ -429,6 +471,61 @@ defmodule Mv.Membership.Setting do description "When true and OIDC is configured, sign-in shows only OIDC (password login hidden)" end + # SMTP configuration (can be overridden by ENV) + attribute :smtp_host, :string do + allow_nil? true + public? true + description "SMTP server hostname (e.g. smtp.example.com)" + end + + attribute :smtp_port, :integer do + allow_nil? true + public? true + description "SMTP server port (e.g. 587 for TLS, 465 for SSL, 25 for plain)" + end + + attribute :smtp_username, :string do + allow_nil? true + public? true + description "SMTP authentication username" + end + + attribute :smtp_password, :string do + allow_nil? true + public? false + description "SMTP authentication password (sensitive)" + sensitive? true + end + + attribute :smtp_ssl, :string do + allow_nil? true + public? true + description "SMTP TLS/SSL mode: 'tls', 'ssl', or 'none'" + end + + attribute :smtp_from_name, :string do + allow_nil? true + public? true + + description "Display name for the transactional email sender (e.g. 'Mila'). Overrides MAIL_FROM_NAME env." + end + + attribute :smtp_from_email, :string do + allow_nil? true + public? true + + description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env." + end + + # Authentication: direct registration toggle + attribute :registration_enabled, :boolean do + allow_nil? false + default true + public? true + + description "When true, users can register via /register; when false, only sign-in and join form remain available." + end + # Join form (Beitrittsformular) settings attribute :join_form_enabled, :boolean do allow_nil? false diff --git a/lib/membership/settings_cache.ex b/lib/membership/settings_cache.ex new file mode 100644 index 0000000..d8581d6 --- /dev/null +++ b/lib/membership/settings_cache.ex @@ -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 diff --git a/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex index 393a220..7312b91 100644 --- a/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex +++ b/lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex @@ -16,6 +16,8 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do import Swoosh.Email use Gettext, backend: MvWeb.Gettext, otp_app: :mv + require Logger + alias Mv.Mailer @doc """ @@ -30,7 +32,8 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do - `_opts` - Additional options (unused) ## Returns - The Swoosh.Email delivery result from `Mailer.deliver!/1`. + `:ok` always. Delivery errors are logged and not re-raised so they do not + crash the caller process (AshAuthentication ignores the return value). """ @impl true def send(user, token, _) do @@ -44,12 +47,24 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do locale: Gettext.get_locale(MvWeb.Gettext) } - new() - |> from(Mailer.mail_from()) - |> to(to_string(user.email)) - |> subject(subject) - |> put_view(MvWeb.EmailsView) - |> render_body("user_confirmation.html", assigns) - |> Mailer.deliver!() + email = + new() + |> from(Mailer.mail_from()) + |> to(to_string(user.email)) + |> subject(subject) + |> put_view(MvWeb.EmailsView) + |> render_body("user_confirmation.html", assigns) + + case Mailer.deliver(email) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error( + "Failed to send user confirmation email to #{user.email}: #{inspect(reason)}" + ) + + :ok + end end end diff --git a/lib/mv/accounts/user/senders/send_password_reset_email.ex b/lib/mv/accounts/user/senders/send_password_reset_email.ex index 74d5d47..e276e20 100644 --- a/lib/mv/accounts/user/senders/send_password_reset_email.ex +++ b/lib/mv/accounts/user/senders/send_password_reset_email.ex @@ -16,6 +16,8 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do import Swoosh.Email use Gettext, backend: MvWeb.Gettext, otp_app: :mv + require Logger + alias Mv.Mailer @doc """ @@ -30,7 +32,8 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do - `_opts` - Additional options (unused) ## Returns - The Swoosh.Email delivery result from `Mailer.deliver!/1`. + `:ok` always. Delivery errors are logged and not re-raised so they do not + crash the caller process (AshAuthentication ignores the return value). """ @impl true def send(user, token, _) do @@ -44,12 +47,21 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do locale: Gettext.get_locale(MvWeb.Gettext) } - new() - |> from(Mailer.mail_from()) - |> to(to_string(user.email)) - |> subject(subject) - |> put_view(MvWeb.EmailsView) - |> render_body("password_reset.html", assigns) - |> Mailer.deliver!() + email = + new() + |> from(Mailer.mail_from()) + |> to(to_string(user.email)) + |> subject(subject) + |> put_view(MvWeb.EmailsView) + |> render_body("password_reset.html", assigns) + + case Mailer.deliver(email) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("Failed to send password reset email to #{user.email}: #{inspect(reason)}") + :ok + end end end diff --git a/lib/mv/application.ex b/lib/mv/application.ex index 6b4a10b..1b6014e 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -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 diff --git a/lib/mv/authorization/checks/oidc_only_active.ex b/lib/mv/authorization/checks/oidc_only_active.ex new file mode 100644 index 0000000..8d56ca1 --- /dev/null +++ b/lib/mv/authorization/checks/oidc_only_active.ex @@ -0,0 +1,16 @@ +defmodule Mv.Authorization.Checks.OidcOnlyActive do + @moduledoc """ + Policy check: true when OIDC-only mode is active (Config.oidc_only?()). + + Used to forbid password sign-in when only OIDC (SSO) sign-in is allowed. + """ + use Ash.Policy.SimpleCheck + + alias Mv.Config + + @impl true + def describe(_opts), do: "OIDC-only mode is active" + + @impl true + def match?(_actor, _context, _opts), do: Config.oidc_only?() +end diff --git a/lib/mv/config.ex b/lib/mv/config.ex index 8b8c088..3494937 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -362,26 +362,41 @@ defmodule Mv.Config do @doc """ Returns the OIDC client secret. In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE). - Otherwise ENV OIDC_CLIENT_SECRET, then Settings. + Otherwise ENV OIDC_CLIENT_SECRET, then Settings (read via explicit select; not in default get_settings). """ @spec oidc_client_secret() :: String.t() | nil def oidc_client_secret do case Application.get_env(:mv, :oidc) do oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret)) - _ -> env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) + _ -> oidc_client_secret_from_env_or_settings() end end + @doc """ + Returns whether the OIDC client secret is set in Settings (for UI badge). Does not expose the value. + """ + @spec oidc_client_secret_set?() :: boolean() + def oidc_client_secret_set? do + present?(get_oidc_client_secret_from_settings()) + end + defp oidc_client_secret_from_config(nil), - do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) + do: oidc_client_secret_from_env_or_settings() defp oidc_client_secret_from_config(secret) when is_binary(secret) do s = String.trim(secret) - if s != "", do: s, else: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) + if s != "", do: s, else: oidc_client_secret_from_env_or_settings() end defp oidc_client_secret_from_config(_), - do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) + do: oidc_client_secret_from_env_or_settings() + + defp oidc_client_secret_from_env_or_settings do + case System.get_env("OIDC_CLIENT_SECRET") do + nil -> get_oidc_client_secret_from_settings() + value -> trim_nil(value) + end + end @doc """ Returns the OIDC admin group name (for role sync). ENV first, then Settings. @@ -449,4 +464,206 @@ defmodule Mv.Config do def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME") def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM") def oidc_only_env_set?, do: env_set?("OIDC_ONLY") + + # --------------------------------------------------------------------------- + # SMTP configuration – ENV overrides Settings; see docs/smtp-configuration-concept.md + # --------------------------------------------------------------------------- + + @doc """ + Returns SMTP host. ENV `SMTP_HOST` overrides Settings. + """ + @spec smtp_host() :: String.t() | nil + def smtp_host do + smtp_env_or_setting("SMTP_HOST", :smtp_host) + end + + @doc """ + Returns SMTP port as integer. ENV `SMTP_PORT` (parsed) overrides Settings. + Returns nil when neither ENV nor Settings provide a valid port. + """ + @spec smtp_port() :: non_neg_integer() | nil + def smtp_port do + case System.get_env("SMTP_PORT") do + nil -> + get_from_settings_integer(:smtp_port) + + value when is_binary(value) -> + case Integer.parse(String.trim(value)) do + {port, _} when port > 0 -> port + _ -> nil + end + end + end + + @doc """ + Returns SMTP username. ENV `SMTP_USERNAME` overrides Settings. + """ + @spec smtp_username() :: String.t() | nil + def smtp_username do + smtp_env_or_setting("SMTP_USERNAME", :smtp_username) + end + + @doc """ + Returns SMTP password. + + Priority: `SMTP_PASSWORD` ENV > `SMTP_PASSWORD_FILE` (file contents) > Settings. + Strips trailing whitespace/newlines from file contents. + """ + @spec smtp_password() :: String.t() | nil + def smtp_password do + case System.get_env("SMTP_PASSWORD") do + nil -> smtp_password_from_file_or_settings() + value -> trim_nil(value) + end + end + + defp smtp_password_from_file_or_settings do + case System.get_env("SMTP_PASSWORD_FILE") do + nil -> get_smtp_password_from_settings() + path -> read_smtp_password_file(path) + end + end + + defp read_smtp_password_file(path) do + case File.read(String.trim(path)) do + {:ok, content} -> trim_nil(content) + {:error, _} -> nil + end + end + + @doc """ + Returns SMTP TLS/SSL mode string (e.g. 'tls', 'ssl', 'none'). + ENV `SMTP_SSL` overrides Settings. + """ + @spec smtp_ssl() :: String.t() | nil + def smtp_ssl do + smtp_env_or_setting("SMTP_SSL", :smtp_ssl) + end + + @doc """ + Returns true when SMTP is configured (host present from ENV or Settings). + """ + @spec smtp_configured?() :: boolean() + def smtp_configured? do + present?(smtp_host()) + end + + @doc """ + Returns true when any SMTP ENV variable is set (used in Settings UI for hints). + """ + @spec smtp_env_configured?() :: boolean() + def smtp_env_configured? do + smtp_host_env_set?() or smtp_port_env_set?() or smtp_username_env_set?() or + smtp_password_env_set?() or smtp_ssl_env_set?() + end + + @doc "Returns true if SMTP_HOST ENV is set." + @spec smtp_host_env_set?() :: boolean() + def smtp_host_env_set?, do: env_set?("SMTP_HOST") + + @doc "Returns true if SMTP_PORT ENV is set." + @spec smtp_port_env_set?() :: boolean() + def smtp_port_env_set?, do: env_set?("SMTP_PORT") + + @doc "Returns true if SMTP_USERNAME ENV is set." + @spec smtp_username_env_set?() :: boolean() + def smtp_username_env_set?, do: env_set?("SMTP_USERNAME") + + @doc "Returns true if SMTP_PASSWORD or SMTP_PASSWORD_FILE ENV is set." + @spec smtp_password_env_set?() :: boolean() + def smtp_password_env_set?, do: env_set?("SMTP_PASSWORD") or env_set?("SMTP_PASSWORD_FILE") + + @doc "Returns true if SMTP_SSL ENV is set." + @spec smtp_ssl_env_set?() :: boolean() + def smtp_ssl_env_set?, do: env_set?("SMTP_SSL") + + # --------------------------------------------------------------------------- + # Transactional email sender identity (mail_from) + # ENV variables MAIL_FROM_NAME / MAIL_FROM_EMAIL take priority; fallback to + # Settings smtp_from_name / smtp_from_email; final fallback: hardcoded defaults. + # --------------------------------------------------------------------------- + + @doc """ + Returns the display name for the transactional email sender. + + Priority: `MAIL_FROM_NAME` ENV > Settings `smtp_from_name` > `"Mila"`. + """ + @spec mail_from_name() :: String.t() + def mail_from_name do + case System.get_env("MAIL_FROM_NAME") do + nil -> get_from_settings(:smtp_from_name) || "Mila" + value -> trim_nil(value) || "Mila" + end + end + + @doc """ + Returns the email address for the transactional email sender. + + Priority: `MAIL_FROM_EMAIL` ENV > Settings `smtp_from_email` > `nil`. + Returns `nil` when not configured (caller should fall back to a safe default). + """ + @spec mail_from_email() :: String.t() | nil + def mail_from_email do + case System.get_env("MAIL_FROM_EMAIL") do + nil -> get_from_settings(:smtp_from_email) + value -> trim_nil(value) + end + end + + @doc "Returns true if MAIL_FROM_NAME ENV is set." + @spec mail_from_name_env_set?() :: boolean() + def mail_from_name_env_set?, do: env_set?("MAIL_FROM_NAME") + + @doc "Returns true if MAIL_FROM_EMAIL ENV is set." + @spec mail_from_email_env_set?() :: boolean() + def mail_from_email_env_set?, do: env_set?("MAIL_FROM_EMAIL") + + # Reads a plain string SMTP setting: ENV first, then Settings. + defp smtp_env_or_setting(env_key, setting_key) do + case System.get_env(env_key) do + nil -> get_from_settings(setting_key) + value -> trim_nil(value) + end + end + + # Reads an integer setting attribute from Settings. + defp get_from_settings_integer(key) do + case Mv.Membership.get_settings() do + {:ok, settings} -> + case Map.get(settings, key) do + v when is_integer(v) and v > 0 -> v + _ -> nil + end + + {:error, _} -> + nil + end + end + + # Reads the SMTP password directly from the DB via an explicit select, + # bypassing the standard read action which excludes smtp_password for security. + defp get_smtp_password_from_settings do + query = Ash.Query.select(Mv.Membership.Setting, [:id, :smtp_password]) + + case Ash.read_one(query, authorize?: false, domain: Mv.Membership) do + {:ok, settings} when not is_nil(settings) -> + settings |> Map.get(:smtp_password) |> trim_nil() + + _ -> + nil + end + end + + # Reads the OIDC client secret via explicit select (excluded from default read, same as smtp_password). + defp get_oidc_client_secret_from_settings do + query = Ash.Query.select(Mv.Membership.Setting, [:id, :oidc_client_secret]) + + case Ash.read_one(query, authorize?: false, domain: Mv.Membership) do + {:ok, settings} when not is_nil(settings) -> + settings |> Map.get(:oidc_client_secret) |> trim_nil() + + _ -> + nil + end + end end diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex index 3d83636..41a77cd 100644 --- a/lib/mv/mailer.ex +++ b/lib/mv/mailer.ex @@ -4,16 +4,188 @@ defmodule Mv.Mailer do Use `mail_from/0` for the configured sender address (join confirmation, user confirmation, password reset). + + ## Sender identity + + The "from" address is determined by priority: + 1. `MAIL_FROM_EMAIL` / `MAIL_FROM_NAME` environment variables + 2. Settings database (`smtp_from_email`, `smtp_from_name`) + 3. Hardcoded default (`"Mila"`, `"noreply@example.com"`) + + **Important:** On most SMTP servers the sender email must be owned by the + authenticated SMTP user. Set `smtp_from_email` to the same address as + `smtp_username` (or an alias allowed by the server). + + ## SMTP adapter configuration + + The SMTP adapter can be configured via: + - **Environment variables** at boot (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, + `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) — configured in `runtime.exs`. + - **Admin Settings** (database) — read at send time via `Mv.Config.smtp_*()` helpers. + Settings-based config is passed per-send via `smtp_config/0`. + + ENV takes priority over Settings (same pattern as OIDC and Vereinfacht). """ use Swoosh.Mailer, otp_app: :mv - @doc """ - Returns the configured "from" address for transactional emails. + import Swoosh.Email + use Gettext, backend: MvWeb.Gettext, otp_app: :mv - Configure in config.exs or runtime.exs as `config :mv, :mail_from, {name, email}`. - Default: `{"Mila", "noreply@example.com"}`. + alias Mv.Smtp.ConfigBuilder + require Logger + + # Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation. + @email_regex ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/ + + @doc """ + Returns the configured "from" address for transactional emails as `{name, email}`. + + Priority: ENV `MAIL_FROM_NAME`/`MAIL_FROM_EMAIL` > Settings `smtp_from_name`/`smtp_from_email` > defaults. """ + @spec mail_from() :: {String.t(), String.t()} def mail_from do - Application.get_env(:mv, :mail_from, {"Mila", "noreply@example.com"}) + {Mv.Config.mail_from_name(), Mv.Config.mail_from_email() || "noreply@example.com"} end + + @doc """ + Sends a test email to the given address. Used from Global Settings SMTP section. + + Returns `{:ok, email}` on success, `{:error, reason}` on failure. + The `reason` is a classified atom for known error categories, or `{:smtp_error, message}` + for SMTP-level errors with a human-readable message, or the raw term for unknown errors. + """ + @spec send_test_email(String.t()) :: + {:ok, Swoosh.Email.t()} | {:error, atom() | {:smtp_error, String.t()} | term()} + def send_test_email(to_email) when is_binary(to_email) do + if valid_email?(to_email) do + subject = gettext("Mila – Test email") + + body = + gettext( + "This is a test email sent from Mila. If you received this, your SMTP configuration is working correctly." + ) + + email = + new() + |> from(mail_from()) + |> to(to_email) + |> subject(subject) + |> text_body(body) + |> html_body("

#{body}

") + + case deliver(email, smtp_config()) do + {:ok, _} = ok -> + ok + + {:error, reason} -> + classified = classify_smtp_error(reason) + Logger.warning("SMTP test email failed: #{inspect(reason)}") + {:error, classified} + end + else + {:error, :invalid_email_address} + end + end + + def send_test_email(_), do: {:error, :invalid_email_address} + + @doc """ + Builds the per-send SMTP config from `Mv.Config` when SMTP is configured via + Settings only (not boot-time ENV). Returns an empty list when the mailer is + already configured at boot (ENV-based), so Swoosh uses the Application config. + + The return value must be a flat keyword list (adapter, relay, port, ...). + Swoosh merges it with Application config; top-level keys override the mailer's + default adapter (e.g. Local in dev), so this delivery uses SMTP. + """ + @spec smtp_config() :: keyword() + def smtp_config do + if Mv.Config.smtp_configured?() and not boot_smtp_configured?() do + verify_mode = + if Application.get_env(:mv, :smtp_verify_peer, false), + do: :verify_peer, + else: :verify_none + + ConfigBuilder.build_opts( + host: Mv.Config.smtp_host(), + port: Mv.Config.smtp_port() || 587, + username: Mv.Config.smtp_username(), + password: Mv.Config.smtp_password(), + ssl_mode: Mv.Config.smtp_ssl() || "tls", + verify_mode: verify_mode + ) + else + [] + end + end + + # --------------------------------------------------------------------------- + # SMTP error classification + # Maps raw gen_smtp error terms to human-readable atoms / structs. + # --------------------------------------------------------------------------- + + @doc false + @spec classify_smtp_error(term()) :: + :sender_rejected + | :auth_failed + | :recipient_rejected + | :tls_failed + | :connection_failed + | {:smtp_error, String.t()} + | term() + def classify_smtp_error({:retries_exceeded, {:temporary_failure, _host, :tls_failed}}), + do: :tls_failed + + def classify_smtp_error({:retries_exceeded, {:network_failure, _host, _}}), + do: :connection_failed + + def classify_smtp_error({:send, {:permanent_failure, _host, msg}}) do + str = if is_list(msg), do: List.to_string(msg), else: to_string(msg) + classify_permanent_failure_message(str) + end + + def classify_smtp_error(reason), do: reason + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp classify_permanent_failure_message(str) do + cond do + smtp_auth_failure?(str) -> :auth_failed + smtp_sender_rejected?(str) -> :sender_rejected + smtp_recipient_rejected?(str) -> :recipient_rejected + true -> {:smtp_error, String.trim(str)} + end + end + + defp smtp_auth_failure?(str), + do: + String.contains?(str, "535") or String.contains?(str, "authentication") or + String.contains?(str, "Authentication") + + defp smtp_sender_rejected?(str), + do: + String.contains?(str, "553") or String.contains?(str, "Sender address rejected") or + String.contains?(str, "not owned") + + defp smtp_recipient_rejected?(str), + do: + String.contains?(str, "550") or String.contains?(str, "No such user") or + String.contains?(str, "no such user") or String.contains?(str, "User unknown") + + # Returns true when the SMTP adapter has been configured at boot time via ENV + # (i.e. the Application config is already set to the SMTP adapter). + defp boot_smtp_configured? do + case Application.get_env(:mv, __MODULE__) do + config when is_list(config) -> Keyword.get(config, :adapter) == Swoosh.Adapters.SMTP + _ -> false + end + end + + defp valid_email?(email) when is_binary(email) do + Regex.match?(@email_regex, String.trim(email)) + end + + defp valid_email?(_), do: false end diff --git a/lib/mv/release.ex b/lib/mv/release.ex index 00dcadf..116b276 100644 --- a/lib/mv/release.ex +++ b/lib/mv/release.ex @@ -6,8 +6,8 @@ defmodule Mv.Release do ## Tasks - `migrate/0` - Runs all pending Ecto migrations. - - `run_seeds/0` - Runs bootstrap seeds (fee types, custom fields, roles, settings). - In production, set `RUN_DEV_SEEDS=true` to also run dev seeds (members, groups, sample data). + - `bootstrap_seeds_applied?/0` - Returns whether bootstrap was already applied (admin user exists). Used to skip re-running seeds. + - `run_seeds/0` - If bootstrap already applied, skips; otherwise runs bootstrap seeds (fee types, custom fields, roles, settings). Set `FORCE_SEEDS=true` to re-run seeds even when already applied. In production, set `RUN_DEV_SEEDS=true` to also run dev seeds (members, groups, sample data). - `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell to update the admin password without redeploying. @@ -19,6 +19,7 @@ defmodule Mv.Release do alias Mv.Authorization.Role require Ash.Query + require Logger def migrate do load_app() @@ -28,13 +29,37 @@ defmodule Mv.Release do end end + @doc """ + Returns whether bootstrap seeds have already been applied (admin user exists). + + We check for the admin user (from ADMIN_EMAIL or default), not the Admin role, + because migrations may create the Admin role for the system actor. Only seeds + create the admin (login) user. Used to skip re-running seeds on subsequent starts. + Call only when the application is already started. + """ + def bootstrap_seeds_applied? do + admin_email = get_env("ADMIN_EMAIL", "admin@localhost") + + case User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(authorize?: false, domain: Mv.Accounts) do + {:ok, %User{}} -> true + _ -> false + end + rescue + e -> + Logger.warning("Could not check seed status (#{inspect(e)}), assuming not applied.") + false + end + @doc """ Runs seed scripts so the database has required bootstrap data (and optionally dev data). - - Always runs bootstrap seeds (fee types, custom fields, roles, system user, settings). - - If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample data). + - Skips if bootstrap was already applied (admin user exists); set `FORCE_SEEDS=true` to override and re-run. + - If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample data) + when bootstrap is run. - Uses paths from the application's priv dir so it works in releases (no Mix). Idempotent. + Uses paths from the application's priv dir so it works in releases (no Mix). """ def run_seeds do case Application.ensure_all_started(@app) do @@ -42,23 +67,27 @@ defmodule Mv.Release do {:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}" end - priv = :code.priv_dir(@app) - bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs") - dev_path = Path.join(priv, "repo/seeds_dev.exs") + if bootstrap_seeds_applied?() and System.get_env("FORCE_SEEDS") != "true" do + IO.puts("Seeds already applied. Skipping. (Set FORCE_SEEDS=true to override)") + else + priv = :code.priv_dir(@app) + bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs") + dev_path = Path.join(priv, "repo/seeds_dev.exs") - prev = Code.compiler_options() - Code.compiler_options(ignore_module_conflict: true) + prev = Code.compiler_options() + Code.compiler_options(ignore_module_conflict: true) - try do - Code.eval_file(bootstrap_path) - IO.puts("✅ Bootstrap seeds completed.") + try do + Code.eval_file(bootstrap_path) + IO.puts("✅ Bootstrap seeds completed.") - if System.get_env("RUN_DEV_SEEDS") == "true" do - Code.eval_file(dev_path) - IO.puts("✅ Dev seeds completed.") + if System.get_env("RUN_DEV_SEEDS") == "true" do + Code.eval_file(dev_path) + IO.puts("✅ Dev seeds completed.") + end + after + Code.compiler_options(prev) end - after - Code.compiler_options(prev) end end diff --git a/lib/mv/smtp/config_builder.ex b/lib/mv/smtp/config_builder.ex new file mode 100644 index 0000000..5018dff --- /dev/null +++ b/lib/mv/smtp/config_builder.ex @@ -0,0 +1,58 @@ +defmodule Mv.Smtp.ConfigBuilder do + @moduledoc """ + Builds Swoosh/gen_smtp SMTP adapter options from connection parameters. + + Single source of truth for TLS/sockopts logic (port 587 vs 465): + - Port 587 (STARTTLS): `gen_tcp` is used first; `sockopts` must NOT contain `:verify`. + - Port 465 (implicit SSL): initial connection is `ssl:connect`; `sockopts` must contain `:verify`. + + Used by `config/runtime.exs` (boot-time ENV) and `Mv.Mailer.smtp_config/0` (Settings-only). + """ + + @doc """ + Builds the keyword list of Swoosh SMTP adapter options. + + Options (keyword list): + - `:host` (required) — relay hostname + - `:port` (required) — port number (e.g. 587 or 465) + - `:ssl_mode` (required) — `"tls"` or `"ssl"` + - `:verify_mode` (required) — `:verify_peer` or `:verify_none` + - `:username` (optional) + - `:password` (optional) + + Nil values are stripped from the result. + """ + @spec build_opts(keyword()) :: keyword() + def build_opts(opts) do + host = Keyword.fetch!(opts, :host) + port = Keyword.fetch!(opts, :port) + username = Keyword.get(opts, :username) + password = Keyword.get(opts, :password) + ssl_mode = Keyword.fetch!(opts, :ssl_mode) + verify_mode = Keyword.fetch!(opts, :verify_mode) + + base_opts = [ + adapter: Swoosh.Adapters.SMTP, + relay: host, + port: port, + username: username, + password: password, + ssl: ssl_mode == "ssl", + tls: if(ssl_mode == "tls", do: :always, else: :never), + auth: :always, + # tls_options: used for STARTTLS (587). For 465, gen_smtp uses sockopts for initial ssl:connect. + tls_options: [verify: verify_mode] + ] + + # Port 465: initial connection is ssl:connect; pass verify in sockopts. + # Port 587: initial connection is gen_tcp; sockopts must NOT contain verify (gen_tcp rejects it). + opts = + if ssl_mode == "ssl" do + Keyword.put(base_opts, :sockopts, verify: verify_mode) + else + base_opts + end + + Enum.reject(opts, fn {_k, v} -> is_nil(v) end) + end +end diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex index 5cab4d2..3aab0ed 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -3,52 +3,70 @@ defmodule MvWeb.AuthOverrides do UI customizations for AshAuthentication Phoenix components. ## Overrides - - `SignIn` - Restricts form width to prevent full-width display - - `Banner` - Replaces default logo with "Mitgliederverwaltung" text - - `HorizontalRule` - Translates "or" text to German + - `SignIn` - Restricts form width and hides the library banner (title is rendered in SignInLive) + - `Banner` - Replaces default logo with text for reset/confirm pages + - `Flash` - Hides library flash (we use flash_group in root layout) ## Documentation For complete reference on available overrides, see: https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html """ use AshAuthentication.Phoenix.Overrides - use Gettext, backend: MvWeb.Gettext - # configure your UI overrides here - - # First argument to `override` is the component name you are overriding. - # The body contains any number of configurations you wish to override - # Below are some examples - - # For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html - - # override AshAuthentication.Phoenix.Components.Banner do - # set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif" - # set :text_class, "bg-red-500" - # end - - # Avoid full-width for the Sign In Form + # Avoid full-width for the Sign In Form. + # Banner is hidden because SignInLive renders its own locale-aware title. override AshAuthentication.Phoenix.Components.SignIn do set :root_class, "md:min-w-md" + set :show_banner, false end - # Replace banner logo with text (no image in light or dark so link has discernible text) + # Replace banner logo with text for reset/confirm pages (no image so link has discernible text). override AshAuthentication.Phoenix.Components.Banner do set :text, "Mitgliederverwaltung" set :image_url, nil set :dark_image_url, nil end - # Translate the "or" in the horizontal rule (between password form and SSO). - # Uses auth domain so it respects the current locale (e.g. "oder" in German). - override AshAuthentication.Phoenix.Components.HorizontalRule do - set :text, dgettext("auth", "or") - end - - # Hide AshAuthentication's Flash component since we use flash_group in root layout - # This prevents duplicate flash messages + # Hide AshAuthentication's Flash component since we use flash_group in root layout. + # This prevents duplicate flash messages. override AshAuthentication.Phoenix.Components.Flash do set :message_class_info, "hidden" set :message_class_error, "hidden" end end + +defmodule MvWeb.AuthOverridesRegistrationDisabled do + @moduledoc """ + When direct registration is disabled in global settings, this override is + prepended in SignInLive so the Password component hides the "Need an account?" + toggle (register_toggle_text: nil disables the register link per library docs). + """ + use AshAuthentication.Phoenix.Overrides + + override AshAuthentication.Phoenix.Components.Password do + set :register_toggle_text, nil + end +end + +defmodule MvWeb.AuthOverridesDE do + @moduledoc """ + German locale-specific overrides for AshAuthentication Phoenix components. + + Prepended to the overrides list in SignInLive when the locale is "de". + Provides runtime-static German text for components that do not use + the `_gettext` mechanism (e.g. HorizontalRule renders its text directly), + and for submit buttons whose disable_text bypasses the POT extraction pipeline. + """ + use AshAuthentication.Phoenix.Overrides + + # HorizontalRule renders text without `_gettext`, so we need a static German string. + override AshAuthentication.Phoenix.Components.HorizontalRule do + set :text, "oder" + end + + # Registering ... disable-text is passed through _gettext but "Registering ..." + # has no dgettext source reference, so we supply the German string directly. + override AshAuthentication.Phoenix.Components.Password.RegisterForm do + set :disable_button_text, "Registrieren..." + end +end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 11a60ef..b5bd763 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -63,6 +63,11 @@ defmodule MvWeb.CoreComponents do values: [:info, :error, :success, :warning], doc: "used for styling and flash lookup" + attr :auto_clear_ms, :integer, + default: nil, + doc: + "when set, flash is auto-dismissed after this many milliseconds (e.g. 5000 for success toasts)" + attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" slot :inner_block, doc: "the optional inner block that renders the flash message" @@ -74,6 +79,9 @@ defmodule MvWeb.CoreComponents do
hide("##{@id}")} role="alert" class="pointer-events-auto" @@ -1295,6 +1303,41 @@ defmodule MvWeb.CoreComponents do """ end + @doc """ + Renders a theme toggle using DaisyUI swap (sun/moon with rotate effect). + + Wired to the theme script in root layout: checkbox uses `data-theme-toggle`, + root script syncs checked state (checked = dark) and listens for `phx:set-theme`. + Use in public header or sidebar. Optional `class` is applied to the wrapper. + """ + attr :class, :string, default: nil, doc: "Optional extra classes for the swap wrapper" + + def theme_swap(assigns) do + assigns = assign(assigns, :wrapper_class, assigns[:class]) + + ~H""" +
+ +
+ """ + end + @doc """ Renders a [Heroicon](https://heroicons.com). diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index a6d75ba..54f589d 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -13,6 +13,98 @@ defmodule MvWeb.Layouts do embed_templates "layouts/*" + @doc """ + Builds the full browser tab title: "Mila", "Mila · Page", or "Mila · Page · Club". + Order is always: Mila · page title · club name. + Uses assigns[:club_name] and the short page label from assigns[:content_title] or + assigns[:page_title]. LiveViews should set content_title (same gettext as sidebar) + and then assign page_title to the result of this function so the client receives + the full title. + """ + def page_title_string(assigns) do + club = assigns[:club_name] + page = assigns[:content_title] || assigns[:page_title] + + parts = + [page, club] + |> Enum.filter(&(is_binary(&1) and String.trim(&1) != "")) + + if parts == [] do + "Mila" + else + "Mila · " <> Enum.join(parts, " · ") + end + end + + @doc """ + Assigns content_title (short label for heading; same gettext as sidebar) and + page_title (full browser tab title). Call from LiveView mount after club_name + is set (e.g. from on_mount). Returns the socket. + """ + def assign_page_title(socket, content_title) do + socket = assign(socket, :content_title, content_title) + assign(socket, :page_title, page_title_string(socket.assigns)) + end + + @doc """ + 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 = + assigns[:club_name] || + case Mv.Membership.get_settings() do + {:ok, s} -> s.club_name || "Mitgliederverwaltung" + _ -> "Mitgliederverwaltung" + end + + assigns = assign(assigns, :club_name, club_name) + + ~H""" +
+
+ Mila Logo + Mitgliederverwaltung +
+ + {@club_name} + +
+
+ + +
+ <.theme_swap /> +
+
+
+
+ {render_slot(@inner_block)} +
+
+ <.flash_group flash={@flash} /> + """ + end + @doc """ Renders the app layout. Can be used with or without a current_user. When current_user is present, it will show the navigation bar. @@ -43,11 +135,11 @@ defmodule MvWeb.Layouts do slot :inner_block, required: true def app(assigns) do - club_name = get_club_name() - join_form_enabled = Mv.Membership.join_form_enabled?() + # Single get_settings() for layout; derive club_name and join_form_enabled to avoid duplicate query. + %{club_name: club_name, join_form_enabled: join_form_enabled} = get_layout_settings() - # TODO: get_join_form_enabled and unprocessed count run on every page load; consider - # loading count only on navigation or caching briefly if performance becomes an issue. + # TODO: unprocessed count runs on every page load when join form enabled; consider + # loading only on navigation or caching briefly if performance becomes an issue. unprocessed_join_requests_count = get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled) @@ -99,24 +191,30 @@ defmodule MvWeb.Layouts do
<% else %> - -
- Mila Logo - + +
+
+ Mila Logo + Mitgliederverwaltung +
+ {@club_name} -
- - -
+
+
+ + +
+ <.theme_swap /> +
@@ -129,12 +227,17 @@ defmodule MvWeb.Layouts do """ end - # Helper function to get club name from settings - # Falls back to "Mitgliederverwaltung" if settings can't be loaded - defp get_club_name do + # Single settings read for layout; returns club_name and join_form_enabled to avoid duplicate get_settings(). + defp get_layout_settings do case Mv.Membership.get_settings() do - {:ok, settings} -> settings.club_name - _ -> "Mitgliederverwaltung" + {:ok, settings} -> + %{ + club_name: settings.club_name || "Mitgliederverwaltung", + join_form_enabled: settings.join_form_enabled == true + } + + _ -> + %{club_name: "Mitgliederverwaltung", join_form_enabled: false} end end @@ -162,7 +265,7 @@ defmodule MvWeb.Layouts do aria-live="polite" class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none" > - <.flash kind={:success} flash={@flash} /> + <.flash kind={:success} flash={@flash} auto_clear_ms={5000} /> <.flash kind={:warning} flash={@flash} /> <.flash kind={:info} flash={@flash} /> <.flash kind={:error} flash={@flash} /> diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index e107d5b..bb900aa 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -7,8 +7,8 @@ - <.live_title default="Mv" suffix=" · Phoenix Framework"> - {assigns[:page_title]} + <.live_title default="Mila"> + {page_title_string(assigns)}