defmodule PhoenixKitWeb.Live.Settings.IntegrationForm do @moduledoc """ Form page for adding or editing an integration connection. - `:new` action — shows provider picker, then setup form with instructions - `:edit` action — shows the setup form for an existing connection """ use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext require Logger alias PhoenixKit.Integrations alias PhoenixKit.Integrations.Events alias PhoenixKit.Integrations.OAuth alias PhoenixKit.Integrations.Providers alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes def mount(_params, _session, socket) do if connected?(socket), do: Events.subscribe() project_title = Settings.get_project_title() socket = socket |> assign(:page_title, gettext("Add Integration")) |> assign(:project_title, project_title) |> assign(:current_path, Routes.path("/admin/settings/integrations")) |> assign(:providers, Providers.all()) |> assign(:selected_provider, nil) |> assign(:provider, nil) |> assign(:name, nil) |> assign(:data, %{}) |> assign(:success, nil) |> assign(:error, nil) |> assign(:new_name, "") |> assign(:testing, false) |> assign(:oauth_state, nil) {:ok, socket} end def handle_params(params, url, socket) do # Store the base redirect URI from the actual browser URL so that # OAuth callbacks use the same origin Google will redirect to. redirect_uri = case URI.parse(url) do %{scheme: scheme, authority: authority, path: path} when is_binary(scheme) and is_binary(authority) -> "#{scheme}://#{authority}#{path}" _ -> nil end socket = assign(socket, :redirect_uri, redirect_uri) socket = apply_action(socket, socket.assigns.live_action, params) {:noreply, socket} end defp apply_action(socket, :new, _params) do socket |> assign(:page_title, gettext("Add Integration")) |> assign(:selected_provider, nil) |> assign(:provider, nil) |> assign(:name, nil) |> assign(:data, %{}) end defp apply_action(socket, :edit, %{"provider" => provider_key, "name" => name} = params) do provider = Providers.get(provider_key) full_key = "#{provider_key}:#{name}" data = case Integrations.get_integration(full_key) do {:ok, d} -> d _ -> %{} end socket = socket |> assign(:page_title, gettext("Edit Integration")) |> assign(:selected_provider, provider_key) |> assign(:provider, provider) |> assign(:name, name) |> assign(:data, data) # Handle OAuth callback (code in query params). # Only process during live WebSocket connection — during dead (static) render # the internal URI may differ from the external URL (e.g. http vs https behind # a reverse proxy), causing redirect_uri mismatch with Google's token endpoint. if connected?(socket) do case params do %{"code" => code, "state" => state} when is_binary(code) and code != "" -> handle_oauth_callback(full_key, code, state, socket) %{"code" => code} when is_binary(code) and code != "" -> handle_oauth_callback(full_key, code, nil, socket) %{"error" => error} -> description = params["error_description"] || error clean_path = Routes.path("/admin/settings/integrations/#{provider_key}/#{name}") socket |> put_flash(:error, gettext("Authorization failed: %{reason}", reason: description)) |> push_navigate(to: clean_path) _ -> socket end else socket end end defp apply_action(socket, :edit, _params) do # Missing provider/name params — redirect back to list socket |> put_flash(:error, gettext("Invalid integration URL")) |> push_navigate(to: Routes.path("/admin/settings/integrations")) end # --------------------------------------------------------------------------- # Events — provider selection (new mode) # --------------------------------------------------------------------------- def handle_event("select_provider", %{"provider" => provider_key}, socket) do provider = Providers.get(provider_key) {:noreply, socket |> assign(:selected_provider, provider_key) |> assign(:provider, provider) |> assign(:new_name, "")} end def handle_event("back_to_providers", _params, socket) do {:noreply, socket |> assign(:selected_provider, nil) |> assign(:provider, nil)} end # --------------------------------------------------------------------------- # Events — create new connection # --------------------------------------------------------------------------- def handle_event("create_connection", %{"name" => name} = params, socket) do provider_key = socket.assigns.selected_provider name = String.trim(name) # Default to "default" if empty name = if name == "", do: "default", else: name case Integrations.add_connection(provider_key, name, actor_uuid(socket)) do {:ok, _} -> save_and_redirect(provider_key, name, params, socket) {:error, :already_exists} -> save_and_redirect(provider_key, name, params, socket) {:error, :empty_name} -> {:noreply, assign(socket, :error, gettext("Please enter a connection name."))} {:error, reason} -> {:noreply, assign(socket, :error, "Failed: #{inspect(reason)}")} end end # --------------------------------------------------------------------------- # Events — save setup credentials (edit mode) # --------------------------------------------------------------------------- def handle_event("save_setup", params, socket) do provider_key = socket.assigns.selected_provider name = socket.assigns.name save_setup_fields(provider_key, name, params, socket) end # --------------------------------------------------------------------------- # Events — OAuth disconnect # --------------------------------------------------------------------------- def handle_event("disconnect_account", _params, socket) do provider_key = socket.assigns.selected_provider name = socket.assigns.name full_key = "#{provider_key}:#{name}" # Keep the setup credentials (client_id/secret) but remove tokens Integrations.disconnect(full_key, actor_uuid(socket)) # Reload data data = case Integrations.get_integration(full_key) do {:ok, d} -> d _ -> %{} end {:noreply, socket |> assign(:data, data) |> assign(:success, gettext("Account disconnected")) |> assign(:error, nil)} end # --------------------------------------------------------------------------- # Events — OAuth connect # --------------------------------------------------------------------------- def handle_event("connect_oauth", _params, socket) do provider_key = socket.assigns.selected_provider name = socket.assigns.name || "default" full_key = "#{provider_key}:#{name}" redirect_uri = socket.assigns[:redirect_uri] || build_redirect_uri(socket, provider_key, name) state = OAuth.generate_state() case Integrations.authorization_url(full_key, redirect_uri, nil, state) do {:ok, url} -> # Store state in integration data for verification on callback save_oauth_state(full_key, state) {:noreply, redirect(socket, external: url)} {:error, :client_id_not_configured} -> {:noreply, assign(socket, :error, gettext("Please save your Client ID first"))} {:error, _} -> {:noreply, assign(socket, :error, gettext("Failed to build authorization URL"))} end end # --------------------------------------------------------------------------- # Events — Test connection # --------------------------------------------------------------------------- def handle_event("test_connection", _params, socket) do send(self(), :do_test_connection) {:noreply, assign(socket, :testing, true)} end def handle_event("dismiss", _params, socket) do {:noreply, assign(socket, success: nil, error: nil)} end # --------------------------------------------------------------------------- # Async handlers # --------------------------------------------------------------------------- def handle_info(:do_test_connection, socket) do provider_key = socket.assigns.selected_provider name = socket.assigns.name full_key = "#{provider_key}:#{name}" provider = socket.assigns.provider uuid = actor_uuid(socket) result = run_connection_test(provider, full_key, uuid) Integrations.record_validation(full_key, result) data = case Integrations.get_integration(full_key) do {:ok, d} -> d _ -> socket.assigns.data end socket = case result do :ok -> socket |> assign(:data, data) |> assign(:success, gettext("Connection verified")) |> assign(:error, nil) {:error, reason} -> socket |> assign(:data, data) |> assign(:error, "#{gettext("Test failed")}: #{reason}") |> assign(:success, nil) end {:noreply, assign(socket, :testing, false)} end # PubSub: reload data when integrations change def handle_info({event, _, _}, socket) when event in [ :integration_setup_saved, :integration_connected, :integration_connection_added, :integration_validated ], do: {:noreply, reload_data(socket)} def handle_info({event, _}, socket) when event in [:integration_disconnected, :integration_connection_removed], do: {:noreply, reload_data(socket)} # Catch-all to prevent crashes from unexpected messages def handle_info(_msg, socket), do: {:noreply, socket} # --------------------------------------------------------------------------- # Private # --------------------------------------------------------------------------- defp handle_oauth_callback(full_key, code, state, socket) do clean_path = Routes.path( "/admin/settings/integrations/#{socket.assigns.selected_provider}/#{socket.assigns.name}" ) # Verify CSRF state token if one was stored case verify_oauth_state(full_key, state) do :ok -> # Use the actual browser URL as redirect_uri (must match what was sent to Google) redirect_uri = socket.assigns[:redirect_uri] || build_redirect_uri(socket, socket.assigns.selected_provider, socket.assigns.name) case Integrations.exchange_code(full_key, code, redirect_uri, actor_uuid(socket)) do {:ok, _data} -> push_navigate(socket, to: clean_path) {:error, reason} -> Logger.warning("[IntegrationForm] OAuth callback failed: #{inspect(reason)}") socket |> put_flash(:error, gettext("Failed to connect. Please try again.")) |> push_navigate(to: clean_path) end {:error, :state_mismatch} -> Logger.warning("[IntegrationForm] OAuth state mismatch for #{full_key}") socket |> put_flash(:error, gettext("Security check failed. Please try connecting again.")) |> push_navigate(to: clean_path) end end # --------------------------------------------------------------------------- # Private # --------------------------------------------------------------------------- defp save_and_redirect(provider_key, name, params, socket) do full_key = "#{provider_key}:#{name}" attrs = extract_setup_attrs(provider_key, params) case Integrations.save_setup(full_key, attrs, actor_uuid(socket)) do {:ok, _data} -> edit_path = Routes.path("/admin/settings/integrations/#{provider_key}/#{name}") {:noreply, push_navigate(socket, to: edit_path)} {:error, _} -> {:noreply, assign(socket, :error, gettext("Failed to save"))} end end defp run_connection_test(_provider, full_key, actor_uuid) do Integrations.validate_connection(full_key, actor_uuid) end defp save_setup_fields(provider_key, name, params, socket) do full_key = "#{provider_key}:#{name}" attrs = extract_setup_attrs(provider_key, params) case Integrations.save_setup(full_key, attrs, actor_uuid(socket)) do {:ok, data} -> {:noreply, socket |> assign(:name, name) |> assign(:data, data) |> assign(:success, gettext("Saved")) |> assign(:error, nil)} {:error, _} -> {:noreply, assign(socket, :error, gettext("Failed to save"))} end end defp extract_setup_attrs(provider_key, params) do case Providers.get(provider_key) do nil -> %{} provider -> Enum.reduce(provider.setup_fields, %{}, fn field, acc -> value = String.trim(params[field.key] || "") # For password fields, skip empty values to keep the existing credential if field.type == :password and value == "" do acc else Map.put(acc, field.key, value) end end) end end defp reload_data(socket) do if socket.assigns.name && socket.assigns.selected_provider do full_key = "#{socket.assigns.selected_provider}:#{socket.assigns.name}" data = case Integrations.get_integration(full_key) do {:ok, d} -> d _ -> %{} end assign(socket, :data, data) else socket end end defp save_oauth_state(full_key, state) do case Integrations.get_integration(full_key) do {:ok, data} -> Integrations.save_setup(full_key, Map.put(data, "oauth_state", state)) _ -> :ok end end defp verify_oauth_state(full_key, callback_state) do case Integrations.get_integration(full_key) do {:ok, %{"oauth_state" => stored_state}} when is_binary(stored_state) and stored_state != "" -> if callback_state == stored_state do # Clear the used state token {:ok, data} = Integrations.get_integration(full_key) Integrations.save_setup(full_key, Map.delete(data, "oauth_state")) :ok else {:error, :state_mismatch} end _ -> # No state was stored (legacy flow or state not required) — allow :ok end end defp build_redirect_uri(socket, provider_key, name) do base = Settings.get_setting("site_url", "") locale = socket.assigns[:current_locale_base] path = Routes.path("/admin/settings/integrations/#{provider_key}/#{name}", locale: locale) if is_binary(base) and base != "" do "#{String.trim_trailing(base, "/")}#{path}" else Logger.warning( "[IntegrationForm] site_url not configured — using localhost fallback for OAuth redirect URI" ) "http://localhost:4000#{path}" end end defp actor_uuid(socket) do case socket.assigns[:phoenix_kit_current_scope] do %{user: %{uuid: uuid}} -> uuid _ -> nil end end # Simple inline markdown: **bold**, [links](url), `code`, and {variables} defp render_markdown_inline(text, vars) do text |> replace_vars(vars) |> String.replace(~r/`(.+?)`/, "\\1") |> String.replace(~r/\*\*(.+?)\*\*/, "\\1") |> String.replace(~r/\[(.+?)\]\((.+?)\)/, "\\1") end defp replace_vars(text, vars) do Enum.reduce(vars, text, fn {key, value}, acc -> escaped = Phoenix.HTML.html_escape(value || "") |> Phoenix.HTML.safe_to_string() String.replace(acc, "{#{key}}", escaped) end) end defp has_setup_credentials?(data, provider) do Enum.all?(provider.setup_fields, fn field -> if field.required do val = data[field.key] is_binary(val) and val != "" else true end end) end defp format_date(nil), do: "" defp format_date(iso_string) when is_binary(iso_string) do case DateTime.from_iso8601(iso_string) do {:ok, dt, _} -> Calendar.strftime(dt, "%Y-%m-%d") _ -> iso_string end end end