Improve oidc only mode (#474)
All checks were successful
continuous-integration/drone/push Build is passing

## Description of the implemented changes
The changes were:
- [x] Bugfixing
- [x] New Feature
- [ ] Breaking Change
- [x] Refactoring

**OIDC-only mode improvements and UX tweaks (success toasts, unauthenticated redirect).**

## What has been changed?

### OIDC-only mode (new feature)
- **Admin settings:** "Only OIDC sign-in" is an immediate toggle at the top of the OIDC section (no save button). Enabling it also turns off "Allow direct registration". When OIDC-only is on, the registration checkbox is disabled and shows a tooltip (DaisyUI `<.tooltip>`).
- **Backend:** Password sign-in is forbidden via Ash policy (`OidcOnlyActive` check). Password registration is blocked via validation `OidcOnlyBlocksPasswordRegistration`. New plug `OidcOnlySignInRedirect`: when OIDC-only and OIDC are configured, GET `/sign-in` redirects to the OIDC flow; GET `/auth/user/password/sign_in_with_token` is rejected with redirect + flash. `AuthController.success/4` also rejects password sign-in when OIDC-only.
- **Tests:** GlobalSettingsLive (OIDC-only UI), AuthController (redirect and password sign-in rejection), User authentication (register_with_password blocked when OIDC-only).

### UX / behaviour (no new feature flag)
- **Success toasts:** Success flash messages auto-dismiss after 5 seconds via JS hook `FlashAutoDismiss` and optional `auto_clear_ms` on `<.flash>` (used for success in root layout and `flash_group`).
- **Unauthenticated users:** Redirect to sign-in without the "You don't have permission to access this page" flash; that message is only shown to logged-in users who lack access. Logic in `LiveHelpers` and `CheckPagePermission` plug; test updated accordingly.

### Other
- Layouts: comment about unprocessed join-request count no longer uses "TODO" (Credo).
- Gettext: German translation for "Home" (Startseite); POT/PO kept in sync.
- CHANGELOG: Unreleased section updated with the above.

## Definition of Done
### Code Quality
- [x] No new technical depths
- [x] Linting passed
- [x] Documentation is added where needed (module docs, comments where non-obvious)

### Accessibility
- [x] New elements are properly defined with html-tags (labels, aria-label on checkboxes)
- [x] Colour contrast follows WCAG criteria (unchanged)
- [x] Aria labels are added when needed (e.g. oidc-only and registration checkboxes)
- [x] Everything is accessible by keyboard (toggles and buttons unchanged)
- [x] Tab-Order is comprehensible
- [x] All interactive elements have a visible focus (existing patterns)

### Testing
- [x] Tests for new code are written (OIDC-only UI, auth controller, user auth; SMTP config builder and mailer)
- [x] All tests pass
- [ ] axe-core dev tools show no critical or major issues (not re-run for this PR; suggest spot-check on settings and sign-in)

## Additional Notes
- **OIDC-only:** When the `OIDC_ONLY` env var is set, the toggle is read-only and shows "(From OIDC_ONLY)". When OIDC is not configured, the toggle is disabled.
- **Invalidation:** Enabling OIDC-only sets `registration_enabled: false` in one update; disabling OIDC-only only updates `oidc_only` (registration left as-is).
- **Review focus:** Plug order in router (OidcOnlySignInRedirect), policy/validation order in User, and that all OIDC-only paths (form, plug, controller) stay consistent.

Reviewed-on: #474
Co-authored-by: Simon <s.thiessen@local-it.org>
Co-committed-by: Simon <s.thiessen@local-it.org>
This commit is contained in:
Simon 2026-03-16 19:09:07 +01:00 committed by simon
parent 9b0f269ab6
commit c381b86b5e
23 changed files with 579 additions and 54 deletions

View file

@ -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)")}
/>
<label for="registration-enabled-checkbox" class="cursor-pointer font-medium">
<label
for="registration-enabled-checkbox"
class={
if @oidc_only, do: "cursor-not-allowed opacity-70", else: "cursor-pointer font-medium"
}
>
{gettext("Allow direct registration (/register)")}
</label>
<%= if @oidc_only do %>
<.tooltip
content={gettext("Only OIDC sign-in is active. This option is disabled.")}
position="top"
>
<span
data-testid="oidc-only-registration-hint"
class="cursor-help text-base-content/70"
>
</span>
</.tooltip>
<% end %>
</div>
<h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3>
@ -638,6 +659,38 @@ defmodule MvWeb.GlobalSettingsLive do
{gettext("Some values are set via environment variables. Those fields are read-only.")}
</p>
<% end %>
<div class="flex items-center gap-3 mb-4">
<input
type="checkbox"
id="oidc-only-checkbox"
data-testid="oidc-only-checkbox"
class="checkbox checkbox-sm"
checked={@oidc_only}
phx-click="toggle_oidc_only"
disabled={@oidc_only_env_set or not @oidc_configured}
aria-label={gettext("Only OIDC sign-in (hide password login)")}
/>
<label
for="oidc-only-checkbox"
class={
if @oidc_only_env_set or not @oidc_configured,
do: "cursor-not-allowed opacity-70",
else: "cursor-pointer font-medium"
}
>
{if @oidc_only_env_set do
gettext("Only OIDC sign-in (hide password login)") <>
" (" <> gettext("From OIDC_ONLY") <> ")"
else
gettext("Only OIDC sign-in (hide password login)")
end}
</label>
</div>
<p class="label-text-alt text-base-content/70 mb-4">
{gettext(
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
)}
</p>
<.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
<div class="grid gap-4">
<.input
@ -744,27 +797,6 @@ defmodule MvWeb.GlobalSettingsLive do
)
}
/>
<div class="form-control">
<.input
field={@form[:oidc_only]}
type="checkbox"
class="checkbox checkbox-sm"
disabled={@oidc_only_env_set or not @oidc_configured}
label={
if @oidc_only_env_set do
gettext("Only OIDC sign-in (hide password login)") <>
" (" <> gettext("From OIDC_ONLY") <> ")"
else
gettext("Only OIDC sign-in (hide password login)")
end
}
/>
<p class="label-text-alt text-base-content/70 mt-1">
{gettext(
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
)}
</p>
</div>
</div>
<.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