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_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 |> assign(:page_title, gettext("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_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""" <.header> {gettext("Settings")} <:subtitle> {gettext("Manage global settings for the association.")}
<%!-- Club Settings Section --%> <.form_section title={gettext("Club Settings")}> <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
<.input field={@form[:club_name]} type="text" label={gettext("Association Name")} required />
<.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save Name")} <%!-- Join Form Section (Beitrittsformular) --%> <.form_section title={gettext("Join Form")}>

{gettext( "Configure the public join form that allows new members to submit a join request." )}

<%!-- Enable/disable --%>
<%!-- Copyable join page link (below checkbox, above field list) --%>

{gettext("Link to the public join page (share this with applicants):")}

<.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")}
<%!-- Field list header + Add button (left-aligned) --%>

{gettext("Fields on the join form")}

<.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")} <%!-- Available fields dropdown (sections: Personal data, Custom fields) --%>
{gettext("Personal data")}
{field.label}
{gettext("Individual fields")}
{field.label}
<%!-- Empty state --%>

{gettext("No fields selected. Add at least the email field.")}

<%!-- Fields table (compact width, reorderable) --%>
<.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 :let={field} label={gettext("Required")} class="w-24 max-w-[9.375rem] text-center" > <: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" />

{gettext("The order of rows determines the field order in the join form.")}

<%!-- SMTP / E-Mail Section --%> <.form_section title={gettext("SMTP / E-Mail")}> <%= if @smtp_env_configured do %>

{gettext("Some values are set via environment variables. Those fields are read-only.")}

<% end %> <%= if @environment == :prod and not @smtp_configured do %>
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" /> {gettext( "SMTP is not configured. Transactional emails (join confirmation, password reset, etc.) will not be delivered reliably." )}
<% end %> <.form for={@form} id="smtp-form" phx-change="validate" phx-submit="save">
<.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)} />
<.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 ) ) } />
<.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") } />

{gettext( "The sender email must be owned by or authorized for the SMTP user on most servers." )}

<.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")} <%!-- Test email: use form phx-submit so the current input value is always sent (e.g. after paste without blur) --%>

{gettext("Test email")}

<.form for={%{}} id="smtp-test-email-form" data-testid="smtp-test-email-form" phx-submit="send_smtp_test_email" class="space-y-3" >
<.button type="submit" variant="secondary" class="mb-1" data-testid="smtp-send-test-email" phx-disable-with={gettext("Sending...")} > {gettext("Send test email")}
<%= if @smtp_test_result do %>
<.smtp_test_result result={@smtp_test_result} />
<% end %>
<%!-- Vereinfacht Integration Section --%> <.form_section title={gettext("Accounting-Software (Vereinfacht) Integration")}> <%= if @vereinfacht_env_configured do %>

{gettext("Some values are set via environment variables. Those fields are read-only.")}

<% end %> <.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save">
<.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" ) } />
<%= 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 %>

<.icon name="hero-exclamation-circle" class="size-5" /> {msg}

<% end %>
<.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" ) } />
<.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 :if={Mv.Config.vereinfacht_configured?()} type="button" variant="secondary" phx-click="test_vereinfacht_connection" phx-disable-with={gettext("Testing...")} > {gettext("Test Integration")} <.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")}
<%= 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 %> <%!-- Authentication: Direct registration + OIDC --%> <.form_section title={gettext("Authentication")}>

{gettext("Direct registration")}

{gettext( "If disabled, users cannot sign up via /register; sign-in and the join form remain available." )}

{gettext("OIDC (Single Sign-On)")}

<%= if @oidc_env_configured do %>

{gettext("Some values are set via environment variables. Those fields are read-only.")}

<% end %> <.form for={@form} id="oidc-form" phx-change="validate" phx-submit="save">
<.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" ) } />
<%= 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 %>

<.icon name="hero-exclamation-circle" class="size-5" /> {msg}

<% end %>
<.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" ) } />
<.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 } />

{gettext( "When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button." )}

<.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")}
""" 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} -> {:ok, fresh_settings} = Membership.get_settings() 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(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?()) |> 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 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 @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"""
<.icon name="hero-check-circle" class="size-5 shrink-0" /> {gettext("Connection successful. API URL, API Key and Club ID are valid.")}
""" end defp vereinfacht_test_result(%{result: {:error, :not_configured}} = assigns) do ~H"""
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" /> {gettext("Not configured. Please set API URL, API Key and Club ID.")}
""" end defp vereinfacht_test_result(%{result: {:error, {:http, _status, :html_response}}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> {gettext( "Connection failed. The URL does not point to a Vereinfacht API (received HTML instead of JSON)." )}
""" end defp vereinfacht_test_result(%{result: {:error, {:http, 401, _}}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> {gettext("Connection failed (HTTP 401): API key is invalid or missing.")}
""" end defp vereinfacht_test_result(%{result: {:error, {:http, 403, _}}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> {gettext( "Connection failed (HTTP 403): Access denied. Please check the Club ID and API key permissions." )}
""" end defp vereinfacht_test_result(%{result: {:error, {:http, 404, _}}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> {gettext( "Connection failed (HTTP 404): API endpoint not found. Please check the API URL (e.g. correct version path)." )}
""" end defp vereinfacht_test_result(%{result: {:error, {:http, status, message}}} = assigns) do assigns = assign(assigns, :status, status) assigns = assign(assigns, :message, message) ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0 mt-0.5" /> {gettext("Connection failed (HTTP %{status}):", status: @status)} {@message}
""" end defp vereinfacht_test_result(%{result: {:error, {:request_failed, _reason}}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0" /> {gettext("Connection failed. Could not reach the API (network error or wrong URL).")}
""" end defp vereinfacht_test_result(%{result: {:error, _}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0" /> {gettext("Connection failed. Unknown error.")}
""" end attr :result, :map, required: true defp vereinfacht_sync_result(assigns) do ~H"""

{gettext("Last sync result:")} {gettext("%{count} synced", count: @result.synced)} <%= if @result.errors != [] do %> {gettext("%{count} failed", count: length(@result.errors))} <% end %>

<%= if @result.errors != [] do %>

{gettext("Failed members:")}

<% end %>
""" end # ---- SMTP test result component ---- attr :result, :any, required: true defp smtp_test_result(%{result: {:ok, _}} = assigns) do ~H"""
<.icon name="hero-check-circle" class="size-5 shrink-0" /> {gettext("Test email sent successfully.")}
""" end defp smtp_test_result(%{result: {:error, :invalid_email_address}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0" /> {gettext("Invalid email address. Please enter a valid recipient address.")}
""" end defp smtp_test_result(%{result: {:error, :not_implemented}} = assigns) do ~H"""
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" /> {gettext("SMTP is not configured. Please set at least the SMTP host.")}
""" end defp smtp_test_result(%{result: {:error, :sender_rejected}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0" /> {gettext( "Sender address rejected. The \"Sender email\" must be owned by or authorized for the SMTP user." )}
""" end defp smtp_test_result(%{result: {:error, :auth_failed}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0" /> {gettext("Authentication failed. Please check the SMTP username and password.")}
""" end defp smtp_test_result(%{result: {:error, :recipient_rejected}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0" /> {gettext("Recipient address rejected by the server.")}
""" end defp smtp_test_result(%{result: {:error, :tls_failed}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0" /> {gettext( "TLS connection failed. Check the TLS/SSL setting and port (587 for TLS, 465 for SSL)." )}
""" end defp smtp_test_result(%{result: {:error, :connection_failed}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0" /> {gettext("Server unreachable. Check host and port.")}
""" end defp smtp_test_result(%{result: {:error, {:smtp_error, message}}} = assigns) when is_binary(message) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0" /> {gettext("SMTP error:")} {@result |> elem(1) |> elem(1)}
""" end defp smtp_test_result(%{result: {:error, _reason}} = assigns) do ~H"""
<.icon name="hero-x-circle" class="size-5 shrink-0" /> {gettext("Failed to send test email. Please check your SMTP configuration.")}
""" 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