towerops/lib/towerops_web/live/org/settings_live.html.heex

1185 lines
52 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"/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">
Organization Settings
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
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: ""
}
>
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: ""}
>
SNMP
</.link>
</li>
<%= if @current_scope.user.is_superuser 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: ""
}
>
MikroTik
</.link>
</li>
<% 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: ""
}
>
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: ""
}
>
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: ""
}
>
Integrations
</.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">
Organization Name
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Update the name of your organization.
</p>
</div>
<div class="md:col-span-2">
<.input
field={@form[:name]}
type="text"
label="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">
Default Organization
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
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">
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">
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">
Site Organization
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
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="Use sites to organize devices"
/>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
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">
Warning: Disabling Sites
</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>
All devices will be removed from their current sites and assigned directly to the organization.
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">
SNMP Configuration
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
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="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="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="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="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="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="Auth Password"
placeholder="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="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="Privacy Password"
placeholder="Min 8 characters"
autocomplete="off"
/>
<% end %>
<% end %>
<.input
field={@form[:snmp_port]}
type="number"
label="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">
Force Apply to All Devices
</h3>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
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="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" />
Apply SNMP Config to All Devices
</button>
</div>
<% end %>
</div>
</div>
<% end %>
<%= if @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">
MikroTik API Configuration
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
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>Experimental Feature:</strong>
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="Enable MikroTik API"
/>
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
<.input
field={@form[:mikrotik_username]}
type="text"
label="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="Password"
placeholder="RouterOS API password"
autocomplete="off"
/>
</div>
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
<.input
field={@form[:mikrotik_port]}
type="number"
label="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="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>Critical Security Warning:</strong>
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>Security Warning:</strong>
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">
Default Agent
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
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="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">
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">
Force Apply to All Devices
</h3>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
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="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" />
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">
Default Agent
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
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"
>
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"
>
Cancel
</.link>
<button
type="submit"
phx-disable-with="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"
>
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">
Invite a Team Member
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
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="flex flex-col gap-4 sm:flex-row sm:items-end">
<div class="flex-1">
<.input
field={@invite_form[:email]}
type="email"
label="Email address"
placeholder="colleague@example.com"
required
/>
</div>
<div class="w-full sm:w-40">
<.input
field={@invite_form[:role]}
type="select"
label="Role"
options={[{"Admin", "admin"}, {"Member", "member"}, {"Viewer", "viewer"}]}
/>
</div>
<div>
<button
type="submit"
phx-disable-with="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"
>
Send Invite
</button>
</div>
</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">
Pending Invitations
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
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">
Email
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Role
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Sent
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
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">
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="Cancel this invitation?"
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
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">
Members
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
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">
User
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Role
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
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">
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"
: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}>
Admin
</option>
<option value="member" selected={member.role == :member}>
Member
</option>
<option value="viewer" selected={member.role == :viewer}>
Viewer
</option>
</select>
</form>
<button
type="button"
phx-click="remove_member"
phx-value-user-id={member.user_id}
data-confirm="Remove this member from the organization?"
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
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-6">
<div
:for={provider <- @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 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">
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 provider.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 %>
<%!-- Sync schedule info --%>
<%= if integration.enabled do %>
<div class="mt-3 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 %>
<%= if provider.id == "gaiia" do %>
Syncs every {integration.sync_interval_minutes} minutes. Reconciliation runs nightly. Also receives real-time webhook updates.
<% end %>
<%= if provider.id == "pagerduty" do %>
Event-driven — no polling needed. TowerOps sends events to PagerDuty in real time:
<ul class="mt-1 space-y-0.5 list-disc list-inside text-xs text-gray-500 dark:text-gray-400">
<li>
<strong class="text-gray-700 dark:text-gray-300">
Device down
</strong>
→ PagerDuty incident triggered (critical)
</li>
<li>
<strong class="text-gray-700 dark:text-gray-300">
Alert acknowledged
</strong>
→ PagerDuty incident acknowledged
</li>
<li>
<strong class="text-gray-700 dark:text-gray-300">
Device recovers
</strong>
→ PagerDuty incident auto-resolved
</li>
</ul>
<% 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")
]}>
</span>
</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"
>
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={@integration_form}
id={"#{provider.id}-form"}
phx-change="validate_integration"
phx-submit="save_integration"
>
<div class="space-y-4">
<.input
field={@integration_form[:api_key]}
type="password"
label={
if(provider.id == "pagerduty",
do: "Integration Key (Routing Key)",
else: "API Key"
)
}
placeholder={
if(provider.id == "pagerduty",
do: "Enter your PagerDuty integration key",
else: "Enter your #{provider.name} API key"
)
}
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_key")}
/>
<%= if provider.id == "pagerduty" do %>
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-4 -mt-1">
<h4 class="text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
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">
<li>
In PagerDuty, go to <strong>Services</strong>
→ select your service (or create one)
</li>
<li>Click the <strong>Integrations</strong> tab</li>
<li>
Click <strong>Add Integration</strong>
→ select <strong>Events API v2</strong>
</li>
<li>Copy the <strong>Integration Key</strong> and paste it above</li>
</ol>
<p class="mt-2 text-xs text-blue-700 dark:text-blue-400">
When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts.
</p>
</div>
<% end %>
<%= if provider.id != "pagerduty" do %>
<.input
field={@integration_form[:sync_interval_minutes]}
type="number"
label="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)
)
}
/>
<% end %>
<%= 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"
>
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"
>
Cancel
</button>
<button
type="submit"
phx-disable-with="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"
>
Save
</button>
</div>
</div>
</div>
</.form>
</div>
<% end %>
<%= if provider.id == "gaiia" && @integrations["gaiia"] && @configuring == "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>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
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="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-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 &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 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>
<% end %>
</Layouts.authenticated>