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 681169f..b94ce50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,19 @@ 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
diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index 8d53484..f84c5ad 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -130,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
@@ -282,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
diff --git a/assets/js/app.js b/assets/js/app.js
index 87f2c25..4c7e3c5 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -113,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() {
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/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/feature-roadmap.md b/docs/feature-roadmap.md
index 6383660..2ec15a5 100644
--- a/docs/feature-roadmap.md
+++ b/docs/feature-roadmap.md
@@ -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/smtp-configuration-concept.md b/docs/smtp-configuration-concept.md
index 8832b5e..13b0d17 100644
--- a/docs/smtp-configuration-concept.md
+++ b/docs/smtp-configuration-concept.md
@@ -105,7 +105,7 @@ 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).
---
@@ -117,7 +117,7 @@ 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.
diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex
index 29a2d4b..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)"
@@ -409,6 +415,10 @@ defmodule Mv.Accounts.User do
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/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/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 8c58c32..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"
diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex
index 5a96001..54f589d 100644
--- a/lib/mv_web/components/layouts.ex
+++ b/lib/mv_web/components/layouts.ex
@@ -265,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 5419b73..bb900aa 100644
--- a/lib/mv_web/components/layouts/root.html.heex
+++ b/lib/mv_web/components/layouts/root.html.heex
@@ -74,7 +74,7 @@
aria-live="polite"
class="z-50 flex flex-col gap-2 toast toast-bottom toast-end"
>
- <.flash id="flash-success-root" kind={:success} flash={@flash} />
+ <.flash id="flash-success-root" kind={:success} flash={@flash} auto_clear_ms={5000} />
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
<.flash id="flash-info-root" kind={:info} flash={@flash} />
<.flash id="flash-error-root" kind={:error} flash={@flash} />
diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex
index 20a76f5..adde4e8 100644
--- a/lib/mv_web/controllers/auth_controller.ex
+++ b/lib/mv_web/controllers/auth_controller.ex
@@ -15,8 +15,23 @@ defmodule MvWeb.AuthController do
use AshAuthentication.Phoenix.Controller
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
+ alias Mv.Config
- def success(conn, activity, user, _token) do
+ def success(conn, {:password, :sign_in} = _activity, user, token) do
+ if Config.oidc_only?() do
+ conn
+ |> put_flash(:error, gettext("Only sign-in via Single Sign-On (SSO) is allowed."))
+ |> redirect(to: sign_in_path_after_oidc_failure())
+ else
+ success_continue(conn, {:password, :sign_in}, user, token)
+ end
+ end
+
+ def success(conn, activity, user, token) do
+ success_continue(conn, activity, user, token)
+ end
+
+ defp success_continue(conn, activity, user, _token) do
return_to = get_session(conn, :return_to) || ~p"/"
message =
@@ -134,7 +149,7 @@ defmodule MvWeb.AuthController do
_ ->
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
- |> redirect(to: ~p"/sign-in")
+ |> redirect(to: sign_in_path_after_oidc_failure())
end
end
@@ -148,7 +163,7 @@ defmodule MvWeb.AuthController do
:error,
gettext("The authentication server is currently unavailable. Please try again later.")
)
- |> redirect(to: ~p"/sign-in")
+ |> redirect(to: sign_in_path_after_oidc_failure())
end
# Handle Assent invalid response errors (configuration or malformed responses)
@@ -161,7 +176,7 @@ defmodule MvWeb.AuthController do
:error,
gettext("Authentication configuration error. Please contact the administrator.")
)
- |> redirect(to: ~p"/sign-in")
+ |> redirect(to: sign_in_path_after_oidc_failure())
end
# Catch-all clause for any other error types
@@ -171,7 +186,7 @@ defmodule MvWeb.AuthController do
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
- |> redirect(to: ~p"/sign-in")
+ |> redirect(to: sign_in_path_after_oidc_failure())
end
# Handle generic AuthenticationFailed errors
@@ -211,10 +226,14 @@ defmodule MvWeb.AuthController do
conn
|> put_flash(:error, error_message)
- |> redirect(to: ~p"/sign-in")
+ |> redirect(to: sign_in_path_after_oidc_failure())
end
end
+ # Path used when redirecting to sign-in after an OIDC failure. The query param tells
+ # OidcOnlySignInRedirect to show the sign-in page instead of redirecting back to OIDC (avoids loop).
+ defp sign_in_path_after_oidc_failure, do: "/sign-in?oidc_failed=1"
+
# Extract meaningful error message from Ash errors
defp extract_meaningful_error_message(errors) do
# Look for specific error messages in InvalidAttribute errors
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index f69b947..cb57631 100644
--- a/lib/mv_web/live/global_settings_live.ex
+++ b/lib/mv_web/live/global_settings_live.ex
@@ -19,6 +19,7 @@ defmodule MvWeb.GlobalSettingsLive do
## Events
- `validate` / `save` - Club settings form
- `toggle_registration_enabled` - Enable/disable direct registration (/register)
+ - `toggle_oidc_only` - Enable/disable OIDC-only sign-in (immediate, outside OIDC form)
- `toggle_join_form_enabled` - Enable/disable the join form
- `add_join_form_field` / `remove_join_form_field` - Manage join form fields
- `toggle_join_form_field_required` - Toggle required flag per field
@@ -80,6 +81,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_admin_group_name_env_set, Mv.Config.oidc_admin_group_name_env_set?())
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
+ |> assign(:oidc_only, Mv.Config.oidc_only?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:registration_enabled, settings.registration_enabled != false)
@@ -625,11 +627,30 @@ defmodule MvWeb.GlobalSettingsLive do
class="checkbox checkbox-sm"
checked={@registration_enabled}
phx-click="toggle_registration_enabled"
+ disabled={@oidc_only}
aria-label={gettext("Allow direct registration (/register)")}
/>
-
{gettext("OIDC (Single Sign-On)")}
@@ -638,6 +659,38 @@ defmodule MvWeb.GlobalSettingsLive do
{gettext("Some values are set via environment variables. Those fields are read-only.")}
<% end %>
+