diff --git a/CHANGELOG.md b/CHANGELOG.md
index d39df9b..7cc8ea5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **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
+- **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.
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/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_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
@@ -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..d279163 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: ~p"/sign-in")
+ 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 =
diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex
index f69b947..25a8942 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 %>
+