- {gettext(
- "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
- )}
-
-
<.button
:if={
@@ -868,18 +900,19 @@ defmodule MvWeb.GlobalSettingsLive do
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
- {:ok, _updated_settings} ->
- {:ok, fresh_settings} = Membership.get_settings()
-
+ {:ok, updated_settings} ->
+ # Use the returned record for the form so saved values show immediately;
+ # get_settings() can return cached data without the new attribute until reload.
test_result =
if saves_vereinfacht, do: Mv.Vereinfacht.test_connection(), else: nil
socket =
socket
- |> assign(:settings, fresh_settings)
- |> assign(:registration_enabled, fresh_settings.registration_enabled != false)
- |> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
+ |> assign(:settings, updated_settings)
+ |> assign(:registration_enabled, updated_settings.registration_enabled != false)
+ |> assign(:vereinfacht_api_key_set, present?(updated_settings.vereinfacht_api_key))
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
+ |> assign(:oidc_only, Mv.Config.oidc_only?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
@@ -916,19 +949,53 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true
def handle_event("toggle_registration_enabled", _params, socket) do
- settings = socket.assigns.settings
- new_value = not socket.assigns.registration_enabled
+ if Mv.Config.oidc_only?() do
+ {:noreply, socket}
+ else
+ settings = socket.assigns.settings
+ new_value = not socket.assigns.registration_enabled
- case Membership.update_settings(settings, %{registration_enabled: new_value}) do
- {:ok, updated_settings} ->
- {:noreply,
- socket
- |> assign(:settings, updated_settings)
- |> assign(:registration_enabled, updated_settings.registration_enabled != false)
- |> assign_form()}
+ case Membership.update_settings(settings, %{registration_enabled: new_value}) do
+ {:ok, updated_settings} ->
+ {:noreply,
+ socket
+ |> assign(:settings, updated_settings)
+ |> assign(:registration_enabled, updated_settings.registration_enabled != false)
+ |> assign_form()}
- {:error, _} ->
- {:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
+ end
+ end
+ end
+
+ @impl true
+ def handle_event("toggle_oidc_only", _params, socket) do
+ if socket.assigns.oidc_only_env_set do
+ {:noreply, socket}
+ else
+ settings = socket.assigns.settings
+ new_value = not socket.assigns.oidc_only
+
+ # When enabling OIDC-only, also disable direct registration; when disabling, only change oidc_only.
+ params =
+ if new_value,
+ do: %{oidc_only: true, registration_enabled: false},
+ else: %{oidc_only: false}
+
+ case Membership.update_settings(settings, params) do
+ {:ok, updated_settings} ->
+ {:noreply,
+ socket
+ |> assign(:settings, updated_settings)
+ |> assign(:oidc_only, updated_settings.oidc_only == true)
+ |> assign(:registration_enabled, updated_settings.registration_enabled != false)
+ |> assign_form()
+ |> put_flash(:success, gettext("Settings updated successfully"))}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, gettext("Failed to update setting."))}
+ end
end
end
diff --git a/lib/mv_web/live_helpers.ex b/lib/mv_web/live_helpers.ex
index 38a0157..4206aa6 100644
--- a/lib/mv_web/live_helpers.ex
+++ b/lib/mv_web/live_helpers.ex
@@ -74,7 +74,7 @@ defmodule MvWeb.LiveHelpers do
socket =
socket
- |> Phoenix.LiveView.put_flash(:error, "You don't have permission to access this page.")
+ |> maybe_put_access_denied_flash(user)
|> Phoenix.LiveView.push_navigate(to: redirect_to)
{:halt, socket}
@@ -82,6 +82,13 @@ defmodule MvWeb.LiveHelpers do
end
end
+ # Only show "no permission" when user is logged in; unauthenticated users are redirected to sign-in without flash.
+ defp maybe_put_access_denied_flash(socket, nil), do: socket
+
+ defp maybe_put_access_denied_flash(socket, _user) do
+ Phoenix.LiveView.put_flash(socket, :error, "You don't have permission to access this page.")
+ end
+
defp ensure_user_role_loaded(socket) do
user = socket.assigns[:current_user]
diff --git a/lib/mv_web/plugs/check_page_permission.ex b/lib/mv_web/plugs/check_page_permission.ex
index ff6d47d..b2f37ec 100644
--- a/lib/mv_web/plugs/check_page_permission.ex
+++ b/lib/mv_web/plugs/check_page_permission.ex
@@ -54,7 +54,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
conn
|> fetch_session()
|> fetch_flash()
- |> put_flash(:error, "You don't have permission to access this page.")
+ |> maybe_put_access_denied_flash(user)
|> redirect(to: redirect_to)
|> halt()
end
@@ -75,6 +75,13 @@ defmodule MvWeb.Plugs.CheckPagePermission do
defp redirect_target(user), do: redirect_target_for_user(user)
+ # Only set "no permission" flash when user is logged in; unauthenticated users get redirect only, no flash.
+ defp maybe_put_access_denied_flash(conn, nil), do: conn
+
+ defp maybe_put_access_denied_flash(conn, _user) do
+ put_flash(conn, :error, "You don't have permission to access this page.")
+ end
+
@doc """
Returns true if the path is public (no auth/permission check).
Used by LiveView hook to skip redirect on sign-in etc.
diff --git a/lib/mv_web/plugs/oidc_only_sign_in_redirect.ex b/lib/mv_web/plugs/oidc_only_sign_in_redirect.ex
new file mode 100644
index 0000000..9cf2a16
--- /dev/null
+++ b/lib/mv_web/plugs/oidc_only_sign_in_redirect.ex
@@ -0,0 +1,73 @@
+defmodule MvWeb.Plugs.OidcOnlySignInRedirect do
+ @moduledoc """
+ When OIDC-only mode is active:
+ - GET /sign-in redirects to the OIDC flow when OIDC is configured (sign-in page skipped).
+ - GET /sign-in?oidc_failed=1 is not redirected, so the sign-in page is shown after an OIDC
+ failure (avoids redirect loop when the provider is down or misconfigured).
+ - GET /auth/user/password/sign_in_with_token is rejected (redirect to /sign-in with error)
+ so password sign-in cannot complete.
+ """
+ import Plug.Conn
+ import Phoenix.Controller
+
+ alias Mv.Config
+
+ def init(opts), do: opts
+
+ def call(conn, _opts) do
+ conn
+ |> maybe_redirect_sign_in_to_oidc()
+ |> maybe_reject_password_token_sign_in()
+ end
+
+ defp maybe_redirect_sign_in_to_oidc(conn) do
+ if conn.request_path != "/sign-in" or conn.method != "GET" do
+ conn
+ else
+ conn = fetch_query_params(conn)
+ maybe_redirect_sign_in_to_oidc_checked(conn)
+ end
+ end
+
+ defp maybe_redirect_sign_in_to_oidc_checked(conn) do
+ cond do
+ # Show sign-in page when returning from OIDC failure to avoid redirect loop.
+ conn.query_params["oidc_failed"] -> conn
+ Config.oidc_only?() and Config.oidc_configured?() -> redirect_and_halt(conn)
+ true -> conn
+ end
+ end
+
+ defp redirect_and_halt(conn) do
+ conn
+ |> redirect(to: "/auth/user/oidc")
+ |> halt()
+ end
+
+ defp maybe_reject_password_token_sign_in(conn) do
+ if conn.halted, do: conn, else: reject_password_token_sign_in_if_applicable(conn)
+ end
+
+ defp reject_password_token_sign_in_if_applicable(conn) do
+ path = conn.request_path
+
+ password_token_path? =
+ path =~ ~r|/auth/user/password/sign_in_with_token| and conn.method == "GET"
+
+ if password_token_path? and Config.oidc_only?() do
+ message =
+ Gettext.dgettext(
+ MvWeb.Gettext,
+ "default",
+ "Only sign-in via Single Sign-On (SSO) is allowed."
+ )
+
+ conn
+ |> put_flash(:error, message)
+ |> redirect(to: "/sign-in")
+ |> halt()
+ else
+ conn
+ end
+ end
+end
diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex
index f115535..591dead 100644
--- a/lib/mv_web/router.ex
+++ b/lib/mv_web/router.ex
@@ -18,6 +18,7 @@ defmodule MvWeb.Router do
plug MvWeb.Plugs.CheckPagePermission
plug MvWeb.Plugs.JoinFormEnabled
plug MvWeb.Plugs.RegistrationEnabled
+ plug MvWeb.Plugs.OidcOnlySignInRedirect
end
pipeline :api do
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index c4cacc8..383fb1c 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -3895,3 +3895,13 @@ msgstr "Rolle %{name}"
#, elixir-autogen, elixir-format
msgid "User %{email}"
msgstr "Benutzer*in %{email}"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Only OIDC sign-in is active. This option is disabled."
+msgstr "Nur OIDC-Anmeldung ist aktiv. Diese Option ist deaktiviert."
+
+#: lib/mv_web/controllers/auth_controller.ex
+#, elixir-autogen, elixir-format
+msgid "Only sign-in via Single Sign-On (SSO) is allowed."
+msgstr "Nur Anmeldung per Single Sign-On (SSO) ist erlaubt."
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index a845e1e..3e2eb5d 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -3895,3 +3895,13 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "User %{email}"
msgstr ""
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Only OIDC sign-in is active. This option is disabled."
+msgstr ""
+
+#: lib/mv_web/controllers/auth_controller.ex
+#, elixir-autogen, elixir-format
+msgid "Only sign-in via Single Sign-On (SSO) is allowed."
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index d01da60..21314a5 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -3895,3 +3895,13 @@ msgstr "Role %{name}"
#, elixir-autogen, elixir-format
msgid "User %{email}"
msgstr "User %{email}"
+
+#: lib/mv_web/live/global_settings_live.ex
+#, elixir-autogen, elixir-format
+msgid "Only OIDC sign-in is active. This option is disabled."
+msgstr ""
+
+#: lib/mv_web/controllers/auth_controller.ex
+#, elixir-autogen, elixir-format
+msgid "Only sign-in via Single Sign-On (SSO) is allowed."
+msgstr ""
diff --git a/test/accounts/user_authentication_test.exs b/test/accounts/user_authentication_test.exs
index 1b47165..b759e8a 100644
--- a/test/accounts/user_authentication_test.exs
+++ b/test/accounts/user_authentication_test.exs
@@ -288,4 +288,31 @@ defmodule Mv.Accounts.UserAuthenticationTest do
end
end
end
+
+ describe "register_with_password when OIDC-only is enabled" do
+ alias Mv.Membership
+
+ test "returns error when OIDC-only is enabled" do
+ {:ok, settings} = Membership.get_settings()
+ original_oidc_only = Map.get(settings, :oidc_only, false)
+ {:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
+
+ try do
+ attrs = %{
+ email: "newuser#{System.unique_integer([:positive])}@example.com",
+ password: "SecurePassword123"
+ }
+
+ result =
+ Mv.Accounts.User
+ |> Ash.Changeset.for_create(:register_with_password, attrs)
+ |> Ash.create()
+
+ assert {:error, _} = result
+ after
+ {:ok, s} = Membership.get_settings()
+ Membership.update_settings(s, %{oidc_only: original_oidc_only})
+ end
+ end
+ end
end
diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs
index e449284..f1fbd76 100644
--- a/test/mv_web/controllers/auth_controller_test.exs
+++ b/test/mv_web/controllers/auth_controller_test.exs
@@ -283,6 +283,141 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token"
end
+ describe "when OIDC-only is enabled" do
+ setup %{conn: authenticated_conn} do
+ {:ok, settings} = Membership.get_settings()
+ original_oidc_only = Map.get(settings, :oidc_only, false)
+ {:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
+
+ conn = build_unauthenticated_conn(authenticated_conn)
+ {:ok, conn: conn, original_oidc_only: original_oidc_only}
+ end
+
+ test "password sign-in is rejected and redirects to sign-in with error", %{
+ conn: conn,
+ original_oidc_only: original
+ } do
+ try do
+ _user =
+ create_test_user(%{
+ email: "password@example.com",
+ password: "secret123",
+ oidc_id: nil
+ })
+
+ {:ok, view, _html} = live(conn, "/sign-in")
+
+ result =
+ view
+ |> form("#user-password-sign-in-with-password",
+ user: %{email: "password@example.com", password: "secret123"}
+ )
+ |> render_submit()
+
+ # When OIDC-only is enabled, password sign-in must not succeed (no redirect to sign_in_with_token).
+ case result do
+ {:error, {:redirect, %{to: to}}} ->
+ refute to =~ "sign_in_with_token",
+ "Expected password sign-in to be rejected when OIDC-only, got redirect to: #{to}"
+
+ _ ->
+ # LiveView re-rendered (e.g. with flash error) instead of redirecting to success
+ :ok
+ end
+ after
+ {:ok, s} = Membership.get_settings()
+ Membership.update_settings(s, %{oidc_only: original})
+ end
+ end
+ end
+
+ describe "GET /sign-in when OIDC-only" do
+ test "redirects to OIDC flow when OIDC-only and OIDC are configured", %{
+ conn: authenticated_conn
+ } do
+ {:ok, settings} = Membership.get_settings()
+
+ prev = %{
+ oidc_only: settings.oidc_only,
+ oidc_client_id: settings.oidc_client_id,
+ oidc_base_url: settings.oidc_base_url,
+ oidc_redirect_uri: settings.oidc_redirect_uri
+ }
+
+ {:ok, _} =
+ Membership.update_settings(settings, %{
+ oidc_only: true,
+ oidc_client_id: "test-client",
+ oidc_base_url: "https://idp.example.com",
+ oidc_redirect_uri: "http://localhost:4000/auth/user/oidc/callback",
+ oidc_client_secret: "test-secret"
+ })
+
+ try do
+ conn = build_unauthenticated_conn(authenticated_conn)
+ conn = get(conn, ~p"/sign-in")
+ assert redirected_to(conn) =~ "/auth/user/oidc"
+ after
+ {:ok, s} = Membership.get_settings()
+ Membership.update_settings(s, prev)
+ end
+ end
+
+ test "returns 200 when OIDC-only but oidc_failed=1 (avoids redirect loop)", %{
+ conn: authenticated_conn
+ } do
+ {:ok, settings} = Membership.get_settings()
+
+ prev = %{
+ oidc_only: settings.oidc_only,
+ oidc_client_id: settings.oidc_client_id,
+ oidc_base_url: settings.oidc_base_url,
+ oidc_redirect_uri: settings.oidc_redirect_uri
+ }
+
+ {:ok, _} =
+ Membership.update_settings(settings, %{
+ oidc_only: true,
+ oidc_client_id: "test-client",
+ oidc_base_url: "https://idp.example.com",
+ oidc_redirect_uri: "http://localhost:4000/auth/user/oidc/callback",
+ oidc_client_secret: "test-secret"
+ })
+
+ try do
+ conn = build_unauthenticated_conn(authenticated_conn)
+ conn = get(conn, "/sign-in?oidc_failed=1")
+ assert conn.status == 200
+ # Sign-in page is shown, not redirect to OIDC
+ assert conn.resp_body =~ "Sign in" or conn.resp_body =~ "sign-in"
+ after
+ {:ok, s} = Membership.get_settings()
+ Membership.update_settings(s, prev)
+ end
+ end
+
+ test "returns 200 when OIDC-only but OIDC not configured", %{conn: authenticated_conn} do
+ {:ok, settings} = Membership.get_settings()
+ original_oidc_only = Map.get(settings, :oidc_only, false)
+ {:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
+
+ try do
+ conn = build_unauthenticated_conn(authenticated_conn)
+ conn = get(conn, ~p"/sign-in")
+ assert conn.status == 200
+ after
+ {:ok, s} = Membership.get_settings()
+ Membership.update_settings(s, %{oidc_only: original_oidc_only})
+ end
+ end
+
+ test "returns 200 when OIDC-only is disabled", %{conn: authenticated_conn} do
+ conn = build_unauthenticated_conn(authenticated_conn)
+ conn = get(conn, ~p"/sign-in")
+ assert conn.status == 200
+ end
+ end
+
# OIDC/Rauthy error handling tests
describe "handle_oidc_failure/2" do
test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{
@@ -298,7 +433,7 @@ defmodule MvWeb.AuthControllerTest do
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
- assert redirected_to(conn) == ~p"/sign-in"
+ assert redirected_to(conn) == "/sign-in?oidc_failed=1"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"The authentication server is currently unavailable. Please try again later."
@@ -320,7 +455,7 @@ defmodule MvWeb.AuthControllerTest do
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, error)
- assert redirected_to(conn) == ~p"/sign-in"
+ assert redirected_to(conn) == "/sign-in?oidc_failed=1"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"Authentication configuration error. Please contact the administrator."
@@ -334,7 +469,7 @@ defmodule MvWeb.AuthControllerTest do
conn = MvWeb.AuthController.failure(conn, {:oidc, :callback}, unknown_reason)
- assert redirected_to(conn) == ~p"/sign-in"
+ assert redirected_to(conn) == "/sign-in?oidc_failed=1"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"Unable to authenticate with OIDC. Please try again."
diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs
index e48c44b..92da11b 100644
--- a/test/mv_web/live/global_settings_live_test.exs
+++ b/test/mv_web/live/global_settings_live_test.exs
@@ -110,4 +110,69 @@ defmodule MvWeb.GlobalSettingsLiveTest do
assert html =~ "SMTP" or html =~ "E-Mail" or html =~ "Settings"
end
end
+
+ describe "Authentication section when OIDC-only is enabled" do
+ setup %{conn: conn} do
+ user = create_test_user(%{email: "admin@example.com"})
+ conn = conn_with_oidc_user(conn, user)
+ {:ok, settings} = Membership.get_settings()
+ original_oidc_only = Map.get(settings, :oidc_only, false)
+ {:ok, _} = Membership.update_settings(settings, %{oidc_only: true})
+ {:ok, conn: conn, original_oidc_only: original_oidc_only}
+ end
+
+ @describetag :ui
+ test "registration checkbox is disabled when OIDC-only is enabled", %{
+ conn: conn,
+ original_oidc_only: original
+ } do
+ try do
+ {:ok, view, _html} = live(conn, ~p"/settings")
+ assert has_element?(view, "#registration-enabled-checkbox[disabled]")
+ after
+ {:ok, s} = Membership.get_settings()
+ Membership.update_settings(s, %{oidc_only: original})
+ end
+ end
+
+ @describetag :ui
+ test "OIDC-only hint is visible when OIDC-only is enabled", %{
+ conn: conn,
+ original_oidc_only: original
+ } do
+ try do
+ {:ok, view, _html} = live(conn, ~p"/settings")
+ assert has_element?(view, "[data-testid='oidc-only-registration-hint']")
+ after
+ {:ok, s} = Membership.get_settings()
+ Membership.update_settings(s, %{oidc_only: original})
+ end
+ end
+
+ test "when OIDC-only is disabled, registration checkbox is enabled and can be toggled", %{
+ conn: conn,
+ original_oidc_only: original
+ } do
+ try do
+ {:ok, settings} = Membership.get_settings()
+ Membership.update_settings(settings, %{oidc_only: false})
+
+ {:ok, view, _html} = live(conn, ~p"/settings")
+ refute has_element?(view, "#registration-enabled-checkbox[disabled]")
+
+ initial_checked =
+ view |> element("#registration-enabled-checkbox") |> render() =~ "checked"
+
+ view
+ |> element("#registration-enabled-checkbox")
+ |> render_click()
+
+ new_checked = view |> element("#registration-enabled-checkbox") |> render() =~ "checked"
+ assert new_checked != initial_checked
+ after
+ {:ok, s} = Membership.get_settings()
+ Membership.update_settings(s, %{oidc_only: original})
+ end
+ end
+ end
end
diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs
index 2798161..76bed74 100644
--- a/test/mv_web/plugs/check_page_permission_test.exs
+++ b/test/mv_web/plugs/check_page_permission_test.exs
@@ -181,13 +181,14 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
end
describe "unauthenticated user" do
- test "nil current_user is denied and redirected to \"/sign-in\"" do
+ test "nil current_user is denied and redirected to \"/sign-in\" without access-denied flash" do
conn = conn_without_user("/members") |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/sign-in"
- assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
+ # Unauthenticated users are redirected to sign-in only; no "no permission" message.
+ refute Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
"You don't have permission to access this page."
end
end
From f8a3cc4c47cdb077d8a9f9d2a641af0c52b18a93 Mon Sep 17 00:00:00 2001
From: Simon
Date: Mon, 16 Mar 2026 19:27:31 +0100
Subject: [PATCH 5/5] Run seeds only once (#475)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description of the implemented changes
The changes were:
- [ ] Bugfixing
- [x] New Feature
- [ ] Breaking Change
- [x] Refactoring
**Seeds run only on first startup.** On every application start (e.g. `just run`, Docker entrypoint), seed scripts are still invoked, but they exit immediately when the admin user already exists. This avoids duplicate seed data (e.g. join requests), keeps startup fast after the first run, and works the same in dev and production.
## What has been changed?
- **`lib/mv/release.ex`**
- Added `bootstrap_seeds_applied?/0`: returns whether the admin user (from `ADMIN_EMAIL` or default `admin@localhost`) exists. We check the admin *user*, not the Admin *role*, so we do not skip when only migrations have run (migrations can create the Admin role for the system actor).
- `run_seeds/0`: if `bootstrap_seeds_applied?()` is true, prints “Seeds already applied (admin user exists). Skipping.” and returns without running bootstrap or dev seeds; otherwise unchanged behaviour.
- Module docs updated for the new function and the skip behaviour.
- **`priv/repo/seeds.exs`**
- Ensures the app is started (`Application.ensure_all_started(:mv)`).
- If `Mv.Release.bootstrap_seeds_applied?()` is true, prints the same skip message and does not run bootstrap or dev seeds; otherwise runs as before (bootstrap + dev seeds in dev/test).
- Comment at the top updated to describe the skip behaviour.
- **Documentation**
- `CODE_GUIDELINES.md` §1.2.1: seeds run on every start but exit early when already applied; mentions `bootstrap_seeds_applied?/0`.
- `docs/admin-bootstrap-and-oidc-role-sync.md`: run_seeds skips when admin user exists; description of `run_seeds/0` updated.
- `CHANGELOG.md` [Unreleased]: new “Seeds run only when needed” entry under Changed.
## Definition of Done
### Code Quality
- [x] No new technical depths
- [x] Linting passed
- [x] Documentation is added where needed
### Accessibility
- [x] New elements are properly defined with html-tags *(no new UI)*
- [x] Colour contrast follows WCAG criteria *(no new UI)*
- [x] Aria labels are added when needed *(no new UI)*
- [x] Everything is accessible by keyboard *(no new UI)*
- [x] Tab-Order is comprehensible *(no new UI)*
- [x] All interactive elements have a visible focus *(no new UI)*
### Testing
- [x] Tests for new code are written *(existing seeds and release tests cover behaviour; idempotency test still passes when second run skips)*
- [x] All tests pass
- [x] axe-core dev tools show no critical or major issues *(no UI changes)*
## Additional Notes
- **Review focus:** Logic in `Mv.Release` and `priv/repo/seeds.exs`; the “already applied” check is a single DB read for the admin user. On failure (e.g. DB down), `bootstrap_seeds_applied?/0` returns `false`, so seeds run (safe for first deploy).
- **Suggested check:** Run `mix test test/seeds_test.exs test/mv/release_test.exs` to confirm seeds and release behaviour.
Reviewed-on: https://git.local-it.org/local-it/mitgliederverwaltung/pulls/475
Co-authored-by: Simon
Co-committed-by: Simon
---
.env.example | 1 +
CHANGELOG.md | 4 +-
CODE_GUIDELINES.md | 6 +-
docs/admin-bootstrap-and-oidc-role-sync.md | 5 +-
lib/mv/release.ex | 65 ++++++++++++++++------
priv/repo/seeds.exs | 34 ++++++-----
6 files changed, 78 insertions(+), 37 deletions(-)
diff --git a/.env.example b/.env.example
index 661593b..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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7cc8ea5..b94ce50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,13 +5,15 @@ 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
diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md
index c0cb543..f84c5ad 100644
--- a/CODE_GUIDELINES.md
+++ b/CODE_GUIDELINES.md
@@ -284,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/docs/admin-bootstrap-and-oidc-role-sync.md b/docs/admin-bootstrap-and-oidc-role-sync.md
index aa5c155..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
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/priv/repo/seeds.exs b/priv/repo/seeds.exs
index 7257f8b..c562a43 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -3,7 +3,9 @@
# mix run priv/repo/seeds.exs
#
# Bootstrap runs in all environments. Dev seeds (members, groups, sample data)
-# run only in dev and test.
+# run only in dev and test. Skips entirely if bootstrap was already applied
+# (admin user exists), so safe to run on every start. Set FORCE_SEEDS=true to
+# re-run seeds even when already applied.
#
# In production (release): seeds are run via Mv.Release.run_seeds/0 from the
# container entrypoint. Set RUN_DEV_SEEDS=true to also run dev seeds there.
@@ -12,19 +14,25 @@
# so that eval_file of bootstrap/dev does not emit "redefining module" warnings;
# it is always restored in `after` to avoid hiding real conflicts elsewhere.
-prev = Code.compiler_options()
-Code.compiler_options(ignore_module_conflict: true)
+_ = Application.ensure_all_started(:mv)
-try do
- # Always run bootstrap (fee types, custom fields, roles, admin, system user, settings)
- Code.eval_file("priv/repo/seeds_bootstrap.exs")
+if Mv.Release.bootstrap_seeds_applied?() and System.get_env("FORCE_SEEDS") != "true" do
+ IO.puts("Seeds already applied. Skipping. (Set FORCE_SEEDS=true to override)")
+else
+ prev = Code.compiler_options()
+ Code.compiler_options(ignore_module_conflict: true)
- # In dev and test only: run dev seeds (20 members, groups, custom field values)
- if Mix.env() in [:dev, :test] do
- Code.eval_file("priv/repo/seeds_dev.exs")
+ try do
+ # Always run bootstrap (fee types, custom fields, roles, admin, system user, settings)
+ Code.eval_file("priv/repo/seeds_bootstrap.exs")
+
+ # In dev and test only: run dev seeds (20 members, groups, custom field values)
+ if Mix.env() in [:dev, :test] do
+ Code.eval_file("priv/repo/seeds_dev.exs")
+ end
+
+ IO.puts("✅ All seeds completed.")
+ after
+ Code.compiler_options(prev)
end
-
- IO.puts("✅ All seeds completed.")
-after
- Code.compiler_options(prev)
end