Packages

phoenix_kit

2.49.1
2.64.1 2.64.0 2.63.0 2.62.2 2.62.1 2.62.0 2.61.0 2.60.3 2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web live settings.ex
Raw

lib/phoenix_kit_web/live/settings.ex

defmodule PhoenixKitWeb.Live.Settings do
  @moduledoc """
  Admin settings management LiveView for PhoenixKit.

  Provides interface for managing system-wide settings including timezone, date/time formats.
  """
  use PhoenixKitWeb, :live_view
  use Gettext, backend: PhoenixKitWeb.Gettext

  alias PhoenixKit.Config.EndpointUrlSync
  alias PhoenixKit.Modules.Storage.URLSigner
  alias PhoenixKit.Settings
  alias PhoenixKit.Settings.Events, as: SettingsEvents
  alias PhoenixKit.Users.OAuthConfig
  alias PhoenixKit.Utils.Date, as: UtilsDate
  alias PhoenixKit.Utils.Routes
  alias PhoenixKit.Utils.TimeZone
  alias PhoenixKitWeb.Actor
  alias PhoenixKitWeb.Live.Settings.UrlTabs

  require Logger

  def mount(_params, _session, socket) do
    # Subscribe to settings changes for live updates (like entities does)
    if connected?(socket) do
      SettingsEvents.subscribe_to_settings()
    end

    # Load current settings from database. S007: this page never renders the
    # OAuth login secrets or AWS keys (that's the Authorization page's job),
    # so it has no reason to hold their real values in socket state at all —
    # list_public_settings/0 (an explicit allow list, not a redaction of
    # list_all_settings/0) leaves them out entirely.
    current_settings = Settings.list_public_settings()
    defaults = Settings.get_defaults()

    # The saved site timezone has to be one of the offered options, or the
    # <select> renders with its FIRST option selected and the next save of this
    # form silently rewrites the site's timezone. Every installation that
    # predates named timezones stores an offset like "0", which is no longer a
    # row on its own, so it is added as one.
    saved_time_zone =
      Map.get(current_settings, "time_zone") || Settings.get_setting("time_zone", "0")

    setting_options =
      Map.put(
        Settings.get_setting_options(),
        "time_zone",
        TimeZone.options(selected: saved_time_zone)
      )

    # Merge defaults with current settings to ensure all keys exist, then
    # take only the public keys again — defaults still lists the restricted
    # ones (with their harmless placeholder values), and the merge must not
    # reintroduce them.
    merged_settings =
      Map.merge(defaults, current_settings)
      |> Map.take(Settings.public_setting_keys())

    # Create form changeset
    changeset = Settings.change_settings(merged_settings)

    socket =
      socket
      |> assign(:page_title, gettext("General"))
      |> assign(:page_subtitle, gettext("Configure system preferences and options"))
      |> assign(:page_section, gettext("Settings"))
      |> assign(:page_section_path, Routes.path("/admin/settings"))
      |> assign(:settings, merged_settings)
      # Track saved values separately
      |> assign(:saved_settings, merged_settings)
      |> assign(:setting_options, setting_options)
      |> assign(:changeset, changeset)
      |> assign(:saving, false)
      |> assign(:show_media_selector, false)
      |> assign(:media_selector_target, nil)
      # Resolved when the selector opens — the host hook may create a folder.
      |> assign(:branding_scope_folder, nil)
      |> assign(
        :project_title,
        merged_settings["project_title"] || PhoenixKit.Config.get(:project_title, "PhoenixKit")
      )

    {:ok, socket}
  end

  # The tab lives in the URL (`?tab=sessions`); see `UrlTabs`.
  def handle_params(params, _url, socket) do
    {:noreply, assign(socket, :active_tab, UrlTabs.active(params, tabs()))}
  end

  defp tabs do
    [
      %{id: "identity", label: gettext("Site Identity"), icon: "hero-identification"},
      %{id: "address", label: gettext("Site Address"), icon: "hero-globe-alt"},
      %{id: "features", label: gettext("Features"), icon: "hero-adjustments-horizontal"},
      %{id: "editor", label: gettext("Content Editor"), icon: "hero-pencil-square"},
      %{id: "datetime", label: gettext("Date & Time"), icon: "hero-clock"}
    ]
  end

  def handle_event("validate_settings", %{"settings" => settings_params}, socket) do
    # Update the changeset with new values for validation
    changeset = Settings.validate_settings(settings_params)

    # Update the current settings to reflect the pending changes (but don't save to DB)
    socket =
      socket
      |> assign(:settings, settings_params)
      |> assign(:changeset, changeset)

    {:noreply, socket}
  end

  def handle_event("save_settings", %{"settings" => settings_params}, socket) do
    socket = assign(socket, :saving, true)

    case Settings.update_settings(settings_params, history_opts(socket)) do
      {:ok, updated_settings} ->
        handle_settings_saved(socket, settings_params, updated_settings)

      {:error, errors} ->
        handle_settings_error(socket, errors)
    end
  end

  def handle_event("reset_to_defaults", _params, socket) do
    # S007: this page never manages the OAuth login secrets or AWS keys, so
    # reset must not touch them either — Settings.get_defaults/0 includes
    # them (aws_access_key_id/aws_secret_access_key can even default to the
    # host's real configured credentials via PhoenixKit.Config.AWS), and
    # update_settings/1 writes every key it's handed. Without this filter,
    # clicking "Reset" here would silently overwrite live OAuth/AWS
    # credentials the confirm dialog never mentions.
    defaults = Settings.get_defaults() |> Map.take(Settings.public_setting_keys())

    # Update public settings to defaults in database
    case Settings.update_settings(defaults, history_opts(socket)) do
      {:ok, updated_settings} ->
        # Sync site_url to Endpoint after reset
        EndpointUrlSync.sync()

        # Update socket with default settings
        changeset = Settings.change_settings(updated_settings)

        socket =
          socket
          |> assign(:settings, updated_settings)
          |> assign(:saved_settings, updated_settings)
          |> assign(:changeset, changeset)
          |> assign(
            :project_title,
            updated_settings["project_title"] ||
              PhoenixKit.Config.get(:project_title, "PhoenixKit")
          )
          |> put_flash(:info, "Settings reset to defaults successfully")

        {:noreply, socket}

      {:error, errors} ->
        error_msg = format_error_message(errors)

        socket =
          socket
          |> put_flash(:error, error_msg)

        {:noreply, socket}
    end
  end

  def handle_event("open_media_selector", %{"target" => target}, socket) do
    {:noreply,
     socket
     |> assign(
       :branding_scope_folder,
       PhoenixKit.UploadsParentFolder.resolve(:branding, Actor.uuid(socket), nil)
     )
     |> assign(:show_media_selector, true)
     |> assign(:media_selector_target, target)}
  end

  def handle_event("clear_image", %{"target" => target}, socket) do
    key = media_target_to_key(target)
    settings = Map.put(socket.assigns.settings, key, "")
    {:noreply, assign(socket, :settings, settings)}
  end

  # Who is saving, for the settings history (`PhoenixKit.Settings.History`).
  defp history_opts(socket) do
    [
      actor_uuid: Actor.uuid(socket),
      source: "settings"
    ]
  end

  ## Media selector callbacks

  def handle_info({:media_selected, file_uuids}, socket) do
    file_uuid = List.first(file_uuids) || ""
    key = media_target_to_key(socket.assigns.media_selector_target)
    settings = Map.put(socket.assigns.settings, key, file_uuid)

    {:noreply,
     socket
     |> assign(:settings, settings)
     |> assign(:show_media_selector, false)
     |> assign(:media_selector_target, nil)}
  end

  def handle_info({:media_selector_closed}, socket) do
    {:noreply,
     socket
     |> assign(:show_media_selector, false)
     |> assign(:media_selector_target, nil)}
  end

  # Every committed settings write is broadcast, including this page's own
  # saves; the page reloads from its own save handler, so the events are
  # accepted and ignored here rather than left to crash the process.
  def handle_info({:setting_changed, _key, _value}, socket) do
    {:noreply, socket}
  end

  def handle_info({:setting_deleted, _key}, socket) do
    {:noreply, socket}
  end

  defp media_target_to_key("logo"), do: "auth_logo_file_uuid"
  defp media_target_to_key("site_icon"), do: "site_icon_file_uuid"
  defp media_target_to_key(_), do: "auth_logo_file_uuid"

  # Handle successful settings save
  defp handle_settings_saved(socket, _settings_params_to_save, updated_settings) do
    # Sync site_url to Endpoint and reload OAuth configuration
    EndpointUrlSync.sync()
    OAuthConfig.configure_providers()

    # Update socket with new settings
    changeset = Settings.change_settings(updated_settings)

    socket =
      socket
      |> assign(:settings, updated_settings)
      |> assign(:saved_settings, updated_settings)
      |> assign(:changeset, changeset)
      |> assign(:saving, false)
      |> assign(
        :project_title,
        updated_settings["project_title"] || PhoenixKit.Config.get(:project_title, "PhoenixKit")
      )
      |> put_flash(:info, "Settings updated successfully")

    {:noreply, socket}
  end

  # Handle settings save error
  defp handle_settings_error(socket, errors) do
    Logger.error("Settings save error: #{inspect(errors)}")

    error_msg = format_error_message(errors)

    socket =
      socket
      |> assign(:saving, false)
      |> put_flash(:error, error_msg)

    {:noreply, socket}
  end

  # Format error messages for display
  defp format_error_message(%Ecto.Changeset{} = changeset) do
    # Extract error messages from changeset
    changeset
    |> Ecto.Changeset.traverse_errors(fn {msg, opts} ->
      Enum.reduce(opts, msg, fn {key, value}, acc ->
        String.replace(acc, "%{#{key}}", to_string(value))
      end)
    end)
    |> Map.values()
    |> List.flatten()
    |> Enum.join(", ")
  end

  # Helper functions for template to show dropdown labels
  def get_timezone_label(value, setting_options) do
    Settings.get_timezone_label(value, setting_options)
  end

  def get_option_label(value, options) do
    Settings.get_option_label(value, options)
  end

  # Helper functions for template to show current format examples
  def get_current_date_example(format) do
    UtilsDate.format_date(Date.utc_today(), format)
  end

  def get_current_time_example(format) do
    UtilsDate.format_time(Time.utc_now(), format)
  end

  def signed_preview_url(file_uuid, variant) do
    URLSigner.signed_url(file_uuid, variant)
  end

  # Host shown in the browser-tab preview's address bar. Best-effort — the
  # mockup is decorative, so any failure just falls back to a placeholder.
  def site_host do
    case URI.parse(Routes.url("/")).host do
      host when is_binary(host) and host != "" -> host
      _ -> "example.com"
    end
  rescue
    _ -> "example.com"
  end
end