diff --git a/.env.example b/.env.example index d63e019..e24b118 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,6 @@ 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 @@ -42,15 +41,3 @@ 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 b94ce50..2c23c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,43 +5,7 @@ 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). -## [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 +## [Unreleased] ### Added - **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index f84c5ad..4aa7566 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -90,8 +90,6 @@ 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 @@ -130,8 +128,6 @@ 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 @@ -284,13 +280,13 @@ end ### 1.2.1 Database Seeds -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. +Seeds are split into **bootstrap** and **dev**: -- **`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.exs`** – Entrypoint. Runs `seeds_bootstrap.exs` always; runs `seeds_dev.exs` only when `Mix.env()` is `:dev` or `:test`. - **`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` (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. +In production, running `mix run priv/repo/seeds.exs` executes only the bootstrap part (no dev seeds). ### 1.3 Domain-Driven Design @@ -1271,34 +1267,7 @@ mix hex.outdated **Mailer and from address:** - `Mv.Mailer` (Swoosh) and `Mv.Mailer.mail_from/0` return the configured sender `{name, 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. +- 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`). **Unified layout (transactional emails):** @@ -1318,11 +1287,7 @@ new() |> put_view(MvWeb.EmailsView) |> put_layout({MvWeb.EmailLayoutView, "layout.html"}) |> render_body("template_name.html", %{assigns}) - -case Mailer.deliver(email) do - {:ok, _} -> :ok - {:error, reason} -> Logger.error("Email delivery failed: #{inspect(reason)}") -end +|> Mailer.deliver!() ``` ### 3.12 Internationalization: Gettext @@ -1350,16 +1315,13 @@ dgettext("auth", "Sign in with email") **Extract and Merge:** ```bash -# Extract new translatable strings and merge into existing .po files (recommended) -mix gettext.extract --merge - -# Alternative: extract only, then merge separately +# Extract new translatable strings mix gettext.extract + +# Merge into existing translations 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 0ad562e..92f7a90 100644 --- a/DESIGN_GUIDELINES.md +++ b/DESIGN_GUIDELINES.md @@ -76,21 +76,6 @@ 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: @@ -98,18 +83,16 @@ 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/85` | +| Subtitle | helper under title | `text-sm text-base-content/70` | | Section title (H2) | section headings | `text-lg font-semibold` | -| 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` | +| 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` | | 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) @@ -221,11 +204,6 @@ 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 d2c51e5..f3ad5a3 100644 --- a/Justfile +++ b/Justfile @@ -10,7 +10,6 @@ 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 d7f873c..e3c6e83 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -154,14 +154,6 @@ 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 4c7e3c5..ee423eb 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -25,14 +25,6 @@ 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 = {} @@ -113,25 +105,6 @@ 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() { @@ -339,10 +312,7 @@ Hooks.SidebarState = { let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: { - _csrf_token: csrfToken, - timezone: getBrowserTimezone() - }, + params: {_csrf_token: csrfToken}, hooks: Hooks }) diff --git a/config/config.exs b/config/config.exs index 7bb4f61..ab55f2a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -46,18 +46,11 @@ 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: [ @@ -96,10 +89,6 @@ 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"} @@ -107,9 +96,6 @@ 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 6a434fa..b8570d8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -223,52 +223,19 @@ if config_env() == :prod do {System.get_env("MAIL_FROM_NAME", "Mila"), System.get_env("MAIL_FROM_EMAIL", "noreply@example.com")} - # 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 + # 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. end diff --git a/config/test.exs b/config/test.exs index ef54982..84ccd70 100644 --- a/config/test.exs +++ b/config/test.exs @@ -58,7 +58,3 @@ 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 5413f91..5e26c85 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` (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()"`. +- **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()"`. - **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,14 +10,13 @@ ### 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` – 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.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.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 @@ -39,7 +38,6 @@ ### 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 6d8e523..a6297ba 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/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. +- **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. - 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 2ec15a5..9c8c835 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:** (none remaining for Authentication UI) +**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) **Current State:** - ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345) @@ -49,11 +49,6 @@ - ✅ **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 @@ -275,9 +270,6 @@ **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 @@ -295,7 +287,6 @@ - ✅ 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 8e6c615..8083a7b 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -93,7 +93,6 @@ - **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. @@ -116,7 +115,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. **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`. +- **Detail:** **`/join_requests/:id`** – single join request with all data (typed fields + `form_data`), actions Approve / Reject. #### Backend (JoinRequest) @@ -196,7 +195,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**, **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`). +- **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`). - **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 deleted file mode 100644 index 00f64c4..0000000 --- a/docs/settings-authentication-mockup.txt +++ /dev/null @@ -1,44 +0,0 @@ -# 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 deleted file mode 100644 index 13b0d17..0000000 --- a/docs/smtp-configuration-concept.md +++ /dev/null @@ -1,133 +0,0 @@ -# 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 0127796..6b9cd1e 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -362,12 +362,6 @@ 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)" @@ -411,14 +405,6 @@ 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 deleted file mode 100644 index e4d9a35..0000000 --- a/lib/accounts/user/validations/oidc_only_blocks_password_registration.ex +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index f2342b7..0000000 --- a/lib/accounts/user/validations/registration_enabled.ex +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index daec4c1..0000000 --- a/lib/membership/join_notifier.ex +++ /dev/null @@ -1,13 +0,0 @@ -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 94907e2..05a9e8d 100644 --- a/lib/membership/join_request.ex +++ b/lib/membership/join_request.ex @@ -77,17 +77,6 @@ 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 @@ -186,11 +175,6 @@ 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 b86ca5d..24716f6 100644 --- a/lib/membership/join_request/changes/approve_request.ex +++ b/lib/membership/join_request/changes/approve_request.ex @@ -16,13 +16,11 @@ 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 9bb0697..ee09b75 100644 --- a/lib/membership/join_request/changes/helpers.ex +++ b/lib/membership/join_request/changes/helpers.ex @@ -16,24 +16,4 @@ 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 deleted file mode 100644 index c8055d2..0000000 --- a/lib/membership/join_request/changes/regenerate_confirmation_token.ex +++ /dev/null @@ -1,33 +0,0 @@ -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 1b9fe1a..2c33a77 100644 --- a/lib/membership/join_request/changes/reject_request.ex +++ b/lib/membership/join_request/changes/reject_request.ex @@ -15,13 +15,11 @@ 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 7fa35dc..2f18f90 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -29,10 +29,8 @@ 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 Mv.Membership.Member - alias Mv.Membership.SettingsCache + alias MvWeb.Emails.JoinConfirmationEmail require Logger admin do @@ -116,16 +114,10 @@ defmodule Mv.Membership do """ def get_settings do - case Process.whereis(SettingsCache) do - nil -> get_settings_uncached() - _pid -> SettingsCache.get() - end - end - - @doc false - def get_settings_uncached do + # Try to get the first (and only) settings record 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 @@ -166,16 +158,9 @@ defmodule Mv.Membership do """ def update_settings(settings, attrs) do - case settings - |> Ash.Changeset.for_update(:update, attrs) - |> Ash.update(domain: __MODULE__) do - {:ok, _updated} = result -> - SettingsCache.invalidate() - result - - error -> - error - end + settings + |> Ash.Changeset.for_update(:update, attrs) + |> Ash.update(domain: __MODULE__) end @doc """ @@ -239,18 +224,11 @@ defmodule Mv.Membership do """ def update_member_field_visibility(settings, visibility_config) do - 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 + settings + |> Ash.Changeset.for_update(:update_member_field_visibility, %{ + member_field_visibility: visibility_config + }) + |> Ash.update(domain: __MODULE__) end @doc """ @@ -283,19 +261,12 @@ defmodule Mv.Membership do field: field, show_in_overview: show_in_overview ) do - 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 + 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__) end @doc """ @@ -329,20 +300,13 @@ defmodule Mv.Membership do show_in_overview: show_in_overview, required: required ) do - 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 + 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__) end @doc """ @@ -400,131 +364,15 @@ defmodule Mv.Membership do - `:actor` - Must be nil for public submit (policy allows only unauthenticated). ## Returns - - `{: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) + - `{:ok, request}` - Created JoinRequest in status pending_confirmation - `{:error, error}` - Validation or authorization error """ def submit_join_request(attrs, opts \\ []) do actor = Keyword.get(opts, :actor) - email = normalize_submit_email(attrs) - - 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() + + # 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. attrs_with_token = Map.put(attrs, :confirmation_token, token) case Ash.create(JoinRequest, attrs_with_token, @@ -533,9 +381,8 @@ defmodule Mv.Membership do domain: __MODULE__ ) do {:ok, request} -> - case join_notifier().send_confirmation(request.email, token, []) do + case JoinConfirmationEmail.send(request.email, token) do {:ok, _email} -> - # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. {:ok, request} {:error, reason} -> @@ -543,7 +390,8 @@ defmodule Mv.Membership do "Join confirmation email failed for #{request.email}: #{inspect(reason)}" ) - {:error, :email_delivery_failed} + # Request was created; return success so the user sees the confirmation message + {:ok, request} end error -> diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 83c5c8b..bc2b1e7 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -15,7 +15,6 @@ 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 @@ -57,20 +56,14 @@ 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, - primary_read_warning?: false + data_layer: AshPostgres.DataLayer # 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 @@ -80,27 +73,8 @@ 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 - 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 + defaults [:read] # Internal create action - not exposed via code interface # Used only as fallback in get_settings/0 if settings don't exist @@ -123,14 +97,6 @@ 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 @@ -160,14 +126,6 @@ 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 @@ -471,61 +429,6 @@ 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 deleted file mode 100644 index d8581d6..0000000 --- a/lib/membership/settings_cache.ex +++ /dev/null @@ -1,85 +0,0 @@ -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 7312b91..393a220 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,8 +16,6 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do import Swoosh.Email use Gettext, backend: MvWeb.Gettext, otp_app: :mv - require Logger - alias Mv.Mailer @doc """ @@ -32,8 +30,7 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do - `_opts` - Additional options (unused) ## Returns - `:ok` always. Delivery errors are logged and not re-raised so they do not - crash the caller process (AshAuthentication ignores the return value). + The Swoosh.Email delivery result from `Mailer.deliver!/1`. """ @impl true def send(user, token, _) do @@ -47,24 +44,12 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do locale: Gettext.get_locale(MvWeb.Gettext) } - 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 + new() + |> from(Mailer.mail_from()) + |> to(to_string(user.email)) + |> subject(subject) + |> put_view(MvWeb.EmailsView) + |> render_body("user_confirmation.html", assigns) + |> Mailer.deliver!() 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 e276e20..74d5d47 100644 --- a/lib/mv/accounts/user/senders/send_password_reset_email.ex +++ b/lib/mv/accounts/user/senders/send_password_reset_email.ex @@ -16,8 +16,6 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do import Swoosh.Email use Gettext, backend: MvWeb.Gettext, otp_app: :mv - require Logger - alias Mv.Mailer @doc """ @@ -32,8 +30,7 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do - `_opts` - Additional options (unused) ## Returns - `:ok` always. Delivery errors are logged and not re-raised so they do not - crash the caller process (AshAuthentication ignores the return value). + The Swoosh.Email delivery result from `Mailer.deliver!/1`. """ @impl true def send(user, token, _) do @@ -47,21 +44,12 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do locale: Gettext.get_locale(MvWeb.Gettext) } - 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 + new() + |> from(Mailer.mail_from()) + |> to(to_string(user.email)) + |> subject(subject) + |> put_view(MvWeb.EmailsView) + |> render_body("password_reset.html", assigns) + |> Mailer.deliver!() end end diff --git a/lib/mv/application.ex b/lib/mv/application.ex index 1b6014e..6b4a10b 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -6,7 +6,6 @@ defmodule Mv.Application do use Application alias Mv.Helpers.SystemActor - alias Mv.Membership.SettingsCache alias Mv.Repo alias Mv.Vereinfacht.SyncFlash alias MvWeb.Endpoint @@ -17,28 +16,20 @@ defmodule Mv.Application do def start(_type, _args) do SyncFlash.create_table!() - # 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 - ] + 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 + ] # 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 deleted file mode 100644 index 8d56ca1..0000000 --- a/lib/mv/authorization/checks/oidc_only_active.ex +++ /dev/null @@ -1,16 +0,0 @@ -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 3494937..8b8c088 100644 --- a/lib/mv/config.ex +++ b/lib/mv/config.ex @@ -362,41 +362,26 @@ 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 (read via explicit select; not in default get_settings). + Otherwise ENV OIDC_CLIENT_SECRET, then 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)) - _ -> oidc_client_secret_from_env_or_settings() + _ -> env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) 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: oidc_client_secret_from_env_or_settings() + do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) defp oidc_client_secret_from_config(secret) when is_binary(secret) do s = String.trim(secret) - if s != "", do: s, else: oidc_client_secret_from_env_or_settings() + if s != "", do: s, else: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) end defp oidc_client_secret_from_config(_), - 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 + do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) @doc """ Returns the OIDC admin group name (for role sync). ENV first, then Settings. @@ -464,206 +449,4 @@ 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 41a77cd..3d83636 100644 --- a/lib/mv/mailer.ex +++ b/lib/mv/mailer.ex @@ -4,188 +4,16 @@ 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 - import Swoosh.Email - use Gettext, backend: MvWeb.Gettext, otp_app: :mv - - 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}`. + Returns the configured "from" address for transactional emails. - Priority: ENV `MAIL_FROM_NAME`/`MAIL_FROM_EMAIL` > Settings `smtp_from_name`/`smtp_from_email` > defaults. + Configure in config.exs or runtime.exs as `config :mv, :mail_from, {name, email}`. + Default: `{"Mila", "noreply@example.com"}`. """ - @spec mail_from() :: {String.t(), String.t()} def mail_from do - {Mv.Config.mail_from_name(), Mv.Config.mail_from_email() || "noreply@example.com"} + Application.get_env(:mv, :mail_from, {"Mila", "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 116b276..00dcadf 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. - - `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). + - `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). - `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,7 +19,6 @@ defmodule Mv.Release do alias Mv.Authorization.Role require Ash.Query - require Logger def migrate do load_app() @@ -29,37 +28,13 @@ 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). - - 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. + - 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). - Uses paths from the application's priv dir so it works in releases (no Mix). + Uses paths from the application's priv dir so it works in releases (no Mix). Idempotent. """ def run_seeds do case Application.ensure_all_started(@app) do @@ -67,27 +42,23 @@ defmodule Mv.Release do {:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}" end - 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") + 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.") - end - after - Code.compiler_options(prev) + 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 end diff --git a/lib/mv/smtp/config_builder.ex b/lib/mv/smtp/config_builder.ex deleted file mode 100644 index 5018dff..0000000 --- a/lib/mv/smtp/config_builder.ex +++ /dev/null @@ -1,58 +0,0 @@ -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 3aab0ed..5cab4d2 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -3,70 +3,52 @@ defmodule MvWeb.AuthOverrides do UI customizations for AshAuthentication Phoenix components. ## Overrides - - `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) + - `SignIn` - Restricts form width to prevent full-width display + - `Banner` - Replaces default logo with "Mitgliederverwaltung" text + - `HorizontalRule` - Translates "or" text to German ## 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 - # Avoid full-width for the Sign In Form. - # Banner is hidden because SignInLive renders its own locale-aware title. + # 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 override AshAuthentication.Phoenix.Components.SignIn do set :root_class, "md:min-w-md" - set :show_banner, false end - # Replace banner logo with text for reset/confirm pages (no image so link has discernible text). + # Replace banner logo with text (no image in light or dark so link has discernible text) override AshAuthentication.Phoenix.Components.Banner do set :text, "Mitgliederverwaltung" set :image_url, nil set :dark_image_url, nil end - # Hide AshAuthentication's Flash component since we use flash_group in root layout. - # This prevents duplicate flash messages. + # 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 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 b5bd763..11a60ef 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -63,11 +63,6 @@ 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" @@ -79,9 +74,6 @@ defmodule MvWeb.CoreComponents do
hide("##{@id}")} role="alert" class="pointer-events-auto" @@ -1303,41 +1295,6 @@ 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 54f589d..a6d75ba 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -13,98 +13,6 @@ 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. @@ -135,11 +43,11 @@ defmodule MvWeb.Layouts do slot :inner_block, required: true def app(assigns) do - # 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() + club_name = get_club_name() + join_form_enabled = Mv.Membership.join_form_enabled?() - # 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. + # 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. unprocessed_join_requests_count = get_unprocessed_join_requests_count(assigns.current_user, join_form_enabled) @@ -191,30 +99,24 @@ defmodule MvWeb.Layouts do
<% else %> - -
-
- Mila Logo - Mitgliederverwaltung -
- + +
+ Mila Logo + {@club_name} -
-
- - -
- <.theme_swap /> -
+
+ + +
@@ -227,17 +129,12 @@ defmodule MvWeb.Layouts do """ end - # Single settings read for layout; returns club_name and join_form_enabled to avoid duplicate get_settings(). - defp get_layout_settings do + # Helper function to get club name from settings + # Falls back to "Mitgliederverwaltung" if settings can't be loaded + defp get_club_name do case Mv.Membership.get_settings() do - {:ok, settings} -> - %{ - club_name: settings.club_name || "Mitgliederverwaltung", - join_form_enabled: settings.join_form_enabled == true - } - - _ -> - %{club_name: "Mitgliederverwaltung", join_form_enabled: false} + {:ok, settings} -> settings.club_name + _ -> "Mitgliederverwaltung" end end @@ -265,7 +162,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} auto_clear_ms={5000} /> + <.flash kind={:success} flash={@flash} /> <.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 bb900aa..e107d5b 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="Mila"> - {page_title_string(assigns)} + <.live_title default="Mv" suffix=" · Phoenix Framework"> + {assigns[:page_title]}