- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia) - Jason.decode! → Jason.decode with error handling for untrusted input - inspect() leak: replace with generic error messages, log details server-side - SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes - SSRF validation added to HTTP monitoring executor and integration credentials - GraphQL complexity limits: always applied, not just in prod - GraphQL introspection: also check GET query params, not just body - Stripe webhook: explicit nil/empty checks for signature and body - Cookie security: secure flag for session (prod), http_only+secure for remember_me - Honeybadger API key: read from env var with fallback - String.to_integer → Integer.parse with fallback for URL params - String.to_atom → whitelist map for HTTP methods - Gaiia webhook: remove secret_len and expected signature from log - Admin API: add rate limiting pipeline - to_atom_keys: per-key fallback instead of all-or-nothing rescue
876 lines
30 KiB
Elixir
876 lines
30 KiB
Elixir
defmodule ToweropsWeb.Org.SettingsLive do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Accounts.UserNotifier
|
|
alias Towerops.Admin.AuditLogger
|
|
alias Towerops.Agents
|
|
alias Towerops.Billing
|
|
alias Towerops.Gaiia.Client, as: GaiiaClient
|
|
alias Towerops.Integrations
|
|
alias Towerops.Integrations.Integration
|
|
alias Towerops.Organizations
|
|
alias Towerops.Organizations.SubscriptionLimits
|
|
alias Towerops.Preseem.Client, as: PreseemClient
|
|
alias Towerops.Sonar.Client, as: SonarClient
|
|
alias Towerops.Splynx.Client, as: SplynxClient
|
|
alias Towerops.Visp.Client, as: VispClient
|
|
|
|
require Logger
|
|
|
|
@billing_provider_ids ~w(gaiia sonar splynx visp)
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
user = socket.assigns.current_scope.user
|
|
available_agents = Agents.list_organization_agent_tokens(organization.id)
|
|
assignment_breakdown = Agents.Stats.get_device_assignment_breakdown(organization.id)
|
|
membership = Organizations.get_membership(organization.id, user.id)
|
|
user_orgs_count = user.id |> Organizations.list_user_organizations() |> length()
|
|
|
|
AuditLogger.log_org_data_accessed(nil, user.id, organization.id, "view_settings")
|
|
|
|
changeset = Organizations.change_organization(organization)
|
|
|
|
# Billing information
|
|
{current_devices, device_limit} = SubscriptionLimits.device_quota(organization)
|
|
{:ok, cost_info} = Billing.estimated_monthly_cost(organization)
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, t("Organization Settings"))
|
|
|> assign(:organization, organization)
|
|
|> assign(:membership, membership)
|
|
|> assign(:user_orgs_count, user_orgs_count)
|
|
|> assign(:available_agents, available_agents)
|
|
|> assign(:assignment_breakdown, assignment_breakdown)
|
|
|> assign(:form, to_form(changeset))
|
|
|> assign(:active_tab, "general")
|
|
# Billing tab assigns
|
|
|> assign(:current_devices, current_devices)
|
|
|> assign(:device_limit, device_limit)
|
|
|> assign(:estimated_cost, cost_info)
|
|
# Members tab assigns - loaded lazily
|
|
|> assign(:members, [])
|
|
|> assign(:pending_invitations, [])
|
|
|> assign(:invite_form, to_form(%{"email" => "", "role" => "technician"}))
|
|
# Integration assigns - loaded lazily when tab is selected
|
|
|> assign(:integrations, %{})
|
|
|> assign(:timezone, socket.assigns.current_scope.timezone)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
tab = Map.get(params, "tab", "general")
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:active_tab, tab)
|
|
|> maybe_load_integrations(tab)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp maybe_load_integrations(socket, "general") do
|
|
integrations = load_integrations(socket.assigns.organization.id)
|
|
assign(socket, :integrations, integrations)
|
|
end
|
|
|
|
defp maybe_load_integrations(socket, "members") do
|
|
org_id = socket.assigns.organization.id
|
|
|
|
socket
|
|
|> assign(:members, Organizations.list_organization_members(org_id))
|
|
|> assign(:pending_invitations, Organizations.list_pending_invitations(org_id))
|
|
end
|
|
|
|
defp maybe_load_integrations(socket, _tab), do: socket
|
|
|
|
# === Org settings events ===
|
|
|
|
@impl true
|
|
def handle_event("validate", %{"organization" => org_params}, socket) do
|
|
changeset =
|
|
socket.assigns.organization
|
|
|> Organizations.change_organization(org_params)
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save", %{"organization" => org_params}, socket) do
|
|
require_admin_or_owner(socket, fn ->
|
|
case Organizations.update_organization(socket.assigns.organization, org_params) do
|
|
{:ok, organization} ->
|
|
changeset = Organizations.change_organization(organization)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Settings saved successfully"))
|
|
|> assign(:organization, organization)
|
|
|> assign(:form, to_form(changeset))}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
|
end
|
|
end)
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("apply_snmp_to_all", _params, socket) do
|
|
require_admin_or_owner(socket, fn ->
|
|
{count, _} = Organizations.apply_snmp_config_to_all_equipment(socket.assigns.organization.id)
|
|
|
|
{:noreply,
|
|
put_flash(socket, :info, t("Applied SNMP configuration to %{count} device records across all sites", count: count))}
|
|
end)
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("apply_agent_to_all", _params, socket) do
|
|
require_admin_or_owner(socket, fn ->
|
|
{count, _} = Organizations.apply_agent_to_all_equipment(socket.assigns.organization.id)
|
|
|
|
{:noreply,
|
|
put_flash(socket, :info, t("Applied default agent to %{count} device records across all sites", count: count))}
|
|
end)
|
|
end
|
|
|
|
# === Members tab events ===
|
|
|
|
@impl true
|
|
def handle_event("send_invitation", %{"email" => email, "role" => role}, socket) do
|
|
require_admin_or_owner(socket, fn ->
|
|
organization = socket.assigns.organization
|
|
user = socket.assigns.current_scope.user
|
|
|
|
attrs = %{
|
|
email: String.trim(email),
|
|
role: role,
|
|
organization_id: organization.id,
|
|
invited_by_id: user.id
|
|
}
|
|
|
|
case Organizations.create_invitation(attrs) do
|
|
{:ok, invitation} ->
|
|
invitation =
|
|
invitation
|
|
|> Towerops.Repo.preload(:invited_by)
|
|
|> Map.put(:organization, organization)
|
|
|
|
accept_url = url(~p"/invitations/#{invitation.token}")
|
|
UserNotifier.deliver_invitation_email(invitation, accept_url)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Invitation sent to %{email}", email: email))
|
|
|> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))
|
|
|> assign(:invite_form, to_form(%{"email" => "", "role" => "technician"}))}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply,
|
|
put_flash(socket, :error, t("Failed to send invitation: %{errors}", errors: error_messages(changeset)))}
|
|
end
|
|
end)
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("cancel_invitation", %{"id" => id}, socket) do
|
|
organization = socket.assigns.organization
|
|
invitation = Towerops.Repo.get!(Towerops.Organizations.Invitation, id)
|
|
|
|
if invitation.organization_id == organization.id do
|
|
Organizations.delete_invitation(invitation)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Invitation cancelled"))
|
|
|> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))}
|
|
else
|
|
{:noreply, put_flash(socket, :error, t("Invitation not found"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("remove_member", %{"user-id" => user_id}, socket) do
|
|
require_admin_or_owner(socket, fn ->
|
|
organization = socket.assigns.organization
|
|
|
|
case Organizations.remove_member(organization.id, user_id) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Member removed"))
|
|
|> assign(:members, Organizations.list_organization_members(organization.id))}
|
|
|
|
{:error, :cannot_remove_owner} ->
|
|
{:noreply, put_flash(socket, :error, t("Cannot remove the organization owner"))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to remove member"))}
|
|
end
|
|
end)
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("change_role", %{"user-id" => user_id, "role" => role}, socket) do
|
|
require_admin_or_owner(socket, fn ->
|
|
organization = socket.assigns.organization
|
|
|
|
case Organizations.update_member_role(organization.id, user_id, role) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Role updated"))
|
|
|> assign(:members, Organizations.list_organization_members(organization.id))}
|
|
|
|
{:error, :cannot_change_owner_role} ->
|
|
{:noreply, put_flash(socket, :error, t("Cannot change the owner's role"))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to update role"))}
|
|
end
|
|
end)
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_default_org", _params, socket) do
|
|
user = socket.assigns.current_scope.user
|
|
organization = socket.assigns.organization
|
|
membership = socket.assigns.membership
|
|
|
|
if membership.is_default do
|
|
{:noreply, put_flash(socket, :error, t("This is already your default organization"))}
|
|
else
|
|
case Organizations.set_default_organization(user.id, organization.id) do
|
|
{:ok, _} ->
|
|
updated_membership = Organizations.get_membership(organization.id, user.id)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "#{organization.name} is now your default organization")
|
|
|> assign(:membership, updated_membership)}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to set default organization"))}
|
|
end
|
|
end
|
|
end
|
|
|
|
# === Integration events ===
|
|
|
|
@impl true
|
|
def handle_event("configure", %{"provider" => provider}, socket) do
|
|
if socket.assigns.configuring == provider do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:configuring, nil)
|
|
|> assign(:integration_form, nil)
|
|
|> assign(:test_result, nil)}
|
|
else
|
|
integration = Map.get(socket.assigns.integrations, provider)
|
|
|
|
form =
|
|
case integration do
|
|
nil ->
|
|
%Integration{}
|
|
|> Integrations.change_integration(%{provider: provider})
|
|
|> to_form()
|
|
|
|
existing ->
|
|
existing
|
|
|> Integrations.change_integration(%{})
|
|
|> to_form()
|
|
end
|
|
|
|
socket =
|
|
if provider == "netbox" do
|
|
assign(socket, :netbox_config, load_netbox_config(integration))
|
|
else
|
|
assign(socket, :integration_credentials, load_integration_credentials(integration, provider))
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:configuring, provider)
|
|
|> assign(:integration_form, form)
|
|
|> assign(:test_result, nil)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("close_config", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:configuring, nil)
|
|
|> assign(:integration_form, nil)
|
|
|> assign(:test_result, nil)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("set_netbox_sync_direction", %{"direction" => direction}, socket) do
|
|
netbox_config = Map.put(socket.assigns.netbox_config, "sync_direction", direction)
|
|
{:noreply, assign(socket, :netbox_config, netbox_config)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("validate_integration", %{"integration" => params}, socket) do
|
|
integration = get_current_integration(socket)
|
|
|
|
changeset =
|
|
integration
|
|
|> Integrations.change_integration(normalize_params(params, integration))
|
|
|> Map.put(:action, :validate)
|
|
|
|
socket =
|
|
case socket.assigns.configuring do
|
|
"netbox" ->
|
|
assign(socket, :netbox_config, %{
|
|
"url" => Map.get(params, "url", ""),
|
|
"api_token" => Map.get(params, "api_token", ""),
|
|
"sync_direction" => Map.get(params, "sync_direction", "pull"),
|
|
"sync_devices" => Map.get(params, "sync_devices", "true") == "true",
|
|
"sync_sites" => Map.get(params, "sync_sites", "true") == "true",
|
|
"sync_ip_addresses" => Map.get(params, "sync_ip_addresses", "false") == "true",
|
|
"sync_interfaces" => Map.get(params, "sync_interfaces", "false") == "true",
|
|
"device_role_filter" => Map.get(params, "device_role_filter", ""),
|
|
"site_filter" => Map.get(params, "site_filter", ""),
|
|
"tag_filter" => Map.get(params, "tag_filter", "")
|
|
})
|
|
|
|
provider ->
|
|
assign(socket, :integration_credentials, extract_credentials_from_params(params, provider))
|
|
end
|
|
|
|
{:noreply, assign(socket, :integration_form, to_form(changeset))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save_integration", %{"integration" => params}, socket) do
|
|
organization = socket.assigns.organization
|
|
provider = socket.assigns.configuring
|
|
existing = Map.get(socket.assigns.integrations, provider)
|
|
attrs = normalize_params(params, existing)
|
|
|
|
result =
|
|
case existing do
|
|
nil ->
|
|
Integrations.create_integration(
|
|
organization.id,
|
|
attrs |> Map.put(:provider, provider) |> Map.put(:enabled, true)
|
|
)
|
|
|
|
integration ->
|
|
Integrations.update_integration(integration, attrs)
|
|
end
|
|
|
|
case result do
|
|
{:ok, _integration} ->
|
|
integrations = load_integrations(organization.id)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:integrations, integrations)
|
|
|> assign(:active_billing_provider, active_billing_provider(integrations))
|
|
|> assign(:configuring, nil)
|
|
|> assign(:integration_form, nil)
|
|
|> assign(:test_result, nil)
|
|
|> put_flash(:info, t("Integration saved successfully"))}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply, assign(socket, :integration_form, to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("test_connection", _params, socket) do
|
|
result = do_test_connection(socket.assigns.configuring, socket)
|
|
{:noreply, assign(socket, :test_result, result)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save_webhook_secret", %{"value" => secret}, socket) do
|
|
case Map.get(socket.assigns.integrations, "gaiia") do
|
|
nil ->
|
|
{:noreply, socket}
|
|
|
|
integration ->
|
|
updated_credentials =
|
|
Map.put(integration.credentials, "webhook_secret", String.trim(secret))
|
|
|
|
case Integrations.update_integration(integration, %{credentials: updated_credentials}) do
|
|
{:ok, _updated} ->
|
|
integrations = load_integrations(socket.assigns.organization.id)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:integrations, integrations)
|
|
|> assign(:active_billing_provider, active_billing_provider(integrations))
|
|
|> put_flash(:info, t("Webhook secret saved"))}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to save webhook secret"))}
|
|
end
|
|
end
|
|
end
|
|
|
|
def handle_event("save_gaiia_app_url", %{"value" => url}, socket) do
|
|
case Map.get(socket.assigns.integrations, "gaiia") do
|
|
nil ->
|
|
{:noreply, socket}
|
|
|
|
integration ->
|
|
updated_credentials =
|
|
Map.put(integration.credentials, "app_url", String.trim(url))
|
|
|
|
case Integrations.update_integration(integration, %{credentials: updated_credentials}) do
|
|
{:ok, _updated} ->
|
|
integrations = load_integrations(socket.assigns.organization.id)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:integrations, integrations)
|
|
|> assign(:active_billing_provider, active_billing_provider(integrations))
|
|
|> put_flash(:info, t("Gaiia app URL saved"))}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to save Gaiia app URL"))}
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_enabled", %{"provider" => provider}, socket) do
|
|
case Map.get(socket.assigns.integrations, provider) do
|
|
nil ->
|
|
{:noreply, socket}
|
|
|
|
integration ->
|
|
case Integrations.update_integration(integration, %{enabled: !integration.enabled}) do
|
|
{:ok, _updated} ->
|
|
integrations = load_integrations(socket.assigns.organization.id)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:integrations, integrations)
|
|
|> assign(:active_billing_provider, active_billing_provider(integrations))
|
|
|> put_flash(:info, t("Integration updated"))}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to update integration"))}
|
|
end
|
|
end
|
|
end
|
|
|
|
# === Billing events ===
|
|
|
|
@impl true
|
|
def handle_event("upgrade_to_paid", _params, socket) do
|
|
org = socket.assigns.organization
|
|
|
|
success_url = url(~p"/orgs/#{org.slug}/settings?tab=billing&status=success")
|
|
cancel_url = url(~p"/orgs/#{org.slug}/settings?tab=billing&status=cancelled")
|
|
|
|
case Billing.create_checkout_session(org,
|
|
success_url: success_url,
|
|
cancel_url: cancel_url
|
|
) do
|
|
{:ok, checkout_url} ->
|
|
{:noreply, redirect(socket, external: checkout_url)}
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Failed to create checkout session: #{inspect(reason)}")
|
|
|
|
{:noreply, put_flash(socket, :error, t("Failed to start checkout. Please try again later."))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("manage_billing", _params, socket) do
|
|
org = socket.assigns.organization
|
|
return_url = url(~p"/orgs/#{org.slug}/settings?tab=billing")
|
|
|
|
case Billing.create_portal_session(org, return_url) do
|
|
{:ok, portal_url} ->
|
|
{:noreply, redirect(socket, external: portal_url)}
|
|
|
|
{:error, :no_stripe_customer} ->
|
|
{:noreply,
|
|
put_flash(
|
|
socket,
|
|
:error,
|
|
t("No billing account found. Please upgrade to a paid plan first.")
|
|
)}
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Failed to create portal session: #{inspect(reason)}")
|
|
|
|
{:noreply, put_flash(socket, :error, t("Failed to open billing portal. Please try again later."))}
|
|
end
|
|
end
|
|
|
|
# === Private helpers (integrations) ===
|
|
|
|
defp do_test_connection("netbox", socket) do
|
|
{url, token} = extract_netbox_credentials(socket)
|
|
|
|
with :ok <- validate_present(url, t("Please enter your NetBox URL first")),
|
|
:ok <- validate_present(token, t("Please enter your NetBox API token first")) do
|
|
format_connection_result(Towerops.NetBox.Client.test_connection(url, token))
|
|
end
|
|
end
|
|
|
|
defp do_test_connection("sonar", socket) do
|
|
{instance_url, api_token} = extract_sonar_credentials(socket)
|
|
|
|
with :ok <- validate_present(instance_url, t("Please enter your Sonar instance URL first")),
|
|
:ok <- validate_present(api_token, t("Please enter your Sonar API token first")) do
|
|
format_connection_result(SonarClient.test_connection(instance_url, api_token))
|
|
end
|
|
end
|
|
|
|
defp do_test_connection("splynx", socket) do
|
|
{instance_url, api_key, api_secret} = extract_splynx_credentials(socket)
|
|
|
|
with :ok <- validate_present(instance_url, t("Please enter your Splynx instance URL first")),
|
|
:ok <- validate_present(api_key, t("Please enter your Splynx API key first")),
|
|
:ok <- validate_present(api_secret, t("Please enter your Splynx API secret first")) do
|
|
format_connection_result(SplynxClient.test_connection(instance_url, api_key, api_secret))
|
|
end
|
|
end
|
|
|
|
defp do_test_connection(provider, socket) do
|
|
api_key = extract_api_key(socket)
|
|
|
|
with :ok <- validate_present(api_key, t("Please enter an API key first")) do
|
|
format_connection_result(test_provider_connection(provider, api_key))
|
|
end
|
|
end
|
|
|
|
defp validate_present(nil, msg), do: {:error, msg}
|
|
defp validate_present("", msg), do: {:error, msg}
|
|
defp validate_present(_, _msg), do: :ok
|
|
|
|
# === Private helpers (integrations) ===
|
|
|
|
defp test_provider_connection("preseem", api_key), do: PreseemClient.test_connection(api_key)
|
|
defp test_provider_connection("gaiia", api_key), do: GaiiaClient.test_connection(api_key)
|
|
|
|
defp test_provider_connection("pagerduty", api_key), do: Towerops.PagerDuty.Client.test_connection(api_key)
|
|
|
|
defp test_provider_connection("visp", api_key), do: VispClient.test_connection(api_key)
|
|
|
|
defp test_provider_connection(_, _api_key), do: {:error, "Unknown provider"}
|
|
|
|
defp load_integrations(organization_id) do
|
|
organization_id
|
|
|> Integrations.list_integrations()
|
|
|> Map.new(fn integration -> {integration.provider, integration} end)
|
|
end
|
|
|
|
defp active_billing_provider(integrations) do
|
|
Enum.find_value(@billing_provider_ids, fn id ->
|
|
case Map.get(integrations, id) do
|
|
%{enabled: true} -> id
|
|
_ -> nil
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp get_current_integration(socket) do
|
|
provider = socket.assigns.configuring
|
|
|
|
case Map.get(socket.assigns.integrations, provider) do
|
|
nil -> %Integration{}
|
|
integration -> integration
|
|
end
|
|
end
|
|
|
|
defp normalize_params(params, existing_integration) do
|
|
provider =
|
|
case existing_integration do
|
|
%Integration{provider: p} -> p
|
|
_ -> Map.get(params, "provider", "")
|
|
end
|
|
|
|
case provider do
|
|
"netbox" -> normalize_netbox_params(params, existing_integration)
|
|
"sonar" -> normalize_sonar_params(params)
|
|
"splynx" -> normalize_splynx_params(params)
|
|
_ -> normalize_standard_params(params, existing_integration)
|
|
end
|
|
end
|
|
|
|
defp normalize_standard_params(params, existing_integration) do
|
|
api_key = Map.get(params, "api_key", "")
|
|
sync_interval = Map.get(params, "sync_interval_minutes")
|
|
|
|
# Use form value if provided, otherwise preserve existing
|
|
webhook_secret =
|
|
case Map.get(params, "webhook_secret") do
|
|
secret when is_binary(secret) and secret != "" ->
|
|
secret
|
|
|
|
_ ->
|
|
case existing_integration do
|
|
%Integration{credentials: %{"webhook_secret" => secret}} when is_binary(secret) ->
|
|
secret
|
|
|
|
_ ->
|
|
""
|
|
end
|
|
end
|
|
|
|
attrs = %{credentials: %{"api_key" => api_key, "webhook_secret" => webhook_secret}}
|
|
|
|
if sync_interval && sync_interval != "" do
|
|
Map.put(attrs, :sync_interval_minutes, sync_interval)
|
|
else
|
|
attrs
|
|
end
|
|
end
|
|
|
|
defp normalize_netbox_params(params, _existing_integration) do
|
|
sync_interval = Map.get(params, "sync_interval_minutes")
|
|
|
|
credentials = %{
|
|
"url" => String.trim(Map.get(params, "url", "")),
|
|
"api_token" => Map.get(params, "api_token", ""),
|
|
"sync_direction" => Map.get(params, "sync_direction", "pull"),
|
|
"sync_devices" => Map.get(params, "sync_devices", "true") == "true",
|
|
"sync_sites" => Map.get(params, "sync_sites", "true") == "true",
|
|
"sync_ip_addresses" => Map.get(params, "sync_ip_addresses", "false") == "true",
|
|
"sync_interfaces" => Map.get(params, "sync_interfaces", "false") == "true",
|
|
"device_role_filter" => String.trim(Map.get(params, "device_role_filter", "")),
|
|
"site_filter" => String.trim(Map.get(params, "site_filter", "")),
|
|
"tag_filter" => String.trim(Map.get(params, "tag_filter", ""))
|
|
}
|
|
|
|
attrs = %{credentials: credentials}
|
|
|
|
if sync_interval && sync_interval != "" do
|
|
Map.put(attrs, :sync_interval_minutes, sync_interval)
|
|
else
|
|
attrs
|
|
end
|
|
end
|
|
|
|
defp get_credential(nil, _key), do: ""
|
|
|
|
defp get_credential(%Integration{credentials: credentials}, key) when is_map(credentials) do
|
|
Map.get(credentials, key, "")
|
|
end
|
|
|
|
defp get_credential(_integration, _key), do: ""
|
|
|
|
defp current_integration(socket) do
|
|
provider = socket.assigns.configuring
|
|
Map.get(socket.assigns.integrations, provider)
|
|
end
|
|
|
|
defp extract_netbox_credentials(socket) do
|
|
# Try form changeset first (user may have typed but not saved)
|
|
changeset = socket.assigns.integration_form.source
|
|
form_creds = Ecto.Changeset.get_field(changeset, :credentials) || %{}
|
|
form_url = Map.get(form_creds, "url", "")
|
|
form_token = Map.get(form_creds, "api_token", "")
|
|
|
|
# Fall back to saved integration
|
|
integration = current_integration(socket)
|
|
saved_url = get_credential(integration, "url")
|
|
saved_token = get_credential(integration, "api_token")
|
|
|
|
url = if form_url == "", do: saved_url, else: form_url
|
|
token = if form_token == "", do: saved_token, else: form_token
|
|
|
|
{url, token}
|
|
end
|
|
|
|
defp extract_api_key(socket) do
|
|
changeset = socket.assigns.integration_form.source
|
|
data = Ecto.Changeset.apply_changes(changeset)
|
|
|
|
case data.credentials do
|
|
%{"api_key" => key} -> key
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp normalize_sonar_params(params) do
|
|
sync_interval = Map.get(params, "sync_interval_minutes")
|
|
|
|
attrs = %{
|
|
credentials: %{
|
|
"instance_url" => String.trim(Map.get(params, "instance_url", "")),
|
|
"api_token" => Map.get(params, "api_token", "")
|
|
}
|
|
}
|
|
|
|
if sync_interval && sync_interval != "" do
|
|
Map.put(attrs, :sync_interval_minutes, sync_interval)
|
|
else
|
|
attrs
|
|
end
|
|
end
|
|
|
|
defp normalize_splynx_params(params) do
|
|
sync_interval = Map.get(params, "sync_interval_minutes")
|
|
|
|
attrs = %{
|
|
credentials: %{
|
|
"instance_url" => String.trim(Map.get(params, "instance_url", "")),
|
|
"api_key" => Map.get(params, "api_key", ""),
|
|
"api_secret" => Map.get(params, "api_secret", "")
|
|
}
|
|
}
|
|
|
|
if sync_interval && sync_interval != "" do
|
|
Map.put(attrs, :sync_interval_minutes, sync_interval)
|
|
else
|
|
attrs
|
|
end
|
|
end
|
|
|
|
defp extract_sonar_credentials(socket) do
|
|
changeset = socket.assigns.integration_form.source
|
|
creds = Ecto.Changeset.get_field(changeset, :credentials) || %{}
|
|
integration = current_integration(socket)
|
|
|
|
instance_url = creds |> Map.get("instance_url", "") |> non_empty(get_credential(integration, "instance_url"))
|
|
api_token = creds |> Map.get("api_token", "") |> non_empty(get_credential(integration, "api_token"))
|
|
|
|
{instance_url, api_token}
|
|
end
|
|
|
|
defp extract_splynx_credentials(socket) do
|
|
changeset = socket.assigns.integration_form.source
|
|
creds = Ecto.Changeset.get_field(changeset, :credentials) || %{}
|
|
integration = current_integration(socket)
|
|
|
|
instance_url = creds |> Map.get("instance_url", "") |> non_empty(get_credential(integration, "instance_url"))
|
|
api_key = creds |> Map.get("api_key", "") |> non_empty(get_credential(integration, "api_key"))
|
|
api_secret = creds |> Map.get("api_secret", "") |> non_empty(get_credential(integration, "api_secret"))
|
|
|
|
{instance_url, api_key, api_secret}
|
|
end
|
|
|
|
defp non_empty("", fallback), do: fallback
|
|
defp non_empty(nil, fallback), do: fallback
|
|
defp non_empty(value, _fallback), do: value
|
|
|
|
defp error_messages(changeset) do
|
|
changeset
|
|
|> Ecto.Changeset.traverse_errors(fn {msg, opts} ->
|
|
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
|
|
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
|
end)
|
|
end)
|
|
|> Enum.map_join(", ", fn {field, msgs} -> "#{field} #{Enum.join(msgs, ", ")}" end)
|
|
end
|
|
|
|
defp format_connection_result({:ok, _body}), do: {:ok, "Connection successful"}
|
|
defp format_connection_result({:error, :unauthorized}), do: {:error, "Invalid API key"}
|
|
defp format_connection_result({:error, :forbidden}), do: {:error, "Access forbidden"}
|
|
|
|
defp format_connection_result({:error, {:unexpected_status, status}}),
|
|
do: {:error, "API returned unexpected status: #{status}"}
|
|
|
|
defp format_connection_result({:error, %{reason: :timeout}}), do: {:error, "Connection timeout - unable to reach API"}
|
|
|
|
defp format_connection_result({:error, %{reason: :econnrefused}}),
|
|
do: {:error, "Connection refused - check API endpoint"}
|
|
|
|
defp format_connection_result({:error, %{reason: :nxdomain}}), do: {:error, "DNS lookup failed - check API endpoint"}
|
|
|
|
defp format_connection_result({:error, {:graphql_errors, errors}}),
|
|
do: {:error, "API query failed: #{inspect(Enum.map(errors, & &1["message"]))}"}
|
|
|
|
defp format_connection_result({:error, reason}) do
|
|
Logger.warning("Integration connection test failed: #{inspect(reason)}")
|
|
{:error, "Connection failed - please check your API credentials and try again"}
|
|
end
|
|
|
|
defp default_netbox_config do
|
|
%{
|
|
"url" => "",
|
|
"api_token" => "",
|
|
"sync_direction" => "pull",
|
|
"sync_devices" => true,
|
|
"sync_sites" => true,
|
|
"sync_ip_addresses" => false,
|
|
"sync_interfaces" => false,
|
|
"device_role_filter" => "",
|
|
"site_filter" => "",
|
|
"tag_filter" => ""
|
|
}
|
|
end
|
|
|
|
defp load_netbox_config(nil), do: default_netbox_config()
|
|
|
|
defp load_netbox_config(%Integration{credentials: creds}) when is_map(creds) do
|
|
%{
|
|
"url" => Map.get(creds, "url", ""),
|
|
"api_token" => Map.get(creds, "api_token", ""),
|
|
"sync_direction" => Map.get(creds, "sync_direction", "pull"),
|
|
"sync_devices" => Map.get(creds, "sync_devices", true),
|
|
"sync_sites" => Map.get(creds, "sync_sites", true),
|
|
"sync_ip_addresses" => Map.get(creds, "sync_ip_addresses", false),
|
|
"sync_interfaces" => Map.get(creds, "sync_interfaces", false),
|
|
"device_role_filter" => Map.get(creds, "device_role_filter", ""),
|
|
"site_filter" => Map.get(creds, "site_filter", ""),
|
|
"tag_filter" => Map.get(creds, "tag_filter", "")
|
|
}
|
|
end
|
|
|
|
defp load_netbox_config(_), do: default_netbox_config()
|
|
|
|
defp load_integration_credentials(integration, "sonar") do
|
|
%{
|
|
"instance_url" => get_credential(integration, "instance_url"),
|
|
"api_token" => get_credential(integration, "api_token")
|
|
}
|
|
end
|
|
|
|
defp load_integration_credentials(integration, "splynx") do
|
|
%{
|
|
"instance_url" => get_credential(integration, "instance_url"),
|
|
"api_key" => get_credential(integration, "api_key"),
|
|
"api_secret" => get_credential(integration, "api_secret")
|
|
}
|
|
end
|
|
|
|
defp load_integration_credentials(integration, _provider) do
|
|
%{"api_key" => get_credential(integration, "api_key")}
|
|
end
|
|
|
|
defp extract_credentials_from_params(params, "sonar") do
|
|
%{
|
|
"instance_url" => Map.get(params, "instance_url", ""),
|
|
"api_token" => Map.get(params, "api_token", "")
|
|
}
|
|
end
|
|
|
|
defp extract_credentials_from_params(params, "splynx") do
|
|
%{
|
|
"instance_url" => Map.get(params, "instance_url", ""),
|
|
"api_key" => Map.get(params, "api_key", ""),
|
|
"api_secret" => Map.get(params, "api_secret", "")
|
|
}
|
|
end
|
|
|
|
defp extract_credentials_from_params(params, _provider) do
|
|
%{"api_key" => Map.get(params, "api_key", "")}
|
|
end
|
|
|
|
defp require_admin_or_owner(socket, fun) do
|
|
role = socket.assigns.membership.role
|
|
|
|
if role in [:owner, :admin] do
|
|
fun.()
|
|
else
|
|
{:noreply, put_flash(socket, :error, t("You don't have permission to perform this action"))}
|
|
end
|
|
end
|
|
end
|