From d603a12322aef5fc4e126cfa58ba8a2f961646f6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 16:15:38 -0600 Subject: [PATCH] feat: add per-organization Gaiia webhook setup UI Auto-generate HMAC-SHA256 webhook secret when enabling the Gaiia integration. Display webhook URL, secret with copy buttons, regenerate option, and setup instructions on the integrations page. --- .../live/org/integrations_live.ex | 52 ++++++- .../live/org/integrations_live.html.heex | 89 +++++++++++ .../live/org/integrations_live_test.exs | 141 ++++++++++++++++++ 3 files changed, 278 insertions(+), 4 deletions(-) diff --git a/lib/towerops_web/live/org/integrations_live.ex b/lib/towerops_web/live/org/integrations_live.ex index bd26fdaf..55d90c6e 100644 --- a/lib/towerops_web/live/org/integrations_live.ex +++ b/lib/towerops_web/live/org/integrations_live.ex @@ -80,7 +80,7 @@ defmodule ToweropsWeb.Org.IntegrationsLive do changeset = integration - |> Integrations.change_integration(normalize_params(params)) + |> Integrations.change_integration(normalize_params(params, integration)) |> Map.put(:action, :validate) {:noreply, assign(socket, :form, to_form(changeset))} @@ -91,7 +91,7 @@ defmodule ToweropsWeb.Org.IntegrationsLive do organization = socket.assigns.organization provider = socket.assigns.configuring existing = Map.get(socket.assigns.integrations, provider) - attrs = normalize_params(params) + attrs = normalize_params(params, existing) result = case existing do @@ -135,6 +135,33 @@ defmodule ToweropsWeb.Org.IntegrationsLive do 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 @@ -176,10 +203,27 @@ defmodule ToweropsWeb.Org.IntegrationsLive do end end - defp normalize_params(params) do + defp normalize_params(params, existing_integration) do api_key = Map.get(params, "api_key", "") - %{credentials: %{"api_key" => api_key}} + webhook_secret = + case existing_integration do + %Integration{credentials: %{"webhook_secret" => secret}} when secret != "" -> + secret + + _ -> + generate_webhook_secret() + end + + %{credentials: %{"api_key" => api_key, "webhook_secret" => webhook_secret}} + 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: "" diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex index cd9103f9..9c332e1d 100644 --- a/lib/towerops_web/live/org/integrations_live.html.heex +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -223,6 +223,95 @@ <% end %> + + <%= if provider.id == "gaiia" && @integrations["gaiia"] do %> +
+

+ Webhook Configuration +

+

+ Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change. +

+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+ +
+
+ Setup Instructions +
+
    +
  1. In your Gaiia admin panel, go to Settings → Webhooks
  2. +
  3. Click "Add Webhook"
  4. +
  5. Paste the Webhook URL above
  6. +
  7. Paste the Webhook Secret above into the "Secret" field
  8. +
  9. Select events: Account, Billing Subscription, Inventory Item
  10. +
  11. Save the webhook
  12. +
+

+ Once configured, Towerops will receive real-time updates when accounts, subscriptions, or inventory items change in Gaiia. +

+
+
+
+ <% end %> diff --git a/test/towerops_web/live/org/integrations_live_test.exs b/test/towerops_web/live/org/integrations_live_test.exs index 269f0f8f..0c93f25b 100644 --- a/test/towerops_web/live/org/integrations_live_test.exs +++ b/test/towerops_web/live/org/integrations_live_test.exs @@ -13,6 +13,147 @@ defmodule ToweropsWeb.Org.IntegrationsLiveTest do %{user: user, organization: organization} end + describe "gaiia webhook setup" do + test "saving a new gaiia integration auto-generates a webhook secret", %{ + conn: conn, + user: user, + organization: org + } do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + view |> element("#configure-gaiia") |> render_click() + + view + |> form("#gaiia-form", %{integration: %{api_key: "test-gaiia-key"}}) + |> render_submit() + + assert {:ok, integration} = Towerops.Integrations.get_integration(org.id, "gaiia") + assert integration.credentials["api_key"] == "test-gaiia-key" + assert integration.credentials["webhook_secret"] + assert String.length(integration.credentials["webhook_secret"]) == 64 + end + + test "saving an existing gaiia integration preserves the webhook secret", %{ + conn: conn, + user: user, + organization: org + } do + # Create integration with a known webhook secret + {:ok, _integration} = + Towerops.Integrations.create_integration(org.id, %{ + provider: "gaiia", + enabled: true, + credentials: %{"api_key" => "old-key", "webhook_secret" => "existing-secret-abc"} + }) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + view |> element("#configure-gaiia") |> render_click() + + view + |> form("#gaiia-form", %{integration: %{api_key: "new-gaiia-key"}}) + |> render_submit() + + assert {:ok, integration} = Towerops.Integrations.get_integration(org.id, "gaiia") + assert integration.credentials["api_key"] == "new-gaiia-key" + assert integration.credentials["webhook_secret"] == "existing-secret-abc" + end + + test "displays webhook URL when gaiia integration exists", %{ + conn: conn, + user: user, + organization: org + } do + {:ok, _integration} = + Towerops.Integrations.create_integration(org.id, %{ + provider: "gaiia", + enabled: true, + credentials: %{"api_key" => "key", "webhook_secret" => "secret123"} + }) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + assert html =~ "/api/v1/webhooks/gaiia/#{org.id}" + end + + test "displays webhook secret when gaiia integration exists", %{ + conn: conn, + user: user, + organization: org + } do + {:ok, _integration} = + Towerops.Integrations.create_integration(org.id, %{ + provider: "gaiia", + enabled: true, + credentials: %{"api_key" => "key", "webhook_secret" => "secret123"} + }) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + assert html =~ "secret123" + end + + test "regenerate webhook secret changes the stored secret", %{ + conn: conn, + user: user, + organization: org + } do + {:ok, _integration} = + Towerops.Integrations.create_integration(org.id, %{ + provider: "gaiia", + enabled: true, + credentials: %{"api_key" => "key", "webhook_secret" => "old-secret"} + }) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + view |> element("#regenerate-webhook-secret") |> render_click() + + assert {:ok, integration} = Towerops.Integrations.get_integration(org.id, "gaiia") + assert integration.credentials["webhook_secret"] != "old-secret" + assert String.length(integration.credentials["webhook_secret"]) == 64 + assert integration.credentials["api_key"] == "key" + end + + test "shows setup instructions when gaiia integration exists", %{ + conn: conn, + user: user, + organization: org + } do + {:ok, _integration} = + Towerops.Integrations.create_integration(org.id, %{ + provider: "gaiia", + enabled: true, + credentials: %{"api_key" => "key", "webhook_secret" => "secret"} + }) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + assert html =~ "Webhook Configuration" + assert html =~ "Webhook URL" + assert html =~ "Webhook Secret" + assert html =~ "Gaiia admin panel" + end + end + describe "index" do test "renders integrations page", %{conn: conn, user: user, organization: org} do {:ok, _view, html} =