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>
152 lines
4.6 KiB
Elixir
152 lines
4.6 KiB
Elixir
defmodule MvWeb.LiveHelpers do
|
|
@moduledoc """
|
|
Shared LiveView lifecycle hooks and helper functions.
|
|
|
|
## on_mount Hooks
|
|
- `:default` - Sets the user's locale from session (defaults to "de")
|
|
- `:ensure_user_role_loaded` - Ensures current_user has role relationship loaded
|
|
- `:check_page_permission_on_params` - Attaches handle_params hook to enforce page permission on client-side navigation (push_patch)
|
|
|
|
## Usage
|
|
Add to LiveView modules via:
|
|
```elixir
|
|
on_mount {MvWeb.LiveHelpers, :default}
|
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
on_mount {MvWeb.LiveHelpers, :check_page_permission_on_params}
|
|
```
|
|
"""
|
|
import Phoenix.Component
|
|
alias Mv.Authorization.Actor
|
|
alias Mv.Membership
|
|
alias MvWeb.Plugs.CheckPagePermission
|
|
|
|
def on_mount(:default, _params, session, socket) do
|
|
locale = session["locale"] || "de"
|
|
Gettext.put_locale(locale)
|
|
|
|
# Browser timezone from LiveSocket connect params (set in app.js via Intl API)
|
|
connect_params = socket.private[:connect_params] || %{}
|
|
timezone = connect_params["timezone"] || connect_params[:timezone]
|
|
|
|
# Club name for browser tab title (Mila · Club · Page)
|
|
club_name =
|
|
case Membership.get_settings() do
|
|
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
|
|
_ -> nil
|
|
end
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:browser_timezone, timezone)
|
|
|> assign(:club_name, club_name)
|
|
|
|
{:cont, socket}
|
|
end
|
|
|
|
def on_mount(:ensure_user_role_loaded, _params, _session, socket) do
|
|
socket = ensure_user_role_loaded(socket)
|
|
{:cont, socket}
|
|
end
|
|
|
|
def on_mount(:check_page_permission_on_params, _params, _session, socket) do
|
|
{:cont,
|
|
Phoenix.LiveView.attach_hook(
|
|
socket,
|
|
:check_page_permission,
|
|
:handle_params,
|
|
&check_page_permission_handle_params/3
|
|
)}
|
|
end
|
|
|
|
defp check_page_permission_handle_params(_params, uri, socket) do
|
|
path = uri |> URI.parse() |> Map.get(:path, "/") || "/"
|
|
|
|
if CheckPagePermission.public_path?(path) do
|
|
{:cont, socket}
|
|
else
|
|
user = socket.assigns[:current_user]
|
|
host = uri |> URI.parse() |> Map.get(:host) || "localhost"
|
|
|
|
if CheckPagePermission.user_can_access_page?(user, path, router: MvWeb.Router, host: host) do
|
|
{:cont, socket}
|
|
else
|
|
redirect_to = CheckPagePermission.redirect_target_for_user(user)
|
|
|
|
socket =
|
|
socket
|
|
|> maybe_put_access_denied_flash(user)
|
|
|> Phoenix.LiveView.push_navigate(to: redirect_to)
|
|
|
|
{:halt, socket}
|
|
end
|
|
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]
|
|
|
|
if user do
|
|
# Use centralized Actor helper to ensure role is loaded
|
|
user_with_role = Actor.ensure_loaded(user)
|
|
assign(socket, :current_user, user_with_role)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Helper function to get the current actor (user) from socket assigns.
|
|
|
|
Provides consistent access pattern across all LiveViews.
|
|
Returns nil if no current_user is present.
|
|
|
|
## Examples
|
|
|
|
actor = current_actor(socket)
|
|
members = Membership.list_members!(actor: actor)
|
|
"""
|
|
@spec current_actor(Phoenix.LiveView.Socket.t()) :: Mv.Accounts.User.t() | nil
|
|
def current_actor(socket) do
|
|
socket.assigns[:current_user] || socket.assigns.current_user
|
|
end
|
|
|
|
@doc """
|
|
Converts an actor to Ash options list for authorization.
|
|
Returns empty list if actor is nil.
|
|
|
|
Delegates to `Mv.Helpers.ash_actor_opts/1` for consistency across the application.
|
|
|
|
## Examples
|
|
|
|
opts = ash_actor_opts(actor)
|
|
Ash.read(query, opts)
|
|
"""
|
|
@spec ash_actor_opts(Mv.Accounts.User.t() | nil) :: keyword()
|
|
defdelegate ash_actor_opts(actor), to: Mv.Helpers
|
|
|
|
@doc """
|
|
Submits an AshPhoenix form with consistent actor handling.
|
|
|
|
This wrapper ensures that actor is always passed via `action_opts`
|
|
in a consistent manner across all LiveViews.
|
|
|
|
## Examples
|
|
|
|
case submit_form(form, params, actor) do
|
|
{:ok, resource} -> # success
|
|
{:error, form} -> # validation errors
|
|
end
|
|
"""
|
|
@spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) ::
|
|
{:ok, Ash.Resource.t()} | {:error, AshPhoenix.Form.t()}
|
|
def submit_form(form, params, actor) do
|
|
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
|
|
end
|
|
end
|