towerops/lib/towerops_web/live/org/settings_live.html.heex
Graham McIntire 9aec87636b
feat: per-organization billing override admin UI
Allow superadmins to override free device limits and per-device pricing
on a per-organization basis. Overrides cascade through subscription
limits, billing calculations, and user-facing settings display.

- Add custom_free_device_limit and custom_price_per_device to organizations
- Add billing_override_changeset with validation
- Update SubscriptionLimits.effective_device_limit to respect overrides
- Update Billing to use effective free count and price per device
- Add Admin.update_billing_overrides with audit logging
- Add override editing UI to /admin/organizations
- Update org settings page to show effective limits/pricing
2026-03-06 13:02:05 -06:00

2220 lines
108 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
>
<div class="pb-5">
<div class="mb-4">
<.link
navigate={~p"/dashboard"}
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 Dashboard
</.link>
</div>
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
{t("Organization Settings")}
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Manage organization defaults, integrations, and configuration")}
</p>
</div>
<header class="border-b border-gray-200 dark:border-white/5">
<nav class="flex overflow-x-auto py-4">
<ul
role="list"
class="flex min-w-full flex-none gap-x-6 px-4 text-sm/6 font-semibold text-gray-500 sm:px-6 lg:px-8 dark:text-gray-400"
>
<li>
<.link
patch={~p"/orgs/#{@organization.slug}/settings?tab=general"}
class={
if @active_tab == "general", do: "text-indigo-600 dark:text-indigo-400", else: ""
}
>
{t("General")}
</.link>
</li>
<li>
<.link
patch={~p"/orgs/#{@organization.slug}/settings?tab=snmp"}
class={if @active_tab == "snmp", do: "text-indigo-600 dark:text-indigo-400", else: ""}
>
{t("SNMP")}
</.link>
</li>
<%= if @current_scope.user.is_superuser do %>
<%= if false do %>
<li>
<.link
patch={~p"/orgs/#{@organization.slug}/settings?tab=mikrotik"}
class={
if @active_tab == "mikrotik",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
{t("MikroTik")}
</.link>
</li>
<% end %>
<% end %>
<li>
<.link
patch={~p"/orgs/#{@organization.slug}/settings?tab=agents"}
class={
if @active_tab == "agents", do: "text-indigo-600 dark:text-indigo-400", else: ""
}
>
{t("Agents")}
</.link>
</li>
<li>
<.link
patch={~p"/orgs/#{@organization.slug}/settings?tab=members"}
class={
if @active_tab == "members", do: "text-indigo-600 dark:text-indigo-400", else: ""
}
>
{t("Members")}
</.link>
</li>
<li>
<.link
patch={~p"/orgs/#{@organization.slug}/settings?tab=integrations"}
class={
if @active_tab == "integrations",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
{t("Integrations")}
</.link>
</li>
<li>
<.link
patch={~p"/orgs/#{@organization.slug}/settings?tab=billing"}
class={
if @active_tab == "billing", do: "text-indigo-600 dark:text-indigo-400", else: ""
}
>
{t("Billing")}
</.link>
</li>
</ul>
</nav>
</header>
<%= if @active_tab not in ["integrations", "members"] do %>
<.form
for={@form}
id="organization-form"
phx-change="validate"
phx-submit="save"
data-1p-ignore
data-form-type="other"
>
<div class="divide-y divide-gray-200 dark:divide-white/10">
<%= if @active_tab == "general" do %>
<!-- Organization Name Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
{t("Organization Name")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t("Update the name of your organization.")}
</p>
</div>
<div class="md:col-span-2">
<.input
field={@form[:name]}
type="text"
label={t("Organization Name")}
required
/>
</div>
</div>
<%= if @user_orgs_count > 1 do %>
<!-- Default Organization Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
{t("Default Organization")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t(
"Set this organization as your default. When you log in, you'll be directed to your default organization."
)}
</p>
</div>
<div class="md:col-span-2">
<%= if @membership.is_default do %>
<div class="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-4">
<div class="flex items-center">
<.icon
name="hero-check-circle"
class="h-5 w-5 text-green-600 dark:text-green-400"
/>
<p class="ml-3 text-sm text-green-800 dark:text-green-200">
{t("This is your default organization")}
</p>
</div>
</div>
<% else %>
<button
type="button"
phx-click="toggle_default_org"
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:shadow-none dark:hover:bg-indigo-400"
>
<.icon name="hero-star" class="h-4 w-4 inline" /> Set as Default Organization
</button>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Click to make this your default organization")}
</p>
<% end %>
</div>
</div>
<% end %>
<!-- Use Sites Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
{t("Site Organization")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t(
"Enable site organization to group devices into physical locations (offices, datacenters, etc.)."
)}
</p>
</div>
<div class="md:col-span-2">
<.input
field={@form[:use_sites]}
type="checkbox"
label={t("Use sites to organize devices")}
/>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t(
"When enabled, you can organize devices into sites. When disabled, all devices belong directly to the organization."
)}
</p>
<%= if @organization.use_sites && @form[:use_sites].value == false do %>
<div class="mt-4 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-4">
<div class="flex">
<div class="shrink-0">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
{t("Warning: Disabling Sites")}
</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>
{t(
"All devices will be removed from their current sites and assigned directly to the organization."
)}
{t(
"Site assignments will be lost, but devices will remain in the organization."
)}
</p>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<%= if @active_tab == "snmp" do %>
<!-- SNMP Configuration Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
{t("SNMP Configuration")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t(
"Set default SNMP settings for all devices in this organization. These can be overridden at the site or device level."
)}
</p>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400 italic">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
Hierarchy: Device > Site > Organization
</p>
</div>
<div class="md:col-span-2">
<.input
field={@form[:snmp_version]}
type="select"
label={t("SNMP Version")}
prompt="Select SNMP version"
options={[{"v1", "1"}, {"v2c", "2c"}, {"v3", "3"}]}
/>
<!-- v1/v2c Community String -->
<%= if @form[:snmp_version].value in ["1", "2c"] do %>
<.input
field={@form[:snmp_community]}
type="text"
label={t("SNMP Community String")}
placeholder="e.g., public"
/>
<% end %>
<!-- v3 Credentials -->
<%= if @form[:snmp_version].value == "3" do %>
<.input
field={@form[:snmpv3_security_level]}
type="select"
label={t("Security Level")}
prompt="Select security level"
options={[
{"No Auth, No Priv", "noAuthNoPriv"},
{"Auth, No Priv", "authNoPriv"},
{"Auth, Priv", "authPriv"}
]}
/>
<.input
field={@form[:snmpv3_username]}
type="text"
label={t("Username")}
placeholder="e.g., snmpuser"
/>
<!-- Auth fields (shown for authNoPriv and authPriv) -->
<%= if @form[:snmpv3_security_level].value in ["authNoPriv", "authPriv"] do %>
<.input
field={@form[:snmpv3_auth_protocol]}
type="select"
label={t("Auth Protocol")}
prompt="Select protocol"
options={[
{"SHA-256 (recommended)", "SHA-256"},
{"SHA-512", "SHA-512"},
{"SHA-384", "SHA-384"},
{"SHA-224", "SHA-224"},
{"SHA (SHA-1)", "SHA"},
{"MD5", "MD5"}
]}
/>
<.input
field={@form[:snmpv3_auth_password]}
type="password"
label={t("Auth Password")}
placeholder={t("Min 8 characters")}
autocomplete="off"
/>
<% end %>
<!-- Priv fields (shown only for authPriv) -->
<%= if @form[:snmpv3_security_level].value == "authPriv" do %>
<.input
field={@form[:snmpv3_priv_protocol]}
type="select"
label={t("Privacy Protocol")}
prompt="Select protocol"
options={[
{"AES-128 (recommended)", "AES"},
{"AES-256", "AES-256"},
{"AES-192", "AES-192"},
{"AES-256-C", "AES-256-C"},
{"DES (legacy)", "DES"}
]}
/>
<.input
field={@form[:snmpv3_priv_password]}
type="password"
label={t("Privacy Password")}
placeholder={t("Min 8 characters")}
autocomplete="off"
/>
<% end %>
<% end %>
<.input
field={@form[:snmp_port]}
type="number"
label={t("SNMP Port")}
placeholder="161"
/>
<%= if @organization.snmp_community do %>
<div class="mt-6 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-4">
<h3 class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
{t("Force Apply to All Devices")}
</h3>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
{t(
"This will override SNMP settings for ALL devices across all sites in this organization."
)}
</p>
<button
type="button"
phx-click="apply_snmp_to_all"
data-confirm={
t(
"This will replace SNMP settings for ALL devices in this organization. Are you sure?"
)
}
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400"
>
<.icon name="hero-arrow-path" class="h-4 w-4 inline" />
{t("Apply SNMP Config to All Devices")}
</button>
</div>
<% end %>
</div>
</div>
<% end %>
<%= if false && @active_tab == "mikrotik" && @current_scope.user.is_superuser do %>
<!-- MikroTik API Configuration Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
{t("MikroTik API Configuration")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t(
"Set default MikroTik RouterOS API credentials for all devices in this organization. Only applies to devices detected as MikroTik."
)}
</p>
<div class="mt-3 rounded-md bg-blue-50 dark:bg-blue-900/20 p-3">
<p class="text-xs text-blue-700 dark:text-blue-300">
<.icon name="hero-beaker" class="h-4 w-4 inline" />
<strong>{t("Experimental Feature:")}</strong>
{t("MikroTik API integration is under active development.")}
</p>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400 italic">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
Hierarchy: Device > Site > Organization
</p>
</div>
<div class="md:col-span-2">
<.input
field={@form[:mikrotik_enabled]}
type="checkbox"
label={t("Enable MikroTik API")}
/>
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
<.input
field={@form[:mikrotik_username]}
type="text"
label={t("Username")}
placeholder="e.g., admin"
autocomplete="off"
/>
</div>
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
<.input
field={@form[:mikrotik_password]}
type="password"
label={t("Password")}
placeholder={t("RouterOS API password")}
autocomplete="off"
/>
</div>
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
<.input
field={@form[:mikrotik_port]}
type="number"
label={t("API Port")}
placeholder="8729"
autocomplete="off"
/>
</div>
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
<.input
field={@form[:mikrotik_use_ssl]}
type="checkbox"
label={t("Use SSL (API-SSL)")}
/>
</div>
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"} #{if @form[:mikrotik_use_ssl].value != false, do: "hidden"}"}>
<div class="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p class="text-sm text-red-700 dark:text-red-300">
<strong>{t("Critical Security Warning:")}</strong>
{t(
"Plain API (port 8728) sends credentials unencrypted over the network. This setting is"
)}
<strong>blocked for devices using cloud pollers</strong>
and should only be used with local agents on trusted networks.
</p>
</div>
</div>
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"} #{if @form[:mikrotik_use_ssl].value == false, do: "hidden"}"}>
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4">
<p class="text-sm text-yellow-700 dark:text-yellow-300">
<strong>{t("Security Warning:")}</strong>
{t(
"Plain API (port 8728) sends credentials unencrypted. Use SSL (port 8729) whenever possible."
)}
</p>
</div>
</div>
</div>
</div>
<% end %>
<%= if @active_tab == "agents" do %>
<!-- Default Agent Section -->
<%= if @available_agents != [] do %>
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
{t("Default Agent")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t(
"Select a default agent for SNMP polling. This will be used for all devices in this organization unless overridden at the site or device level."
)}
</p>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400 italic">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
Hierarchy: Device > Site > Organization
</p>
</div>
<div class="md:col-span-2">
<.input
field={@form[:default_agent_token_id]}
type="select"
label={t("Default Remote Agent")}
prompt="No default agent - cloud polling"
options={Enum.map(@available_agents, &{&1.name, &1.id})}
/>
<div class="rounded-md bg-gray-50 dark:bg-gray-800/50 p-4">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("Current Device Assignment:")}
</p>
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<div class="flex items-center gap-2">
<.icon name="hero-server" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.direct}</strong> device-level override
</span>
</div>
<div class="flex items-center gap-2">
<.icon name="hero-building-office" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.site}</strong> inherited from site
</span>
</div>
<div class="flex items-center gap-2">
<.icon name="hero-building-office-2" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.organization}</strong>
inheriting organization default
</span>
</div>
<div class="flex items-center gap-2">
<.icon name="hero-cloud" class="h-4 w-4" />
<span>
<strong>{@assignment_breakdown.cloud}</strong> cloud polling (no agent)
</span>
</div>
</div>
</div>
<%= if @organization.default_agent_token_id do %>
<div class="mt-6 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-4">
<h3 class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
{t("Force Apply to All Devices")}
</h3>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
{t(
"This will assign the default agent to ALL devices across all sites in this organization."
)}
</p>
<button
type="button"
phx-click="apply_agent_to_all"
data-confirm={
t(
"This will replace agent assignments for ALL devices in this organization. Are you sure?"
)
}
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400"
>
<.icon name="hero-arrow-path" class="h-4 w-4 inline" />
{t("Apply Default Agent to All Devices")}
</button>
</div>
<% end %>
</div>
</div>
<% else %>
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
{t("Default Agent")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t("No agents configured yet.")}
<.link
navigate={~p"/agents"}
class="text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
>
{t("Create an agent")}
</.link>
to enable remote polling.
</p>
</div>
<div class="md:col-span-2">
<!-- Empty space for layout consistency -->
</div>
</div>
<% end %>
<% end %>
</div>
<!-- Save Button at Bottom (not shown on integrations tab) -->
<div class="flex items-center justify-end gap-x-6 border-t border-gray-200 px-4 py-4 sm:px-6 lg:px-8 dark:border-white/10">
<.link
navigate={~p"/dashboard"}
class="text-sm font-semibold text-gray-900 dark:text-white"
>
{t("Cancel")}
</.link>
<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 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
>
{t("Save Settings")}
</button>
</div>
</.form>
<% end %>
<%= if @active_tab == "members" do %>
<div class="divide-y divide-gray-200 dark:divide-white/10">
<!-- Invite form (owner/admin only) -->
<%= if @membership.role in [:owner, :admin] do %>
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
{t("Invite a Team Member")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t("Send an invitation email to add someone to this organization.")}
</p>
</div>
<div class="md:col-span-2">
<.form for={@invite_form} id="invite-form" phx-submit="send_invitation">
<div class="grid gap-4 sm:grid-cols-[1fr_10rem_auto] sm:items-end">
<.input
field={@invite_form[:email]}
type="email"
label={t("Email address")}
placeholder="colleague@example.com"
required
/>
<.input
field={@invite_form[:role]}
type="select"
label={t("Role")}
options={[
{"Admin", "admin"},
{"Executive", "executive"},
{"Technician", "technician"},
{"Viewer", "viewer"}
]}
/>
<button
type="submit"
phx-disable-with={t("Sending...")}
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:shadow-none dark:hover:bg-indigo-400 h-[42px]"
>
{t("Send Invite")}
</button>
</div>
</.form>
</div>
</div>
<% end %>
<!-- Pending invitations -->
<%= if @pending_invitations != [] do %>
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
{t("Pending Invitations")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t("Invitations that haven't been accepted yet.")}
</p>
</div>
<div class="md:col-span-2">
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-white/5">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Email")}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Role")}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Sent")}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Expires")}
</th>
<%= if @membership.role in [:owner, :admin] do %>
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Actions")}
</th>
<% end %>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-transparent">
<tr :for={invitation <- @pending_invitations}>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
{invitation.email}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
{invitation.role}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
<.timestamp
datetime={invitation.inserted_at}
timezone={@timezone}
format="absolute"
/>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
<.timestamp
datetime={invitation.expires_at}
timezone={@timezone}
format="absolute"
/>
</td>
<%= if @membership.role in [:owner, :admin] do %>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<button
type="button"
phx-click="cancel_invitation"
phx-value-id={invitation.id}
data-confirm={t("Cancel this invitation?")}
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
{t("Cancel")}
</button>
</td>
<% end %>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<% end %>
<!-- Current members -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
{t("Members")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t("People who have access to this organization.")}
</p>
</div>
<div class="md:col-span-2">
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-white/5">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("User")}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Role")}
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Joined")}
</th>
<%= if @membership.role in [:owner, :admin] do %>
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Actions")}
</th>
<% end %>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-transparent">
<tr :for={member <- @members}>
<td class="whitespace-nowrap px-4 py-3">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{member.user.email}
</div>
</div>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
case member.role do
:owner ->
"bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
:admin ->
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
:executive ->
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
role when role in [:technician, :member] ->
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
:viewer ->
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400"
end
]}>
{member.role}
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
<.timestamp
datetime={member.inserted_at}
timezone={@timezone}
format="absolute"
/>
</td>
<%= if @membership.role in [:owner, :admin] do %>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<%= if member.role != :owner do %>
<div class="flex items-center justify-end gap-3">
<form phx-change="change_role" phx-value-user-id={member.user_id}>
<select
name="role"
class="rounded-md border-gray-300 py-1 pl-2 pr-8 text-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
>
<option value="admin" selected={member.role == :admin}>
{t("Admin")}
</option>
<option value="executive" selected={member.role == :executive}>
{t("Executive")}
</option>
<option
value="technician"
selected={member.role in [:technician, :member]}
>
{t("Technician")}
</option>
<option value="viewer" selected={member.role == :viewer}>
{t("Viewer")}
</option>
</select>
</form>
<button
type="button"
phx-click="remove_member"
phx-value-user-id={member.user_id}
data-confirm={t("Remove this member from the organization?")}
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
{t("Remove")}
</button>
</div>
<% end %>
</td>
<% end %>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<% end %>
<%= if @active_tab == "integrations" do %>
<div class="mt-8 space-y-5">
<%= for {provider, idx} <- Enum.with_index(@providers) do %>
<%!-- Section headers --%>
<%= if idx == 0 do %>
<div class="flex flex-wrap items-center gap-3">
<h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Billing & Subscriber Management
</h3>
<span class="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-semibold text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
<.icon name="hero-arrow-path" class="h-3 w-3" /> 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>
<% end %>
<%= if Map.has_key?(provider, :category) and (idx == 0 or Enum.at(@providers, idx - 1) |> Map.get(:category) != provider.category) do %>
<div class="pt-4 mt-4 border-t border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{provider.category}
</h3>
</div>
<% end %>
<div class={[
"relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all dark:bg-white/[0.03]",
cond do
@integrations[provider.id] && @integrations[provider.id].last_sync_status == "failed" ->
"border-red-200 dark:border-red-500/30"
@integrations[provider.id] && @integrations[provider.id].enabled ->
"border-green-200 dark:border-green-500/20"
true ->
"border-gray-200 dark:border-white/10"
end
]}>
<%!-- Left status accent bar --%>
<div class={[
"absolute inset-y-0 left-0 w-1",
cond do
@integrations[provider.id] &&
@integrations[provider.id].last_sync_status == "failed" ->
"bg-red-500"
@integrations[provider.id] && @integrations[provider.id].enabled ->
"bg-green-500"
@integrations[provider.id] ->
"bg-gray-300 dark:bg-gray-600"
true ->
"bg-gray-200 dark:bg-gray-700"
end
]} />
<div class="pl-5 pr-6 py-5">
<%!-- === Header Row === --%>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class={[
"flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
case provider.id do
"preseem" -> "bg-blue-100 dark:bg-blue-900/40"
"gaiia" -> "bg-purple-100 dark:bg-purple-900/40"
"pagerduty" -> "bg-emerald-100 dark:bg-emerald-900/40"
"netbox" -> "bg-orange-100 dark:bg-orange-900/40"
"sonar" -> "bg-violet-100 dark:bg-violet-900/40"
"splynx" -> "bg-blue-100 dark:bg-blue-900/40"
"visp" -> "bg-green-100 dark:bg-green-900/40"
_ -> "bg-indigo-100 dark:bg-indigo-900/40"
end
]}>
<.icon
name={provider.icon}
class={[
"h-5 w-5",
case provider.id do
"preseem" -> "text-blue-600 dark:text-blue-400"
"gaiia" -> "text-purple-600 dark:text-purple-400"
"pagerduty" -> "text-emerald-600 dark:text-emerald-400"
"netbox" -> "text-orange-600 dark:text-orange-400"
"sonar" -> "text-violet-600 dark:text-violet-400"
"splynx" -> "text-blue-600 dark:text-blue-400"
"visp" -> "text-green-600 dark:text-green-400"
_ -> "text-indigo-600 dark:text-indigo-400"
end
]}
/>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{provider.name}
</h3>
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
{provider.description}
</p>
</div>
</div>
<%!-- === Actions (top right) === --%>
<div class="flex items-center gap-4 shrink-0 ml-4">
<%= if integration = @integrations[provider.id] do %>
<div class="flex items-center gap-2">
<span class={[
"text-xs font-medium",
if(integration.enabled,
do: "text-green-700 dark:text-green-400",
else: "text-gray-500 dark:text-gray-400"
)
]}>
{if integration.enabled, do: "Enabled", else: "Disabled"}
</span>
<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 dark:focus:ring-offset-gray-900",
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")
]}>
</span>
</button>
</div>
<% end %>
<button
type="button"
id={"configure-#{provider.id}"}
phx-click="configure"
phx-value-provider={provider.id}
class={[
"rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors",
if(@configuring == provider.id,
do:
"bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10",
else:
"bg-white text-gray-900 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"
)
]}
>
{if @configuring == provider.id, do: "Close", else: "Configure"}
</button>
</div>
</div>
<%!-- === Status Row (when configured) === --%>
<%= if integration = @integrations[provider.id] do %>
<%!-- Failed sync banner --%>
<%= if integration.last_sync_status == "failed" do %>
<div class="mt-4 flex items-start gap-3 rounded-lg bg-red-50 px-4 py-3 dark:bg-red-900/20">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 shrink-0 text-red-500 dark:text-red-400 mt-0.5"
/>
<div>
<p class="text-sm font-medium text-red-800 dark:text-red-300">
{t("Last sync failed")}
</p>
<%= if integration.last_sync_message && integration.last_sync_message != "" do %>
<p class="mt-0.5 text-sm text-red-700 dark:text-red-400">
{integration.last_sync_message}
</p>
<% end %>
<%= if integration.last_synced_at do %>
<p class="mt-1 text-xs text-red-600/70 dark:text-red-400/60">
<.timestamp
datetime={integration.last_synced_at}
timezone={@timezone}
format="absolute"
/>
</p>
<% end %>
</div>
</div>
<% else %>
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
<%!-- Status dot + label --%>
<div class="flex items-center gap-2">
<span class={[
"inline-block h-2 w-2 rounded-full",
if(integration.enabled,
do: "bg-green-500",
else: "bg-gray-400 dark:bg-gray-500"
)
]} />
<span class="text-sm text-gray-600 dark:text-gray-400">
{if integration.enabled, do: "Connected", else: "Paused"}
</span>
</div>
<%!-- Last synced --%>
<%= if integration.last_synced_at do %>
<div class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
<.icon name="hero-clock" class="h-3.5 w-3.5" />
<span>
{t("Synced")}
<.timestamp
datetime={integration.last_synced_at}
timezone={@timezone}
format="absolute"
/>
</span>
</div>
<% else %>
<span class="text-sm italic text-gray-400 dark:text-gray-500">
{t("Never synced")}
</span>
<% end %>
<%!-- Sync status badge (success/partial) --%>
<%= if integration.last_sync_status && integration.last_sync_status not in ["never", "failed"] do %>
<span class={[
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium",
case integration.last_sync_status do
"success" ->
"bg-green-50 text-green-700 ring-1 ring-inset ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20"
"partial" ->
"bg-yellow-50 text-yellow-700 ring-1 ring-inset ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20"
_ ->
"bg-gray-50 text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20"
end
]}>
<%= if integration.last_sync_status == "success" do %>
<.icon name="hero-check-circle-mini" class="h-3.5 w-3.5" />
<% end %>
{String.capitalize(integration.last_sync_status)}
</span>
<% end %>
<%!-- Sync message (success/partial) --%>
<%= if integration.last_sync_message && integration.last_sync_message != "" && integration.last_sync_status != "failed" do %>
<span class="text-sm text-gray-500 dark:text-gray-400">
{integration.last_sync_message}
</span>
<% end %>
<%!-- Next sync --%>
<%= if integration.last_synced_at && integration.enabled do %>
<span class="text-xs text-gray-400 dark:text-gray-500">
Next in ~{next_sync_minutes(
integration.last_synced_at,
integration.sync_interval_minutes
)}m
</span>
<% end %>
<%!-- Sync schedule note --%>
<%= if integration.enabled && provider.id == "pagerduty" do %>
<span class="text-xs text-gray-400 dark:text-gray-500">
· Event-driven (real-time)
</span>
<% end %>
</div>
<% end %>
<%!-- === Provider Links Row === --%>
<%= if provider.id == "preseem" || provider.id == "gaiia" do %>
<div class="mt-3 flex flex-wrap gap-3">
<%= if provider.id == "preseem" do %>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"
}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-cpu-chip" class="h-3.5 w-3.5" /> Manage Devices
</.link>
<.link
navigate={~p"/insights?source=preseem"}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-chart-bar" class="h-3.5 w-3.5" /> Network Insights
</.link>
<% end %>
<%= if provider.id == "gaiia" do %>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"
}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-arrows-right-left" class="h-3.5 w-3.5" /> Entity Mapping
</.link>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"
}
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
>
<.icon name="hero-scale" class="h-3.5 w-3.5" /> Reconciliation
</.link>
<% end %>
</div>
<% end %>
<% end %>
</div>
<%!-- === Configure Panel === --%>
<%= if @configuring == provider.id do %>
<div class="border-t border-gray-200 bg-gray-50/50 px-6 py-6 dark:border-white/10 dark:bg-white/[0.02]">
<%= if provider.id == "netbox" do %>
<.form
for={@integration_form}
id="netbox-form"
phx-change="validate_integration"
phx-submit="save_integration"
>
<div class="space-y-6">
<%!-- Connection Section --%>
<div>
<div class="flex items-center gap-2 mb-1">
<.icon name="hero-link" class="h-4 w-4 text-gray-400" />
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Connection")}
</h4>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
{t(
"Your NetBox instance URL and API token. The token needs read permission at minimum."
)}
{t("Write permission is required if you want to push data to NetBox.")}
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
{t("NetBox URL")}
</label>
<input
type="text"
name="integration[url]"
value={get_credential(@integrations["netbox"], "url")}
placeholder="https://netbox.example.com"
autocomplete="off"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
<div>
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
{t("API Token")}
</label>
<input
type="password"
name="integration[api_token]"
value={get_credential(@integrations["netbox"], "api_token")}
placeholder={t("Enter your NetBox API token")}
autocomplete="off"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
</div>
</div>
<%!-- Sync Direction Section --%>
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
<div class="flex items-center gap-2 mb-1">
<.icon name="hero-arrows-right-left" class="h-4 w-4 text-gray-400" />
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Sync Direction")}
</h4>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
{t(
"Choose how data flows between TowerOps and NetBox. You can change this later."
)}
</p>
<input
type="hidden"
name="integration[sync_direction]"
value={@netbox_config["sync_direction"] || "pull"}
/>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div
phx-click="set_netbox_sync_direction"
phx-value-direction="pull"
class={[
"relative flex cursor-pointer rounded-lg border p-4 shadow-xs transition-all",
if(
@netbox_config["sync_direction"] in ["pull", "", nil],
do:
"border-indigo-600 ring-2 ring-indigo-600 bg-indigo-50 dark:bg-indigo-900/20",
else:
"border-gray-200 dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20 hover:shadow-md"
)
]}
>
<div class="flex flex-col">
<span class="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
<.icon name="hero-arrow-down-tray" class="h-4 w-4 text-indigo-600" />
NetBox → TowerOps
</span>
<span class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{t(
"NetBox is the source of truth. Import devices, sites, and IPs from NetBox into TowerOps."
)}
</span>
</div>
</div>
<div
phx-click="set_netbox_sync_direction"
phx-value-direction="push"
class={[
"relative flex cursor-pointer rounded-lg border p-4 shadow-xs transition-all",
if(
@netbox_config["sync_direction"] == "push",
do:
"border-indigo-600 ring-2 ring-indigo-600 bg-indigo-50 dark:bg-indigo-900/20",
else:
"border-gray-200 dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20 hover:shadow-md"
)
]}
>
<div class="flex flex-col">
<span class="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
<.icon name="hero-arrow-up-tray" class="h-4 w-4 text-orange-600" />
TowerOps → NetBox
</span>
<span class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{t(
"TowerOps is the source of truth. Push discovered devices and monitoring data to NetBox."
)}
</span>
</div>
</div>
<div
phx-click="set_netbox_sync_direction"
phx-value-direction="both"
class={[
"relative flex cursor-pointer rounded-lg border p-4 shadow-xs transition-all",
if(
@netbox_config["sync_direction"] == "both",
do:
"border-indigo-600 ring-2 ring-indigo-600 bg-indigo-50 dark:bg-indigo-900/20",
else:
"border-gray-200 dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20 hover:shadow-md"
)
]}
>
<div class="flex flex-col">
<span class="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
<.icon
name="hero-arrows-right-left"
class="h-4 w-4 text-green-600"
/> Bidirectional
</span>
<span class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{t(
"Merge data from both systems. Conflicts resolved by most-recently-updated. Requires write token."
)}
</span>
</div>
</div>
</div>
</div>
<%!-- What to Sync Section --%>
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
<div class="flex items-center gap-2 mb-1">
<.icon name="hero-circle-stack" class="h-4 w-4 text-gray-400" />
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("What to Sync")}
</h4>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
{t(
"Choose which NetBox objects participate in sync. Devices and sites are recommended at minimum."
)}
</p>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5">
<input
type="hidden"
name="integration[sync_devices]"
value="false"
/>
<input
type="checkbox"
name="integration[sync_devices]"
value="true"
checked={@netbox_config["sync_devices"]}
class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-600"
/>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{t("Devices")}
</span>
<p class="text-xs text-gray-500 dark:text-gray-400">
{t("Name, IP, role, platform, status, serial number")}
</p>
</div>
</label>
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5">
<input
type="hidden"
name="integration[sync_sites]"
value="false"
/>
<input
type="checkbox"
name="integration[sync_sites]"
value="true"
checked={@netbox_config["sync_sites"]}
class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-600"
/>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{t("Sites")}
</span>
<p class="text-xs text-gray-500 dark:text-gray-400">
{t("Site names, locations, addresses, and coordinates")}
</p>
</div>
</label>
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5">
<input
type="hidden"
name="integration[sync_ip_addresses]"
value="false"
/>
<input
type="checkbox"
name="integration[sync_ip_addresses]"
value="true"
checked={@netbox_config["sync_ip_addresses"]}
class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-600"
/>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{t("IP Addresses")}
</span>
<p class="text-xs text-gray-500 dark:text-gray-400">
{t("IPAM data — assigned IPs, prefixes, VRFs")}
</p>
</div>
</label>
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5">
<input
type="hidden"
name="integration[sync_interfaces]"
value="false"
/>
<input
type="checkbox"
name="integration[sync_interfaces]"
value="true"
checked={@netbox_config["sync_interfaces"]}
class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-600"
/>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{t("Interfaces")}
</span>
<p class="text-xs text-gray-500 dark:text-gray-400">
{t("Physical and virtual interfaces, LAGs, connections")}
</p>
</div>
</label>
</div>
</div>
<%!-- Filtering Section --%>
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
<div class="flex items-center gap-2 mb-1">
<.icon name="hero-funnel" class="h-4 w-4 text-gray-400" />
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Filters")}
</h4>
<span class="text-xs font-normal text-gray-400 dark:text-gray-500">
(optional)
</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
{t(
"Narrow the sync scope. Leave blank to sync everything. Comma-separated for multiple values."
)}
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Device Role")}
</label>
<input
type="text"
name="integration[device_role_filter]"
value={@netbox_config["device_role_filter"]}
placeholder="e.g. access-point, router"
class="block w-full rounded-md border-gray-300 py-1.5 text-sm shadow-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Site")}
</label>
<input
type="text"
name="integration[site_filter]"
value={@netbox_config["site_filter"]}
placeholder="e.g. tower-north, main-pop"
class="block w-full rounded-md border-gray-300 py-1.5 text-sm shadow-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Tag")}
</label>
<input
type="text"
name="integration[tag_filter]"
value={@netbox_config["tag_filter"]}
placeholder="e.g. monitored, production"
class="block w-full rounded-md border-gray-300 py-1.5 text-sm shadow-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
/>
</div>
</div>
</div>
<%!-- Sync Interval --%>
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
<div class="max-w-xs">
<.input
field={@integration_form[:sync_interval_minutes]}
type="number"
label={t("Sync interval (minutes)")}
min="5"
value={
if(@integrations["netbox"],
do: @integrations["netbox"].sync_interval_minutes,
else: 30
)
}
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t(
"How often TowerOps checks NetBox for changes. 30 minutes is recommended for most setups."
)}
</p>
</div>
</div>
<%!-- Test & Save --%>
<%= if @test_result do %>
<div class={[
"flex items-start gap-3 rounded-lg 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
]}>
<%= case @test_result do %>
<% {:ok, msg} -> %>
<.icon
name="hero-check-circle"
class="h-5 w-5 shrink-0 text-green-500 dark:text-green-400"
/>
<p class="text-sm font-medium text-green-800 dark:text-green-300">
{msg}
</p>
<% {:error, msg} -> %>
<.icon
name="hero-x-circle"
class="h-5 w-5 shrink-0 text-red-500 dark:text-red-400"
/>
<p class="text-sm font-medium text-red-800 dark:text-red-300">
{msg}
</p>
<% end %>
</div>
<% end %>
<div class="flex items-center justify-end gap-x-3 border-t border-gray-200 pt-5 dark:border-white/10">
<button
type="button"
phx-click="test_connection"
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" />
Test Connection
</button>
<button
type="submit"
phx-disable-with={t("Saving...")}
class="rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{t("Save Integration")}
</button>
</div>
</div>
</.form>
<% else %>
<%= if provider.id == "sonar" do %>
<.form
for={@integration_form}
id="sonar-form"
phx-change="validate_integration"
phx-submit="save_integration"
>
<div class="space-y-6">
<div>
<div class="flex items-center gap-2 mb-1">
<.icon name="hero-link" class="h-4 w-4 text-gray-400" />
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Connection")}
</h4>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
{t("Your Sonar instance URL and API token from the Sonar admin panel.")}
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
{t("Instance URL")}
</label>
<input
type="text"
name="integration[instance_url]"
value={get_credential(@integrations["sonar"], "instance_url")}
placeholder="https://myisp.sonar.software"
autocomplete="off"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
<div>
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
{t("API Token")}
</label>
<input
type="password"
name="integration[api_token]"
value={get_credential(@integrations["sonar"], "api_token")}
placeholder={t("Enter your Sonar API token")}
autocomplete="off"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
</div>
</div>
<div class="border-t border-gray-200 pt-5 dark:border-white/10">
<div class="max-w-xs">
<.input
field={@integration_form[:sync_interval_minutes]}
type="number"
label={t("Sync interval (minutes)")}
min="5"
value={
if(@integrations["sonar"],
do: @integrations["sonar"].sync_interval_minutes,
else: 10
)
}
/>
</div>
</div>
<%= if @test_result do %>
<div class={[
"flex items-start gap-3 rounded-lg 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 font-medium",
case @test_result do
{:ok, _} -> "text-green-800 dark:text-green-300"
{:error, _} -> "text-red-800 dark:text-red-300"
end
]}>
{elem(@test_result, 1)}
</p>
</div>
<% end %>
<div class="flex items-center justify-end gap-x-3 border-t border-gray-200 pt-5 dark:border-white/10">
<button
type="button"
phx-click="test_connection"
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" />
Test Connection
</button>
<button
type="submit"
phx-disable-with={t("Saving...")}
class="rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{t("Save Integration")}
</button>
</div>
</div>
</.form>
<% else %>
<%= if provider.id == "splynx" do %>
<.form
for={@integration_form}
id="splynx-form"
phx-change="validate_integration"
phx-submit="save_integration"
>
<div class="space-y-6">
<div>
<div class="flex items-center gap-2 mb-1">
<.icon name="hero-link" class="h-4 w-4 text-gray-400" />
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Connection")}
</h4>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
{t(
"Your Splynx instance URL, API key, and API secret. Find these in Splynx under Administration → API Keys."
)}
</p>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
{t("Instance URL")}
</label>
<input
type="text"
name="integration[instance_url]"
value={get_credential(@integrations["splynx"], "instance_url")}
placeholder="https://myisp.splynx.app"
autocomplete="off"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
{t("API Key")}
</label>
<input
type="password"
name="integration[api_key]"
value={get_credential(@integrations["splynx"], "api_key")}
placeholder={t("Enter your Splynx API key")}
autocomplete="off"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
<div>
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
{t("API Secret")}
</label>
<input
type="password"
name="integration[api_secret]"
value={get_credential(@integrations["splynx"], "api_secret")}
placeholder={t("Enter your Splynx API secret")}
autocomplete="off"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 pt-5 dark:border-white/10">
<div class="max-w-xs">
<.input
field={@integration_form[:sync_interval_minutes]}
type="number"
label={t("Sync interval (minutes)")}
min="5"
value={
if(@integrations["splynx"],
do: @integrations["splynx"].sync_interval_minutes,
else: 10
)
}
/>
</div>
</div>
<%= if @test_result do %>
<div class={[
"flex items-start gap-3 rounded-lg 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 font-medium",
case @test_result do
{:ok, _} -> "text-green-800 dark:text-green-300"
{:error, _} -> "text-red-800 dark:text-red-300"
end
]}>
{elem(@test_result, 1)}
</p>
</div>
<% end %>
<div class="flex items-center justify-end gap-x-3 border-t border-gray-200 pt-5 dark:border-white/10">
<button
type="button"
phx-click="test_connection"
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" />
Test Connection
</button>
<button
type="submit"
phx-disable-with={t("Saving...")}
class="rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{t("Save Integration")}
</button>
</div>
</div>
</.form>
<% else %>
<.form
for={@integration_form}
id={"#{provider.id}-form"}
phx-change="validate_integration"
phx-submit="save_integration"
>
<div class="space-y-5">
<div>
<div class="flex items-center gap-2 mb-4">
<.icon name="hero-key" class="h-4 w-4 text-gray-400" />
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Connection Settings")}
</h4>
</div>
<div>
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
{if(provider.id == "pagerduty",
do: "Integration Key (Routing Key)",
else: "API Key"
)}
</label>
<input
type="password"
name="integration[api_key]"
value={get_credential(@integrations[provider.id], "api_key")}
placeholder={
if(provider.id == "pagerduty",
do: "Enter your PagerDuty integration key",
else: "Enter your #{provider.name} API key"
)
}
autocomplete="off"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
/>
</div>
</div>
<%= if provider.id == "pagerduty" do %>
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-4">
<h4 class="flex items-center gap-1.5 text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
<.icon name="hero-information-circle" class="h-4 w-4" />
{t("Where to find your Integration Key")}
</h4>
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-5">
<li>
In PagerDuty, go to <strong>{t("Services")}</strong>
→ select your service (or create one)
</li>
<li>Click the <strong>{t("Integrations")}</strong> tab</li>
<li>
Click <strong>{t("Add Integration")}</strong>
→ select <strong>{t("Events API v2")}</strong>
</li>
<li>
Copy the <strong>{t("Integration Key")}</strong>
and paste it above
</li>
</ol>
<p class="mt-2.5 text-xs text-blue-700 dark:text-blue-400 ml-5">
{t(
"When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts."
)}
</p>
</div>
<div class="border-t border-gray-200 pt-5 mt-5 dark:border-white/10">
<div class="flex items-center gap-2 mb-4">
<.icon name="hero-arrow-path" class="h-4 w-4 text-gray-400" />
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Two-Way Sync (Webhooks)")}
</h4>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{t(
"When someone resolves or acknowledges an incident directly in PagerDuty,"
)}
{t("TowerOps will automatically update the corresponding alert.")}
</p>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Webhook URL")}
</label>
<div class="flex items-center gap-2">
<code class="text-xs bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 px-2.5 py-1.5 rounded-md break-all">
{ToweropsWeb.Endpoint.url()}/api/v1/webhooks/pagerduty/{@current_scope.organization.id}
</code>
</div>
</div>
<.input
name="integration[webhook_secret]"
type="password"
label={t("Webhook Signing Secret")}
value={
if(@integrations[provider.id],
do:
get_in(@integrations[provider.id].credentials, [
"webhook_secret"
]) ||
"",
else: ""
)
}
placeholder={t("Optional — paste from PagerDuty webhook extension")}
/>
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-3 mt-3">
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-1">
<li>
In PagerDuty, go to <strong>{t("Integrations")}</strong>
→ <strong>Generic Webhooks (v3)</strong>
</li>
<li>
{t("Add a subscription with the Webhook URL above")}
</li>
<li>
Select events: <strong>incident.resolved</strong>
and <strong>incident.acknowledged</strong>
</li>
<li>
Copy the <strong>{t("Signing Secret")}</strong>
and paste it above
</li>
</ol>
</div>
</div>
<% end %>
<%= if provider.id != "pagerduty" do %>
<div class="border-t border-gray-200 pt-5 dark:border-white/10">
<div class="flex items-center gap-2 mb-4">
<.icon name="hero-clock" class="h-4 w-4 text-gray-400" />
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Sync Settings")}
</h4>
</div>
<div class="max-w-xs">
<.input
field={@integration_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)
)
}
/>
</div>
</div>
<% end %>
<%= if @test_result do %>
<div class={[
"flex items-start gap-3 rounded-lg 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 font-medium",
case @test_result do
{:ok, _} -> "text-green-800 dark:text-green-300"
{:error, _} -> "text-red-800 dark:text-red-300"
end
]}>
{elem(@test_result, 1)}
</p>
</div>
<% end %>
<div class="flex items-center justify-between border-t border-gray-200 pt-5 dark:border-white/10">
<button
type="button"
id="test-connection"
phx-click="test_connection"
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
>
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" />
Test Connection
</button>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="close_config"
class="rounded-lg px-4 py-2.5 text-sm font-semibold text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
{t("Cancel")}
</button>
<button
type="submit"
phx-disable-with={t("Saving...")}
class="rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
{t("Save Integration")}
</button>
</div>
</div>
</div>
</.form>
<% end %>
<% end %>
<% end %>
<%!-- Gaiia Webhook Section --%>
<%= if provider.id == "gaiia" && @integrations["gaiia"] do %>
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
<div class="flex items-center gap-2 mb-1">
<.icon name="hero-globe-alt" class="h-4 w-4 text-gray-400" />
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Webhook Configuration")}
</h4>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-6">
{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>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{t("Paste the secret key generated by Gaiia when you create the webhook.")}
</p>
<div class="mt-1 flex items-center gap-2">
<input
id="gaiia-webhook-secret"
type="password"
name="webhook_secret"
value={get_credential(@integrations["gaiia"], "webhook_secret")}
placeholder={t("Paste the secret from Gaiia")}
phx-blur="save_webhook_secret"
class="block w-full rounded-md border-gray-300 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"
/>
</div>
</div>
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
<h5 class="flex items-center gap-1.5 text-xs font-semibold text-blue-800 dark:text-blue-300">
<.icon name="hero-information-circle" class="h-4 w-4" />
Setup Instructions
</h5>
<ol class="mt-2 list-inside list-decimal space-y-1 text-xs text-blue-700 dark:text-blue-400 ml-5">
<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>Under Events, select &quot;All Events&quot;</li>
<li>Gaiia will generate a secret key — copy it</li>
<li>Paste the secret key into the Webhook Secret field above</li>
<li>Save the webhook</li>
</ol>
<p class="mt-2.5 text-xs text-blue-600 dark:text-blue-400 ml-5">
{t(
"Once configured, Towerops will receive real-time updates when accounts, subscriptions, or inventory items change in Gaiia."
)}
</p>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
<%= if @active_tab == "billing" do %>
<div class="space-y-6 px-4 py-6 sm:p-6 lg:p-8">
<div class="border rounded-lg p-6 dark:border-white/10">
<h2 class="text-xl font-semibold mb-4 dark:text-white">{t("Subscription & Billing")}</h2>
<dl class="space-y-3">
<div>
<dt class="text-sm text-gray-600 dark:text-gray-400">{t("Current Plan")}</dt>
<dd class="text-lg font-semibold capitalize dark:text-white">
{@organization.subscription_plan || "free"}
</dd>
</div>
<div>
<dt class="text-sm text-gray-600 dark:text-gray-400">{t("Device Usage")}</dt>
<dd class="text-lg dark:text-white">
{@current_devices} {t("devices")}
<%= if @device_limit != :unlimited do %>
<span class="text-gray-500 dark:text-gray-400">
/ {@device_limit} {t("limit")}
</span>
<% end %>
</dd>
</div>
<%= if @organization.subscription_plan == "paid" do %>
<div>
<dt class="text-sm text-gray-600 dark:text-gray-400">
{t("Estimated Monthly Cost")}
</dt>
<dd class="text-2xl font-bold text-green-600 dark:text-green-400">
${@estimated_cost.cost_usd}
</dd>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{@estimated_cost.billable} {t("billable devices")} × ${@estimated_cost.price_per_device}
</p>
</div>
<%= if @organization.subscription_status == "active" do %>
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50 rounded p-3">
<p class="text-sm text-green-800 dark:text-green-300">
✓ {t("Subscription Active")}
</p>
<%= if @organization.subscription_current_period_end do %>
<p class="text-xs text-green-600 dark:text-green-400 mt-1">
{t("Next billing")}: {Calendar.strftime(
@organization.subscription_current_period_end,
"%B %d, %Y"
)}
</p>
<% end %>
</div>
<% end %>
<%= if @organization.subscription_status == "past_due" do %>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800/50 rounded p-3">
<p class="text-sm text-yellow-800 dark:text-yellow-300">
⚠ {t("Payment Past Due")}
</p>
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
{t("Please update your payment method to avoid service interruption.")}
</p>
</div>
<% end %>
<% else %>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 rounded p-3">
<p class="text-sm text-blue-800 dark:text-blue-300">
{t("Free Plan - First %{count} devices included",
count: @estimated_cost.free_included
)}
</p>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">
{t("Upgrade to monitor unlimited devices at $%{price}/device/month",
price: @estimated_cost.price_per_device
)}
</p>
</div>
<% end %>
</dl>
<div class="mt-6 flex gap-3">
<%= if @organization.subscription_plan == "free" do %>
<.button phx-click="upgrade_to_paid" class="btn-primary">
{t("Upgrade to Paid Plan")}
</.button>
<% else %>
<.button phx-click="manage_billing" class="btn-secondary">
{t("Manage Billing")}
</.button>
<% end %>
</div>
</div>
<%= if @organization.subscription_plan == "paid" do %>
<div class="border rounded-lg p-6 dark:border-white/10">
<h3 class="text-lg font-semibold mb-3 dark:text-white">{t("Billing Information")}</h3>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">{t("Subscription Status")}</dt>
<dd class="font-medium capitalize dark:text-white">
{@organization.subscription_status}
</dd>
</div>
<%= if @organization.subscription_current_period_start do %>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">{t("Current Period Start")}</dt>
<dd class="font-medium dark:text-white">
{Calendar.strftime(@organization.subscription_current_period_start, "%B %d, %Y")}
</dd>
</div>
<% end %>
<%= if @organization.subscription_current_period_end do %>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">{t("Current Period End")}</dt>
<dd class="font-medium dark:text-white">
{Calendar.strftime(@organization.subscription_current_period_end, "%B %d, %Y")}
</dd>
</div>
<% end %>
<%= if @organization.payment_method_status do %>
<div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400">{t("Payment Method")}</dt>
<dd class={[
"font-medium capitalize",
if(@organization.payment_method_status == "valid",
do: "text-green-600 dark:text-green-400",
else: "text-yellow-600 dark:text-yellow-400"
)
]}>
{String.replace(@organization.payment_method_status, "_", " ")}
</dd>
</div>
<% end %>
</dl>
</div>
<% end %>
</div>
<% end %>
</Layouts.authenticated>