Global settings: API key redaction and per-field ENV

Never put API key in form/DOM; show (set) badge, drop blank on save.
Per-field disabled when ENV set; save button only when not all from ENV.
This commit is contained in:
Moritz 2026-02-19 00:14:01 +01:00
parent e864dee8fe
commit 329c2d50ec
Signed by: moritz
GPG key ID: 1020A035E5DD0824

View file

@ -45,12 +45,21 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:active_editing_section, nil) |> assign(:active_editing_section, nil)
|> assign(:locale, locale) |> assign(:locale, locale)
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?()) |> 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_api_key_set, present?(settings.vereinfacht_api_key))
|> assign(:last_vereinfacht_sync_result, nil) |> assign(:last_vereinfacht_sync_result, nil)
|> assign_form() |> assign_form()
{:ok, socket} {:ok, socket}
end 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 @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -83,9 +92,7 @@ defmodule MvWeb.GlobalSettingsLive do
<.form_section title={gettext("Vereinfacht Integration")}> <.form_section title={gettext("Vereinfacht Integration")}>
<%= if @vereinfacht_env_configured do %> <%= if @vereinfacht_env_configured do %>
<p class="text-sm text-base-content/70 mb-4"> <p class="text-sm text-base-content/70 mb-4">
{gettext( {gettext("Some values are set via environment variables. Those fields are read-only.")}
"Configured via environment variables (VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID). Fields below are read-only."
)}
</p> </p>
<% end %> <% end %>
<.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save">
@ -94,35 +101,53 @@ defmodule MvWeb.GlobalSettingsLive do
field={@form[:vereinfacht_api_url]} field={@form[:vereinfacht_api_url]}
type="text" type="text"
label={gettext("API URL")} label={gettext("API URL")}
disabled={@vereinfacht_env_configured} disabled={@vereinfacht_api_url_env_set}
placeholder={ placeholder={
if(@vereinfacht_env_configured, if(@vereinfacht_api_url_env_set,
do: gettext("From VEREINFACHT_API_URL"), do: gettext("From VEREINFACHT_API_URL"),
else: "https://api.verein.visuel.dev/api/v1" else: "https://api.verein.visuel.dev/api/v1"
) )
} }
/> />
<.input <div class="form-control">
field={@form[:vereinfacht_api_key]} <label class="label" for={@form[:vereinfacht_api_key].id}>
type="password" <span class="label-text">{gettext("API Key")}</span>
label={gettext("API Key")} <%= if @vereinfacht_api_key_set do %>
disabled={@vereinfacht_env_configured} <span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
placeholder={ <% end %>
if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_API_KEY"), else: nil) </label>
} <.input
/> field={@form[:vereinfacht_api_key]}
type="password"
label=""
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
)
)
}
/>
</div>
<.input <.input
field={@form[:vereinfacht_club_id]} field={@form[:vereinfacht_club_id]}
type="text" type="text"
label={gettext("Club ID")} label={gettext("Club ID")}
disabled={@vereinfacht_env_configured} disabled={@vereinfacht_club_id_env_set}
placeholder={ placeholder={
if(@vereinfacht_env_configured, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2")
} }
/> />
</div> </div>
<.button <.button
:if={not @vereinfacht_env_configured} :if={
not (@vereinfacht_api_url_env_set and @vereinfacht_api_key_env_set and
@vereinfacht_club_id_env_set)
}
phx-disable-with={gettext("Saving...")} phx-disable-with={gettext("Saving...")}
variant="primary" variant="primary"
class="mt-2" class="mt-2"
@ -206,15 +231,17 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true @impl true
def handle_event("save", %{"setting" => setting_params}, socket) do def handle_event("save", %{"setting" => setting_params}, socket) do
actor = MvWeb.LiveHelpers.current_actor(socket) actor = MvWeb.LiveHelpers.current_actor(socket)
# Never send blank API key so we do not overwrite the stored secret (security)
setting_params_clean = drop_blank_vereinfacht_api_key(setting_params)
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
{:ok, _updated_settings} -> {:ok, _updated_settings} ->
# Reload settings from database to ensure all dependent data is updated
{:ok, fresh_settings} = Membership.get_settings() {:ok, fresh_settings} = Membership.get_settings()
socket = socket =
socket socket
|> assign(:settings, fresh_settings) |> assign(:settings, fresh_settings)
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|> put_flash(:info, gettext("Settings updated successfully")) |> put_flash(:info, gettext("Settings updated successfully"))
|> assign_form() |> assign_form()
@ -225,6 +252,16 @@ defmodule MvWeb.GlobalSettingsLive do
end end
end 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
@impl true @impl true
def handle_info({:custom_field_saved, _custom_field, action}, socket) do def handle_info({:custom_field_saved, _custom_field, action}, socket) do
send_update(MvWeb.CustomFieldLive.IndexComponent, send_update(MvWeb.CustomFieldLive.IndexComponent,
@ -305,9 +342,12 @@ defmodule MvWeb.GlobalSettingsLive do
end end
defp assign_form(%{assigns: %{settings: settings}} = socket) do defp assign_form(%{assigns: %{settings: settings}} = socket) do
# Never put API key into form/DOM to avoid secret leak in source or DevTools
settings_for_form = %{settings | vereinfacht_api_key: nil}
form = form =
AshPhoenix.Form.for_update( AshPhoenix.Form.for_update(
settings, settings_for_form,
:update, :update,
api: Membership, api: Membership,
as: "setting", as: "setting",