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.
This commit is contained in:
parent
20ec04379d
commit
d603a12322
3 changed files with 278 additions and 4 deletions
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -223,6 +223,95 @@
|
|||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if provider.id == "gaiia" && @integrations["gaiia"] do %>
|
||||
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Webhook Configuration
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Webhook URL
|
||||
</label>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
id="gaiia-webhook-url"
|
||||
type="text"
|
||||
readonly
|
||||
value={webhook_url(@organization.id)}
|
||||
class="block w-full rounded-md border-gray-300 bg-gray-50 py-1.5 text-sm text-gray-900 shadow-xs sm:leading-6 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-hook="CopyToClipboard"
|
||||
data-target="#gaiia-webhook-url"
|
||||
id="copy-webhook-url"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<.icon name="hero-clipboard" class="h-3.5 w-3.5" /> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Webhook Secret
|
||||
</label>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
id="gaiia-webhook-secret"
|
||||
type="text"
|
||||
readonly
|
||||
value={get_credential(@integrations["gaiia"], "webhook_secret")}
|
||||
class="block w-full rounded-md border-gray-300 bg-gray-50 py-1.5 font-mono text-sm text-gray-900 shadow-xs sm:leading-6 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-hook="CopyToClipboard"
|
||||
data-target="#gaiia-webhook-secret"
|
||||
id="copy-webhook-secret"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<.icon name="hero-clipboard" class="h-3.5 w-3.5" /> Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
id="regenerate-webhook-secret"
|
||||
phx-click="regenerate_webhook_secret"
|
||||
data-confirm="Are you sure? Any existing Gaiia webhook using the current secret will stop working."
|
||||
class="text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Regenerate Secret
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<h5 class="text-xs font-medium text-blue-800 dark:text-blue-300">
|
||||
Setup Instructions
|
||||
</h5>
|
||||
<ol class="mt-2 list-inside list-decimal space-y-1 text-xs text-blue-700 dark:text-blue-400">
|
||||
<li>In your Gaiia admin panel, go to Settings → Webhooks</li>
|
||||
<li>Click "Add Webhook"</li>
|
||||
<li>Paste the Webhook URL above</li>
|
||||
<li>Paste the Webhook Secret above into the "Secret" field</li>
|
||||
<li>Select events: Account, Billing Subscription, Inventory Item</li>
|
||||
<li>Save the webhook</li>
|
||||
</ol>
|
||||
<p class="mt-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
Once configured, Towerops will receive real-time updates when accounts, subscriptions, or inventory items change in Gaiia.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
|
|
|
|||
|
|
@ -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} =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue