{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">
{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) --%>
<.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="outline"
phx-click="test_vereinfacht_connection"
phx-disable-with={gettext("Testing...")}
>
{gettext("Test Integration")}
<.button
:if={Mv.Config.vereinfacht_configured?()}
type="button"
variant="outline"
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 %>
<%!-- OIDC Section --%>
<.form_section title={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_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(: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_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
# ---- 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"""