towerops/lib/towerops_web/live/org/settings_live.ex

512 lines
16 KiB
Elixir

defmodule ToweropsWeb.Org.SettingsLive do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Admin.AuditLogger
alias Towerops.Agents
alias Towerops.Gaiia.Client, as: GaiiaClient
alias Towerops.Integrations
alias Towerops.Integrations.Integration
alias Towerops.Organizations
alias Towerops.Accounts.UserNotifier
alias Towerops.Preseem.Client, as: PreseemClient
require Logger
@providers [
%{
id: "preseem",
name: "Preseem",
description: "QoE monitoring and subscriber experience analytics for wireless ISPs.",
icon: "hero-signal"
},
%{
id: "gaiia",
name: "Gaiia",
description:
"Billing and subscriber management. Enables outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.",
icon: "hero-user-group"
},
%{
id: "pagerduty",
name: "PagerDuty",
description:
"Incident management and on-call alerting. Automatically triggers, acknowledges, and resolves PagerDuty incidents from TowerOps alerts.",
icon: "hero-bell-alert"
}
]
@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)
{:ok,
socket
|> 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")
# Members tab assigns - loaded lazily
|> assign(:members, [])
|> assign(:pending_invitations, [])
|> assign(:invite_form, to_form(%{"email" => "", "role" => "member"}))
# Integration assigns - loaded lazily when tab is selected
|> assign(:providers, @providers)
|> assign(:integrations, %{})
|> assign(:configuring, nil)
|> assign(:integration_form, nil)
|> assign(:test_result, nil)
|> 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, "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, "integrations") do
integrations = load_integrations(socket.assigns.organization.id)
assign(socket, :integrations, integrations)
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
case Organizations.update_organization(socket.assigns.organization, org_params) do
{:ok, organization} ->
changeset = Organizations.change_organization(organization)
{:noreply,
socket
|> put_flash(:info, "Settings saved successfully")
|> assign(:organization, organization)
|> assign(:form, to_form(changeset))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
@impl true
def handle_event("apply_snmp_to_all", _params, socket) do
{count, _} = Organizations.apply_snmp_config_to_all_equipment(socket.assigns.organization.id)
{:noreply, put_flash(socket, :info, "Applied SNMP configuration to #{count} device records across all sites")}
end
@impl true
def handle_event("apply_agent_to_all", _params, socket) do
{count, _} = Organizations.apply_agent_to_all_equipment(socket.assigns.organization.id)
{:noreply, put_flash(socket, :info, "Applied default agent to #{count} device records across all sites")}
end
# === Members tab events ===
@impl true
def handle_event("send_invitation", %{"email" => email, "role" => role}, socket) do
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 | organization: organization}
accept_url = url(~p"/invitations/#{invitation.token}")
UserNotifier.deliver_invitation_email(invitation, accept_url)
{:noreply,
socket
|> put_flash(:info, "Invitation sent to #{email}")
|> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))
|> assign(:invite_form, to_form(%{"email" => "", "role" => "member"}))}
{:error, changeset} ->
{:noreply,
socket
|> put_flash(:error, "Failed to send invitation: #{error_messages(changeset)}")}
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, "Invitation cancelled")
|> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))}
else
{:noreply, put_flash(socket, :error, "Invitation not found")}
end
end
@impl true
def handle_event("remove_member", %{"user-id" => user_id}, socket) do
organization = socket.assigns.organization
case Organizations.remove_member(organization.id, user_id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Member removed")
|> assign(:members, Organizations.list_organization_members(organization.id))}
{:error, :cannot_remove_owner} ->
{:noreply, put_flash(socket, :error, "Cannot remove the organization owner")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to remove member")}
end
end
@impl true
def handle_event("change_role", %{"user-id" => user_id, "role" => role}, socket) do
organization = socket.assigns.organization
case Organizations.update_member_role(organization.id, user_id, role) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Role updated")
|> assign(:members, Organizations.list_organization_members(organization.id))}
{:error, :cannot_change_owner_role} ->
{:noreply, put_flash(socket, :error, "Cannot change the owner's role")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to update role")}
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, "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, "Failed to set default organization")}
end
end
end
# === Integration events ===
@impl true
def handle_event("configure", %{"provider" => provider}, socket) do
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
{:noreply,
socket
|> assign(:configuring, provider)
|> assign(:integration_form, form)
|> assign(:test_result, nil)}
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("validate_integration", %{"integration" => params}, socket) do
integration = get_current_integration(socket)
changeset =
integration
|> Integrations.change_integration(normalize_params(params, integration))
|> Map.put(:action, :validate)
{: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(:configuring, nil)
|> assign(:integration_form, nil)
|> assign(:test_result, nil)
|> put_flash(:info, "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
api_key = extract_api_key(socket)
provider = socket.assigns.configuring
if api_key == "" or is_nil(api_key) do
{:noreply, assign(socket, :test_result, {:error, "Please enter an API key first"})}
else
result = test_provider_connection(provider, api_key)
{:noreply, assign(socket, :test_result, format_connection_result(result))}
end
end
@impl true
def handle_event("regenerate_webhook_secret", _params, socket) do
case Map.get(socket.assigns.integrations, "gaiia") do
nil ->
{:noreply, socket}
integration ->
new_secret = generate_webhook_secret()
updated_credentials =
Map.put(integration.credentials, "webhook_secret", new_secret)
case Integrations.update_integration(integration, %{credentials: updated_credentials}) do
{:ok, _updated} ->
integrations = load_integrations(socket.assigns.organization.id)
{:noreply,
socket
|> assign(:integrations, integrations)
|> put_flash(:info, "Webhook secret regenerated")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to regenerate webhook secret")}
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)
|> put_flash(:info, "Integration updated")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to update integration")}
end
end
end
# === 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(_, _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 get_current_integration(socket) do
provider = socket.assigns.configuring
case Map.get(socket.assigns.integrations, provider) do
nil -> %Integration{}
integration -> integration
end
end
defp next_sync_minutes(nil, _interval), do: nil
defp next_sync_minutes(last_synced_at, interval) do
next_sync = DateTime.add(last_synced_at, interval * 60, :second)
diff = DateTime.diff(next_sync, DateTime.utc_now(), :second)
max(0, div(diff, 60))
end
defp normalize_params(params, existing_integration) do
api_key = Map.get(params, "api_key", "")
sync_interval = Map.get(params, "sync_interval_minutes")
webhook_secret =
case existing_integration do
%Integration{credentials: %{"webhook_secret" => secret}} when secret != "" ->
secret
_ ->
generate_webhook_secret()
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 generate_webhook_secret do
32 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
end
defp webhook_url(organization_id) do
ToweropsWeb.Endpoint.url() <> "/api/v1/webhooks/gaiia/#{organization_id}"
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 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 error_messages(changeset) do
Ecto.Changeset.traverse_errors(changeset, 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
end