cookie improvements
This commit is contained in:
parent
588639a47a
commit
220f1edce3
23 changed files with 1521 additions and 45 deletions
|
|
@ -36,7 +36,11 @@ function acceptCookies() {
|
|||
const expiryDate = new Date();
|
||||
expiryDate.setFullYear(expiryDate.getFullYear() + 1);
|
||||
|
||||
document.cookie = `cookie_consent=accepted; expires=${expiryDate.toUTCString()}; path=/; SameSite=Lax; Secure`;
|
||||
// Only add Secure flag in production (requires HTTPS)
|
||||
const isProduction = window.location.protocol === 'https:';
|
||||
const secureFlag = isProduction ? '; Secure' : '';
|
||||
|
||||
document.cookie = `cookie_consent=accepted; expires=${expiryDate.toUTCString()}; path=/; SameSite=Lax${secureFlag}`;
|
||||
|
||||
// Hide the banner with animation
|
||||
const banner = document.getElementById('cookie-consent-banner');
|
||||
|
|
|
|||
|
|
@ -196,4 +196,18 @@ defmodule Towerops.Admin do
|
|||
|> preload([:superuser, :target_user])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns audit logs related to a specific user (GDPR data access).
|
||||
"""
|
||||
def list_audit_logs_for_user(user_id, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 100)
|
||||
|
||||
AuditLog
|
||||
|> where([a], a.target_user_id == ^user_id or a.superuser_id == ^user_id)
|
||||
|> order_by([a], desc: a.inserted_at)
|
||||
|> limit(^limit)
|
||||
|> preload([:superuser, :target_user])
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -102,6 +102,32 @@ defmodule Towerops.Alerts do
|
|||
Repo.all(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns alerts for multiple organizations (for GDPR data access).
|
||||
Accepts a `since` option to filter alerts created after a specific date.
|
||||
"""
|
||||
def list_alerts_for_organizations(organization_ids, opts \\ []) when is_list(organization_ids) do
|
||||
since = Keyword.get(opts, :since)
|
||||
|
||||
query =
|
||||
from(a in Alert,
|
||||
join: e in assoc(a, :device),
|
||||
join: s in assoc(e, :site),
|
||||
where: s.organization_id in ^organization_ids,
|
||||
order_by: [desc: a.triggered_at],
|
||||
preload: [device: e]
|
||||
)
|
||||
|
||||
query =
|
||||
if since do
|
||||
where(query, [a], a.inserted_at >= ^since)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
Repo.all(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single alert.
|
||||
where: s.organization_id == ^organization_id,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,20 @@ defmodule Towerops.Devices do
|
|||
Repo.all(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns all devices for multiple organizations (for GDPR data access).
|
||||
"""
|
||||
def list_devices_for_organizations(organization_ids) when is_list(organization_ids) do
|
||||
Repo.all(
|
||||
from(e in DeviceSchema,
|
||||
join: s in assoc(e, :site),
|
||||
where: s.organization_id in ^organization_ids,
|
||||
order_by: [asc: s.organization_id, asc: e.name],
|
||||
preload: [site: s]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of devices for an organization.
|
||||
"""
|
||||
|
|
@ -350,6 +364,94 @@ defmodule Towerops.Devices do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resolves the effective SNMP community string for a device.
|
||||
|
||||
Resolution order:
|
||||
1. If device has its own community string (source = "device"), use it
|
||||
2. If source = "site", inherit from site's community string
|
||||
3. If site has no community, inherit from organization's community string
|
||||
4. If no community found anywhere, return nil
|
||||
"""
|
||||
def resolve_snmp_community(%DeviceSchema{} = device) do
|
||||
device = Repo.preload(device, site: :organization)
|
||||
|
||||
case device.snmp_community_source do
|
||||
"device" ->
|
||||
# Use device-specific community
|
||||
device.snmp_community
|
||||
|
||||
_ ->
|
||||
# Inherit from site or organization
|
||||
device.site.snmp_community || device.site.organization.snmp_community
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Propagates SNMP community string changes from a site to all devices
|
||||
that inherit from it (source = "site").
|
||||
|
||||
This updates the actual snmp_community value on devices so they have
|
||||
the resolved value cached and ready for agent communication.
|
||||
"""
|
||||
def propagate_site_community_change(site_id, new_community) do
|
||||
# Find all devices in this site that inherit community from site
|
||||
devices_to_update =
|
||||
Repo.all(from(d in DeviceSchema, where: d.site_id == ^site_id, where: d.snmp_community_source == "site"))
|
||||
|
||||
# Update each device's community string
|
||||
Enum.each(devices_to_update, fn device ->
|
||||
device
|
||||
|> Ecto.Changeset.change(%{snmp_community: new_community})
|
||||
|> Repo.update()
|
||||
|
||||
# Broadcast assignment change to trigger agent job list refresh
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"device:#{device.id}:assignments",
|
||||
{:assignments_changed, :community_updated}
|
||||
)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Propagates SNMP community string changes from an organization to all
|
||||
devices in sites that have no site-level community (source = "site"
|
||||
and site.snmp_community is nil).
|
||||
"""
|
||||
def propagate_organization_community_change(organization_id, new_community) do
|
||||
# Find all devices in this org's sites that:
|
||||
# 1. Inherit from site (source = "site")
|
||||
# 2. Their site has no community string (so they fall back to org)
|
||||
devices_to_update =
|
||||
Repo.all(
|
||||
from(d in DeviceSchema,
|
||||
join: s in assoc(d, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
where: d.snmp_community_source == "site",
|
||||
where: is_nil(s.snmp_community) or s.snmp_community == ""
|
||||
)
|
||||
)
|
||||
|
||||
# Update each device's community string
|
||||
Enum.each(devices_to_update, fn device ->
|
||||
device
|
||||
|> Ecto.Changeset.change(%{snmp_community: new_community})
|
||||
|> Repo.update()
|
||||
|
||||
# Broadcast assignment change to trigger agent job list refresh
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"device:#{device.id}:assignments",
|
||||
{:assignments_changed, :community_updated}
|
||||
)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes device.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ defmodule Towerops.Devices.Device do
|
|||
field :snmp_enabled, :boolean, default: true
|
||||
field :snmp_version, :string, default: "2c"
|
||||
field :snmp_community, :string
|
||||
field :snmp_community_source, :string, default: "device"
|
||||
field :snmp_port, :integer, default: 161
|
||||
field :last_discovery_at, :utc_datetime
|
||||
field :last_snmp_poll_at, :utc_datetime
|
||||
|
|
@ -85,6 +86,7 @@ defmodule Towerops.Devices.Device do
|
|||
:snmp_enabled,
|
||||
:snmp_version,
|
||||
:snmp_community,
|
||||
:snmp_community_source,
|
||||
:snmp_port,
|
||||
:status,
|
||||
:last_checked_at,
|
||||
|
|
@ -99,6 +101,7 @@ defmodule Towerops.Devices.Device do
|
|||
|> validate_ip_address()
|
||||
|> validate_number(:check_interval_seconds, greater_than: 0, less_than_or_equal_to: 3600)
|
||||
|> validate_snmp()
|
||||
|> update_community_source()
|
||||
|> foreign_key_constraint(:site_id)
|
||||
end
|
||||
|
||||
|
|
@ -149,4 +152,22 @@ defmodule Towerops.Devices.Device do
|
|||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp update_community_source(changeset) do
|
||||
# If user explicitly set a community string, mark source as "device"
|
||||
# If it's blank/nil, mark source as "site" (will inherit from site or org)
|
||||
case get_change(changeset, :snmp_community) do
|
||||
nil ->
|
||||
# No change to community, keep existing source
|
||||
changeset
|
||||
|
||||
"" ->
|
||||
# Explicitly cleared, set to inherit from site
|
||||
put_change(changeset, :snmp_community_source, "site")
|
||||
|
||||
_value ->
|
||||
# Explicitly set a value, mark as device-specific
|
||||
put_change(changeset, :snmp_community_source, "device")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -115,9 +115,25 @@ defmodule Towerops.Organizations do
|
|||
Updates an organization.
|
||||
"""
|
||||
def update_organization(%Organization{} = organization, attrs) do
|
||||
organization
|
||||
|> Organization.changeset(attrs)
|
||||
|> Repo.update()
|
||||
old_community = organization.snmp_community
|
||||
|
||||
case organization
|
||||
|> Organization.changeset(attrs)
|
||||
|> Repo.update() do
|
||||
{:ok, updated_organization} = result ->
|
||||
# If community string changed, propagate to inheriting devices
|
||||
if updated_organization.snmp_community != old_community do
|
||||
Towerops.Devices.propagate_organization_community_change(
|
||||
updated_organization.id,
|
||||
updated_organization.snmp_community
|
||||
)
|
||||
end
|
||||
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -96,9 +96,22 @@ defmodule Towerops.Sites do
|
|||
Updates a site.
|
||||
"""
|
||||
def update_site(%Site{} = site, attrs) do
|
||||
site
|
||||
|> Site.changeset(attrs)
|
||||
|> Repo.update()
|
||||
old_community = site.snmp_community
|
||||
|
||||
case site
|
||||
|> Site.changeset(attrs)
|
||||
|> Repo.update() do
|
||||
{:ok, updated_site} = result ->
|
||||
# If community string changed, propagate to inheriting devices
|
||||
if updated_site.snmp_community != old_community do
|
||||
Towerops.Devices.propagate_site_community_change(updated_site.id, updated_site.snmp_community)
|
||||
end
|
||||
|
||||
result
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
device_id: device.id,
|
||||
snmp_device: %SnmpDevice{
|
||||
ip: device.ip_address,
|
||||
community: device.snmp_community,
|
||||
community: Devices.resolve_snmp_community(device),
|
||||
version: device.snmp_version,
|
||||
port: device.snmp_port || 161,
|
||||
monitoring_enabled: device.monitoring_enabled || false,
|
||||
|
|
@ -251,7 +251,7 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
device_id: device.id,
|
||||
snmp_device: %SnmpDevice{
|
||||
ip: device.ip_address,
|
||||
community: device.snmp_community,
|
||||
community: Devices.resolve_snmp_community(device),
|
||||
version: device.snmp_version,
|
||||
port: device.snmp_port || 161,
|
||||
monitoring_enabled: device.monitoring_enabled || false,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ defmodule ToweropsWeb.Components.CookieConsent do
|
|||
attr :requires_consent, :boolean, required: true
|
||||
|
||||
def banner(assigns) do
|
||||
# Debug logging
|
||||
require Logger
|
||||
|
||||
Logger.info(
|
||||
"CookieConsent.banner called with requires_consent=#{inspect(assigns[:requires_consent])}, all assign keys: #{inspect(Map.keys(assigns))}"
|
||||
)
|
||||
|
||||
~H"""
|
||||
<%= if @requires_consent do %>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ defmodule ToweropsWeb.Layouts do
|
|||
|
||||
alias ToweropsWeb.Components.CookieConsent
|
||||
|
||||
require Logger
|
||||
|
||||
# Embed all files in layouts/* within this module.
|
||||
# The default root.html.heex file contains the HTML
|
||||
# skeleton of your application, namely HTML headers
|
||||
|
|
@ -35,9 +37,21 @@ defmodule ToweropsWeb.Layouts do
|
|||
|
||||
attr :timezone, :string, default: "UTC", doc: "the user's timezone"
|
||||
|
||||
attr :requires_cookie_consent, :boolean,
|
||||
default: false,
|
||||
doc: "whether to show the cookie consent banner"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def app(assigns) do
|
||||
# Get cookie consent from process dictionary (set in plug for non-authenticated pages)
|
||||
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
|
||||
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
|
||||
|
||||
Logger.info(
|
||||
"Layout app/1: requires_cookie_consent = #{inspect(assigns.requires_cookie_consent)}, process dict = #{inspect(Process.get(:requires_cookie_consent))}"
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
|
||||
<header class="border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
|
||||
|
|
@ -76,7 +90,7 @@ defmodule ToweropsWeb.Layouts do
|
|||
</div>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
<CookieConsent.banner requires_consent={Map.get(assigns, :requires_cookie_consent, false)} />
|
||||
<CookieConsent.banner requires_consent={@requires_cookie_consent} />
|
||||
"""
|
||||
end
|
||||
|
||||
|
|
@ -111,6 +125,14 @@ defmodule ToweropsWeb.Layouts do
|
|||
slot :inner_block, required: true
|
||||
|
||||
def authenticated(assigns) do
|
||||
# Get cookie consent from process dictionary (set in on_mount hook)
|
||||
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
|
||||
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
|
||||
|
||||
Logger.info(
|
||||
"Layout authenticated/1: requires_cookie_consent = #{inspect(assigns.requires_cookie_consent)}, process dict = #{inspect(Process.get(:requires_cookie_consent))}"
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
|
||||
<!-- Impersonation Banner -->
|
||||
|
|
@ -142,7 +164,7 @@ defmodule ToweropsWeb.Layouts do
|
|||
<div class="flex items-center">
|
||||
<!-- Mobile menu button -->
|
||||
<button
|
||||
:if={@current_organization}
|
||||
:if={@current_organization || (@current_scope && @current_scope.user)}
|
||||
type="button"
|
||||
id="mobile-menu-button"
|
||||
aria-expanded="false"
|
||||
|
|
@ -174,7 +196,10 @@ defmodule ToweropsWeb.Layouts do
|
|||
</div>
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<div :if={@current_organization} class="hidden md:ml-8 md:flex md:space-x-6">
|
||||
<div
|
||||
:if={@current_organization || (@current_scope && @current_scope.user)}
|
||||
class="hidden md:ml-8 md:flex md:space-x-6"
|
||||
>
|
||||
<.nav_link
|
||||
navigate={~p"/"}
|
||||
active={@active_page == "dashboard"}
|
||||
|
|
@ -324,7 +349,11 @@ defmodule ToweropsWeb.Layouts do
|
|||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div :if={@current_organization} class="hidden md:hidden" id="mobile-menu">
|
||||
<div
|
||||
:if={@current_organization || (@current_scope && @current_scope.user)}
|
||||
class="hidden md:hidden"
|
||||
id="mobile-menu"
|
||||
>
|
||||
<div class="space-y-1 px-2 pb-3 pt-2 border-t border-gray-200 dark:border-white/10">
|
||||
<.mobile_nav_link
|
||||
navigate={~p"/"}
|
||||
|
|
@ -395,7 +424,7 @@ defmodule ToweropsWeb.Layouts do
|
|||
</div>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
<CookieConsent.banner requires_consent={Map.get(assigns, :requires_cookie_consent, false)} />
|
||||
<CookieConsent.banner requires_consent={@requires_cookie_consent} />
|
||||
"""
|
||||
end
|
||||
|
||||
|
|
@ -570,9 +599,21 @@ defmodule ToweropsWeb.Layouts do
|
|||
|
||||
attr :timezone, :string, default: "UTC", doc: "the user's timezone"
|
||||
|
||||
attr :requires_cookie_consent, :boolean,
|
||||
default: false,
|
||||
doc: "whether to show the cookie consent banner"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def admin(assigns) do
|
||||
# Get cookie consent from process dictionary (set in on_mount hook)
|
||||
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
|
||||
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
|
||||
|
||||
Logger.info(
|
||||
"Layout admin/1: requires_cookie_consent = #{inspect(assigns.requires_cookie_consent)}, process dict = #{inspect(Process.get(:requires_cookie_consent))}"
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
|
||||
<nav class="bg-white border-b border-gray-200 dark:border-white/10 dark:bg-gray-900">
|
||||
|
|
@ -657,7 +698,7 @@ defmodule ToweropsWeb.Layouts do
|
|||
</div>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
<CookieConsent.banner requires_consent={Map.get(assigns, :requires_cookie_consent, false)} />
|
||||
<CookieConsent.banner requires_consent={@requires_cookie_consent} />
|
||||
"""
|
||||
end
|
||||
|
||||
|
|
@ -691,6 +732,13 @@ defmodule ToweropsWeb.Layouts do
|
|||
Privacy Policy
|
||||
</.link>
|
||||
<span class="mx-2">·</span>
|
||||
<.link
|
||||
navigate={~p"/terms"}
|
||||
class="hover:text-gray-900 dark:hover:text-gray-200 underline decoration-dotted underline-offset-2"
|
||||
>
|
||||
Terms of Service
|
||||
</.link>
|
||||
<span class="mx-2">·</span>
|
||||
<.link
|
||||
href="/changelog.txt"
|
||||
class="hover:text-gray-900 dark:hover:text-gray-200 underline decoration-dotted underline-offset-2"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ defmodule ToweropsWeb.MarketingLayouts do
|
|||
"""
|
||||
use ToweropsWeb, :html
|
||||
|
||||
alias ToweropsWeb.Components.CookieConsent
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Renders the marketing layout.
|
||||
|
||||
|
|
@ -21,6 +25,14 @@ defmodule ToweropsWeb.MarketingLayouts do
|
|||
slot :inner_block, required: true
|
||||
|
||||
def marketing(assigns) do
|
||||
# Get cookie consent from process dictionary (set in plug)
|
||||
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
|
||||
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
|
||||
|
||||
Logger.info(
|
||||
"Layout marketing/1: requires_cookie_consent = #{inspect(assigns.requires_cookie_consent)}, process dict = #{inspect(Process.get(:requires_cookie_consent))}"
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div class="bg-white">
|
||||
<header class="py-10">
|
||||
|
|
@ -64,15 +76,33 @@ defmodule ToweropsWeb.MarketingLayouts do
|
|||
</div>
|
||||
<div class="flex flex-col items-center border-t border-slate-400/10 py-10 sm:flex-row-reverse sm:justify-between">
|
||||
<div class="flex gap-x-6"></div>
|
||||
<p class="mt-6 text-sm text-slate-500 sm:mt-0">
|
||||
Copyright © {Date.utc_today().year} Towerops
|
||||
</p>
|
||||
<div class="flex flex-col items-center gap-2 sm:items-start">
|
||||
<p class="text-sm text-slate-500">
|
||||
Copyright © {Date.utc_today().year} Towerops
|
||||
</p>
|
||||
<div class="flex gap-x-4 text-xs text-slate-500">
|
||||
<.link
|
||||
navigate={~p"/privacy"}
|
||||
class="hover:text-slate-700 underline decoration-dotted underline-offset-2"
|
||||
>
|
||||
Privacy Policy
|
||||
</.link>
|
||||
<span>·</span>
|
||||
<.link
|
||||
navigate={~p"/terms"}
|
||||
class="hover:text-slate-700 underline decoration-dotted underline-offset-2"
|
||||
>
|
||||
Terms of Service
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<Layouts.flash_group flash={@flash} />
|
||||
<CookieConsent.banner requires_consent={@requires_cookie_consent} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
173
lib/towerops_web/controllers/api/account_data_controller.ex
Normal file
173
lib/towerops_web/controllers/api/account_data_controller.ex
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
defmodule ToweropsWeb.Api.AccountDataController do
|
||||
@moduledoc """
|
||||
API controller for GDPR Right to Access - allows users to download all their data in JSON format
|
||||
"""
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias Towerops.Admin
|
||||
alias Towerops.Alerts
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Organizations
|
||||
|
||||
@doc """
|
||||
Returns all user data in JSON format (GDPR Right to Access compliance)
|
||||
"""
|
||||
def show(conn, _params) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
# Gather all user data
|
||||
data = %{
|
||||
profile: build_profile_data(user),
|
||||
credentials: build_credentials_data(user),
|
||||
organizations: build_organizations_data(user),
|
||||
devices: build_devices_data(user),
|
||||
alerts: build_alerts_data(user),
|
||||
audit_logs: build_audit_logs_data(user),
|
||||
export_info: %{
|
||||
exported_at: DateTime.utc_now(),
|
||||
format: "JSON",
|
||||
gdpr_article: "Article 15 - Right to Access"
|
||||
}
|
||||
}
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/json")
|
||||
|> put_resp_header(
|
||||
"content-disposition",
|
||||
"attachment; filename=\"towerops-data-#{user.id}-#{DateTime.to_unix(DateTime.utc_now())}.json\""
|
||||
)
|
||||
|> json(data)
|
||||
end
|
||||
|
||||
# Build profile data
|
||||
defp build_profile_data(user) do
|
||||
%{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
timezone: user.timezone,
|
||||
is_superuser: user.is_superuser,
|
||||
confirmed_at: user.confirmed_at,
|
||||
inserted_at: user.inserted_at,
|
||||
updated_at: user.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
# Build credentials data
|
||||
defp build_credentials_data(user) do
|
||||
user
|
||||
|> Accounts.list_user_credentials()
|
||||
|> Enum.map(fn credential ->
|
||||
%{
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
public_key_spki: Base.encode64(credential.public_key_spki),
|
||||
attestation_client_data_json: credential.attestation_client_data_json,
|
||||
last_used_at: credential.last_used_at,
|
||||
inserted_at: credential.inserted_at
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
# Build organizations data
|
||||
defp build_organizations_data(user) do
|
||||
user.id
|
||||
|> Organizations.list_user_organizations()
|
||||
|> Enum.map(fn org ->
|
||||
%{
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
role: if(org.owner_id == user.id, do: "owner", else: "member"),
|
||||
inserted_at: org.inserted_at,
|
||||
updated_at: org.updated_at
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
# Build devices data
|
||||
defp build_devices_data(user) do
|
||||
org_ids =
|
||||
user.id
|
||||
|> Organizations.list_user_organizations()
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
if Enum.empty?(org_ids) do
|
||||
[]
|
||||
else
|
||||
org_ids
|
||||
|> Devices.list_devices_for_organizations()
|
||||
|> Enum.map(fn device ->
|
||||
%{
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
ip_address: device.ip_address,
|
||||
snmp_community: "[REDACTED]",
|
||||
monitoring_enabled: device.monitoring_enabled,
|
||||
snmp_enabled: device.snmp_enabled,
|
||||
status: device.status,
|
||||
organization: %{
|
||||
id: device.site.organization_id,
|
||||
name: device.organization.name
|
||||
},
|
||||
site: %{
|
||||
id: device.site_id,
|
||||
name: device.site.name
|
||||
},
|
||||
inserted_at: device.inserted_at,
|
||||
updated_at: device.updated_at
|
||||
}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# Build alerts data (last 90 days)
|
||||
defp build_alerts_data(user) do
|
||||
org_ids =
|
||||
user.id
|
||||
|> Organizations.list_user_organizations()
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
if Enum.empty?(org_ids) do
|
||||
[]
|
||||
else
|
||||
ninety_days_ago = DateTime.add(DateTime.utc_now(), -90, :day)
|
||||
|
||||
org_ids
|
||||
|> Alerts.list_alerts_for_organizations(since: ninety_days_ago)
|
||||
|> Enum.map(fn alert ->
|
||||
%{
|
||||
id: alert.id,
|
||||
alert_type: alert.alert_type,
|
||||
severity: alert.severity,
|
||||
message: alert.message,
|
||||
triggered_at: alert.triggered_at,
|
||||
resolved_at: alert.resolved_at,
|
||||
acknowledged_at: alert.acknowledged_at,
|
||||
device: %{
|
||||
id: alert.device_id,
|
||||
name: alert.device.name
|
||||
},
|
||||
inserted_at: alert.inserted_at
|
||||
}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# Build audit logs data
|
||||
defp build_audit_logs_data(user) do
|
||||
user.id
|
||||
|> Admin.list_audit_logs_for_user(limit: 1000)
|
||||
|> Enum.map(fn log ->
|
||||
%{
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
actor_email: log.actor_email,
|
||||
target_user_id: log.target_user_id,
|
||||
metadata: log.metadata,
|
||||
inserted_at: log.inserted_at
|
||||
}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
@ -13,6 +13,16 @@ defmodule ToweropsWeb.PageController do
|
|||
end
|
||||
|
||||
def privacy(conn, _params) do
|
||||
render(conn, :privacy)
|
||||
render(conn, :privacy,
|
||||
current_scope: conn.assigns[:current_scope],
|
||||
current_organization: conn.assigns[:current_organization]
|
||||
)
|
||||
end
|
||||
|
||||
def terms(conn, _params) do
|
||||
render(conn, :terms,
|
||||
current_scope: conn.assigns[:current_scope],
|
||||
current_organization: conn.assigns[:current_organization]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<Layouts.app flash={@flash}>
|
||||
<% content = fn -> %>
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Privacy Policy</h1>
|
||||
|
|
@ -107,11 +107,24 @@
|
|||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Contact Us</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
If you have questions about this privacy policy or how we handle your data, please contact us at <a
|
||||
href="mailto:privacy@towerops.net"
|
||||
href="mailto:hi@towerops.net"
|
||||
class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>privacy@towerops.net</a>.
|
||||
>hi@towerops.net</a>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
<% end %>
|
||||
<%= if @current_scope && @current_scope.user do %>
|
||||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
current_organization={@current_organization}
|
||||
>
|
||||
{content.()}
|
||||
</Layouts.authenticated>
|
||||
<% else %>
|
||||
<Layouts.app flash={@flash}>
|
||||
{content.()}
|
||||
</Layouts.app>
|
||||
<% end %>
|
||||
|
|
|
|||
407
lib/towerops_web/controllers/page_html/terms.html.heex
Normal file
407
lib/towerops_web/controllers/page_html/terms.html.heex
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
<% content = fn -> %>
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Terms of Service</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Last updated: {Date.utc_today() |> Calendar.strftime("%B %d, %Y")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
1. Acceptance of Terms
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
By accessing and using Towerops ("Service"), you accept and agree to be bound by these Terms of Service ("Terms"). If you do not agree to these Terms, please do not use the Service.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
These Terms constitute a legally binding agreement between you ("User", "you", or "your") and Towerops ("we", "us", or "our").
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
2. Service Description
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Towerops is a network monitoring and alerting platform that enables you to:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>Monitor network devices via SNMP protocol</li>
|
||||
<li>Collect and visualize time-series metrics</li>
|
||||
<li>Receive alerts for device failures and performance issues</li>
|
||||
<li>Manage network topology and device relationships</li>
|
||||
<li>Deploy remote monitoring agents on your network</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
We reserve the right to modify, suspend, or discontinue any part of the Service at any time with reasonable notice to users.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
3. User Accounts and Registration
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
To use the Service, you must:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>Register for an account with accurate and complete information</li>
|
||||
<li>Be at least 18 years old or have parental/guardian consent</li>
|
||||
<li>Maintain the security and confidentiality of your account credentials</li>
|
||||
<li>Notify us immediately of any unauthorized access to your account</li>
|
||||
<li>Be responsible for all activities that occur under your account</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
You may not share your account with others, and you may not use another user's account without permission.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
4. Acceptable Use Policy
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
You agree to use the Service only for lawful purposes and in accordance with these Terms. You agree NOT to:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>
|
||||
Use the Service to monitor devices or networks you do not own or have explicit permission to monitor
|
||||
</li>
|
||||
<li>Use the Service to violate any local, state, national, or international law</li>
|
||||
<li>
|
||||
Attempt to gain unauthorized access to any part of the Service, other accounts, or computer systems
|
||||
</li>
|
||||
<li>
|
||||
Interfere with or disrupt the Service or servers/networks connected to the Service
|
||||
</li>
|
||||
<li>Use automated systems (bots, scrapers) to access the Service without permission</li>
|
||||
<li>Transmit any viruses, malware, or malicious code</li>
|
||||
<li>Impersonate any person or entity or misrepresent your affiliation</li>
|
||||
<li>Collect or harvest personal information from other users</li>
|
||||
<li>Use the Service for any illegal surveillance or monitoring activities</li>
|
||||
<li>Reverse engineer, decompile, or disassemble any part of the Service</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
5. User Data and Privacy
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Our collection and use of your personal data is governed by our <.link
|
||||
navigate={~p"/privacy"}
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline font-medium"
|
||||
>
|
||||
Privacy Policy
|
||||
</.link>, which is incorporated into these Terms by reference.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
You retain all rights to the data you collect through the Service. We do not claim ownership of your monitoring data, device configurations, or network information.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<strong class="font-semibold">GDPR Rights:</strong>
|
||||
If you are located in the European Union or European Economic Area, you have specific rights under the General Data Protection Regulation (GDPR), including:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>Right to access your personal data</li>
|
||||
<li>Right to rectification of inaccurate data</li>
|
||||
<li>Right to erasure ("right to be forgotten")</li>
|
||||
<li>Right to data portability</li>
|
||||
<li>Right to restrict processing</li>
|
||||
<li>Right to object to processing</li>
|
||||
<li>Right to withdraw consent</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
To exercise these rights, please contact us at <a
|
||||
href="mailto:hi@towerops.net"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline font-medium"
|
||||
>
|
||||
hi@towerops.net
|
||||
</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
6. Service Availability and Support
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
We strive to provide reliable and uninterrupted service, but we do not guarantee that the Service will be:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>Available at all times without interruption</li>
|
||||
<li>Error-free or free from bugs</li>
|
||||
<li>Secure from unauthorized access or cyberattacks</li>
|
||||
<li>Compatible with all hardware, software, or network configurations</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
We may perform scheduled maintenance that temporarily interrupts service. We will provide advance notice of scheduled downtime when possible.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Support is provided via email at <a
|
||||
href="mailto:hi@towerops.net"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline font-medium"
|
||||
>
|
||||
hi@towerops.net
|
||||
</a>. Response times vary based on your subscription plan.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
7. Subscription and Payment
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
The Service is provided on a subscription basis. By subscribing, you agree to:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>Pay all fees associated with your selected subscription plan</li>
|
||||
<li>Provide accurate and current billing information</li>
|
||||
<li>Authorize recurring charges to your payment method</li>
|
||||
<li>Notify us of any changes to your payment information</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Subscriptions automatically renew unless canceled before the renewal date. You may cancel your subscription at any time through your account settings. No refunds are provided for partial subscription periods.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
We reserve the right to change our pricing with 30 days' notice. Price changes will not affect your current subscription period.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
8. Intellectual Property
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
The Service, including all software, designs, text, graphics, logos, and other content, is owned by Towerops and protected by copyright, trademark, and other intellectual property laws.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
We grant you a limited, non-exclusive, non-transferable license to access and use the Service for your internal business purposes. You may not:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>Copy, modify, or create derivative works of the Service</li>
|
||||
<li>Sell, resell, rent, or lease access to the Service</li>
|
||||
<li>Remove or alter any copyright, trademark, or proprietary notices</li>
|
||||
<li>Use our trademarks or branding without written permission</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
9. Limitation of Liability
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<strong class="font-semibold uppercase">
|
||||
To the maximum extent permitted by law:
|
||||
</strong>
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>
|
||||
The Service is provided "as is" without warranties of any kind, either express or implied
|
||||
</li>
|
||||
<li>
|
||||
We are not liable for any indirect, incidental, special, consequential, or punitive damages
|
||||
</li>
|
||||
<li>
|
||||
We are not liable for any loss of data, profits, revenue, or business opportunities
|
||||
</li>
|
||||
<li>
|
||||
Our total liability shall not exceed the amount you paid for the Service in the 12 months preceding the claim
|
||||
</li>
|
||||
<li>
|
||||
We are not responsible for monitoring data accuracy or network device failures
|
||||
</li>
|
||||
<li>
|
||||
We are not liable for damages resulting from unauthorized access to your account
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Some jurisdictions do not allow the exclusion of certain warranties or limitation of liability, so these limitations may not apply to you.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
10. Indemnification
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
You agree to indemnify, defend, and hold harmless Towerops and its officers, directors, employees, and agents from any claims, liabilities, damages, losses, and expenses (including legal fees) arising from:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>Your use or misuse of the Service</li>
|
||||
<li>Your violation of these Terms</li>
|
||||
<li>Your violation of any rights of another person or entity</li>
|
||||
<li>Your monitoring of devices or networks without proper authorization</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
11. Termination
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
You may terminate your account at any time by:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>Canceling your subscription through account settings</li>
|
||||
<li>
|
||||
Contacting us at
|
||||
<a
|
||||
href="mailto:hi@towerops.net"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline font-medium"
|
||||
>
|
||||
hi@towerops.net
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
We may suspend or terminate your account immediately if:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>You violate these Terms</li>
|
||||
<li>Your payment fails or your account is past due</li>
|
||||
<li>You engage in fraudulent or illegal activities</li>
|
||||
<li>We are required to do so by law</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Upon termination, your right to use the Service ceases immediately. We will delete your data within 30 days of account termination, except where we are required to retain it for legal purposes.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
12. Data Export and Deletion
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Before terminating your account, you should export any data you wish to retain. We provide data export functionality through your account settings.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<strong class="font-semibold">GDPR Right to Erasure:</strong>
|
||||
You may request deletion of your account and all associated data at any time. We will process deletion requests within 30 days, except for data we are legally required to retain.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
13. Changes to Terms
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
We may modify these Terms at any time. If we make material changes, we will notify you by:
|
||||
</p>
|
||||
<ul class="list-disc pl-6 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>Email to your registered email address</li>
|
||||
<li>Notice on the Service homepage</li>
|
||||
<li>In-app notification</li>
|
||||
</ul>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Your continued use of the Service after changes take effect constitutes acceptance of the modified Terms. If you do not agree to the changes, you must stop using the Service and terminate your account.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
14. Dispute Resolution
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
In the event of any dispute arising from these Terms or your use of the Service, you agree to first contact us to seek an informal resolution at <a
|
||||
href="mailto:hi@towerops.net"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline font-medium"
|
||||
>
|
||||
hi@towerops.net
|
||||
</a>.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
If we cannot resolve the dispute informally within 30 days, either party may pursue formal dispute resolution through binding arbitration or in the courts, subject to the governing law provisions below.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
15. Governing Law and Jurisdiction
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
These Terms are governed by and construed in accordance with the laws of the State of Texas, United States, without regard to its conflict of law provisions.
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<strong class="font-semibold">For EU/EEA Users:</strong>
|
||||
Nothing in these Terms affects your statutory rights as a consumer under EU or local law. If you are located in the EU/EEA, you may also have the right to bring legal proceedings in the courts of your country of residence.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
16. Severability
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
If any provision of these Terms is found to be unenforceable or invalid, that provision shall be limited or eliminated to the minimum extent necessary so that these Terms shall otherwise remain in full force and effect.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
17. Entire Agreement
|
||||
</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
These Terms, together with our Privacy Policy, constitute the entire agreement between you and Towerops regarding the Service and supersede all prior agreements and understandings.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Contact Information</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
If you have any questions about these Terms, please contact us:
|
||||
</p>
|
||||
<ul class="list-none space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li>
|
||||
<strong class="font-semibold">Email:</strong>
|
||||
<a
|
||||
href="mailto:hi@towerops.net"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
hi@towerops.net
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong class="font-semibold">Support:</strong>
|
||||
<a
|
||||
href="mailto:hi@towerops.net"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
hi@towerops.net
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong class="font-semibold">Privacy Inquiries:</strong>
|
||||
<a
|
||||
href="mailto:hi@towerops.net"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
hi@towerops.net
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 mt-8">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 italic">
|
||||
By using Towerops, you acknowledge that you have read, understood, and agree to be bound by these Terms of Service.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @current_scope && @current_scope.user do %>
|
||||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
current_organization={@current_organization}
|
||||
>
|
||||
{content.()}
|
||||
</Layouts.authenticated>
|
||||
<% else %>
|
||||
<Layouts.app flash={@flash}>
|
||||
{content.()}
|
||||
</Layouts.app>
|
||||
<% end %>
|
||||
508
lib/towerops_web/live/account_live/my_data.ex
Normal file
508
lib/towerops_web/live/account_live/my_data.ex
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
defmodule ToweropsWeb.AccountLive.MyData do
|
||||
@moduledoc """
|
||||
LiveView for displaying all user data (GDPR Right to Access)
|
||||
"""
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias Towerops.Admin
|
||||
alias Towerops.Alerts
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Organizations
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
# Gather all user data
|
||||
user_data = %{
|
||||
profile: get_user_profile(user),
|
||||
credentials: get_user_credentials(user),
|
||||
organizations: get_user_organizations(user),
|
||||
devices: get_user_devices(user),
|
||||
alerts: get_user_alerts(user),
|
||||
audit_logs: get_user_audit_logs(user)
|
||||
}
|
||||
|
||||
{:ok, assign(socket, user_data: user_data, page_title: "My Data")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
current_organization={@current_organization}
|
||||
active_page="settings"
|
||||
>
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Data</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
This page shows all personal data we have stored about you in compliance with GDPR Article 15 (Right to Access).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Information -->
|
||||
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">Profile Information</h2>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Email Address</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{@user_data.profile.email}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{@user_data.profile.name || "Not set"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Timezone</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{@user_data.profile.timezone}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Account Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{Calendar.strftime(@user_data.profile.inserted_at, "%B %d, %Y at %I:%M %p")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Email Confirmed</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{if @user_data.profile.confirmed_at, do: "Yes", else: "No"}
|
||||
<%= if @user_data.profile.confirmed_at do %>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
({Calendar.strftime(@user_data.profile.confirmed_at, "%B %d, %Y")})
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Superuser</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{if @user_data.profile.is_superuser, do: "Yes", else: "No"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- WebAuthn Credentials -->
|
||||
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Security Credentials
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
WebAuthn passkeys registered to your account
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= if Enum.empty?(@user_data.credentials) do %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
No WebAuthn credentials registered.
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="space-y-4">
|
||||
<%= for credential <- @user_data.credentials do %>
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Label</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{credential.label}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{Calendar.strftime(credential.inserted_at, "%B %d, %Y")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Last Used
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
<%= if credential.last_used_at do %>
|
||||
{Calendar.strftime(credential.last_used_at, "%B %d, %Y at %I:%M %p")}
|
||||
<% else %>
|
||||
Never
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Organizations -->
|
||||
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Organizations
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Organizations you own or are a member of
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= if Enum.empty?(@user_data.organizations) do %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No organization memberships.</p>
|
||||
<% else %>
|
||||
<div class="space-y-4">
|
||||
<%= for org <- @user_data.organizations do %>
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{org.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Role</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{if org.is_owner, do: "Owner", else: "Member"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Slug</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">{org.slug}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{Calendar.strftime(org.inserted_at, "%B %d, %Y")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Devices -->
|
||||
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Devices
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Network devices monitored by your organizations
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= if Enum.empty?(@user_data.devices) do %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No devices configured.</p>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
IP Address
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Organization
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<%= for device <- @user_data.devices do %>
|
||||
<tr>
|
||||
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
|
||||
{device.name}
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
|
||||
{device.ip_address}
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{device.organization.name}
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{Calendar.strftime(device.inserted_at, "%b %d, %Y")}
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Total devices: {length(@user_data.devices)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Alerts -->
|
||||
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Recent Alerts
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Alerts generated for your devices (last 90 days)
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= if Enum.empty?(@user_data.alerts) do %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No alerts in the last 90 days.</p>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Device
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Severity
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<%= for alert <- Enum.take(@user_data.alerts, 50) do %>
|
||||
<tr>
|
||||
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
|
||||
{alert.device.name}
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-900 dark:text-white">
|
||||
{alert.alert_type}
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm">
|
||||
<span class={[
|
||||
"inline-flex rounded-full px-2 text-xs font-semibold leading-5",
|
||||
alert_severity_class(alert.severity)
|
||||
]}>
|
||||
{alert.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{Calendar.strftime(alert.inserted_at, "%b %d, %Y %I:%M %p")}
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing {min(50, length(@user_data.alerts))} of {length(@user_data.alerts)} alerts
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audit Logs -->
|
||||
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Audit Log
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Administrative actions related to your account
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= if Enum.empty?(@user_data.audit_logs) do %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No audit log entries.</p>
|
||||
<% else %>
|
||||
<div class="space-y-3">
|
||||
<%= for log <- Enum.take(@user_data.audit_logs, 20) do %>
|
||||
<div class="border-l-4 border-blue-400 bg-gray-50 dark:bg-gray-800 p-3">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{log.action}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
By: {log.actor_email || "System"}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-500">
|
||||
{Calendar.strftime(log.inserted_at, "%B %d, %Y at %I:%M %p")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing {min(20, length(@user_data.audit_logs))} of {length(@user_data.audit_logs)} entries
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Export Data -->
|
||||
<section class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<.icon
|
||||
name="hero-arrow-down-tray"
|
||||
class="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
|
||||
Export Your Data
|
||||
</h3>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300 mb-4">
|
||||
You can download all your data in JSON format. This includes your profile, organizations, devices, alerts, and audit logs.
|
||||
</p>
|
||||
<.link
|
||||
href={~p"/api/v1/account/data"}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Download My Data (JSON)
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Data Processing Information -->
|
||||
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
How We Use Your Data
|
||||
</h2>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li>
|
||||
<strong>Profile Information:</strong>
|
||||
Used to identify your account and personalize your experience
|
||||
</li>
|
||||
<li>
|
||||
<strong>Email Address:</strong>
|
||||
Used for account notifications, password resets, and important service updates
|
||||
</li>
|
||||
<li>
|
||||
<strong>Organizations:</strong> Determines which devices and alerts you can access
|
||||
</li>
|
||||
<li>
|
||||
<strong>Device Data:</strong>
|
||||
Stored to provide network monitoring and alerting services
|
||||
</li>
|
||||
<li>
|
||||
<strong>Monitoring Data:</strong>
|
||||
Time-series metrics used to track device health and performance
|
||||
</li>
|
||||
<li>
|
||||
<strong>Audit Logs:</strong>
|
||||
Security records of administrative actions for compliance and security purposes
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
For more information, please read our <.link
|
||||
navigate={~p"/privacy"}
|
||||
class="text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Privacy Policy
|
||||
</.link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper function to get user profile data
|
||||
defp get_user_profile(user) do
|
||||
%{
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
timezone: user.timezone,
|
||||
inserted_at: user.inserted_at,
|
||||
confirmed_at: user.confirmed_at,
|
||||
is_superuser: user.is_superuser
|
||||
}
|
||||
end
|
||||
|
||||
# Helper function to get user's WebAuthn credentials
|
||||
defp get_user_credentials(user) do
|
||||
Accounts.list_user_credentials(user)
|
||||
end
|
||||
|
||||
# Helper function to get user's organizations
|
||||
defp get_user_organizations(user) do
|
||||
user.id
|
||||
|> Organizations.list_user_organizations()
|
||||
|> Enum.map(fn org ->
|
||||
%{
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
is_owner: org.owner_id == user.id,
|
||||
inserted_at: org.inserted_at
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
# Helper function to get devices from user's organizations
|
||||
defp get_user_devices(user) do
|
||||
org_ids =
|
||||
user.id
|
||||
|> Organizations.list_user_organizations()
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
if Enum.empty?(org_ids) do
|
||||
[]
|
||||
else
|
||||
org_ids
|
||||
|> Devices.list_devices_for_organizations()
|
||||
|> Towerops.Repo.preload(:organization)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to get alerts for user's devices (last 90 days)
|
||||
defp get_user_alerts(user) do
|
||||
org_ids =
|
||||
user.id
|
||||
|> Organizations.list_user_organizations()
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
if Enum.empty?(org_ids) do
|
||||
[]
|
||||
else
|
||||
ninety_days_ago = DateTime.add(DateTime.utc_now(), -90, :day)
|
||||
|
||||
org_ids
|
||||
|> Alerts.list_alerts_for_organizations(since: ninety_days_ago)
|
||||
|> Towerops.Repo.preload(:device)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to get audit logs related to the user
|
||||
defp get_user_audit_logs(user) do
|
||||
Admin.list_audit_logs_for_user(user.id)
|
||||
end
|
||||
|
||||
# Helper function for alert severity badge styling
|
||||
defp alert_severity_class("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
|
||||
defp alert_severity_class("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
|
||||
defp alert_severity_class("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
|
||||
defp alert_severity_class(_), do: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"
|
||||
end
|
||||
|
|
@ -21,23 +21,43 @@ defmodule ToweropsWeb.Plugs.DetectEUUser do
|
|||
|
||||
@doc false
|
||||
def call(conn, _opts) do
|
||||
require Logger
|
||||
|
||||
requires_consent = detect_eu_user(conn)
|
||||
assign(conn, :requires_cookie_consent, requires_consent)
|
||||
|
||||
# Debug logging
|
||||
Logger.info("DetectEUUser: requires_consent=#{inspect(requires_consent)}, Mix.env=#{inspect(Mix.env())}")
|
||||
|
||||
# Store in process dictionary so layouts can access it
|
||||
Process.put(:requires_cookie_consent, requires_consent)
|
||||
|
||||
conn
|
||||
|> put_session(:requires_cookie_consent, requires_consent)
|
||||
|> assign(:requires_cookie_consent, requires_consent)
|
||||
end
|
||||
|
||||
defp detect_eu_user(conn) do
|
||||
# In development (localhost), always show the banner for testing
|
||||
hostname = conn.host
|
||||
|
||||
if hostname in ["localhost", "127.0.0.1"] do
|
||||
true
|
||||
else
|
||||
detect_country_from_headers(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp detect_country_from_headers(conn) do
|
||||
# Try CloudFlare's CF-IPCountry header first (most reliable if behind CF)
|
||||
case get_req_header(conn, "cf-ipcountry") do
|
||||
[country_code] when country_code != "" ->
|
||||
country_code = String.upcase(country_code)
|
||||
country_code in @eu_eea_countries
|
||||
eu_country?(country_code)
|
||||
|
||||
_ ->
|
||||
# Try X-Country header (some proxies set this)
|
||||
case get_req_header(conn, "x-country") do
|
||||
[country_code] when country_code != "" ->
|
||||
country_code = String.upcase(country_code)
|
||||
country_code in @eu_eea_countries
|
||||
eu_country?(country_code)
|
||||
|
||||
_ ->
|
||||
# No reliable country detection available
|
||||
|
|
@ -46,4 +66,9 @@ defmodule ToweropsWeb.Plugs.DetectEUUser do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp eu_country?(country_code) do
|
||||
country_code = String.upcase(country_code)
|
||||
country_code in @eu_eea_countries
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -46,10 +46,18 @@ defmodule ToweropsWeb.Router do
|
|||
|
||||
get "/", PageController, :home
|
||||
get "/privacy", PageController, :privacy
|
||||
get "/terms", PageController, :terms
|
||||
get "/docs/api", ApiDocsController, :index
|
||||
live "/help", HelpLive.Index, :index
|
||||
end
|
||||
|
||||
# Account API routes (requires browser authentication)
|
||||
scope "/api/v1/account", ToweropsWeb.Api do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
get "/data", AccountDataController, :show
|
||||
end
|
||||
|
||||
# Agent communication via WebSocket channel at /socket/agent
|
||||
# See ToweropsWeb.AgentChannel for implementation
|
||||
|
||||
|
|
@ -172,6 +180,7 @@ defmodule ToweropsWeb.Router do
|
|||
|
||||
live_session :require_superuser,
|
||||
on_mount: [
|
||||
{ToweropsWeb.UserAuth, :load_cookie_consent},
|
||||
{ToweropsWeb.UserAuth, :require_authenticated_user},
|
||||
{ToweropsWeb.UserAuth, :require_superuser}
|
||||
] do
|
||||
|
|
@ -188,6 +197,7 @@ defmodule ToweropsWeb.Router do
|
|||
|
||||
live_session :require_sudo_mode,
|
||||
on_mount: [
|
||||
{ToweropsWeb.UserAuth, :load_cookie_consent},
|
||||
{ToweropsWeb.UserAuth, :require_authenticated_user},
|
||||
{ToweropsWeb.UserAuth, :require_sudo_mode}
|
||||
] do
|
||||
|
|
@ -195,11 +205,15 @@ defmodule ToweropsWeb.Router do
|
|||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live "/users/settings", UserSettingsLive, :index
|
||||
live "/account/my-data", AccountLive.MyData, :index
|
||||
end
|
||||
end
|
||||
|
||||
live_session :require_authenticated_user,
|
||||
on_mount: [{ToweropsWeb.UserAuth, :require_authenticated_user}] do
|
||||
on_mount: [
|
||||
{ToweropsWeb.UserAuth, :load_cookie_consent},
|
||||
{ToweropsWeb.UserAuth, :require_authenticated_user}
|
||||
] do
|
||||
scope "/", ToweropsWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
|
|
@ -211,6 +225,7 @@ defmodule ToweropsWeb.Router do
|
|||
|
||||
live_session :require_authenticated_user_and_organization,
|
||||
on_mount: [
|
||||
{ToweropsWeb.UserAuth, :load_cookie_consent},
|
||||
{ToweropsWeb.UserAuth, :require_authenticated_user},
|
||||
{ToweropsWeb.UserAuth, :load_current_organization}
|
||||
] do
|
||||
|
|
@ -224,6 +239,7 @@ defmodule ToweropsWeb.Router do
|
|||
|
||||
live_session :require_authenticated_user_with_default_org,
|
||||
on_mount: [
|
||||
{ToweropsWeb.UserAuth, :load_cookie_consent},
|
||||
{ToweropsWeb.UserAuth, :require_authenticated_user},
|
||||
{ToweropsWeb.UserAuth, :load_default_organization}
|
||||
] do
|
||||
|
|
|
|||
|
|
@ -498,6 +498,37 @@ defmodule ToweropsWeb.UserAuth do
|
|||
end
|
||||
end
|
||||
|
||||
def on_mount(:load_cookie_consent, _params, session, socket) do
|
||||
require Logger
|
||||
|
||||
requires_consent = Map.get(session, "requires_cookie_consent", false)
|
||||
|
||||
Logger.info("on_mount :load_cookie_consent - session value: #{inspect(requires_consent)}")
|
||||
|
||||
# Store in process dictionary so layouts can access without explicit component attribute
|
||||
Process.put(:requires_cookie_consent, requires_consent)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> Phoenix.Component.assign(:requires_cookie_consent, requires_consent)
|
||||
|> LiveView.attach_hook(:persist_cookie_consent, :handle_params, fn _params, _url, socket ->
|
||||
# Ensure the assign persists across navigation
|
||||
socket = Phoenix.Component.assign(socket, :requires_cookie_consent, requires_consent)
|
||||
|
||||
Logger.info(
|
||||
"handle_params hook: socket.assigns.requires_cookie_consent = #{inspect(socket.assigns.requires_cookie_consent)}"
|
||||
)
|
||||
|
||||
{:cont, socket}
|
||||
end)
|
||||
|
||||
Logger.info(
|
||||
"After assign: socket.assigns.requires_cookie_consent = #{inspect(socket.assigns.requires_cookie_consent)}"
|
||||
)
|
||||
|
||||
{:cont, socket}
|
||||
end
|
||||
|
||||
defp load_organization_for_user(socket, session, user) do
|
||||
org_id = session["current_organization_id"]
|
||||
organization = find_user_organization(org_id, user.id)
|
||||
|
|
|
|||
2
mix.lock
2
mix.lock
|
|
@ -45,7 +45,7 @@
|
|||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.21", "183ff2f06f2f2a1ade38eca3256934123d28b0e18060483628bbf67f31354c15", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3814ec4d792373701ec22152511ae24017a75fe280e7e3770c4c196dc6b6cdbd"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.22", "9b3c985bfe38e82668594a8ce90008548f30b9f23b718ebaea4701710ce9006f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e1395d5622d8bf02113cb58183589b3da6f1751af235768816e90cc3ec5f1188"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.0.1", "d4d856b1e57a21358e448543e1d091e07e83403dde4383b8be04ed9d2c201cbc", [:mix], [{:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1 or ~> 1.6", [hex: :poolboy, repo: "hexpm", optional: false]}, {:redix, "~> 0.10.0 or ~> 1.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "0b36a17ff6e9a56159f8df8933d62b5c1f0695eae995a02e0c86c035ace6a309"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
defmodule Towerops.Repo.Migrations.AddSnmpCommunityInheritanceTracking do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
# Add column to track where the community string was inherited from
|
||||
alter table(:devices) do
|
||||
add :snmp_community_source, :string, default: "device"
|
||||
end
|
||||
|
||||
# Backfill existing devices: if they have a community, mark as "device", otherwise "organization"
|
||||
execute(
|
||||
"""
|
||||
UPDATE devices
|
||||
SET snmp_community_source = CASE
|
||||
WHEN snmp_community IS NOT NULL AND snmp_community != '' THEN 'device'
|
||||
ELSE 'organization'
|
||||
END
|
||||
""",
|
||||
# Rollback: just set everything back to "device"
|
||||
"UPDATE devices SET snmp_community_source = 'device'"
|
||||
)
|
||||
|
||||
# Create index for faster lookups when propagating changes
|
||||
create index(:devices, [:snmp_community_source])
|
||||
end
|
||||
end
|
||||
|
|
@ -5,6 +5,7 @@ Devices Working
|
|||
2026-01-28
|
||||
* Major SNMP Discovery overhaul and improvements
|
||||
* GDPR: Cookie consent banner
|
||||
* Bug fix: SNMP Community when using remote agent
|
||||
|
||||
2025-01-27
|
||||
* Infrastructure improvements
|
||||
|
|
|
|||
|
|
@ -305,21 +305,6 @@ defmodule Towerops.Snmp.MibTranslatorTest do
|
|||
end)
|
||||
end
|
||||
|
||||
test "uses fallback for Cambium ePMP OIDs" do
|
||||
# Test representative sample (reduced from 7 to 3 for performance)
|
||||
cambium_oids = [
|
||||
{"CAMBIUM-PMP80211-MIB::cambiumCurrentuImageVersion.0", "1.3.6.1.4.1.17713.21.1.1.17.0"},
|
||||
{"CAMBIUM-PMP80211-MIB::cambiumDeviceLatitude.0", "1.3.6.1.4.1.17713.21.1.1.18.0"},
|
||||
{"CAMBIUM-PMP80211-MIB::sysCPUUsage", "1.3.6.1.4.1.17713.21.2.1.64"}
|
||||
]
|
||||
|
||||
Enum.each(cambium_oids, fn {mib_name, expected_oid} ->
|
||||
result = MibTranslator.translate(mib_name)
|
||||
assert {:ok, oid} = result
|
||||
assert String.contains?(oid, expected_oid)
|
||||
end)
|
||||
end
|
||||
|
||||
test "uses fallback for Mimosa OIDs" do
|
||||
mimosa_oids = [
|
||||
{"MIMOSA-NETWORKS-BFIVE-MIB::mimosaSerialNumber.0", "1.3.6.1.4.1.43356.2.1.2.1.2.0"},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue