diff --git a/.env.example b/.env.example index e24b118..d63e019 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ ASSOCIATION_NAME="Sportsclub XYZ" # Optional: Admin user (created/updated on container start via Release.seed_admin) # In production, set these so the first admin can log in. Change password without redeploy: # bin/mv eval "Mv.Release.seed_admin()" (with new ADMIN_PASSWORD or ADMIN_PASSWORD_FILE) +# FORCE_SEEDS=true re-runs bootstrap seeds even when admin user exists (e.g. after changing roles/custom fields). # ADMIN_EMAIL=admin@example.com # ADMIN_PASSWORD=secure-password # ADMIN_PASSWORD_FILE=/run/secrets/admin_password @@ -41,3 +42,15 @@ ASSOCIATION_NAME="Sportsclub XYZ" # VEREINFACHT_API_KEY=your-api-key # VEREINFACHT_CLUB_ID=2 # VEREINFACHT_APP_URL=https://app.verein.visuel.dev + +# Optional: Mail / SMTP (transactional emails). If set, overrides Settings UI. +# Export current UI settings to .env: mix mv.export_smtp_to_env +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USERNAME=user +# SMTP_PASSWORD=secret +# SMTP_PASSWORD_FILE=/run/secrets/smtp_password +# SMTP_SSL=tls +# SMTP_VERIFY_PEER=false +# MAIL_FROM_EMAIL=noreply@example.com +# MAIL_FROM_NAME=Mila diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c23c01..b94ce50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,43 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.1.1] - 2026-03-16 + +### Added +- **FORCE_SEEDS** – Environment variable. When set to `"true"`, bootstrap (and optionally dev) seeds are run even when the admin user already exists, so you can re-apply changed seed data (e.g. new roles or custom fields) without deleting the admin user. +- **Improved OIDC-only mode** – Admin can enable “Only OIDC sign-in” in settings; when enabled, direct registration is disabled and sign-in page redirects to OIDC when configured. +- **Success toast auto-dismiss** – Success flash messages (e.g. “Settings saved”) hide automatically after 5 seconds instead of requiring the user to close them. + +### Changed +- **Seeds run only when needed** – Bootstrap and dev seeds are skipped on application start when the admin user already exists (`Mv.Release.bootstrap_seeds_applied?/0`). This avoids duplicate data and speeds up startup in dev and production after the first run. Set `FORCE_SEEDS=true` to override and re-run. +- **Unauthenticated access** – Users who are not logged in are redirected to sign-in without showing a “no permission” message; the message is only shown to logged-in users who lack access. + +### Fixed +- **SMTP configuration** – Repaired so that both port 587 (TLS/STARTTLS) and 465 (SSL) work correctly. + +## [1.1.0] - 2026-03-13 + +### Added +- **Browser timezone for datetime display** – Date/time values (e.g. join request submitted at, approved at, rejected at) are shown in the user’s local timezone. +- **Registration toggle** – New global setting to disable direct registration (`/register`). When disabled, visitors are redirected to sign-in and the register link is hidden; join form remains available. +- **Configurable SMTP in global settings** – SMTP host, port, user, password, and TLS options configurable via Admin → Global Settings. Test-email action to verify delivery. Join confirmation and other transactional emails use this configuration. +- **Theme and language selector on unauthenticated pages** – Sign-in and join pages now offer theme (light/dark) and locale (e.g. German/English) controls in the header. +- **Duplicate-email handling for join form** – If an applicant’s email is already a member or already has a pending join request, the system sends a clarifying email (already-member or already-pending) and shows the same success message (anti-enumeration). +- **Reviewed-by display for join requests** – Approval UI shows who reviewed a request via a dedicated display field, without loading the User record. +- **Improved field order and seeds for join request approval** – Approval screen field order improved; seed data updated for join-form and approval flows. +- **Tests for SMTP mailer configuration** – Tests for SMTP config and for join confirmation email delivery failure (domain and LiveView). + +### Changed +- **SMTP settings layout** – SMTP options reordered and grouped in global settings for clearer configuration. +- **Join confirmation mail** – Uses configurable SMTP from settings; on delivery failure the join form shows an error and no success message. +- **i18n** – Gettext catalogs updated for new and changed strings. + +### Fixed +- **Login page translation** – Corrected translation/locale handling on the sign-in page. + +--- + +## [1.0.0] and earlier ### Added - **Roles and Permissions System (RBAC)** - Complete implementation (#345, 2026-01-08) diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 0cb8d65..f84c5ad 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -90,6 +90,8 @@ lib/ │ ├── custom_field.ex # Custom field (definition) resource │ ├── custom_field_value.ex # Custom field value resource │ ├── setting.ex # Global settings (singleton resource; incl. join form config) +│ ├── settings_cache.ex # Process cache for get_settings (TTL; invalidate on update; not started in test) +│ ├── join_notifier.ex # Behaviour for join emails (confirmation, already member, already pending) │ ├── setting/ # Setting changes (NormalizeJoinFormSettings, etc.) │ ├── group.ex # Group resource │ ├── member_group.ex # MemberGroup join table resource @@ -128,6 +130,8 @@ lib/ │ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields) │ ├── application.ex # OTP application │ ├── mailer.ex # Email mailer +│ ├── smtp/ +│ │ └── config_builder.ex # SMTP adapter opts (TLS/sockopts); used by runtime.exs and Mailer │ ├── release.ex # Release tasks │ ├── repo.ex # Database repository │ ├── secrets.ex # Secret management @@ -280,13 +284,13 @@ end ### 1.2.1 Database Seeds -Seeds are split into **bootstrap** and **dev**: +Seeds are split into **bootstrap** and **dev**. They run on every start (e.g. `just run`, Docker entrypoint) but **exit early** if already applied so startup stays fast and no duplicate data is created. -- **`priv/repo/seeds.exs`** – Entrypoint. Runs `seeds_bootstrap.exs` always; runs `seeds_dev.exs` only when `Mix.env()` is `:dev` or `:test`. +- **`priv/repo/seeds.exs`** – Entrypoint. If the admin user (ADMIN_EMAIL or default) already exists, skips entirely (unless `FORCE_SEEDS=true`); otherwise runs `seeds_bootstrap.exs` and, in dev/test, `seeds_dev.exs`. - **`priv/repo/seeds_bootstrap.exs`** – Creates only data required for system startup: membership fee types, custom fields, roles, admin user, system user, global settings (including default membership fee type). No members, no groups. Used in all environments (dev, test, prod). - **`priv/repo/seeds_dev.exs`** – Creates 20 sample members, groups, and optional custom field values. Run only in dev and test. -In production, running `mix run priv/repo/seeds.exs` executes only the bootstrap part (no dev seeds). +In production, running `mix run priv/repo/seeds.exs` (or `Mv.Release.run_seeds/0`) executes only the bootstrap part when not yet applied (no dev seeds unless `RUN_DEV_SEEDS=true`). The “already applied” check uses `Mv.Release.bootstrap_seeds_applied?/0` (admin user exists). Set `FORCE_SEEDS=true` to re-run seeds even when already applied. ### 1.3 Domain-Driven Design @@ -1275,6 +1279,8 @@ mix hex.outdated - SMTP can be configured via **ENV variables** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_PASSWORD_FILE`, `SMTP_SSL`) or via **Admin Settings** (database: `smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_ssl`). ENV takes priority (same pattern as OIDC/Vereinfacht). - **Sensitive settings in DB:** `smtp_password` and `oidc_client_secret` are excluded from the default read of the Setting resource; they are loaded only via explicit select when needed (e.g. `Mv.Config.smtp_password/0`, `Mv.Config.oidc_client_secret/0`). This avoids exposing secrets through `get_settings()`. +- **Settings cache:** `Mv.Membership.get_settings/0` uses `Mv.Membership.SettingsCache` when the cache process is running (not in test). Cache has a short TTL and is invalidated on every settings update. This avoids repeated DB reads on hot paths (e.g. `RegistrationEnabled` validation, `Layouts.public_page`). In test, the cache is not started so all callers use `get_settings_uncached/0` in the test process (Ecto Sandbox). +- **Join emails (domain → web):** The domain calls `Mv.Membership.JoinNotifier` (config `:join_notifier`, default `MvWeb.JoinNotifierImpl`) for sending join confirmation, already-member, and already-pending emails. This keeps the domain independent of the web layer; tests can override the notifier. - Sender identity is also configurable via ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`) or Settings (`smtp_from_name`, `smtp_from_email`). - `SMTP_PASSWORD_FILE`: path to a file containing the password (Docker Secrets / Kubernetes secrets pattern); overridden by `SMTP_PASSWORD` when both are set. - `SMTP_SSL` values: `tls` (default, port 587), `ssl` (port 465), `none` (port 25). @@ -1290,6 +1296,10 @@ mix hex.outdated - `SendPasswordResetEmail` and `SendNewUserConfirmationEmail` use `Mv.Mailer.deliver/1` (not `deliver!/1`). Errors are logged via `Logger.error` and not re-raised so they never crash the caller process. +**Join confirmation email:** + +- Join emails are sent via `Mv.Membership.JoinNotifier` (default impl: `MvWeb.JoinNotifierImpl` calling `JoinConfirmationEmail`, etc.). `MvWeb.Emails.JoinConfirmationEmail` uses `Mailer.deliver(email, Mailer.smtp_config())` so it uses the same SMTP configuration as the test mail (Settings or boot ENV). On delivery failure, `Mv.Membership.submit_join_request/2` returns `{:error, :email_delivery_failed}` (and logs via `Logger.error`); the JoinLive shows an error message and no success UI. + **Unified layout (transactional emails):** - All transactional emails (join confirmation, user confirmation, password reset) use the same layout: `MvWeb.EmailLayoutView` (layout) and `MvWeb.EmailsView` (body templates). diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md index 92f7a90..0ad562e 100644 --- a/DESIGN_GUIDELINES.md +++ b/DESIGN_GUIDELINES.md @@ -76,6 +76,21 @@ For LiveViews that render an edit or new form (e.g. member, group, role, user, c If the `<.header>` is outside the `<.form>`, the submit button must reference the form via the `form` attribute (e.g. `form="user-form"`). +### 2.3 Public / unauthenticated pages (Join, Sign-in, Join Confirm) + +Pages that do not require authentication (e.g. `/join`, `/sign-in`, `/confirm_join/:token`) use a unified layout via the **`Layouts.public_page`** component: + +- **Component:** `Layouts.public_page` renders: + - **Header:** Logo + "Mitgliederverwaltung" (left) | Club name centered via absolute positioning | Language selector + theme swap (sun/moon, DaisyUI swap with rotate) (right) + - Main content slot, Flash group. No sidebar, no authenticated-layout logic. +- **Content:** DaisyUI **hero** section (`hero`, `hero-content`) for the main message or form, so all public pages share the same visual structure. The hero is constrained in width (`max-w-4xl mx-auto`) and content is left-aligned (`hero-content flex-col items-start text-left`). +- **Locale handling:** The language selector uses `Gettext.get_locale(MvWeb.Gettext)` (backend-specific) to correctly reflect the active locale. `SignInLive` sets both `Gettext.put_locale(MvWeb.Gettext, locale)` and `Gettext.put_locale(locale)` to keep global and backend locales in sync. +- **Translations for AshAuthentication components:** AshAuthentication’s `_gettext` mechanism translates button labels (e.g. “Sign in” → “Anmelden”, “Register” → “Registrieren”) at runtime via `gettext_fn: {MvWeb.Gettext, "auth"}`. Components that do NOT use `_gettext` (e.g. `HorizontalRule`) receive static German overrides via **`MvWeb.AuthOverridesDE`**, which is prepended to the overrides list in `SignInLive` when the locale is `"de"`. +- **Implementation:** + - **Sign-in** (`SignInLive`): Uses `use Phoenix.LiveView` (not `use MvWeb, :live_view`) so AshAuthentication’s sign_in_route live_session on_mount chain is not mixed with LiveHelpers hooks. Renders `` with the SignIn component inside a hero. Displays a locale-aware `

` title (“Anmelden” / “Registrieren”) above the AshAuthentication component (the library’s Banner is hidden via `show_banner: false`). + - **Join** (`JoinLive`): Uses `use MvWeb, :live_view` and wraps content in `` with a hero for the form. + - **Join Confirm** (controller): Uses `JoinConfirmHTML` with a template that wraps content in `` and a hero block for the result, so the confirm page shares the same header and chrome as Join and Sign-in. + ## 3) Typography (system) Use these standard roles: @@ -83,16 +98,18 @@ Use these standard roles: | Role | Use | Class | |---|---|---| | Page title (H1) | main page title | `text-xl font-semibold leading-8` | -| Subtitle | helper under title | `text-sm text-base-content/70` | +| Subtitle | helper under title | `text-sm text-base-content/85` | | Section title (H2) | section headings | `text-lg font-semibold` | -| Helper text | under inputs | `text-sm text-base-content/70` | -| Fine print | small hints | `text-xs text-base-content/60` | -| Empty state | no data | `text-base-content/60 italic` | +| Helper text | under inputs | `text-sm text-base-content/85` | +| Fine print | small hints | `text-xs text-base-content/80` | +| Empty state | no data | `text-base-content/80 italic` | | Destructive text | danger | `text-error` | **MUST:** Page titles via `<.header>`. **MUST:** Section titles via `<.form_section title="…">` (for forms) or a consistent section wrapper (if you introduce a `<.card>` later). +**Form labels (WCAG 2.2 AA):** DaisyUI `.label` defaults to 60% opacity and fails contrast. We override it in `app.css` to 85% of `base-content` so labels stay slightly de‑emphasised vs body text but meet the 4.5:1 minimum. Use `class="label"` and `` as usual; no extra classes needed. + --- ## 4) States: Loading, Empty, Error (mandatory consistency) @@ -204,6 +221,11 @@ If these cannot be met, use `secondary`/`outline` instead of `ghost`. - **MUST:** Required fields are marked consistently (UI indicator + accessible text). - **SHOULD:** If required-ness is configurable via settings, display it consistently in the form. +### 6.4 Form layout (settings / long forms) +- **SHOULD:** On wide viewports, use a responsive grid so related fields share a row and reduce scrolling (e.g. `grid grid-cols-1 lg:grid-cols-2` or `lg:grid-cols-[2fr_5rem_1fr]` for mixed widths). +- **SHOULD:** Limit the main content width for readability (e.g. Settings page uses `max-w-4xl mx-auto px-4` around the content area below the header). +- **Example:** SMTP settings use three rows on large screens (Host, Port, TLS/SSL | Username, Password | Sender email, Sender name) without subsection labels. + --- ## 7) Lists, Search & Filters (mandatory UX consistency) diff --git a/Justfile b/Justfile index f3ad5a3..d2c51e5 100644 --- a/Justfile +++ b/Justfile @@ -10,6 +10,7 @@ install-dependencies: mix deps.get migrate-database: + mix compile mix ash.setup reset-database: diff --git a/assets/css/app.css b/assets/css/app.css index e3c6e83..d7f873c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -154,6 +154,14 @@ background-color: var(--color-base-100); } +/* WCAG 2.2 AA (4.5:1 for normal text): Form labels. DaisyUI .label uses 60% opacity, + which fails contrast. Override to 85% of base-content so labels stay slightly + de‑emphasised vs body text but meet the minimum ratio. Match .label directly + so the override applies even when data-theme is not yet set (e.g. initial load). */ +.label { + color: color-mix(in oklab, var(--color-base-content) 85%, transparent); +} + /* WCAG 2.2 AA (4.5:1 for normal text): Badge text must contrast with badge background. Theme tokens *-content are often too light on * backgrounds in light theme, and badge-soft uses variant as text on a light tint (low contrast). We override diff --git a/assets/js/app.js b/assets/js/app.js index ee423eb..4c7e3c5 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -25,6 +25,14 @@ import Sortable from "../vendor/sortable" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +function getBrowserTimezone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || null + } catch (_e) { + return null + } +} + // Hooks for LiveView components let Hooks = {} @@ -105,6 +113,25 @@ Hooks.FocusRestore = { } } +// FlashAutoDismiss: after a delay, clear the flash so the toast hides without user clicking X (e.g. success toasts) +Hooks.FlashAutoDismiss = { + mounted() { + const ms = this.el.dataset.autoClearMs + if (!ms) return + const delay = parseInt(ms, 10) + if (delay > 0) { + this.timer = setTimeout(() => { + const key = this.el.dataset.clearFlashKey || "success" + this.pushEvent("lv:clear-flash", {key}) + }, delay) + } + }, + + destroyed() { + if (this.timer) clearTimeout(this.timer) + } +} + // TabListKeydown hook: WCAG tab pattern — prevent default for ArrowLeft/ArrowRight so the server can handle tab switch (roving tabindex) Hooks.TabListKeydown = { mounted() { @@ -312,7 +339,10 @@ Hooks.SidebarState = { let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken}, + params: { + _csrf_token: csrfToken, + timezone: getBrowserTimezone() + }, hooks: Hooks }) diff --git a/config/config.exs b/config/config.exs index 35e4160..7bb4f61 100644 --- a/config/config.exs +++ b/config/config.exs @@ -46,6 +46,9 @@ 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], @@ -104,6 +107,9 @@ config :mv, :mail_from, {"Mila", "noreply@example.com"} # Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP. config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10 +# Join emails: notifier implementation (domain → web abstraction). Override in test to inject a mock. +config :mv, :join_notifier, MvWeb.JoinNotifierImpl + # Configure esbuild (the version is required) config :esbuild, version: "0.17.11", diff --git a/config/runtime.exs b/config/runtime.exs index 1c55f64..6a434fa 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -226,11 +226,7 @@ if config_env() == :prod do # 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.Config.smtp_*() helpers. - # - # TLS/SSL options (tls_options, sockopts) are duplicated here and in Mv.Mailer.smtp_config/0 - # because boot config must be set in this file; the Mailer uses the same logic for - # Settings-only config. Keep verify behaviour in sync (see SMTP_VERIFY_PEER below). + # 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 @@ -264,20 +260,14 @@ if config_env() == :prod do verify_mode = if smtp_verify_peer, do: :verify_peer, else: :verify_none smtp_opts = - [ - adapter: Swoosh.Adapters.SMTP, - relay: String.trim(smtp_host_env), + 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: smtp_ssl_mode == "ssl", - tls: if(smtp_ssl_mode == "tls", do: :always, else: :never), - auth: :always, - # tls_options: STARTTLS (587); sockopts: direct SSL (465). - tls_options: [verify: verify_mode], - sockopts: [verify: verify_mode] - ] - |> Enum.reject(fn {_k, v} -> is_nil(v) end) + ssl_mode: smtp_ssl_mode, + verify_mode: verify_mode + ) config :mv, Mv.Mailer, smtp_opts end diff --git a/config/test.exs b/config/test.exs index 84ccd70..ef54982 100644 --- a/config/test.exs +++ b/config/test.exs @@ -58,3 +58,7 @@ config :mv, :sql_sandbox, true # Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute) config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2 + +# Ash: silence "after_transaction hooks in surrounding transaction" warning when using +# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected). +config :ash, warn_on_transaction_hooks?: false diff --git a/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md index 5e26c85..5413f91 100644 --- a/docs/admin-bootstrap-and-oidc-role-sync.md +++ b/docs/admin-bootstrap-and-oidc-role-sync.md @@ -2,7 +2,7 @@ ## Overview -- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (bootstrap seeds; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`. +- **Admin bootstrap:** In production, the Docker entrypoint runs migrate, then `Mv.Release.run_seeds/0` (skips if admin user already exists unless `FORCE_SEEDS=true`; set `RUN_DEV_SEEDS=true` to also run dev seeds), then `seed_admin/0` from ENV, then the server. Password can be changed without redeploy via `bin/mv eval "Mv.Release.seed_admin()"`. - **OIDC role sync:** Optional mapping from OIDC groups (e.g. from Authentik profile scope) to the Admin role. Users in the configured admin group get the Admin role on registration and on each sign-in. ## Admin Bootstrap (Part A) @@ -10,13 +10,14 @@ ### Environment Variables - `RUN_DEV_SEEDS` – If set to `"true"`, `run_seeds/0` also runs dev seeds (members, groups, sample data). Otherwise only bootstrap seeds run. +- `FORCE_SEEDS` – If set to `"true"`, seeds are run even when the admin user already exists (e.g. after changing bootstrap data such as roles or custom fields). Otherwise seeds are skipped when bootstrap was already applied. - `ADMIN_EMAIL` – Email of the admin user to create/update. If unset, seed_admin/0 does nothing. - `ADMIN_PASSWORD` – Password for the admin user. If unset (and no file), no new user is created; if a user with ADMIN_EMAIL already exists (e.g. OIDC-only), their role is set to Admin (no password change). - `ADMIN_PASSWORD_FILE` – Path to a file containing the password (e.g. Docker secret). ### Release Tasks -- `Mv.Release.run_seeds/0` – Runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Idempotent. +- `Mv.Release.run_seeds/0` – If the admin user already exists (bootstrap already applied), skips unless `FORCE_SEEDS=true`; otherwise runs bootstrap seeds (fee types, custom fields, roles, settings). If `RUN_DEV_SEEDS` env is `"true"`, also runs dev seeds (members, groups, sample data). Safe to call on every start. - `Mv.Release.seed_admin/0` – Reads ADMIN_EMAIL and password from ADMIN_PASSWORD or ADMIN_PASSWORD_FILE. If both email and password are set: creates or updates the user with the Admin role. If only ADMIN_EMAIL is set: sets the Admin role on an existing user with that email (for OIDC-only admins); does not create a user. Idempotent. ### Entrypoint @@ -38,6 +39,7 @@ ### Sign-in page (OIDC-only mode) - `OIDC_ONLY` (or Settings → OIDC → "Only OIDC sign-in") – When set to true/1/yes and OIDC is configured, the sign-in page shows only the Single Sign-On button (password login is hidden). ENV takes precedence over Settings. +- **Redirect loop fix:** After an OIDC failure (e.g. provider down), the app redirects to `/sign-in?oidc_failed=1`. The plug `OidcOnlySignInRedirect` does not redirect that request back to OIDC, so the sign-in page is shown with the error (no endless redirect). ### Sync Logic diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index a6297ba..6d8e523 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -806,7 +806,7 @@ end - **Senders migrated:** `SendNewUserConfirmationEmail`, `SendPasswordResetEmail` use layout + `Mv.Mailer.mail_from/0`. - **Cleanup:** Mix task `mix join_requests.cleanup_expired` hard-deletes JoinRequests in `pending_confirmation` with expired `confirmation_token_expires_at` (authorize?: false). For cron/Oban. - **Gettext:** New email strings in default domain; German translations in de/LC_MESSAGES/default.po; English msgstr filled for email-related strings. -- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/1` and returns `{:ok, email}` \| `{:error, reason}`; domain logs delivery errors but still returns `{:ok, request}` so the user sees success. Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders. +- **PR review follow-ups (Join confirmation):** Join confirmation email uses `Mailer.deliver/2` with `Mailer.smtp_config/0` (same config as test mail). On delivery failure the domain returns `{:error, :email_delivery_failed}` (logged via `Logger.error`), and the JoinLive shows an error message (no success UI). Comment in `submit_join_request/2` clarifies that the raw token is hashed by `JoinRequest.Changes.SetConfirmationToken`. Cleanup task uses `Ash.bulk_destroy` and logs partial errors without halting. Layout uses assigns `app_name` and `locale` (from config/Gettext) instead of hardcoded "Mila" and `lang="de"`. Production `runtime.exs` sets `:mail_from` from ENV (`MAIL_FROM_NAME`, `MAIL_FROM_EMAIL`). Layout reference unified to `"layout.html"`; redundant `put_layout` removed from senders. - Tests: `join_request_test.exs`, `join_request_submit_email_test.exs`, `join_confirm_controller_test.exs` – all pass. **Subtask 3 – Admin: Join form settings (done):** diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 03f1cce..2ec15a5 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -36,10 +36,10 @@ **Closed Issues:** - ✅ [#171](https://git.local-it.org/local-it/mitgliederverwaltung/issues/171) - OIDC handling and linking (closed 2025-11-13) +- ✅ [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen — fixed via `MvWeb.AuthOverridesDE` locale-specific module (2026-03-13) +- ✅ [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen — fixed locale selector bug with `Gettext.get_locale(MvWeb.Gettext)` (2026-03-13) -**Open Issues:** -- [#146](https://git.local-it.org/local-it/mitgliederverwaltung/issues/146) - Translate "or" in the login screen (Low) -- [#144](https://git.local-it.org/local-it/mitgliederverwaltung/issues/144) - Add language switch dropdown to login screen (Low) +**Open Issues:** (none remaining for Authentication UI) **Current State:** - ✅ **Role-based access control (RBAC)** - Implemented (2026-01-08, PR #346, closes #345) @@ -49,6 +49,11 @@ - ✅ **Page-level authorization** - LiveView page access control - ✅ **System role protection** - Critical roles cannot be deleted +**Planned: OIDC-only mode (TDD, tests first):** +- Admin Settings: When OIDC-only is enabled, disable "Allow direct registration" toggle and show hint (tests in `GlobalSettingsLiveTest`). +- Backend: Reject password sign-in and `register_with_password` when OIDC-only (tests in `AuthControllerTest`, `Accounts`). +- GET `/sign-in` redirect to OIDC when OIDC-only and OIDC configured (tests in `AuthControllerTest`). Implementation to follow after tests. + **Missing Features:** - ❌ Password reset flow - ❌ Email verification diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 8083a7b..8e6c615 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -93,6 +93,7 @@ - **Placement:** Own section **"Onboarding / Join"** in global settings, **above** "Custom fields", **below** "Vereinsdaten" (club data). - **Join form enabled:** Checkbox (e.g. `join_form_enabled`). When set, the public `/join` page is active and the following config applies. +- **Copyable join link:** When the join form is enabled, a copyable full URL to the `/join` page is shown below the checkbox (above the field list), with a short hint so admins can share it with applicants. - **Field selection:** From **all existing** member fields (from `Mv.Constants.member_fields()`) and **custom fields**, the admin selects which fields appear on the join form. Stored as a list/set of field identifiers (no separate table); display in settings as a simple list, e.g. **badges with X to remove** (similar to the groups overview). Adding fields: e.g. dropdown or modal to pick from remaining fields. Detailed UX for this subsection is to be specified in a **separate subtask**. - **Technically required fields:** The only field that must always be required for the join flow is **email**. All other fields can be optional or marked as required per admin choice; implementation should support a "required" flag per selected join-form field. - **Other:** Which entry paths are enabled, approval workflow (who can approve) – to be detailed in Step 2 and later specs. @@ -115,7 +116,7 @@ Implementation spec for Subtask 5. #### Route and pages - **List:** **`/join_requests`** – list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit. -- **Detail:** **`/join_requests/:id`** – single join request with all data (typed fields + `form_data`), actions Approve / Reject. +- **Detail:** **`/join_requests/:id`** – single join request. **Two blocks:** (1) **Applicant data** – all form fields (typed + `form_data`) merged and shown in join-form order; (2) **Status and review** – submitted_at, status, and when decided: approved_at/rejected_at, reviewed by. Actions Approve / Reject when status is `submitted`. #### Backend (JoinRequest) @@ -195,7 +196,7 @@ Implementation spec for Subtask 5. - **Pre-confirmation store:** **DB only.** Same JoinRequest resource; no ETS, no stateless token. Confirmation token stored as **hash** in DB; raw token only in email link. **24h** retention for `pending_confirmation`; **hard-delete** of expired records via scheduled job (e.g. Oban cron). - **Confirmation route:** **`/confirm_join/:token`** so existing `starts_with?(path, "/confirm")` covers it. - **Public path for `/join`:** **Add `/join` explicitly** to the page-permission plug’s `public_path?/1` (e.g. in `CheckPagePermission`) so unauthenticated users can reach the join page. -- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`). +- **JoinRequest schema:** Status `pending_confirmation` | `submitted` | `approved` | `rejected`. Typed: **email** (required), **first_name**, **last_name** (optional). **form_data** (jsonb) + **schema_version** for remaining form fields. **confirmation_token_hash**, **confirmation_token_expires_at**; **submitted_at**, **approved_at**, **rejected_at**, **reviewed_by_user_id**, **reviewed_by_display** (denormalized reviewer email for "Geprüft von" without loading User) for audit. Idempotent confirm (unique constraint on token hash or update only when status is `pending_confirmation`). - **Approval outcome:** Admin-configurable. Default: approval creates Member only (no User). Optional "create User on approval" is **left for later**. - **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug). - **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**. diff --git a/docs/settings-authentication-mockup.txt b/docs/settings-authentication-mockup.txt new file mode 100644 index 0000000..00f64c4 --- /dev/null +++ b/docs/settings-authentication-mockup.txt @@ -0,0 +1,44 @@ +# Settings page – Authentication section (ASCII mockup) + +Structure after renaming "OIDC" to "Authentication" and adding the registration toggle. +Subsections use their own headings (h3) inside the main "Authentication" form_section. + ++------------------------------------------------------------------+ +| Settings | +| Manage global settings for the association. | ++------------------------------------------------------------------+ + ++-- Club Settings -------------------------------------------------+ +| Association Name: [________________] [Save Name] | ++------------------------------------------------------------------+ + ++-- Join Form -----------------------------------------------------+ +| ... (unchanged) | ++------------------------------------------------------------------+ + ++-- SMTP / E-Mail -------------------------------------------------+ +| ... | ++------------------------------------------------------------------+ + ++-- Accounting-Software (Vereinfacht) Integration -----------------+ +| ... | ++------------------------------------------------------------------+ + ++-- Authentication ------------------------------------------------+ <-- main section (renamed from "OIDC (Single Sign-On)") +| | +| Direct registration | <-- subsection heading (h3) +| [x] Allow direct registration (/register) | +| If disabled, users cannot sign up via /register; sign-in | +| and the join form remain available. | +| | +| OIDC (Single Sign-On) | <-- subsection heading (h3) +| (Some values are set via environment variables...) | +| Client ID: [________________] | +| Base URL: [________________] | +| Redirect URI: [________________] | +| Client Secret: [________________] (set) | +| Admin group name: [________________] | +| Groups claim: [________________] | +| [ ] Only OIDC sign-in (hide password login) | +| [Save OIDC Settings] | ++------------------------------------------------------------------+ diff --git a/docs/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md index 30fd7de..13b0d17 100644 --- a/docs/smtp-configuration-concept.md +++ b/docs/smtp-configuration-concept.md @@ -44,6 +44,8 @@ When an ENV variable is set, the corresponding Settings field is read-only in th **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 @@ -82,13 +84,19 @@ Provided by `Mv.Config.mail_from_name/0` and `Mv.Config.mail_from_email/0`. --- -## 9. AshAuthentication Senders +## 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`. --- -## 10. TLS / SSL in OTP 27 +## 11. TLS / SSL in OTP 27 OTP 26+ enforces `verify_peer` by default, which fails for self-signed or internal SMTP server certificates. @@ -97,11 +105,11 @@ By default, TLS certificate verification is relaxed (`verify_none`) so self-sign - **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. -Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) use the same verify mode. The logic is duplicated in `config/runtime.exs` (boot) and `Mv.Mailer.smtp_config/0` (Settings-only); keep in sync. +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). --- -## 11. Summary Checklist +## 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. @@ -109,16 +117,17 @@ Both `tls_options` (STARTTLS, port 587) and `sockopts` (direct SSL, port 465) us - [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 + sockopts). +- [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. --- -## 12. Follow-up / Future Work +## 13. Follow-up / Future Work - **SMTP password at-rest encryption:** The `smtp_password` attribute is currently stored in plaintext in the `settings` table. It is excluded from default reads (same pattern as `oidc_client_secret`); both are read only via explicit select when needed. For production systems at-rest encryption (e.g. with [Cloak](https://hexdocs.pm/cloak)) should be considered and tracked as a follow-up issue. - **Error classification:** SMTP error categorization currently uses substring matching on server messages (e.g. "535", "authentication"). A more robust approach would be to pattern-match on `gen_smtp` error tuples first where possible, and fall back to string analysis only when needed. Server wording varies; consider extending patterns as new providers are used. diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index 6b9cd1e..0127796 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -362,6 +362,12 @@ defmodule Mv.Accounts.User do # Authorization Policies # Order matters: Most specific policies first, then general permission check policies do + # When OIDC-only is active, password sign-in is forbidden (SSO only). + policy action(:sign_in_with_password) do + forbid_if Mv.Authorization.Checks.OidcOnlyActive + authorize_if always() + end + # AshAuthentication bypass (registration/login without actor) bypass AshAuthentication.Checks.AshAuthenticationInteraction do description "Allow AshAuthentication internal operations (registration, login)" @@ -405,6 +411,14 @@ defmodule Mv.Accounts.User do where: [action_is([:register_with_password, :admin_set_password])], message: "must have length of at least 8" + # Block direct registration when disabled in global settings + validate {Mv.Accounts.User.Validations.RegistrationEnabled, []}, + where: [action_is(:register_with_password)] + + # Block password registration when OIDC-only mode is active + validate {Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration, []}, + where: [action_is(:register_with_password)] + # Email uniqueness check for all actions that change the email attribute # Validates that user email is not already used by another (unlinked) member validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember diff --git a/lib/accounts/user/validations/oidc_only_blocks_password_registration.ex b/lib/accounts/user/validations/oidc_only_blocks_password_registration.ex new file mode 100644 index 0000000..e4d9a35 --- /dev/null +++ b/lib/accounts/user/validations/oidc_only_blocks_password_registration.ex @@ -0,0 +1,27 @@ +defmodule Mv.Accounts.User.Validations.OidcOnlyBlocksPasswordRegistration do + @moduledoc """ + Validation that blocks direct registration (register_with_password) when + OIDC-only mode is active. In OIDC-only mode, sign-in and registration are + only allowed via OIDC (SSO). + """ + use Ash.Resource.Validation + + @impl true + def init(opts), do: {:ok, opts} + + @impl true + def validate(_changeset, _opts, _context) do + if Mv.Config.oidc_only?() do + {:error, + field: :base, + message: + Gettext.dgettext( + MvWeb.Gettext, + "default", + "Registration with password is disabled when only OIDC sign-in is active." + )} + else + :ok + end + end +end diff --git a/lib/accounts/user/validations/registration_enabled.ex b/lib/accounts/user/validations/registration_enabled.ex new file mode 100644 index 0000000..f2342b7 --- /dev/null +++ b/lib/accounts/user/validations/registration_enabled.ex @@ -0,0 +1,31 @@ +defmodule Mv.Accounts.User.Validations.RegistrationEnabled do + @moduledoc """ + Validation that blocks direct registration (register_with_password) when + registration is disabled in global settings. Used so that even direct API/form + submissions cannot register when the setting is off. + """ + use Ash.Resource.Validation + + alias Mv.Membership + + @impl true + def init(opts), do: {:ok, opts} + + @impl true + def validate(_changeset, _opts, _context) do + case Membership.get_settings() do + {:ok, %{registration_enabled: true}} -> + :ok + + _ -> + {:error, + field: :base, + message: + Gettext.dgettext( + MvWeb.Gettext, + "default", + "Registration is disabled. Please use the join form or contact an administrator." + )} + end + end +end diff --git a/lib/membership/join_notifier.ex b/lib/membership/join_notifier.ex new file mode 100644 index 0000000..daec4c1 --- /dev/null +++ b/lib/membership/join_notifier.ex @@ -0,0 +1,13 @@ +defmodule Mv.Membership.JoinNotifier do + @moduledoc """ + Behaviour for sending join-related emails (confirmation, already member, already pending). + + The domain calls this module instead of MvWeb.Emails directly, so the domain layer + does not depend on the web layer. The default implementation is set in config + (`config :mv, :join_notifier, MvWeb.JoinNotifierImpl`). Tests can override with a mock. + """ + @callback send_confirmation(email :: String.t(), token :: String.t(), opts :: keyword()) :: + {:ok, term()} | {:error, term()} + @callback send_already_member(email :: String.t()) :: {:ok, term()} | {:error, term()} + @callback send_already_pending(email :: String.t()) :: {:ok, term()} | {:error, term()} +end diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex index 05a9e8d..94907e2 100644 --- a/lib/membership/join_request.ex +++ b/lib/membership/join_request.ex @@ -77,6 +77,17 @@ defmodule Mv.Membership.JoinRequest do change Mv.Membership.JoinRequest.Changes.RejectRequest end + + # Internal: resend confirmation (new token) when user submits form again with same email. + # Called from domain with authorize?: false; not exposed to public. + update :regenerate_confirmation_token do + description "Set new confirmation token and expiry (resend flow)" + require_atomic? false + + argument :confirmation_token, :string, allow_nil?: false + + change Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken + end end policies do @@ -175,6 +186,11 @@ defmodule Mv.Membership.JoinRequest do attribute :approved_at, :utc_datetime_usec attribute :rejected_at, :utc_datetime_usec attribute :reviewed_by_user_id, :uuid + + attribute :reviewed_by_display, :string do + description "Denormalized reviewer display (e.g. email) for UI without loading User" + end + attribute :source, :string create_timestamp :inserted_at diff --git a/lib/membership/join_request/changes/approve_request.ex b/lib/membership/join_request/changes/approve_request.ex index 24716f6..b86ca5d 100644 --- a/lib/membership/join_request/changes/approve_request.ex +++ b/lib/membership/join_request/changes/approve_request.ex @@ -16,11 +16,13 @@ defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do if current_status == :submitted do reviewed_by_id = Helpers.actor_id(context.actor) + reviewed_by_display = Helpers.actor_email(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :approved) |> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now()) |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) + |> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display) else Ash.Changeset.add_error(changeset, field: :status, diff --git a/lib/membership/join_request/changes/helpers.ex b/lib/membership/join_request/changes/helpers.ex index ee09b75..9bb0697 100644 --- a/lib/membership/join_request/changes/helpers.ex +++ b/lib/membership/join_request/changes/helpers.ex @@ -16,4 +16,24 @@ defmodule Mv.Membership.JoinRequest.Changes.Helpers do end def actor_id(_), do: nil + + @doc """ + Extracts the actor's email for display (e.g. reviewed_by_display). + + Supports both atom and string keys for compatibility with different actor representations. + """ + @spec actor_email(term()) :: String.t() | nil + def actor_email(nil), do: nil + + def actor_email(actor) when is_map(actor) do + raw = Map.get(actor, :email) || Map.get(actor, "email") + if is_nil(raw), do: nil, else: actor_email_string(raw) + end + + def actor_email(_), do: nil + + defp actor_email_string(raw) do + s = raw |> to_string() |> String.trim() + if s == "", do: nil, else: s + end end diff --git a/lib/membership/join_request/changes/regenerate_confirmation_token.ex b/lib/membership/join_request/changes/regenerate_confirmation_token.ex new file mode 100644 index 0000000..c8055d2 --- /dev/null +++ b/lib/membership/join_request/changes/regenerate_confirmation_token.ex @@ -0,0 +1,33 @@ +defmodule Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken do + @moduledoc """ + Sets a new confirmation token hash and expiry on an existing join request (resend flow). + + Used when the user submits the join form again with the same email while a request + is still pending_confirmation. Internal use only (domain calls with authorize?: false). + """ + use Ash.Resource.Change + + alias Mv.Membership.JoinRequest + + @confirmation_validity_hours 24 + + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() + def change(changeset, _opts, _context) do + token = Ash.Changeset.get_argument(changeset, :confirmation_token) + + if is_binary(token) and token != "" do + now = DateTime.utc_now() + expires_at = DateTime.add(now, @confirmation_validity_hours, :hour) + + changeset + |> Ash.Changeset.force_change_attribute( + :confirmation_token_hash, + JoinRequest.hash_confirmation_token(token) + ) + |> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at) + |> Ash.Changeset.force_change_attribute(:confirmation_sent_at, now) + else + changeset + end + end +end diff --git a/lib/membership/join_request/changes/reject_request.ex b/lib/membership/join_request/changes/reject_request.ex index 2c33a77..1b9fe1a 100644 --- a/lib/membership/join_request/changes/reject_request.ex +++ b/lib/membership/join_request/changes/reject_request.ex @@ -15,11 +15,13 @@ defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do if current_status == :submitted do reviewed_by_id = Helpers.actor_id(context.actor) + reviewed_by_display = Helpers.actor_email(context.actor) changeset |> Ash.Changeset.force_change_attribute(:status, :rejected) |> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now()) |> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id) + |> Ash.Changeset.force_change_attribute(:reviewed_by_display, reviewed_by_display) else Ash.Changeset.add_error(changeset, field: :status, diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 2f18f90..7fa35dc 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -29,8 +29,10 @@ defmodule Mv.Membership do require Ash.Query import Ash.Expr alias Ash.Error.Query.NotFound, as: NotFoundError + alias Mv.Helpers.SystemActor alias Mv.Membership.JoinRequest - alias MvWeb.Emails.JoinConfirmationEmail + alias Mv.Membership.Member + alias Mv.Membership.SettingsCache require Logger admin do @@ -114,10 +116,16 @@ defmodule Mv.Membership do """ def get_settings do - # Try to get the first (and only) settings record + case Process.whereis(SettingsCache) do + nil -> get_settings_uncached() + _pid -> SettingsCache.get() + end + end + + @doc false + def get_settings_uncached do case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do {:ok, nil} -> - # No settings exist - create as fallback (should normally be created via seed script) default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name" Mv.Membership.Setting @@ -158,9 +166,16 @@ defmodule Mv.Membership do """ def update_settings(settings, attrs) do - settings - |> Ash.Changeset.for_update(:update, attrs) - |> Ash.update(domain: __MODULE__) + case settings + |> Ash.Changeset.for_update(:update, attrs) + |> Ash.update(domain: __MODULE__) do + {:ok, _updated} = result -> + SettingsCache.invalidate() + result + + error -> + error + end end @doc """ @@ -224,11 +239,18 @@ defmodule Mv.Membership do """ def update_member_field_visibility(settings, visibility_config) do - settings - |> Ash.Changeset.for_update(:update_member_field_visibility, %{ - member_field_visibility: visibility_config - }) - |> Ash.update(domain: __MODULE__) + case settings + |> Ash.Changeset.for_update(:update_member_field_visibility, %{ + member_field_visibility: visibility_config + }) + |> Ash.update(domain: __MODULE__) do + {:ok, _} = result -> + SettingsCache.invalidate() + result + + error -> + error + end end @doc """ @@ -261,12 +283,19 @@ defmodule Mv.Membership do field: field, show_in_overview: show_in_overview ) do - settings - |> Ash.Changeset.new() - |> Ash.Changeset.set_argument(:field, field) - |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) - |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{}) - |> Ash.update(domain: __MODULE__) + case settings + |> Ash.Changeset.new() + |> Ash.Changeset.set_argument(:field, field) + |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) + |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{}) + |> Ash.update(domain: __MODULE__) do + {:ok, _} = result -> + SettingsCache.invalidate() + result + + error -> + error + end end @doc """ @@ -300,13 +329,20 @@ defmodule Mv.Membership do show_in_overview: show_in_overview, required: required ) do - settings - |> Ash.Changeset.new() - |> Ash.Changeset.set_argument(:field, field) - |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) - |> Ash.Changeset.set_argument(:required, required) - |> Ash.Changeset.for_update(:update_single_member_field, %{}) - |> Ash.update(domain: __MODULE__) + case settings + |> Ash.Changeset.new() + |> Ash.Changeset.set_argument(:field, field) + |> Ash.Changeset.set_argument(:show_in_overview, show_in_overview) + |> Ash.Changeset.set_argument(:required, required) + |> Ash.Changeset.for_update(:update_single_member_field, %{}) + |> Ash.update(domain: __MODULE__) do + {:ok, _} = result -> + SettingsCache.invalidate() + result + + error -> + error + end end @doc """ @@ -364,15 +400,131 @@ defmodule Mv.Membership do - `:actor` - Must be nil for public submit (policy allows only unauthenticated). ## Returns - - `{:ok, request}` - Created JoinRequest in status pending_confirmation + - `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent + - `{:ok, :notified_already_member}` - Email already a member; notice sent by email only (no request created) + - `{:ok, :notified_already_pending}` - Email already has pending/submitted request; notice or resend sent by email only + - `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged) - `{:error, error}` - Validation or authorization error """ def submit_join_request(attrs, opts \\ []) do actor = Keyword.get(opts, :actor) - token = Map.get(attrs, :confirmation_token) || generate_confirmation_token() + email = normalize_submit_email(attrs) - # Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken - # hashes it before persist. Only the hash is stored; the raw token is sent in the email link. + pending = + if email != nil and email != "", do: pending_join_request_with_email(email), else: nil + + cond do + email != nil and email != "" and member_exists_with_email?(email) -> + send_already_member_and_return(email) + + pending != nil -> + handle_already_pending(email, pending) + + true -> + do_create_join_request(attrs, actor) + end + end + + defp normalize_submit_email(attrs) do + raw = attrs["email"] || attrs[:email] + if is_binary(raw), do: String.trim(raw), else: nil + end + + defp member_exists_with_email?(email) when is_binary(email) do + system_actor = SystemActor.get_system_actor() + opts = [actor: system_actor, domain: __MODULE__] + + case Ash.get(Member, %{email: email}, opts) do + {:ok, _member} -> true + _ -> false + end + end + + defp member_exists_with_email?(_), do: false + + defp pending_join_request_with_email(email) when is_binary(email) do + system_actor = SystemActor.get_system_actor() + + query = + JoinRequest + |> Ash.Query.filter(expr(email == ^email and status in [:pending_confirmation, :submitted])) + |> Ash.Query.sort(inserted_at: :desc) + |> Ash.Query.limit(1) + + case Ash.read_one(query, actor: system_actor, domain: __MODULE__) do + {:ok, request} -> request + _ -> nil + end + end + + defp pending_join_request_with_email(_), do: nil + + defp join_notifier do + Application.get_env(:mv, :join_notifier, MvWeb.JoinNotifierImpl) + end + + defp send_already_member_and_return(email) do + case join_notifier().send_already_member(email) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("Join already-member email failed for #{email}: #{inspect(reason)}") + end + + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. + {:ok, :notified_already_member} + end + + defp handle_already_pending(email, existing) do + if existing.status == :pending_confirmation do + resend_confirmation_to_pending(email, existing) + else + send_already_pending_and_return(email) + end + end + + defp resend_confirmation_to_pending(email, request) do + new_token = generate_confirmation_token() + + case request + |> Ash.Changeset.for_update(:regenerate_confirmation_token, %{ + confirmation_token: new_token + }) + |> Ash.update(domain: __MODULE__, authorize?: false) do + {:ok, _updated} -> + case join_notifier().send_confirmation(email, new_token, resend: true) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("Join resend confirmation email failed for #{email}: #{inspect(reason)}") + end + + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. + {:ok, :notified_already_pending} + + {:error, _} -> + # Fallback: do not create duplicate; send generic pending email + send_already_pending_and_return(email) + end + end + + defp send_already_pending_and_return(email) do + case join_notifier().send_already_pending(email) do + {:ok, _} -> + :ok + + {:error, reason} -> + Logger.error("Join already-pending email failed for #{email}: #{inspect(reason)}") + end + + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. + {:ok, :notified_already_pending} + end + + defp do_create_join_request(attrs, actor) do + token = Map.get(attrs, :confirmation_token) || generate_confirmation_token() attrs_with_token = Map.put(attrs, :confirmation_token, token) case Ash.create(JoinRequest, attrs_with_token, @@ -381,8 +533,9 @@ defmodule Mv.Membership do domain: __MODULE__ ) do {:ok, request} -> - case JoinConfirmationEmail.send(request.email, token) do + case join_notifier().send_confirmation(request.email, token, []) do {:ok, _email} -> + # Delay is applied by the caller (e.g. JoinLive) to avoid blocking the process. {:ok, request} {:error, reason} -> @@ -390,8 +543,7 @@ defmodule Mv.Membership do "Join confirmation email failed for #{request.email}: #{inspect(reason)}" ) - # Request was created; return success so the user sees the confirmation message - {:ok, request} + {:error, :email_delivery_failed} end error -> diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index ce63589..83c5c8b 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -15,6 +15,7 @@ defmodule Mv.Membership.Setting do (e.g., `%{"first_name" => true, "last_name" => true}`). Email is always required; other fields default to optional. - `include_joining_cycle` - Whether to include the joining cycle in membership fee generation (default: true) - `default_membership_fee_type_id` - Default membership fee type for new members (optional) + - `registration_enabled` - Whether direct registration via /register is allowed (default: true) - `join_form_enabled` - Whether the public /join page is active (default: false) - `join_form_field_ids` - Ordered list of field IDs shown on the join form. Each entry is either a member field name string (e.g. "email") or a custom field UUID. Email is always @@ -129,6 +130,7 @@ defmodule Mv.Membership.Setting do :smtp_ssl, :smtp_from_name, :smtp_from_email, + :registration_enabled, :join_form_enabled, :join_form_field_ids, :join_form_field_required @@ -165,6 +167,7 @@ defmodule Mv.Membership.Setting do :smtp_ssl, :smtp_from_name, :smtp_from_email, + :registration_enabled, :join_form_enabled, :join_form_field_ids, :join_form_field_required @@ -514,6 +517,15 @@ defmodule Mv.Membership.Setting do description "Email address for the transactional email sender. Must be owned by the SMTP user. Overrides MAIL_FROM_EMAIL env." end + # Authentication: direct registration toggle + attribute :registration_enabled, :boolean do + allow_nil? false + default true + public? true + + description "When true, users can register via /register; when false, only sign-in and join form remain available." + end + # Join form (Beitrittsformular) settings attribute :join_form_enabled, :boolean do allow_nil? false diff --git a/lib/membership/settings_cache.ex b/lib/membership/settings_cache.ex new file mode 100644 index 0000000..d8581d6 --- /dev/null +++ b/lib/membership/settings_cache.ex @@ -0,0 +1,85 @@ +defmodule Mv.Membership.SettingsCache do + @moduledoc """ + Process-based cache for global settings to avoid repeated DB reads on hot paths + (e.g. RegistrationEnabled validation, Layouts.public_page, Plugs). + + Uses a short TTL (default 60 seconds). Cache is invalidated on every settings + update so that changes take effect quickly. If no settings process exists + (e.g. in tests), get/1 falls back to direct read. + """ + use GenServer + + @default_ttl_seconds 60 + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Returns cached settings or fetches and caches them. Uses TTL; invalidate on update. + """ + def get do + case Process.whereis(__MODULE__) do + nil -> + # No cache process (e.g. test) – read directly + do_fetch() + + _pid -> + GenServer.call(__MODULE__, :get, 10_000) + end + end + + @doc """ + Invalidates the cache so the next get/0 will refetch from the database. + Call after update_settings and any other path that mutates settings. + """ + def invalidate do + case Process.whereis(__MODULE__) do + nil -> :ok + _pid -> GenServer.cast(__MODULE__, :invalidate) + end + end + + @impl true + def init(opts) do + ttl = Keyword.get(opts, :ttl_seconds, @default_ttl_seconds) + state = %{ttl_seconds: ttl, cached: nil, expires_at: nil} + {:ok, state} + end + + @impl true + def handle_call(:get, _from, state) do + now = System.monotonic_time(:second) + expired? = state.expires_at == nil or state.expires_at <= now + + {result, new_state} = + if expired? do + fetch_and_cache(now, state) + else + {{:ok, state.cached}, state} + end + + {:reply, result, new_state} + end + + defp fetch_and_cache(now, state) do + case do_fetch() do + {:ok, settings} = ok -> + expires = now + state.ttl_seconds + {ok, %{state | cached: settings, expires_at: expires}} + + err -> + result = if state.cached, do: {:ok, state.cached}, else: err + {result, state} + end + end + + @impl true + def handle_cast(:invalidate, state) do + {:noreply, %{state | cached: nil, expires_at: nil}} + end + + defp do_fetch do + Mv.Membership.get_settings_uncached() + end +end diff --git a/lib/mv/application.ex b/lib/mv/application.ex index 6b4a10b..1b6014e 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -6,6 +6,7 @@ defmodule Mv.Application do use Application alias Mv.Helpers.SystemActor + alias Mv.Membership.SettingsCache alias Mv.Repo alias Mv.Vereinfacht.SyncFlash alias MvWeb.Endpoint @@ -16,20 +17,28 @@ defmodule Mv.Application do def start(_type, _args) do SyncFlash.create_table!() - children = [ - Telemetry, - Repo, - {JoinRateLimit, [clean_period: :timer.minutes(1)]}, - {Task.Supervisor, name: Mv.TaskSupervisor}, - {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, - {Phoenix.PubSub, name: Mv.PubSub}, - {AshAuthentication.Supervisor, otp_app: :my}, - SystemActor, - # Start a worker by calling: Mv.Worker.start_link(arg) - # {Mv.Worker, arg}, - # Start to serve requests, typically the last entry - Endpoint - ] + # SettingsCache not started in test so get_settings runs in the test process (Ecto Sandbox). + cache_children = + if Application.get_env(:mv, :environment) == :test, do: [], else: [SettingsCache] + + children = + [ + Telemetry, + Repo + ] ++ + cache_children ++ + [ + {JoinRateLimit, [clean_period: :timer.minutes(1)]}, + {Task.Supervisor, name: Mv.TaskSupervisor}, + {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: Mv.PubSub}, + {AshAuthentication.Supervisor, otp_app: :my}, + SystemActor, + # Start a worker by calling: Mv.Worker.start_link(arg) + # {Mv.Worker, arg}, + # Start to serve requests, typically the last entry + Endpoint + ] # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options diff --git a/lib/mv/authorization/checks/oidc_only_active.ex b/lib/mv/authorization/checks/oidc_only_active.ex new file mode 100644 index 0000000..8d56ca1 --- /dev/null +++ b/lib/mv/authorization/checks/oidc_only_active.ex @@ -0,0 +1,16 @@ +defmodule Mv.Authorization.Checks.OidcOnlyActive do + @moduledoc """ + Policy check: true when OIDC-only mode is active (Config.oidc_only?()). + + Used to forbid password sign-in when only OIDC (SSO) sign-in is allowed. + """ + use Ash.Policy.SimpleCheck + + alias Mv.Config + + @impl true + def describe(_opts), do: "OIDC-only mode is active" + + @impl true + def match?(_actor, _context, _opts), do: Config.oidc_only?() +end diff --git a/lib/mv/mailer.ex b/lib/mv/mailer.ex index e5ac4e9..41a77cd 100644 --- a/lib/mv/mailer.ex +++ b/lib/mv/mailer.ex @@ -31,6 +31,7 @@ defmodule Mv.Mailer do 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. @@ -100,31 +101,19 @@ defmodule Mv.Mailer do @spec smtp_config() :: keyword() def smtp_config do if Mv.Config.smtp_configured?() and not boot_smtp_configured?() do - 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 = if Application.get_env(:mv, :smtp_verify_peer, false), do: :verify_peer, else: :verify_none - [ - adapter: Swoosh.Adapters.SMTP, - relay: host, - port: port, - ssl: ssl_mode == "ssl", - tls: if(ssl_mode == "tls", do: :always, else: :never), - auth: :always, - username: username, - password: password, - # tls_options: STARTTLS (587); sockopts: direct SSL (465). Verify from :smtp_verify_peer (ENV SMTP_VERIFY_PEER). - tls_options: [verify: verify_mode], - sockopts: [verify: verify_mode] - ] - |> Enum.reject(fn {_k, v} -> is_nil(v) end) + 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 diff --git a/lib/mv/release.ex b/lib/mv/release.ex index 00dcadf..116b276 100644 --- a/lib/mv/release.ex +++ b/lib/mv/release.ex @@ -6,8 +6,8 @@ defmodule Mv.Release do ## Tasks - `migrate/0` - Runs all pending Ecto migrations. - - `run_seeds/0` - Runs bootstrap seeds (fee types, custom fields, roles, settings). - In production, set `RUN_DEV_SEEDS=true` to also run dev seeds (members, groups, sample data). + - `bootstrap_seeds_applied?/0` - Returns whether bootstrap was already applied (admin user exists). Used to skip re-running seeds. + - `run_seeds/0` - If bootstrap already applied, skips; otherwise runs bootstrap seeds (fee types, custom fields, roles, settings). Set `FORCE_SEEDS=true` to re-run seeds even when already applied. In production, set `RUN_DEV_SEEDS=true` to also run dev seeds (members, groups, sample data). - `seed_admin/0` - Ensures an admin user exists from ENV (ADMIN_EMAIL, ADMIN_PASSWORD or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell to update the admin password without redeploying. @@ -19,6 +19,7 @@ defmodule Mv.Release do alias Mv.Authorization.Role require Ash.Query + require Logger def migrate do load_app() @@ -28,13 +29,37 @@ defmodule Mv.Release do end end + @doc """ + Returns whether bootstrap seeds have already been applied (admin user exists). + + We check for the admin user (from ADMIN_EMAIL or default), not the Admin role, + because migrations may create the Admin role for the system actor. Only seeds + create the admin (login) user. Used to skip re-running seeds on subsequent starts. + Call only when the application is already started. + """ + def bootstrap_seeds_applied? do + admin_email = get_env("ADMIN_EMAIL", "admin@localhost") + + case User + |> Ash.Query.filter(email == ^admin_email) + |> Ash.read_one(authorize?: false, domain: Mv.Accounts) do + {:ok, %User{}} -> true + _ -> false + end + rescue + e -> + Logger.warning("Could not check seed status (#{inspect(e)}), assuming not applied.") + false + end + @doc """ Runs seed scripts so the database has required bootstrap data (and optionally dev data). - - Always runs bootstrap seeds (fee types, custom fields, roles, system user, settings). - - If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample data). + - Skips if bootstrap was already applied (admin user exists); set `FORCE_SEEDS=true` to override and re-run. + - If `RUN_DEV_SEEDS` env is set to `"true"`, also runs dev seeds (members, groups, sample data) + when bootstrap is run. - Uses paths from the application's priv dir so it works in releases (no Mix). Idempotent. + Uses paths from the application's priv dir so it works in releases (no Mix). """ def run_seeds do case Application.ensure_all_started(@app) do @@ -42,23 +67,27 @@ defmodule Mv.Release do {:error, {app, reason}} -> raise "Failed to start #{inspect(app)}: #{inspect(reason)}" end - priv = :code.priv_dir(@app) - bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs") - dev_path = Path.join(priv, "repo/seeds_dev.exs") + if bootstrap_seeds_applied?() and System.get_env("FORCE_SEEDS") != "true" do + IO.puts("Seeds already applied. Skipping. (Set FORCE_SEEDS=true to override)") + else + priv = :code.priv_dir(@app) + bootstrap_path = Path.join(priv, "repo/seeds_bootstrap.exs") + dev_path = Path.join(priv, "repo/seeds_dev.exs") - prev = Code.compiler_options() - Code.compiler_options(ignore_module_conflict: true) + prev = Code.compiler_options() + Code.compiler_options(ignore_module_conflict: true) - try do - Code.eval_file(bootstrap_path) - IO.puts("✅ Bootstrap seeds completed.") + try do + Code.eval_file(bootstrap_path) + IO.puts("✅ Bootstrap seeds completed.") - if System.get_env("RUN_DEV_SEEDS") == "true" do - Code.eval_file(dev_path) - IO.puts("✅ Dev seeds completed.") + if System.get_env("RUN_DEV_SEEDS") == "true" do + Code.eval_file(dev_path) + IO.puts("✅ Dev seeds completed.") + end + after + Code.compiler_options(prev) end - after - Code.compiler_options(prev) end end diff --git a/lib/mv/smtp/config_builder.ex b/lib/mv/smtp/config_builder.ex new file mode 100644 index 0000000..5018dff --- /dev/null +++ b/lib/mv/smtp/config_builder.ex @@ -0,0 +1,58 @@ +defmodule Mv.Smtp.ConfigBuilder do + @moduledoc """ + Builds Swoosh/gen_smtp SMTP adapter options from connection parameters. + + Single source of truth for TLS/sockopts logic (port 587 vs 465): + - Port 587 (STARTTLS): `gen_tcp` is used first; `sockopts` must NOT contain `:verify`. + - Port 465 (implicit SSL): initial connection is `ssl:connect`; `sockopts` must contain `:verify`. + + Used by `config/runtime.exs` (boot-time ENV) and `Mv.Mailer.smtp_config/0` (Settings-only). + """ + + @doc """ + Builds the keyword list of Swoosh SMTP adapter options. + + Options (keyword list): + - `:host` (required) — relay hostname + - `:port` (required) — port number (e.g. 587 or 465) + - `:ssl_mode` (required) — `"tls"` or `"ssl"` + - `:verify_mode` (required) — `:verify_peer` or `:verify_none` + - `:username` (optional) + - `:password` (optional) + + Nil values are stripped from the result. + """ + @spec build_opts(keyword()) :: keyword() + def build_opts(opts) do + host = Keyword.fetch!(opts, :host) + port = Keyword.fetch!(opts, :port) + username = Keyword.get(opts, :username) + password = Keyword.get(opts, :password) + ssl_mode = Keyword.fetch!(opts, :ssl_mode) + verify_mode = Keyword.fetch!(opts, :verify_mode) + + base_opts = [ + adapter: Swoosh.Adapters.SMTP, + relay: host, + port: port, + username: username, + password: password, + ssl: ssl_mode == "ssl", + tls: if(ssl_mode == "tls", do: :always, else: :never), + auth: :always, + # tls_options: used for STARTTLS (587). For 465, gen_smtp uses sockopts for initial ssl:connect. + tls_options: [verify: verify_mode] + ] + + # Port 465: initial connection is ssl:connect; pass verify in sockopts. + # Port 587: initial connection is gen_tcp; sockopts must NOT contain verify (gen_tcp rejects it). + opts = + if ssl_mode == "ssl" do + Keyword.put(base_opts, :sockopts, verify: verify_mode) + else + base_opts + end + + Enum.reject(opts, fn {_k, v} -> is_nil(v) end) + end +end diff --git a/lib/mv_web/auth_overrides.ex b/lib/mv_web/auth_overrides.ex index 5cab4d2..3aab0ed 100644 --- a/lib/mv_web/auth_overrides.ex +++ b/lib/mv_web/auth_overrides.ex @@ -3,52 +3,70 @@ defmodule MvWeb.AuthOverrides do UI customizations for AshAuthentication Phoenix components. ## Overrides - - `SignIn` - Restricts form width to prevent full-width display - - `Banner` - Replaces default logo with "Mitgliederverwaltung" text - - `HorizontalRule` - Translates "or" text to German + - `SignIn` - Restricts form width and hides the library banner (title is rendered in SignInLive) + - `Banner` - Replaces default logo with text for reset/confirm pages + - `Flash` - Hides library flash (we use flash_group in root layout) ## Documentation For complete reference on available overrides, see: https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html """ use AshAuthentication.Phoenix.Overrides - use Gettext, backend: MvWeb.Gettext - # configure your UI overrides here - - # First argument to `override` is the component name you are overriding. - # The body contains any number of configurations you wish to override - # Below are some examples - - # For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html - - # override AshAuthentication.Phoenix.Components.Banner do - # set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif" - # set :text_class, "bg-red-500" - # end - - # Avoid full-width for the Sign In Form + # Avoid full-width for the Sign In Form. + # Banner is hidden because SignInLive renders its own locale-aware title. override AshAuthentication.Phoenix.Components.SignIn do set :root_class, "md:min-w-md" + set :show_banner, false end - # Replace banner logo with text (no image in light or dark so link has discernible text) + # Replace banner logo with text for reset/confirm pages (no image so link has discernible text). override AshAuthentication.Phoenix.Components.Banner do set :text, "Mitgliederverwaltung" set :image_url, nil set :dark_image_url, nil end - # Translate the "or" in the horizontal rule (between password form and SSO). - # Uses auth domain so it respects the current locale (e.g. "oder" in German). - override AshAuthentication.Phoenix.Components.HorizontalRule do - set :text, dgettext("auth", "or") - end - - # Hide AshAuthentication's Flash component since we use flash_group in root layout - # This prevents duplicate flash messages + # Hide AshAuthentication's Flash component since we use flash_group in root layout. + # This prevents duplicate flash messages. override AshAuthentication.Phoenix.Components.Flash do set :message_class_info, "hidden" set :message_class_error, "hidden" end end + +defmodule MvWeb.AuthOverridesRegistrationDisabled do + @moduledoc """ + When direct registration is disabled in global settings, this override is + prepended in SignInLive so the Password component hides the "Need an account?" + toggle (register_toggle_text: nil disables the register link per library docs). + """ + use AshAuthentication.Phoenix.Overrides + + override AshAuthentication.Phoenix.Components.Password do + set :register_toggle_text, nil + end +end + +defmodule MvWeb.AuthOverridesDE do + @moduledoc """ + German locale-specific overrides for AshAuthentication Phoenix components. + + Prepended to the overrides list in SignInLive when the locale is "de". + Provides runtime-static German text for components that do not use + the `_gettext` mechanism (e.g. HorizontalRule renders its text directly), + and for submit buttons whose disable_text bypasses the POT extraction pipeline. + """ + use AshAuthentication.Phoenix.Overrides + + # HorizontalRule renders text without `_gettext`, so we need a static German string. + override AshAuthentication.Phoenix.Components.HorizontalRule do + set :text, "oder" + end + + # Registering ... disable-text is passed through _gettext but "Registering ..." + # has no dgettext source reference, so we supply the German string directly. + override AshAuthentication.Phoenix.Components.Password.RegisterForm do + set :disable_button_text, "Registrieren..." + end +end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 11a60ef..b5bd763 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -63,6 +63,11 @@ defmodule MvWeb.CoreComponents do values: [:info, :error, :success, :warning], doc: "used for styling and flash lookup" + attr :auto_clear_ms, :integer, + default: nil, + doc: + "when set, flash is auto-dismissed after this many milliseconds (e.g. 5000 for success toasts)" + attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" slot :inner_block, doc: "the optional inner block that renders the flash message" @@ -74,6 +79,9 @@ defmodule MvWeb.CoreComponents do
hide("##{@id}")} role="alert" class="pointer-events-auto" @@ -1295,6 +1303,41 @@ defmodule MvWeb.CoreComponents do """ end + @doc """ + Renders a theme toggle using DaisyUI swap (sun/moon with rotate effect). + + Wired to the theme script in root layout: checkbox uses `data-theme-toggle`, + root script syncs checked state (checked = dark) and listens for `phx:set-theme`. + Use in public header or sidebar. Optional `class` is applied to the wrapper. + """ + attr :class, :string, default: nil, doc: "Optional extra classes for the swap wrapper" + + def theme_swap(assigns) do + assigns = assign(assigns, :wrapper_class, assigns[:class]) + + ~H""" +
+ +
+ """ + end + @doc """ Renders a [Heroicon](https://heroicons.com). diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 2979eb4..54f589d 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -13,6 +13,98 @@ defmodule MvWeb.Layouts do embed_templates "layouts/*" + @doc """ + Builds the full browser tab title: "Mila", "Mila · Page", or "Mila · Page · Club". + Order is always: Mila · page title · club name. + Uses assigns[:club_name] and the short page label from assigns[:content_title] or + assigns[:page_title]. LiveViews should set content_title (same gettext as sidebar) + and then assign page_title to the result of this function so the client receives + the full title. + """ + def page_title_string(assigns) do + club = assigns[:club_name] + page = assigns[:content_title] || assigns[:page_title] + + parts = + [page, club] + |> Enum.filter(&(is_binary(&1) and String.trim(&1) != "")) + + if parts == [] do + "Mila" + else + "Mila · " <> Enum.join(parts, " · ") + end + end + + @doc """ + Assigns content_title (short label for heading; same gettext as sidebar) and + page_title (full browser tab title). Call from LiveView mount after club_name + is set (e.g. from on_mount). Returns the socket. + """ + def assign_page_title(socket, content_title) do + socket = assign(socket, :content_title, content_title) + assign(socket, :page_title, page_title_string(socket.assigns)) + end + + @doc """ + Renders the public (unauthenticated) page layout: header with logo + "Mitgliederverwaltung" left, + club name centered, language selector right; plus main content and flash group. Use for sign-in, join, and join-confirm pages so they + share the same chrome without the sidebar or authenticated layout logic. + + Pass optional `:club_name` from the parent (e.g. LiveView mount) to avoid a settings read in the component. + """ + attr :flash, :map, required: true, doc: "the map of flash messages" + + attr :club_name, :string, + default: nil, + doc: "optional; if set, avoids get_settings() in the component" + + slot :inner_block, required: true + + def public_page(assigns) do + club_name = + assigns[:club_name] || + case Mv.Membership.get_settings() do + {:ok, s} -> s.club_name || "Mitgliederverwaltung" + _ -> "Mitgliederverwaltung" + end + + assigns = assign(assigns, :club_name, club_name) + + ~H""" +
+
+ Mila Logo + Mitgliederverwaltung +
+ + {@club_name} + +
+
+ + +
+ <.theme_swap /> +
+
+
+
+ {render_slot(@inner_block)} +
+
+ <.flash_group flash={@flash} /> + """ + end + @doc """ Renders the app layout. Can be used with or without a current_user. When current_user is present, it will show the navigation bar. @@ -99,24 +191,30 @@ defmodule MvWeb.Layouts do
<% else %> - -
- Mila Logo - + +
+
+ Mila Logo + Mitgliederverwaltung +
+ {@club_name} -
- - -
+
+
+ + +
+ <.theme_swap /> +
@@ -167,7 +265,7 @@ defmodule MvWeb.Layouts do aria-live="polite" class="z-50 toast toast-bottom toast-end flex flex-col gap-2 pointer-events-none" > - <.flash kind={:success} flash={@flash} /> + <.flash kind={:success} flash={@flash} auto_clear_ms={5000} /> <.flash kind={:warning} flash={@flash} /> <.flash kind={:info} flash={@flash} /> <.flash kind={:error} flash={@flash} /> diff --git a/lib/mv_web/components/layouts/root.html.heex b/lib/mv_web/components/layouts/root.html.heex index e107d5b..bb900aa 100644 --- a/lib/mv_web/components/layouts/root.html.heex +++ b/lib/mv_web/components/layouts/root.html.heex @@ -7,8 +7,8 @@ - <.live_title default="Mv" suffix=" · Phoenix Framework"> - {assigns[:page_title]} + <.live_title default="Mila"> + {page_title_string(assigns)}