towerops/lib/towerops_web/live/org/integrations_live.html.heex
Graham McIntie 91946946a6 Make billing integration exclusivity more obvious
Add 'Choose one' badge with indigo border to billing category,
clarify that only one billing platform can be active at a time.
2026-02-16 08:22:47 -06:00

646 lines
30 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4">
<.link
navigate={~p"/orgs/#{@organization.slug}/settings"}
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Settings
</.link>
</div>
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
{t("Integrations")}
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Connect third-party services to enhance your monitoring capabilities.")}
</p>
</div>
<div class="mt-8 space-y-10">
<div :for={category <- @provider_categories}>
<div class="mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{category.name}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{category.description}
</p>
</div>
<%= if category.exclusive do %>
<%!-- Exclusive category (billing): show as picker + single config panel --%>
<% active_billing =
Enum.find(category.providers, fn p ->
@integrations[p.id] && @integrations[p.id].enabled
end) %>
<div class="rounded-lg border-2 border-indigo-200 bg-white p-6 dark:border-indigo-800/50 dark:bg-white/5">
<div class="mb-4 flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
<.icon name="hero-arrow-path" class="h-3.5 w-3.5" />
Choose one
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
— only one billing platform can be active at a time
</span>
</div>
<%= if active_billing do %>
<%!-- Active billing provider --%>
<% integration = @integrations[active_billing.id] %>
<div class="flex items-start justify-between">
<div class="flex items-start gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/30">
<.icon
name={active_billing.icon}
class="h-6 w-6 text-indigo-600 dark:text-indigo-400"
/>
</div>
<div>
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{active_billing.name}
</h3>
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/30 dark:text-green-400">
Connected
</span>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{active_billing.description}
</p>
<div class="mt-3 flex flex-wrap items-center gap-4">
<%= if integration.last_synced_at do %>
<span class="text-xs text-gray-500 dark:text-gray-400">
{t("Last synced:")}
<.timestamp
datetime={integration.last_synced_at}
timezone={@timezone}
format="absolute"
/>
</span>
<% end %>
<%= if integration.last_sync_status && integration.last_sync_status != "never" do %>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
case integration.last_sync_status do
"success" ->
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
"partial" ->
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
"failed" ->
"bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
_ ->
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
end
]}>
{String.capitalize(integration.last_sync_status)}
</span>
<% end %>
<%= if integration.last_synced_at do %>
<span class="text-xs text-gray-400 dark:text-gray-500">
· Next sync in ~{next_sync_minutes(
integration.last_synced_at,
integration.sync_interval_minutes
)}m
</span>
<% end %>
<%= if active_billing.id == "gaiia" do %>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"
}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Entity Mapping →
</.link>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"
}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Reconciliation →
</.link>
<% end %>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="toggle_enabled"
phx-value-provider={active_billing.id}
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-indigo-600 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
>
<span class="pointer-events-none inline-block h-5 w-5 translate-x-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" />
</button>
<button
type="button"
id={"configure-#{active_billing.id}"}
phx-click="configure"
phx-value-provider={active_billing.id}
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
{t("Configure")}
</button>
</div>
</div>
<%!-- Switch provider link --%>
<div class="mt-4 border-t border-gray-100 pt-3 dark:border-white/5">
<p class="text-xs text-gray-400 dark:text-gray-500">
Using {active_billing.name}. To switch providers, disable this integration first.
</p>
</div>
<% else %>
<%!-- No active billing — show provider picker --%>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-300 font-medium">
{t("Which billing platform does your organization use?")}
</p>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<button
:for={provider <- category.providers}
type="button"
id={"configure-#{provider.id}"}
phx-click="configure"
phx-value-provider={provider.id}
class={[
"group relative flex flex-col items-center rounded-lg border-2 p-4 text-center transition-all hover:border-indigo-300 hover:bg-indigo-50/50 dark:hover:border-indigo-700 dark:hover:bg-indigo-900/10",
if(@configuring == provider.id,
do:
"border-indigo-500 bg-indigo-50/50 dark:border-indigo-600 dark:bg-indigo-900/20",
else: "border-gray-200 dark:border-white/10"
)
]}
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-50 group-hover:bg-indigo-100 dark:bg-indigo-900/30">
<.icon
name={provider.icon}
class="h-5 w-5 text-indigo-600 dark:text-indigo-400"
/>
</div>
<span class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{provider.name}
</span>
<span class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{provider.description}
</span>
</button>
</div>
<% end %>
<%!-- Config form for whichever billing provider is being configured --%>
<%= if @configuring && find_category_id(@configuring) == "billing" do %>
<% provider = Enum.find(category.providers, &(&1.id == @configuring)) %>
<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 mb-4">
Configure {provider.name}
</h4>
<.form
for={@form}
id={"#{provider.id}-form"}
phx-change="validate"
phx-submit="save"
>
<input type="hidden" name="integration[provider]" value={provider.id} />
<div class="space-y-4">
<%= if provider.id in ["splynx", "sonar"] do %>
<.input
field={@form[:instance_url]}
type="text"
label={t("Instance URL")}
placeholder={"https://your-instance.#{provider.id}.com"}
autocomplete="off"
value={get_credential(@integrations[provider.id], "instance_url")}
/>
<% end %>
<%= if provider.id == "sonar" do %>
<.input
field={@form[:api_token]}
type="password"
label={t("API Token")}
placeholder="Enter your Sonar API token"
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_token")}
/>
<% else %>
<.input
field={@form[:api_key]}
type="password"
label={t("API Key")}
placeholder={"Enter your #{provider.name} API key"}
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_key")}
/>
<% end %>
<%= if provider.id == "splynx" do %>
<.input
field={@form[:api_secret]}
type="password"
label={t("API Secret")}
placeholder="Enter your Splynx API secret"
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_secret")}
/>
<% end %>
<.input
field={@form[:sync_interval_minutes]}
type="number"
label={t("Sync interval (minutes)")}
min="5"
value={
if(@integrations[provider.id],
do: @integrations[provider.id].sync_interval_minutes,
else: if(provider.id == "gaiia", do: 15, else: 10)
)
}
/>
<%= if @test_result do %>
<div class={[
"rounded-md p-4",
case @test_result do
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
end
]}>
<p class={[
"text-sm",
case @test_result do
{:ok, _} -> "text-green-800 dark:text-green-200"
{:error, _} -> "text-red-800 dark:text-red-200"
end
]}>
{elem(@test_result, 1)}
</p>
</div>
<% end %>
<div class="flex items-center justify-between pt-4">
<button
type="button"
id="test-connection"
phx-click="test_connection"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
{t("Test Connection")}
</button>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="close_config"
class="text-sm font-semibold text-gray-900 dark:text-white"
>
{t("Cancel")}
</button>
<button
type="submit"
phx-disable-with={t("Saving...")}
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{t("Save")}
</button>
</div>
</div>
</div>
</.form>
</div>
<%!-- Gaiia webhook config --%>
<%= if @configuring == "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">
{t("Webhook Configuration")}
</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t(
"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">
{t("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">
{t("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={
t(
"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"
>
{t("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">
{t("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 &quot;Add Webhook&quot;</li>
<li>Paste the Webhook URL above</li>
<li>Paste the Webhook Secret above into the &quot;Secret&quot; field</li>
<li>Select events: Account, Billing Subscription, Inventory Item</li>
<li>Save the webhook</li>
</ol>
</div>
</div>
</div>
<% end %>
<% end %>
</div>
<% else %>
<%!-- Non-exclusive categories: one card per provider --%>
<div class="space-y-4">
<div
:for={provider <- category.providers}
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-white/10 dark:bg-white/5"
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/30">
<.icon
name={provider.icon}
class="h-6 w-6 text-indigo-600 dark:text-indigo-400"
/>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{provider.name}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{provider.description}
</p>
<%= if integration = @integrations[provider.id] do %>
<div class="mt-3 flex flex-wrap items-center gap-4">
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
if(integration.enabled,
do:
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
else: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
)
]}>
{if integration.enabled, do: "Enabled", else: "Disabled"}
</span>
<%= if integration.last_synced_at do %>
<span class="text-xs text-gray-500 dark:text-gray-400">
{t("Last synced:")}
<.timestamp
datetime={integration.last_synced_at}
timezone={@timezone}
format="absolute"
/>
</span>
<% end %>
<%= if integration.last_sync_status && integration.last_sync_status != "never" do %>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
case integration.last_sync_status do
"success" ->
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
"partial" ->
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
"failed" ->
"bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
_ ->
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
end
]}>
{String.capitalize(integration.last_sync_status)}
</span>
<% end %>
<%= if integration.last_synced_at do %>
<span class="text-xs text-gray-400 dark:text-gray-500">
· Next sync in ~{next_sync_minutes(
integration.last_synced_at,
integration.sync_interval_minutes
)}m
</span>
<% end %>
<%= if provider.id == "preseem" do %>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"
}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Manage Devices →
</.link>
<.link
navigate={~p"/insights?source=preseem"}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Network Insights →
</.link>
<% end %>
<%= if integration.enabled do %>
<div class="mt-3 w-full rounded-md bg-gray-50 p-3 dark:bg-gray-800/50">
<h4 class="text-xs font-semibold text-gray-700 dark:text-gray-300">
<.icon name="hero-clock" class="h-3.5 w-3.5 inline" /> Sync Schedule
</h4>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<%= if provider.id == "preseem" do %>
Syncs every {integration.sync_interval_minutes} minutes. Baselines and fleet profiles computed nightly.
<% end %>
</p>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<div class="flex items-center gap-3">
<%= if integration = @integrations[provider.id] do %>
<button
type="button"
phx-click="toggle_enabled"
phx-value-provider={provider.id}
class={[
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2",
if(integration.enabled,
do: "bg-indigo-600",
else: "bg-gray-200 dark:bg-gray-700"
)
]}
>
<span class={[
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
if(integration.enabled, do: "translate-x-5", else: "translate-x-0")
]} />
</button>
<% end %>
<button
type="button"
id={"configure-#{provider.id}"}
phx-click="configure"
phx-value-provider={provider.id}
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
{t("Configure")}
</button>
</div>
</div>
<%= if @configuring == provider.id do %>
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
<.form
for={@form}
id={"#{provider.id}-form"}
phx-change="validate"
phx-submit="save"
>
<input type="hidden" name="integration[provider]" value={provider.id} />
<div class="space-y-4">
<.input
field={@form[:api_key]}
type="password"
label={t("API Key")}
placeholder={"Enter your #{provider.name} API key"}
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_key")}
/>
<.input
field={@form[:sync_interval_minutes]}
type="number"
label={t("Sync interval (minutes)")}
min="5"
value={
if(@integrations[provider.id],
do: @integrations[provider.id].sync_interval_minutes,
else: 10
)
}
/>
<%= if @test_result do %>
<div class={[
"rounded-md p-4",
case @test_result do
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
end
]}>
<p class={[
"text-sm",
case @test_result do
{:ok, _} -> "text-green-800 dark:text-green-200"
{:error, _} -> "text-red-800 dark:text-red-200"
end
]}>
{elem(@test_result, 1)}
</p>
</div>
<% end %>
<div class="flex items-center justify-between pt-4">
<button
type="button"
id="test-connection"
phx-click="test_connection"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
{t("Test Connection")}
</button>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="close_config"
class="text-sm font-semibold text-gray-900 dark:text-white"
>
{t("Cancel")}
</button>
<button
type="submit"
phx-disable-with={t("Saving...")}
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{t("Save")}
</button>
</div>
</div>
</div>
</.form>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
</Layouts.authenticated>