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>
1659 lines
61 KiB
Elixir
1659 lines
61 KiB
Elixir
defmodule MvWeb.GlobalSettingsLive do
|
|
@moduledoc """
|
|
LiveView for managing global application settings (Vereinsdaten).
|
|
|
|
## Features
|
|
- Edit the association/club name
|
|
- Configure the public join form (Beitrittsformular)
|
|
- Manage custom fields
|
|
- Real-time form validation
|
|
- Success/error feedback
|
|
|
|
## Settings
|
|
- `club_name` - The name of the association/club (required)
|
|
- `registration_enabled` - Whether direct registration via /register is allowed
|
|
- `join_form_enabled` - Whether the public /join page is active
|
|
- `join_form_field_ids` - Ordered list of field IDs shown on the join form
|
|
- `join_form_field_required` - Map of field ID => required boolean
|
|
|
|
## 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
|
|
- `toggle_add_field_dropdown` / `hide_add_field_dropdown` - Dropdown visibility
|
|
- Join form changes (enable/disable, add/remove fields, required toggles) are persisted immediately
|
|
|
|
## Note
|
|
Settings is a singleton resource - there is only one settings record.
|
|
The club_name can also be set via the `ASSOCIATION_NAME` environment variable.
|
|
|
|
CSV member import has been moved to the Import/Export page (`/admin/import-export`).
|
|
"""
|
|
use MvWeb, :live_view
|
|
|
|
require Ash.Query
|
|
import Ash.Expr
|
|
|
|
alias Mv.Helpers
|
|
alias Mv.Helpers.SystemActor
|
|
alias Mv.Membership
|
|
alias Mv.Membership.Member, as: MemberResource
|
|
alias MvWeb.Helpers.MemberHelpers
|
|
alias MvWeb.Translations.MemberFields
|
|
|
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
|
|
|
@impl true
|
|
def mount(_params, session, socket) do
|
|
{:ok, settings} = Membership.get_settings()
|
|
|
|
# Get locale from session; same fallback as router/LiveUserAuth (respects config :default_locale in test)
|
|
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
|
|
Gettext.put_locale(MvWeb.Gettext, locale)
|
|
|
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
|
custom_fields = load_custom_fields(actor)
|
|
|
|
environment = Application.get_env(:mv, :environment, :dev)
|
|
|
|
socket =
|
|
socket
|
|
|> Layouts.assign_page_title(gettext("Basic settings"))
|
|
|> assign(:settings, settings)
|
|
|> assign(:locale, locale)
|
|
|> assign(:environment, environment)
|
|
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
|
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
|
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|
|
|> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?())
|
|
|> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?())
|
|
|> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key))
|
|
|> assign(:last_vereinfacht_sync_result, nil)
|
|
|> assign(:vereinfacht_test_result, nil)
|
|
|> assign(:oidc_env_configured, Mv.Config.oidc_env_configured?())
|
|
|> assign(:oidc_client_id_env_set, Mv.Config.oidc_client_id_env_set?())
|
|
|> assign(:oidc_base_url_env_set, Mv.Config.oidc_base_url_env_set?())
|
|
|> assign(:oidc_redirect_uri_env_set, Mv.Config.oidc_redirect_uri_env_set?())
|
|
|> assign(:oidc_client_secret_env_set, Mv.Config.oidc_client_secret_env_set?())
|
|
|> 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)
|
|
|> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?())
|
|
|> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?())
|
|
|> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?())
|
|
|> assign(:smtp_username_env_set, Mv.Config.smtp_username_env_set?())
|
|
|> assign(:smtp_password_env_set, Mv.Config.smtp_password_env_set?())
|
|
|> assign(:smtp_ssl_env_set, Mv.Config.smtp_ssl_env_set?())
|
|
|> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?())
|
|
|> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?())
|
|
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))
|
|
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|
|
|> assign(:smtp_test_result, nil)
|
|
|> assign(:smtp_test_to_email, "")
|
|
|> assign_join_form_state(settings, custom_fields)
|
|
|> assign(:join_url, url(socket.endpoint, ~p"/join"))
|
|
|> assign_form()
|
|
|
|
{:ok, socket}
|
|
end
|
|
|
|
defp present?(nil), do: false
|
|
defp present?(""), do: false
|
|
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
|
defp present?(_), do: false
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
|
|
<.header>
|
|
{gettext("Basic settings")}
|
|
<:subtitle>
|
|
{gettext("Manage global settings for the association.")}
|
|
</:subtitle>
|
|
</.header>
|
|
|
|
<div class="mt-6 space-y-6 max-w-4xl px-4">
|
|
<%!-- Club Settings Section --%>
|
|
<.form_section title={gettext("Club Settings")}>
|
|
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
|
|
<div class="w-100">
|
|
<.input
|
|
field={@form[:club_name]}
|
|
type="text"
|
|
label={gettext("Association Name")}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
|
{gettext("Save Name")}
|
|
</.button>
|
|
</.form>
|
|
</.form_section>
|
|
<%!-- Join Form Section (Beitrittsformular) --%>
|
|
<.form_section title={gettext("Join Form")}>
|
|
<p class="text-sm text-base-content/70 mb-4">
|
|
{gettext(
|
|
"Configure the public join form that allows new members to submit a join request."
|
|
)}
|
|
</p>
|
|
|
|
<%!-- Enable/disable --%>
|
|
<div class="flex items-center gap-3 mb-3">
|
|
<input
|
|
type="checkbox"
|
|
id="join-form-enabled-checkbox"
|
|
class="checkbox checkbox-sm"
|
|
checked={@join_form_enabled}
|
|
phx-click="toggle_join_form_enabled"
|
|
aria-label={gettext("Join form enabled")}
|
|
/>
|
|
<label for="join-form-enabled-checkbox" class="cursor-pointer font-medium">
|
|
{gettext("Join form enabled")}
|
|
</label>
|
|
</div>
|
|
|
|
<div :if={@join_form_enabled}>
|
|
<%!-- Copyable join page link (below checkbox, above field list) --%>
|
|
<div class="mb-4 p-3 rounded-lg border border-base-300 bg-base-200/50">
|
|
<p class="text-sm text-base-content/70 mb-2">
|
|
{gettext("Link to the public join page (share this with applicants):")}
|
|
</p>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<input
|
|
type="text"
|
|
readonly
|
|
value={@join_url}
|
|
class="input input-bordered input-sm flex-1 min-w-0 font-mono text-sm"
|
|
aria-label={gettext("Join page URL")}
|
|
/>
|
|
<.button
|
|
variant="secondary"
|
|
size="sm"
|
|
id="copy-join-url-btn"
|
|
phx-hook="CopyToClipboard"
|
|
phx-click="copy_join_url"
|
|
aria-label={gettext("Copy join page URL")}
|
|
>
|
|
<.icon name="hero-clipboard-document" class="size-4" />
|
|
{gettext("Copy")}
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Field list header + Add button (left-aligned) --%>
|
|
<h3 class="font-medium mb-3">{gettext("Fields on the join form")}</h3>
|
|
<div class="relative mb-3 w-fit" phx-click-away="hide_add_field_dropdown">
|
|
<.button
|
|
type="button"
|
|
variant="primary"
|
|
phx-click="toggle_add_field_dropdown"
|
|
disabled={
|
|
Enum.empty?(@available_join_form_member_fields) and
|
|
Enum.empty?(@available_join_form_custom_fields)
|
|
}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={to_string(@show_add_field_dropdown)}
|
|
>
|
|
<.icon name="hero-plus" class="size-4" />
|
|
{gettext("Add field")}
|
|
</.button>
|
|
|
|
<%!-- Available fields dropdown (sections: Personal data, Custom fields) --%>
|
|
<div
|
|
:if={@show_add_field_dropdown}
|
|
class="absolute left-0 mt-1 w-56 bg-base-100 border border-base-300 rounded-lg shadow-lg z-10 max-h-64 overflow-y-auto"
|
|
role="listbox"
|
|
aria-label={gettext("Available fields")}
|
|
>
|
|
<div :if={not Enum.empty?(@available_join_form_member_fields)} class="pt-2">
|
|
<div class="px-4 py-1 text-xs font-semibold text-base-content/60 uppercase tracking-wide">
|
|
{gettext("Personal data")}
|
|
</div>
|
|
<div
|
|
:for={field <- @available_join_form_member_fields}
|
|
role="option"
|
|
tabindex="0"
|
|
class="px-4 py-2 cursor-pointer hover:bg-base-200 text-sm"
|
|
phx-click="add_join_form_field"
|
|
phx-value-field_id={field.id}
|
|
>
|
|
{field.label}
|
|
</div>
|
|
</div>
|
|
<div
|
|
:if={not Enum.empty?(@available_join_form_custom_fields)}
|
|
class={
|
|
if(Enum.empty?(@available_join_form_member_fields),
|
|
do: "pt-2",
|
|
else: "border-t border-base-300"
|
|
)
|
|
}
|
|
>
|
|
<div class="px-4 py-1 text-xs font-semibold text-base-content/60 uppercase tracking-wide">
|
|
{gettext("Individual fields")}
|
|
</div>
|
|
<div
|
|
:for={field <- @available_join_form_custom_fields}
|
|
role="option"
|
|
tabindex="0"
|
|
class="px-4 py-2 cursor-pointer hover:bg-base-200 text-sm last:pb-2"
|
|
phx-click="add_join_form_field"
|
|
phx-value-field_id={field.id}
|
|
>
|
|
{field.label}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Empty state --%>
|
|
<p :if={Enum.empty?(@join_form_fields)} class="text-sm text-base-content/60 italic mb-4">
|
|
{gettext("No fields selected. Add at least the email field.")}
|
|
</p>
|
|
|
|
<%!-- Fields table (compact width, reorderable) --%>
|
|
<div :if={not Enum.empty?(@join_form_fields)} class="mb-4">
|
|
<.sortable_table
|
|
id="join-form-fields-table"
|
|
rows={@join_form_fields}
|
|
row_id={fn field -> "join-field-#{field.id}" end}
|
|
reorder_event="reorder_join_form_field"
|
|
>
|
|
<:col :let={field} label={gettext("Field")} class="min-w-[14rem]">
|
|
{field.label}
|
|
</:col>
|
|
<:col
|
|
:let={field}
|
|
label={gettext("Required")}
|
|
class="w-24 max-w-[9.375rem] text-center"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
class="checkbox checkbox-sm"
|
|
checked={field.required}
|
|
disabled={not field.can_toggle_required}
|
|
phx-click={if field.can_toggle_required, do: "toggle_join_form_field_required"}
|
|
phx-value-field_id={field.id}
|
|
aria-label={gettext("Required")}
|
|
/>
|
|
</:col>
|
|
<:action :let={field}>
|
|
<.tooltip content={gettext("Remove")} position="left">
|
|
<.button
|
|
type="button"
|
|
variant="danger"
|
|
size="sm"
|
|
disabled={not field.can_remove}
|
|
class={if(not field.can_remove, do: "opacity-50 cursor-not-allowed", else: "")}
|
|
phx-click="remove_join_form_field"
|
|
phx-value-field_id={field.id}
|
|
aria-label={gettext("Remove field %{label}", label: field.label)}
|
|
>
|
|
<.icon name="hero-trash" class="size-4" />
|
|
</.button>
|
|
</.tooltip>
|
|
</:action>
|
|
</.sortable_table>
|
|
<p class="mt-2 text-sm text-base-content/60">
|
|
{gettext("The order of rows determines the field order in the join form.")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</.form_section>
|
|
<%!-- SMTP / E-Mail Section --%>
|
|
<.form_section title={gettext("SMTP / E-Mail")}>
|
|
<%= if @smtp_env_configured do %>
|
|
<p class="text-sm text-base-content/70 mb-4">
|
|
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
|
</p>
|
|
<% end %>
|
|
|
|
<%= if @environment == :prod and not @smtp_configured do %>
|
|
<div class="mb-4 flex items-start gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
|
|
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
|
|
<span>
|
|
{gettext(
|
|
"SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably."
|
|
)}
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
|
|
<.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save">
|
|
<div class="">
|
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_5rem_1fr]">
|
|
<.input
|
|
field={@form[:smtp_host]}
|
|
type="text"
|
|
label={gettext("Host")}
|
|
disabled={@smtp_host_env_set}
|
|
placeholder={
|
|
if(@smtp_host_env_set,
|
|
do: gettext("From SMTP_HOST"),
|
|
else: "smtp.example.com"
|
|
)
|
|
}
|
|
/>
|
|
<.input
|
|
field={@form[:smtp_port]}
|
|
type="number"
|
|
label={gettext("Port")}
|
|
disabled={@smtp_port_env_set}
|
|
placeholder={if(@smtp_port_env_set, do: gettext("From SMTP_PORT"), else: "587")}
|
|
/>
|
|
<.input
|
|
field={@form[:smtp_ssl]}
|
|
type="select"
|
|
label={gettext("TLS/SSL")}
|
|
disabled={@smtp_ssl_env_set}
|
|
options={[
|
|
{gettext("TLS (port 587, recommended)"), "tls"},
|
|
{gettext("SSL (port 465)"), "ssl"},
|
|
{gettext("None (port 25, insecure)"), "none"}
|
|
]}
|
|
placeholder={if(@smtp_ssl_env_set, do: gettext("From SMTP_SSL"), else: nil)}
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
<.input
|
|
field={@form[:smtp_username]}
|
|
type="text"
|
|
label={gettext("Username")}
|
|
disabled={@smtp_username_env_set}
|
|
placeholder={
|
|
if(@smtp_username_env_set,
|
|
do: gettext("From SMTP_USERNAME"),
|
|
else: "user@example.com"
|
|
)
|
|
}
|
|
/>
|
|
<.input
|
|
field={@form[:smtp_password]}
|
|
type="password"
|
|
label={gettext("Password")}
|
|
disabled={@smtp_password_env_set}
|
|
placeholder={
|
|
if(@smtp_password_env_set,
|
|
do: gettext("From SMTP_PASSWORD"),
|
|
else:
|
|
if(@smtp_password_set,
|
|
do: gettext("Leave blank to keep current"),
|
|
else: nil
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
<.input
|
|
field={@form[:smtp_from_email]}
|
|
type="email"
|
|
label={gettext("Sender email (From)")}
|
|
disabled={@smtp_from_email_env_set}
|
|
placeholder={
|
|
if(@smtp_from_email_env_set,
|
|
do: gettext("From MAIL_FROM_EMAIL"),
|
|
else: "noreply@example.com"
|
|
)
|
|
}
|
|
/>
|
|
<.input
|
|
field={@form[:smtp_from_name]}
|
|
type="text"
|
|
label={gettext("Sender name (From)")}
|
|
disabled={@smtp_from_name_env_set}
|
|
placeholder={
|
|
if(@smtp_from_name_env_set, do: gettext("From MAIL_FROM_NAME"), else: "Mila")
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p class="mb-3 text-sm text-base-content/60">
|
|
{gettext(
|
|
"The sender email must be owned by or authorized for the SMTP user on most servers."
|
|
)}
|
|
</p>
|
|
<.button
|
|
:if={
|
|
not (@smtp_host_env_set and @smtp_port_env_set and @smtp_username_env_set and
|
|
@smtp_password_env_set and @smtp_ssl_env_set and @smtp_from_email_env_set and
|
|
@smtp_from_name_env_set)
|
|
}
|
|
phx-disable-with={gettext("Saving...")}
|
|
variant="primary"
|
|
class="mt-2"
|
|
>
|
|
{gettext("Save SMTP Settings")}
|
|
</.button>
|
|
</.form>
|
|
|
|
<%!-- Test email: use form phx-submit so the current input value is always sent (e.g. after paste without blur) --%>
|
|
<div class="mt-6">
|
|
<h3 class="font-medium mb-3">{gettext("Test email")}</h3>
|
|
<.form
|
|
for={%{}}
|
|
id="smtp-test-email-form"
|
|
data-testid="smtp-test-email-form"
|
|
phx-submit="send_smtp_test_email"
|
|
class="space-y-3"
|
|
>
|
|
<div class="flex flex-wrap items-end gap-3">
|
|
<fieldset class="fieldset">
|
|
<label>
|
|
<span class="mb-1 label">{gettext("Recipient")}</span>
|
|
<input
|
|
id="smtp-test-to-email"
|
|
type="email"
|
|
name="to_email"
|
|
data-testid="smtp-test-email-input"
|
|
value={@smtp_test_to_email}
|
|
class="w-full input input-bordered"
|
|
placeholder="test@example.com"
|
|
phx-change="update_smtp_test_to_email"
|
|
/>
|
|
</label>
|
|
</fieldset>
|
|
<.button
|
|
type="submit"
|
|
variant="secondary"
|
|
class="mb-1"
|
|
data-testid="smtp-send-test-email"
|
|
phx-disable-with={gettext("Sending...")}
|
|
>
|
|
{gettext("Send test email")}
|
|
</.button>
|
|
</div>
|
|
</.form>
|
|
<%= if @smtp_test_result do %>
|
|
<div data-testid="smtp-test-result">
|
|
<.smtp_test_result result={@smtp_test_result} />
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</.form_section>
|
|
|
|
<%!-- Vereinfacht Integration Section --%>
|
|
<.form_section title={gettext("Accounting-Software (Vereinfacht) Integration")}>
|
|
<%= if @vereinfacht_env_configured do %>
|
|
<p class="text-sm text-base-content/70 mb-4">
|
|
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
|
</p>
|
|
<% end %>
|
|
<.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save">
|
|
<div class="grid gap-4">
|
|
<.input
|
|
field={@form[:vereinfacht_api_url]}
|
|
type="text"
|
|
label={gettext("API URL")}
|
|
disabled={@vereinfacht_api_url_env_set}
|
|
placeholder={
|
|
if(@vereinfacht_api_url_env_set,
|
|
do: gettext("From VEREINFACHT_API_URL"),
|
|
else: "https://api.verein.visuel.dev/api/v1"
|
|
)
|
|
}
|
|
/>
|
|
<fieldset class="mb-2 fieldset">
|
|
<label>
|
|
<span class="mb-1 label">{gettext("API Key")}</span>
|
|
<%= if @vereinfacht_api_key_set do %>
|
|
<span class="label-text-alt">
|
|
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
|
|
</span>
|
|
<% end %>
|
|
<input
|
|
type="password"
|
|
name={@form[:vereinfacht_api_key].name}
|
|
id={@form[:vereinfacht_api_key].id}
|
|
value={
|
|
Phoenix.HTML.Form.normalize_value("password", @form[:vereinfacht_api_key].value)
|
|
}
|
|
class={
|
|
if Phoenix.Component.used_input?(@form[:vereinfacht_api_key]) &&
|
|
@form[:vereinfacht_api_key].errors != [],
|
|
do: "w-full input input-error",
|
|
else: "w-full input"
|
|
}
|
|
disabled={@vereinfacht_api_key_env_set}
|
|
placeholder={
|
|
if(@vereinfacht_api_key_env_set,
|
|
do: gettext("From VEREINFACHT_API_KEY"),
|
|
else:
|
|
if(@vereinfacht_api_key_set,
|
|
do: gettext("Leave blank to keep current"),
|
|
else: nil
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<%= for msg <- (
|
|
if Phoenix.Component.used_input?(@form[:vereinfacht_api_key]) do
|
|
Enum.map(@form[:vereinfacht_api_key].errors, &MvWeb.CoreComponents.translate_error/1)
|
|
else
|
|
[]
|
|
end
|
|
) do %>
|
|
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
|
<.icon name="hero-exclamation-circle" class="size-5" />
|
|
{msg}
|
|
</p>
|
|
<% end %>
|
|
</fieldset>
|
|
<.input
|
|
field={@form[:vereinfacht_club_id]}
|
|
type="text"
|
|
label={gettext("Club ID")}
|
|
disabled={@vereinfacht_club_id_env_set}
|
|
placeholder={
|
|
if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2")
|
|
}
|
|
/>
|
|
<.input
|
|
field={@form[:vereinfacht_app_url]}
|
|
type="text"
|
|
label={gettext("App URL (contact view link)")}
|
|
disabled={@vereinfacht_app_url_env_set}
|
|
placeholder={
|
|
if(@vereinfacht_app_url_env_set,
|
|
do: gettext("From VEREINFACHT_APP_URL"),
|
|
else: "https://app.verein.visuel.dev"
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<.button
|
|
:if={
|
|
not (@vereinfacht_api_url_env_set and @vereinfacht_api_key_env_set and
|
|
@vereinfacht_club_id_env_set)
|
|
}
|
|
phx-disable-with={gettext("Saving...")}
|
|
variant="primary"
|
|
class="mt-2"
|
|
>
|
|
{gettext("Save Vereinfacht Settings")}
|
|
</.button>
|
|
<div class="mt-2 flex flex-wrap gap-2">
|
|
<.button
|
|
:if={Mv.Config.vereinfacht_configured?()}
|
|
type="button"
|
|
variant="secondary"
|
|
phx-click="test_vereinfacht_connection"
|
|
phx-disable-with={gettext("Testing...")}
|
|
>
|
|
{gettext("Test Integration")}
|
|
</.button>
|
|
<.button
|
|
:if={Mv.Config.vereinfacht_configured?()}
|
|
type="button"
|
|
variant="secondary"
|
|
phx-click="sync_vereinfacht_contacts"
|
|
phx-disable-with={gettext("Syncing...")}
|
|
>
|
|
{gettext("Sync all members without Vereinfacht contact")}
|
|
</.button>
|
|
</div>
|
|
<%= if @vereinfacht_test_result do %>
|
|
<.vereinfacht_test_result result={@vereinfacht_test_result} />
|
|
<% end %>
|
|
<%= if @last_vereinfacht_sync_result do %>
|
|
<.vereinfacht_sync_result result={@last_vereinfacht_sync_result} />
|
|
<% end %>
|
|
</.form>
|
|
</.form_section>
|
|
<%!-- Authentication: Direct registration + OIDC --%>
|
|
<.form_section title={gettext("Authentication")}>
|
|
<h3 class="font-medium mb-3">{gettext("Direct registration")}</h3>
|
|
<p class="text-sm text-base-content/70 mb-4">
|
|
{gettext(
|
|
"If disabled, users cannot sign up via /register; sign-in and the join form remain available."
|
|
)}
|
|
</p>
|
|
<div class="flex items-center gap-3 mb-6">
|
|
<input
|
|
type="checkbox"
|
|
id="registration-enabled-checkbox"
|
|
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={
|
|
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>
|
|
<%= if @oidc_env_configured do %>
|
|
<p class="text-sm text-base-content/70 mb-4">
|
|
{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
|
|
field={@form[:oidc_client_id]}
|
|
type="text"
|
|
label={gettext("Client ID")}
|
|
disabled={@oidc_client_id_env_set}
|
|
placeholder={
|
|
if(@oidc_client_id_env_set, do: gettext("From OIDC_CLIENT_ID"), else: "mv")
|
|
}
|
|
/>
|
|
<.input
|
|
field={@form[:oidc_base_url]}
|
|
type="text"
|
|
label={gettext("Base URL")}
|
|
disabled={@oidc_base_url_env_set}
|
|
placeholder={
|
|
if(@oidc_base_url_env_set,
|
|
do: gettext("From OIDC_BASE_URL"),
|
|
else: "http://localhost:8080/auth/v1"
|
|
)
|
|
}
|
|
/>
|
|
<.input
|
|
field={@form[:oidc_redirect_uri]}
|
|
type="text"
|
|
label={gettext("Redirect URI")}
|
|
disabled={@oidc_redirect_uri_env_set}
|
|
placeholder={
|
|
if(@oidc_redirect_uri_env_set,
|
|
do: gettext("From OIDC_REDIRECT_URI"),
|
|
else: "http://localhost:4000/auth/user/oidc/callback"
|
|
)
|
|
}
|
|
/>
|
|
<fieldset class="mb-2 fieldset">
|
|
<label>
|
|
<span class="mb-1 label">{gettext("Client Secret")}</span>
|
|
<%= if @oidc_client_secret_set do %>
|
|
<span class="label-text-alt">
|
|
<.badge variant="neutral" size="sm">{gettext("(set)")}</.badge>
|
|
</span>
|
|
<% end %>
|
|
<input
|
|
type="password"
|
|
name={@form[:oidc_client_secret].name}
|
|
id={@form[:oidc_client_secret].id}
|
|
value={
|
|
Phoenix.HTML.Form.normalize_value("password", @form[:oidc_client_secret].value)
|
|
}
|
|
class={
|
|
if Phoenix.Component.used_input?(@form[:oidc_client_secret]) &&
|
|
@form[:oidc_client_secret].errors != [],
|
|
do: "w-full input input-error",
|
|
else: "w-full input"
|
|
}
|
|
disabled={@oidc_client_secret_env_set}
|
|
placeholder={
|
|
if(@oidc_client_secret_env_set,
|
|
do: gettext("From OIDC_CLIENT_SECRET"),
|
|
else:
|
|
if(@oidc_client_secret_set,
|
|
do: gettext("Leave blank to keep current"),
|
|
else: nil
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<%= for msg <- (
|
|
if Phoenix.Component.used_input?(@form[:oidc_client_secret]) do
|
|
Enum.map(@form[:oidc_client_secret].errors, &MvWeb.CoreComponents.translate_error/1)
|
|
else
|
|
[]
|
|
end
|
|
) do %>
|
|
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
|
<.icon name="hero-exclamation-circle" class="size-5" />
|
|
{msg}
|
|
</p>
|
|
<% end %>
|
|
</fieldset>
|
|
<.input
|
|
field={@form[:oidc_admin_group_name]}
|
|
type="text"
|
|
label={gettext("Admin group name")}
|
|
disabled={@oidc_admin_group_name_env_set}
|
|
placeholder={
|
|
if(@oidc_admin_group_name_env_set,
|
|
do: gettext("From OIDC_ADMIN_GROUP_NAME"),
|
|
else: gettext("e.g. admin")
|
|
)
|
|
}
|
|
/>
|
|
<.input
|
|
field={@form[:oidc_groups_claim]}
|
|
type="text"
|
|
label={gettext("Groups claim")}
|
|
disabled={@oidc_groups_claim_env_set}
|
|
placeholder={
|
|
if(@oidc_groups_claim_env_set,
|
|
do: gettext("From OIDC_GROUPS_CLAIM"),
|
|
else: "groups"
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<.button
|
|
:if={
|
|
not (@oidc_client_id_env_set and @oidc_base_url_env_set and
|
|
@oidc_redirect_uri_env_set and @oidc_client_secret_env_set and
|
|
@oidc_admin_group_name_env_set and @oidc_groups_claim_env_set and
|
|
@oidc_only_env_set)
|
|
}
|
|
phx-disable-with={gettext("Saving...")}
|
|
variant="primary"
|
|
class="mt-2"
|
|
>
|
|
{gettext("Save OIDC Settings")}
|
|
</.button>
|
|
</.form>
|
|
</.form_section>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("validate", %{"setting" => setting_params}, socket) do
|
|
{:noreply,
|
|
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
|
end
|
|
|
|
# phx-change can fire without "setting" (e.g. only _target when focusing). Do not validate
|
|
# with previous form params to avoid surprising behaviour; wait for the next event with setting data.
|
|
def handle_event("validate", _params, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("update_smtp_test_to_email", %{"to_email" => email}, socket) do
|
|
{:noreply, assign(socket, :smtp_test_to_email, email)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("send_smtp_test_email", params, socket) do
|
|
to_email =
|
|
(params["to_email"] || socket.assigns.smtp_test_to_email || "")
|
|
|> String.trim()
|
|
|
|
result = Mv.Mailer.send_test_email(to_email)
|
|
{:noreply, assign(socket, :smtp_test_result, result)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("test_vereinfacht_connection", _params, socket) do
|
|
result = Mv.Vereinfacht.test_connection()
|
|
{:noreply, assign(socket, :vereinfacht_test_result, result)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("sync_vereinfacht_contacts", _params, socket) do
|
|
case Mv.Vereinfacht.sync_members_without_contact() do
|
|
{:ok, %{synced: synced, errors: errors}} ->
|
|
errors_with_names = enrich_sync_errors(errors)
|
|
result = %{synced: synced, errors: errors_with_names}
|
|
|
|
{flash_kind, flash_message} =
|
|
if(errors_with_names == [],
|
|
do: {:success, gettext("Synced %{count} member(s) to Vereinfacht.", count: synced)},
|
|
else:
|
|
{:warning,
|
|
gettext("Synced %{count} member(s). %{error_count} failed.",
|
|
count: synced,
|
|
error_count: length(errors_with_names)
|
|
)}
|
|
)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:last_vereinfacht_sync_result, result)
|
|
|> put_flash(flash_kind, flash_message)
|
|
|
|
{:noreply, socket}
|
|
|
|
{:error, :not_configured} ->
|
|
{:noreply,
|
|
put_flash(
|
|
socket,
|
|
:error,
|
|
gettext("Vereinfacht is not configured. Set API URL, API Key, and Club ID.")
|
|
)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save", %{"setting" => setting_params}, socket) do
|
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
|
|
|
# Never send blank API key / client secret / smtp password so we do not overwrite stored secrets
|
|
setting_params_clean =
|
|
setting_params
|
|
|> drop_blank_vereinfacht_api_key()
|
|
|> drop_blank_oidc_client_secret()
|
|
|> drop_blank_smtp_password()
|
|
|
|
saves_vereinfacht = vereinfacht_params?(setting_params_clean)
|
|
|
|
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
|
|
{: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, 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()))
|
|
|> assign(:smtp_from_name_env_set, Mv.Config.mail_from_name_env_set?())
|
|
|> assign(:smtp_from_email_env_set, Mv.Config.mail_from_email_env_set?())
|
|
|> assign(:vereinfacht_test_result, test_result)
|
|
|> put_flash(:success, gettext("Settings updated successfully"))
|
|
|> assign_form()
|
|
|
|
{:noreply, socket}
|
|
|
|
{:error, form} ->
|
|
{:noreply, assign(socket, form: form)}
|
|
end
|
|
end
|
|
|
|
# ---- Join form event handlers ----
|
|
|
|
@impl true
|
|
def handle_event("copy_join_url", _params, socket) do
|
|
socket =
|
|
socket
|
|
|> push_event("copy_to_clipboard", %{text: socket.assigns.join_url})
|
|
|> put_flash(:success, gettext("Join page URL copied to clipboard."))
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_join_form_enabled", _params, socket) do
|
|
socket = assign(socket, :join_form_enabled, not socket.assigns.join_form_enabled)
|
|
{:noreply, persist_join_form_settings(socket)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_registration_enabled", _params, socket) do
|
|
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()}
|
|
|
|
{: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
|
|
|
|
@impl true
|
|
def handle_event("toggle_add_field_dropdown", _params, socket) do
|
|
{:noreply,
|
|
assign(socket, :show_add_field_dropdown, not socket.assigns.show_add_field_dropdown)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("hide_add_field_dropdown", _params, socket) do
|
|
{:noreply, assign(socket, :show_add_field_dropdown, false)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("add_join_form_field", %{"field_id" => field_id}, socket) do
|
|
member_avail = socket.assigns.available_join_form_member_fields
|
|
custom_avail = socket.assigns.available_join_form_custom_fields
|
|
current = socket.assigns.join_form_fields
|
|
|
|
field_to_add =
|
|
Enum.find(member_avail, &(&1.id == field_id)) ||
|
|
Enum.find(custom_avail, &(&1.id == field_id))
|
|
|
|
socket =
|
|
if field_to_add do
|
|
full_field = %{
|
|
id: field_to_add.id,
|
|
label: field_to_add.label,
|
|
type: field_to_add.type,
|
|
required: false,
|
|
can_remove: field_to_add.id != "email",
|
|
can_toggle_required: field_to_add.id != "email"
|
|
}
|
|
|
|
new_fields = current ++ [full_field]
|
|
new_member = Enum.reject(member_avail, &(&1.id == field_id))
|
|
new_custom = Enum.reject(custom_avail, &(&1.id == field_id))
|
|
|
|
socket
|
|
|> assign(:join_form_fields, new_fields)
|
|
|> assign(:available_join_form_member_fields, new_member)
|
|
|> assign(:available_join_form_custom_fields, new_custom)
|
|
|> assign(:show_add_field_dropdown, false)
|
|
else
|
|
socket
|
|
end
|
|
|
|
{:noreply, persist_join_form_settings(socket)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("remove_join_form_field", %{"field_id" => field_id}, socket) do
|
|
if field_id == "email" do
|
|
{:noreply, socket}
|
|
else
|
|
current = socket.assigns.join_form_fields
|
|
custom_fields = socket.assigns.join_form_custom_fields
|
|
new_fields = Enum.reject(current, &(&1.id == field_id))
|
|
new_ids = Enum.map(new_fields, & &1.id)
|
|
|
|
%{member_fields: new_member, custom_fields: new_custom} =
|
|
build_available_join_form_fields(new_ids, custom_fields)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:join_form_fields, new_fields)
|
|
|> assign(:available_join_form_member_fields, new_member)
|
|
|> assign(:available_join_form_custom_fields, new_custom)
|
|
|> persist_join_form_settings()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_join_form_field_required", %{"field_id" => "email"}, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_join_form_field_required", %{"field_id" => field_id}, socket) do
|
|
new_fields =
|
|
Enum.map(socket.assigns.join_form_fields, &toggle_required_if_matches(&1, field_id))
|
|
|
|
socket = assign(socket, :join_form_fields, new_fields) |> persist_join_form_settings()
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event(
|
|
"reorder_join_form_field",
|
|
%{"from_index" => from_idx, "to_index" => to_idx},
|
|
socket
|
|
)
|
|
when is_integer(from_idx) and is_integer(to_idx) do
|
|
fields = socket.assigns.join_form_fields
|
|
new_fields = reorder_list(fields, from_idx, to_idx)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:join_form_fields, new_fields)
|
|
|> persist_join_form_settings()
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
# Ignore malformed reorder events (e.g. nil indices from aborted drags)
|
|
def handle_event("reorder_join_form_field", _params, socket), do: {:noreply, socket}
|
|
|
|
defp persist_join_form_settings(socket) do
|
|
settings = socket.assigns.settings
|
|
field_ids = Enum.map(socket.assigns.join_form_fields, & &1.id)
|
|
|
|
required_map =
|
|
socket.assigns.join_form_fields
|
|
|> Map.new(fn field -> {field.id, field.required} end)
|
|
|
|
attrs = %{
|
|
join_form_enabled: socket.assigns.join_form_enabled,
|
|
join_form_field_ids: field_ids,
|
|
join_form_field_required: required_map
|
|
}
|
|
|
|
case Membership.update_settings(settings, attrs) do
|
|
{:ok, updated_settings} ->
|
|
custom_fields = socket.assigns.join_form_custom_fields
|
|
|
|
socket
|
|
|> assign(:settings, updated_settings)
|
|
|> assign_join_form_state(updated_settings, custom_fields)
|
|
|
|
{:error, _error} ->
|
|
put_flash(socket, :error, gettext("Could not save join form settings."))
|
|
end
|
|
end
|
|
|
|
@vereinfacht_param_keys ~w[vereinfacht_api_url vereinfacht_api_key vereinfacht_club_id vereinfacht_app_url]
|
|
|
|
defp vereinfacht_params?(params) when is_map(params) do
|
|
Enum.any?(@vereinfacht_param_keys, &Map.has_key?(params, &1))
|
|
end
|
|
|
|
defp drop_blank_vereinfacht_api_key(params) when is_map(params) do
|
|
case params do
|
|
%{"vereinfacht_api_key" => v} when v in [nil, ""] ->
|
|
Map.delete(params, "vereinfacht_api_key")
|
|
|
|
_ ->
|
|
params
|
|
end
|
|
end
|
|
|
|
defp drop_blank_oidc_client_secret(params) when is_map(params) do
|
|
case params do
|
|
%{"oidc_client_secret" => v} when v in [nil, ""] ->
|
|
Map.delete(params, "oidc_client_secret")
|
|
|
|
_ ->
|
|
params
|
|
end
|
|
end
|
|
|
|
defp drop_blank_smtp_password(params) when is_map(params) do
|
|
case params do
|
|
%{"smtp_password" => v} when v in [nil, ""] ->
|
|
Map.delete(params, "smtp_password")
|
|
|
|
_ ->
|
|
params
|
|
end
|
|
end
|
|
|
|
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
|
# Show ENV values in disabled fields (Vereinfacht, OIDC, SMTP); never expose secrets in form
|
|
settings_display =
|
|
settings
|
|
|> merge_vereinfacht_env_values()
|
|
|> merge_oidc_env_values()
|
|
|> merge_smtp_env_values()
|
|
|
|
settings_for_form = %{
|
|
settings_display
|
|
| vereinfacht_api_key: nil,
|
|
oidc_client_secret: nil,
|
|
smtp_password: nil
|
|
}
|
|
|
|
form =
|
|
AshPhoenix.Form.for_update(
|
|
settings_for_form,
|
|
:update,
|
|
api: Membership,
|
|
as: "setting",
|
|
forms: [auto?: true]
|
|
)
|
|
|
|
assign(socket, form: to_form(form))
|
|
end
|
|
|
|
defp put_if_env_set(map, _key, false, _value), do: map
|
|
defp put_if_env_set(map, key, true, value), do: Map.put(map, key, value)
|
|
|
|
defp merge_vereinfacht_env_values(s) do
|
|
s
|
|
|> put_if_env_set(
|
|
:vereinfacht_api_url,
|
|
Mv.Config.vereinfacht_api_url_env_set?(),
|
|
Mv.Config.vereinfacht_api_url()
|
|
)
|
|
|> put_if_env_set(
|
|
:vereinfacht_club_id,
|
|
Mv.Config.vereinfacht_club_id_env_set?(),
|
|
Mv.Config.vereinfacht_club_id()
|
|
)
|
|
|> put_if_env_set(
|
|
:vereinfacht_app_url,
|
|
Mv.Config.vereinfacht_app_url_env_set?(),
|
|
Mv.Config.vereinfacht_app_url()
|
|
)
|
|
end
|
|
|
|
defp merge_oidc_env_values(s) do
|
|
s
|
|
|> put_if_env_set(
|
|
:oidc_client_id,
|
|
Mv.Config.oidc_client_id_env_set?(),
|
|
Mv.Config.oidc_client_id()
|
|
)
|
|
|> put_if_env_set(
|
|
:oidc_base_url,
|
|
Mv.Config.oidc_base_url_env_set?(),
|
|
Mv.Config.oidc_base_url()
|
|
)
|
|
|> put_if_env_set(
|
|
:oidc_redirect_uri,
|
|
Mv.Config.oidc_redirect_uri_env_set?(),
|
|
Mv.Config.oidc_redirect_uri()
|
|
)
|
|
|> put_if_env_set(
|
|
:oidc_admin_group_name,
|
|
Mv.Config.oidc_admin_group_name_env_set?(),
|
|
Mv.Config.oidc_admin_group_name()
|
|
)
|
|
|> put_if_env_set(
|
|
:oidc_groups_claim,
|
|
Mv.Config.oidc_groups_claim_env_set?(),
|
|
Mv.Config.oidc_groups_claim()
|
|
)
|
|
|> put_if_oidc_only_env_set()
|
|
end
|
|
|
|
defp put_if_oidc_only_env_set(s) do
|
|
if Mv.Config.oidc_only_env_set?() do
|
|
Map.put(s, :oidc_only, Mv.Config.oidc_only?())
|
|
else
|
|
s
|
|
end
|
|
end
|
|
|
|
defp merge_smtp_env_values(s) do
|
|
s
|
|
|> put_if_env_set(:smtp_host, Mv.Config.smtp_host_env_set?(), Mv.Config.smtp_host())
|
|
|> put_if_env_set(:smtp_port, Mv.Config.smtp_port_env_set?(), Mv.Config.smtp_port())
|
|
|> put_if_env_set(
|
|
:smtp_username,
|
|
Mv.Config.smtp_username_env_set?(),
|
|
Mv.Config.smtp_username()
|
|
)
|
|
|> put_if_env_set(:smtp_ssl, Mv.Config.smtp_ssl_env_set?(), Mv.Config.smtp_ssl())
|
|
|> put_if_env_set(
|
|
:smtp_from_email,
|
|
Mv.Config.mail_from_email_env_set?(),
|
|
Mv.Config.mail_from_email()
|
|
)
|
|
|> put_if_env_set(
|
|
:smtp_from_name,
|
|
Mv.Config.mail_from_name_env_set?(),
|
|
Mv.Config.mail_from_name()
|
|
)
|
|
end
|
|
|
|
defp enrich_sync_errors([]), do: []
|
|
|
|
defp enrich_sync_errors(errors) when is_list(errors) do
|
|
name_by_id = fetch_member_names_by_ids(Enum.map(errors, fn {id, _} -> id end))
|
|
|
|
Enum.map(errors, fn {member_id, reason} ->
|
|
%{
|
|
member_id: member_id,
|
|
member_name: Map.get(name_by_id, member_id) || to_string(member_id),
|
|
message: Mv.Vereinfacht.format_error(reason),
|
|
detail: extract_vereinfacht_detail(reason)
|
|
}
|
|
end)
|
|
end
|
|
|
|
defp fetch_member_names_by_ids(ids) do
|
|
actor = SystemActor.get_system_actor()
|
|
opts = Helpers.ash_actor_opts(actor)
|
|
query = Ash.Query.filter(MemberResource, expr(id in ^ids))
|
|
|
|
case Ash.read(query, opts) do
|
|
{:ok, members} ->
|
|
Map.new(members, fn m -> {m.id, MemberHelpers.display_name(m)} end)
|
|
|
|
_ ->
|
|
%{}
|
|
end
|
|
end
|
|
|
|
defp extract_vereinfacht_detail({:http, _status, detail}) when is_binary(detail), do: detail
|
|
defp extract_vereinfacht_detail(_), do: nil
|
|
|
|
defp translate_vereinfacht_message(%{detail: detail}) when is_binary(detail) do
|
|
gettext("Vereinfacht: %{detail}",
|
|
detail: Gettext.dgettext(MvWeb.Gettext, "default", detail)
|
|
)
|
|
end
|
|
|
|
defp translate_vereinfacht_message(%{message: message}) do
|
|
Gettext.dgettext(MvWeb.Gettext, "default", message)
|
|
end
|
|
|
|
attr :result, :any, required: true
|
|
|
|
defp vereinfacht_test_result(%{result: {:ok, :connected}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-success bg-success/10 text-success-aa text-sm">
|
|
<.icon name="hero-check-circle" class="size-5 shrink-0" />
|
|
<span>{gettext("Connection successful. API URL, API Key and Club ID are valid.")}</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp vereinfacht_test_result(%{result: {:error, :not_configured}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
|
|
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
|
<span>{gettext("Not configured. Please set API URL, API Key and Club ID.")}</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp vereinfacht_test_result(%{result: {:error, {:http, _status, :html_response}}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
|
|
<span>
|
|
{gettext(
|
|
"Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)."
|
|
)}
|
|
</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp vereinfacht_test_result(%{result: {:error, {:http, 401, _}}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
|
|
<span>{gettext("Connection failed (HTTP 401): API key is invalid or missing.")}</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp vereinfacht_test_result(%{result: {:error, {:http, 403, _}}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
|
|
<span>
|
|
{gettext(
|
|
"Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions."
|
|
)}
|
|
</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp vereinfacht_test_result(%{result: {:error, {:http, 404, _}}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
|
|
<span>
|
|
{gettext(
|
|
"Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)."
|
|
)}
|
|
</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp vereinfacht_test_result(%{result: {:error, {:http, status, message}}} = assigns) do
|
|
assigns = assign(assigns, :status, status)
|
|
assigns = assign(assigns, :message, message)
|
|
|
|
~H"""
|
|
<div class="mt-3 flex items-start gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" />
|
|
<span>
|
|
{gettext("Connection failed (HTTP %{status}):", status: @status)}
|
|
<span class="ml-1">{@message}</span>
|
|
</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp vereinfacht_test_result(%{result: {:error, {:request_failed, _reason}}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
|
<span>
|
|
{gettext("Connection failed. Could not reach the API (network error or wrong URL).")}
|
|
</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp vereinfacht_test_result(%{result: {:error, _}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
|
<span>{gettext("Connection failed. Unknown error.")}</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
attr :result, :map, required: true
|
|
|
|
defp vereinfacht_sync_result(assigns) do
|
|
~H"""
|
|
<div class="mt-4 p-4 rounded-lg border border-base-300 bg-base-200 space-y-2">
|
|
<p class="font-medium">
|
|
{gettext("Last sync result:")}
|
|
<span class="text-success-aa ml-1">{gettext("%{count} synced", count: @result.synced)}</span>
|
|
<%= if @result.errors != [] do %>
|
|
<span class="text-error-aa ml-1">
|
|
{gettext("%{count} failed", count: length(@result.errors))}
|
|
</span>
|
|
<% end %>
|
|
</p>
|
|
<%= if @result.errors != [] do %>
|
|
<p class="text-sm text-base-content/70 mt-2">{gettext("Failed members:")}</p>
|
|
<ul class="list-disc list-inside text-sm space-y-1 max-h-48 overflow-y-auto">
|
|
<%= for err <- @result.errors do %>
|
|
<li>
|
|
<span class="font-medium">{err.member_name}</span>: {translate_vereinfacht_message(err)}
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
<% end %>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ---- SMTP test result component ----
|
|
|
|
attr :result, :any, required: true
|
|
|
|
defp smtp_test_result(%{result: {:ok, _}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-success bg-success/10 text-success-aa text-sm">
|
|
<.icon name="hero-check-circle" class="size-5 shrink-0" />
|
|
<span>{gettext("Test email sent successfully.")}</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp smtp_test_result(%{result: {:error, :invalid_email_address}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
|
<span>{gettext("Invalid email address. Please enter a valid recipient address.")}</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp smtp_test_result(%{result: {:error, :not_implemented}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
|
|
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
|
<span>{gettext("SMTP is not configured. Please set at least the SMTP host.")}</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp smtp_test_result(%{result: {:error, :sender_rejected}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
|
<span>
|
|
{gettext(
|
|
"Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user."
|
|
)}
|
|
</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp smtp_test_result(%{result: {:error, :auth_failed}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
|
<span>
|
|
{gettext("Authentication failed. Please check the SMTP username and password.")}
|
|
</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp smtp_test_result(%{result: {:error, :recipient_rejected}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
|
<span>{gettext("Recipient address rejected by the server.")}</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp smtp_test_result(%{result: {:error, :tls_failed}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
|
<span>
|
|
{gettext(
|
|
"TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)."
|
|
)}
|
|
</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp smtp_test_result(%{result: {:error, :connection_failed}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
|
<span>
|
|
{gettext("Server unreachable. Check host and port.")}
|
|
</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp smtp_test_result(%{result: {:error, {:smtp_error, message}}} = assigns)
|
|
when is_binary(message) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
|
<span>
|
|
{gettext("SMTP error:")} {@result |> elem(1) |> elem(1)}
|
|
</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp smtp_test_result(%{result: {:error, _reason}} = assigns) do
|
|
~H"""
|
|
<div class="mt-3 flex items-center gap-2 p-3 rounded-lg border border-error bg-error/10 text-error-aa text-sm">
|
|
<.icon name="hero-x-circle" class="size-5 shrink-0" />
|
|
<span>{gettext("Failed to send test email. Please check your SMTP configuration.")}</span>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ---- Join form helper functions ----
|
|
|
|
defp assign_join_form_state(socket, settings, custom_fields) do
|
|
enabled = settings.join_form_enabled || false
|
|
raw_ids = settings.join_form_field_ids || []
|
|
field_ids = if "email" in raw_ids, do: raw_ids, else: ["email" | raw_ids]
|
|
required_config = settings.join_form_field_required || %{}
|
|
|
|
join_form_fields = build_join_form_fields(field_ids, required_config, custom_fields)
|
|
|
|
%{member_fields: member_avail, custom_fields: custom_avail} =
|
|
build_available_join_form_fields(field_ids, custom_fields)
|
|
|
|
socket
|
|
|> assign(:join_form_enabled, enabled)
|
|
|> assign(:join_form_fields, join_form_fields)
|
|
|> assign(:available_join_form_member_fields, member_avail)
|
|
|> assign(:available_join_form_custom_fields, custom_avail)
|
|
|> assign(:show_add_field_dropdown, false)
|
|
|> assign(:join_form_custom_fields, custom_fields)
|
|
end
|
|
|
|
defp build_join_form_fields(field_ids, required_config, custom_fields) do
|
|
Enum.map(field_ids, fn id ->
|
|
label = join_form_field_label(id, custom_fields)
|
|
required = if id == "email", do: true, else: Map.get(required_config, id, false)
|
|
type = if id in member_field_id_strings(), do: :member_field, else: :custom_field
|
|
|
|
%{
|
|
id: id,
|
|
label: label,
|
|
required: required,
|
|
can_remove: id != "email",
|
|
can_toggle_required: id != "email",
|
|
type: type
|
|
}
|
|
end)
|
|
end
|
|
|
|
defp build_available_join_form_fields(selected_ids, custom_fields) do
|
|
member_fields =
|
|
Mv.Constants.member_fields()
|
|
|> Enum.reject(fn field -> Atom.to_string(field) in selected_ids end)
|
|
|> Enum.map(fn field ->
|
|
%{id: Atom.to_string(field), label: MemberFields.label(field), type: :member_field}
|
|
end)
|
|
|
|
custom_field_entries =
|
|
custom_fields
|
|
|> Enum.reject(fn cf -> cf.id in selected_ids end)
|
|
|> Enum.map(fn cf ->
|
|
%{id: cf.id, label: cf.name, type: :custom_field}
|
|
end)
|
|
|> Enum.sort_by(& &1.label)
|
|
|
|
%{member_fields: member_fields, custom_fields: custom_field_entries}
|
|
end
|
|
|
|
defp join_form_field_label(id, custom_fields) do
|
|
if id in member_field_id_strings() do
|
|
MemberFields.label(String.to_existing_atom(id))
|
|
else
|
|
case Enum.find(custom_fields, &(&1.id == id)) do
|
|
nil -> id
|
|
cf -> cf.name
|
|
end
|
|
end
|
|
end
|
|
|
|
defp toggle_required_if_matches(%{id: id} = field, id),
|
|
do: Map.put(field, :required, not field.required)
|
|
|
|
defp toggle_required_if_matches(field, _field_id), do: field
|
|
|
|
defp reorder_list(list, from_index, to_index) do
|
|
item = Enum.at(list, from_index)
|
|
rest = List.delete_at(list, from_index)
|
|
List.insert_at(rest, to_index, item)
|
|
end
|
|
|
|
defp member_field_id_strings do
|
|
Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
|
end
|
|
|
|
defp load_custom_fields(nil), do: []
|
|
|
|
defp load_custom_fields(actor) do
|
|
case Ash.read(Mv.Membership.CustomField,
|
|
actor: actor,
|
|
domain: Mv.Membership,
|
|
authorize?: true
|
|
) do
|
|
{:ok, fields} -> fields
|
|
{:error, _} -> []
|
|
end
|
|
end
|
|
end
|