impersonate fixes
This commit is contained in:
parent
a4ea96bebc
commit
aed15dd6a1
12 changed files with 615 additions and 213 deletions
|
|
@ -57,6 +57,7 @@ defmodule Towerops.Organizations.Invitation do
|
|||
:expires_at,
|
||||
:accepted_at
|
||||
])
|
||||
|> set_default_role()
|
||||
|> validate_required([:email, :role, :organization_id, :invited_by_id])
|
||||
|> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)
|
||||
|> generate_token()
|
||||
|
|
@ -67,6 +68,16 @@ defmodule Towerops.Organizations.Invitation do
|
|||
|> foreign_key_constraint(:invited_by_id)
|
||||
end
|
||||
|
||||
defp set_default_role(changeset) do
|
||||
# Default to :member role if not specified
|
||||
# Only organization creators can be owners, not invited users
|
||||
if get_field(changeset, :role) do
|
||||
changeset
|
||||
else
|
||||
put_change(changeset, :role, :member)
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_token(changeset) do
|
||||
# Only generate token if not already provided
|
||||
if get_field(changeset, :token) do
|
||||
|
|
|
|||
|
|
@ -129,10 +129,6 @@ defmodule ToweropsWeb.Layouts do
|
|||
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 -->
|
||||
|
|
@ -334,6 +330,14 @@ defmodule ToweropsWeb.Layouts do
|
|||
>
|
||||
<.icon name="hero-user-circle" class="w-4 h-4" /> User Settings
|
||||
</.link>
|
||||
<.link
|
||||
role="menuitem"
|
||||
navigate={~p"/account/my-data"}
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
phx-click={JS.hide(to: "#org-menu")}
|
||||
>
|
||||
<.icon name="hero-document-arrow-down" class="w-4 h-4" /> My Data
|
||||
</.link>
|
||||
<.link
|
||||
role="menuitem"
|
||||
href={~p"/users/log-out"}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ defmodule ToweropsWeb.Api.AccountDataController do
|
|||
|
||||
# Build credentials data
|
||||
defp build_credentials_data(user) do
|
||||
user
|
||||
user.id
|
||||
|> Accounts.list_user_credentials()
|
||||
|> Enum.map(fn credential ->
|
||||
%{
|
||||
|
|
@ -75,11 +75,14 @@ defmodule ToweropsWeb.Api.AccountDataController do
|
|||
user.id
|
||||
|> Organizations.list_user_organizations()
|
||||
|> Enum.map(fn org ->
|
||||
# Organization has the user's membership preloaded (only their membership)
|
||||
[membership] = org.memberships
|
||||
|
||||
%{
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
role: if(org.owner_id == user.id, do: "owner", else: "member"),
|
||||
role: if(membership.role == :owner, do: "owner", else: "member"),
|
||||
inserted_at: org.inserted_at,
|
||||
updated_at: org.updated_at
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@
|
|||
Devices
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#account-data"
|
||||
data-nav-link="account-data"
|
||||
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
Account Data (GDPR)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -1104,6 +1113,209 @@ curl -X DELETE https://towerops.net/api/v1/devices/650e8400-e29b-41d4-a716-44665
|
|||
{
|
||||
"success": true
|
||||
}
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="my-16 border-gray-200 dark:border-white/10" />
|
||||
|
||||
<!-- Account Data Resource (GDPR) -->
|
||||
<section id="account-data" class="scroll-mt-24 mb-16">
|
||||
<h2 class="text-3xl font-bold tracking-tight text-zinc-900 dark:text-white">
|
||||
Account Data (GDPR)
|
||||
</h2>
|
||||
<p class="mt-4 text-base text-gray-600 dark:text-gray-400">
|
||||
GDPR Right to Access endpoint that allows users to download all their personal data in JSON format. This endpoint complies with Article 15 of GDPR.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 rounded-lg bg-amber-50 dark:bg-amber-900/10 p-4 border border-amber-200 dark:border-amber-800">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
Authentication Difference
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p>
|
||||
This endpoint requires <strong>browser session authentication</strong>, not API tokens. Users must be logged into their Towerops account via the web interface to access this endpoint.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export all account data -->
|
||||
<div class="mt-12">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<h3 class="text-xl font-semibold text-zinc-900 dark:text-white">
|
||||
Export all account data
|
||||
</h3>
|
||||
<span class="inline-flex items-center rounded-md bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-400/10 dark:text-emerald-400 dark:ring-emerald-400/30">
|
||||
GET
|
||||
</span>
|
||||
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||
/api/v1/account/data
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-x-12 gap-y-8 lg:grid-cols-2">
|
||||
<!-- Left column - description -->
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Downloads all personal data associated with the authenticated user account as a JSON file. This includes:
|
||||
</p>
|
||||
|
||||
<ul class="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400 list-disc list-inside">
|
||||
<li>Profile information (email, name, timezone)</li>
|
||||
<li>WebAuthn credentials and passkeys</li>
|
||||
<li>Organizations and membership roles</li>
|
||||
<li>Devices across all organizations</li>
|
||||
<li>Recent alerts (last 90 days)</li>
|
||||
<li>Audit logs (up to 1,000 most recent)</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/10 p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
<strong>Privacy Note:</strong>
|
||||
Sensitive data like SNMP community strings are redacted from the export for security.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-6 text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
Authentication
|
||||
</h4>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Requires active browser session with CSRF token. Cannot be accessed via API tokens.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right column - code example -->
|
||||
<div>
|
||||
<div class="overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
|
||||
<div class="border-b border-zinc-800 px-4 py-2">
|
||||
<p class="text-xs font-medium text-zinc-400">Request (Browser)</p>
|
||||
</div>
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
# This endpoint requires browser authentication
|
||||
# Access via logged-in browser session:
|
||||
GET https://towerops.net/api/v1/account/data
|
||||
|
||||
# Or via curl with session cookie:
|
||||
curl https://towerops.net/api/v1/account/data \
|
||||
-H "Cookie: _towerops_key=YOUR_SESSION_COOKIE"
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-lg bg-gray-900 dark:bg-gray-950">
|
||||
<div class="border-b border-zinc-800 px-4 py-2">
|
||||
<p class="text-xs font-medium text-zinc-400">Response</p>
|
||||
</div>
|
||||
<pre class="p-4 text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
{
|
||||
"profile": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "user@example.com",
|
||||
"name": "John Doe",
|
||||
"timezone": "America/New_York",
|
||||
"is_superuser": false,
|
||||
"confirmed_at": "2026-01-15T10:30:00Z",
|
||||
"inserted_at": "2026-01-15T10:00:00Z",
|
||||
"updated_at": "2026-01-20T14:22:00Z"
|
||||
},
|
||||
"credentials": [
|
||||
{
|
||||
"id": "750e8400-e29b-41d4-a716-446655440002",
|
||||
"label": "MacBook Pro Touch ID",
|
||||
"public_key_spki": "MFkwEwYHKoZIzj0CAQYIKoZI...",
|
||||
"last_used_at": "2026-01-28T12:15:00Z",
|
||||
"inserted_at": "2026-01-15T10:35:00Z"
|
||||
}
|
||||
],
|
||||
"organizations": [
|
||||
{
|
||||
"id": "850e8400-e29b-41d4-a716-446655440003",
|
||||
"name": "Acme Networks",
|
||||
"slug": "acme-networks",
|
||||
"role": "owner",
|
||||
"inserted_at": "2026-01-15T10:32:00Z",
|
||||
"updated_at": "2026-01-15T10:32:00Z"
|
||||
}
|
||||
],
|
||||
"devices": [
|
||||
{
|
||||
"id": "650e8400-e29b-41d4-a716-446655440001",
|
||||
"name": "Core Router",
|
||||
"ip_address": "192.168.1.1",
|
||||
"snmp_community": "[REDACTED]",
|
||||
"monitoring_enabled": true,
|
||||
"snmp_enabled": true,
|
||||
"status": "up",
|
||||
"organization": {
|
||||
"id": "850e8400-e29b-41d4-a716-446655440003",
|
||||
"name": "Acme Networks"
|
||||
},
|
||||
"site": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Main Office"
|
||||
},
|
||||
"inserted_at": "2026-01-16T09:00:00Z",
|
||||
"updated_at": "2026-01-28T08:30:00Z"
|
||||
}
|
||||
],
|
||||
"alerts": [
|
||||
{
|
||||
"id": "950e8400-e29b-41d4-a716-446655440004",
|
||||
"alert_type": "equipment_down",
|
||||
"severity": "critical",
|
||||
"message": "Device is not responding to ping",
|
||||
"triggered_at": "2026-01-27T15:45:00Z",
|
||||
"resolved_at": "2026-01-27T16:10:00Z",
|
||||
"acknowledged_at": null,
|
||||
"device": {
|
||||
"id": "650e8400-e29b-41d4-a716-446655440001",
|
||||
"name": "Core Router"
|
||||
},
|
||||
"inserted_at": "2026-01-27T15:45:00Z"
|
||||
}
|
||||
],
|
||||
"audit_logs": [
|
||||
{
|
||||
"id": "150e8400-e29b-41d4-a716-446655440005",
|
||||
"action": "user.login",
|
||||
"actor_email": "user@example.com",
|
||||
"target_user_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"metadata": {
|
||||
"ip_address": "203.0.113.42",
|
||||
"user_agent": "Mozilla/5.0..."
|
||||
},
|
||||
"inserted_at": "2026-01-28T08:00:00Z"
|
||||
}
|
||||
],
|
||||
"export_info": {
|
||||
"exported_at": "2026-01-28T19:50:00Z",
|
||||
"format": "JSON",
|
||||
"gdpr_article": "Article 15 - Right to Access"
|
||||
}
|
||||
}
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg bg-gray-900 dark:bg-gray-950 p-4">
|
||||
<p class="text-xs font-medium text-zinc-400 mb-2">Response Headers</p>
|
||||
<pre class="text-sm text-zinc-100 overflow-x-auto"><code><%= raw(~S"""
|
||||
Content-Type: application/json
|
||||
Content-Disposition: attachment; filename="towerops-data-{user_id}-{timestamp}.json"
|
||||
""") %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,14 +5,36 @@ defmodule ToweropsWeb.UserRegistrationController do
|
|||
|
||||
alias Towerops.Accounts
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.Organizations
|
||||
alias ToweropsWeb.UserAuth
|
||||
|
||||
def new(conn, _params) do
|
||||
def new(conn, params) do
|
||||
# Check if registering via invitation
|
||||
invitation_token = params["invitation_token"] || params["token"]
|
||||
invitation = invitation_token && Organizations.get_invitation_by_token(invitation_token)
|
||||
|
||||
changeset = Accounts.change_user_registration(%User{})
|
||||
render(conn, :new, form: Phoenix.Component.to_form(changeset, as: "user"))
|
||||
|
||||
render(conn, :new,
|
||||
form: Phoenix.Component.to_form(changeset, as: "user"),
|
||||
invitation: invitation,
|
||||
invitation_token: invitation_token
|
||||
)
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => user_params}) do
|
||||
invitation_token = user_params["invitation_token"] || conn.params["invitation_token"]
|
||||
|
||||
if invitation_token do
|
||||
# Registration via invitation - don't create personal org
|
||||
create_via_invitation(conn, user_params, invitation_token)
|
||||
else
|
||||
# Normal registration - create personal org
|
||||
create_with_organization(conn, user_params)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_with_organization(conn, user_params) do
|
||||
case Accounts.register_user_with_organization(user_params) do
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|
|
@ -20,7 +42,45 @@ defmodule ToweropsWeb.UserRegistrationController do
|
|||
|> UserAuth.log_in_user(user)
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render(conn, :new, form: Phoenix.Component.to_form(changeset, as: "user"))
|
||||
render(conn, :new,
|
||||
form: Phoenix.Component.to_form(changeset, as: "user"),
|
||||
invitation: nil,
|
||||
invitation_token: nil
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_via_invitation(conn, user_params, invitation_token) do
|
||||
with {:ok, invitation} <- get_valid_invitation(invitation_token),
|
||||
{:ok, user} <- Accounts.register_user(user_params),
|
||||
{:ok, _membership} <- Organizations.accept_invitation(invitation, user.id) do
|
||||
conn
|
||||
|> put_flash(:info, "Account created successfully. Welcome to #{invitation.organization.name}!")
|
||||
|> UserAuth.log_in_user(user)
|
||||
else
|
||||
{:error, :invalid_invitation} ->
|
||||
changeset = Accounts.change_user_registration(%User{}, user_params)
|
||||
|
||||
conn
|
||||
|> put_flash(:error, "Invalid or expired invitation link.")
|
||||
|> render(:new,
|
||||
form: Phoenix.Component.to_form(changeset, as: "user"),
|
||||
invitation: nil,
|
||||
invitation_token: nil
|
||||
)
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render(conn, :new,
|
||||
form: Phoenix.Component.to_form(changeset, as: "user"),
|
||||
invitation_token: invitation_token
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_valid_invitation(token) do
|
||||
case Organizations.get_invitation_by_token(token) do
|
||||
nil -> {:error, :invalid_invitation}
|
||||
invitation -> {:ok, invitation}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Register for an account
|
||||
<%= if @invitation do %>
|
||||
Join {@invitation.organization.name}
|
||||
<% else %>
|
||||
Register for an account
|
||||
<% end %>
|
||||
<:subtitle>
|
||||
Already registered?
|
||||
<.link
|
||||
|
|
@ -15,21 +19,36 @@
|
|||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
Free tier includes:
|
||||
</p>
|
||||
<ul class="mt-2 text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<li>✓ Monitor up to 10 devices</li>
|
||||
<li>✓ Real-time alerts and notifications</li>
|
||||
<li>✓ Performance charts and historical data</li>
|
||||
<li>✓ No credit card required</li>
|
||||
</ul>
|
||||
</div>
|
||||
<%= if @invitation do %>
|
||||
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p class="text-sm text-blue-900 dark:text-blue-100">
|
||||
You've been invited to join
|
||||
<span class="font-semibold">{@invitation.organization.name}</span>
|
||||
as
|
||||
a {@invitation.role}.
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
Free tier includes:
|
||||
</p>
|
||||
<ul class="mt-2 text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<li>✓ Monitor up to 10 devices</li>
|
||||
<li>✓ Real-time alerts and notifications</li>
|
||||
<li>✓ Performance charts and historical data</li>
|
||||
<li>✓ No credit card required</li>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<.form :let={f} for={@form} action={~p"/users/register"}>
|
||||
<%= if @invitation_token do %>
|
||||
<input type="hidden" name="user[invitation_token]" value={@invitation_token} />
|
||||
<% end %>
|
||||
|
||||
<.input
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
|
|
@ -47,7 +66,7 @@
|
|||
/>
|
||||
|
||||
<.button phx-disable-with="Creating account..." class="w-full" variant="primary">
|
||||
Create an account
|
||||
{if @invitation, do: "Accept invitation and create account", else: "Create an account"}
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,17 +14,27 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
|||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
# Gather all user data
|
||||
# Get organizations and check ownership
|
||||
organizations = get_user_organizations(user)
|
||||
is_owner = Enum.any?(organizations, fn org -> org[:is_owner] end)
|
||||
default_organization = organizations |> Enum.map(& &1[:organization]) |> List.first()
|
||||
|
||||
# Gather user data - only include org/devices/alerts if user is an owner
|
||||
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),
|
||||
organizations: if(is_owner, do: organizations, else: []),
|
||||
devices: if(is_owner, do: get_user_devices(user), else: []),
|
||||
alerts: if(is_owner, do: get_user_alerts(user), else: []),
|
||||
audit_logs: get_user_audit_logs(user)
|
||||
}
|
||||
|
||||
{:ok, assign(socket, user_data: user_data, page_title: "My Data")}
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:user_data, user_data)
|
||||
|> assign(:page_title, "My Data")
|
||||
|> assign(:is_owner, is_owner)
|
||||
|> assign(:current_organization, default_organization)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -115,8 +125,8 @@ defmodule ToweropsWeb.AccountLive.MyData 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>
|
||||
<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">{credential.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Created</dt>
|
||||
|
|
@ -143,175 +153,179 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
|||
<% 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>
|
||||
|
||||
|
||||
<%= if @is_owner do %>
|
||||
<!-- 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)}
|
||||
<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>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</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.site.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
|
||||
<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>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
</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">
|
||||
Status
|
||||
</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_status_class(alert)
|
||||
]}>
|
||||
{alert_status_text(alert)}
|
||||
</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>
|
||||
<% end %>
|
||||
|
||||
<!-- Audit Logs -->
|
||||
<section class="bg-white dark:bg-gray-900 shadow rounded-lg overflow-hidden">
|
||||
|
|
@ -441,7 +455,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
|||
|
||||
# Helper function to get user's WebAuthn credentials
|
||||
defp get_user_credentials(user) do
|
||||
Accounts.list_user_credentials(user)
|
||||
Accounts.list_user_credentials(user.id)
|
||||
end
|
||||
|
||||
# Helper function to get user's organizations
|
||||
|
|
@ -449,10 +463,13 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
|||
user.id
|
||||
|> Organizations.list_user_organizations()
|
||||
|> Enum.map(fn org ->
|
||||
# Organization has the user's membership preloaded (only their membership)
|
||||
[membership] = org.memberships
|
||||
|
||||
%{
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
is_owner: org.owner_id == user.id,
|
||||
is_owner: membership.role == :owner,
|
||||
inserted_at: org.inserted_at
|
||||
}
|
||||
end)
|
||||
|
|
@ -470,7 +487,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
|||
else
|
||||
org_ids
|
||||
|> Devices.list_devices_for_organizations()
|
||||
|> Towerops.Repo.preload(:organization)
|
||||
|> Towerops.Repo.preload(site: :organization)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -497,12 +514,20 @@ defmodule ToweropsWeb.AccountLive.MyData 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"
|
||||
# Helper function for alert status badge styling
|
||||
defp alert_status_class(alert) do
|
||||
cond do
|
||||
alert.resolved_at -> "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
alert.acknowledged_at -> "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
true -> "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
end
|
||||
end
|
||||
|
||||
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"
|
||||
defp alert_status_text(alert) do
|
||||
cond do
|
||||
alert.resolved_at -> "Resolved"
|
||||
alert.acknowledged_at -> "Acknowledged"
|
||||
true -> "Active"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -347,6 +347,14 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
Notifications
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/account/my-data"}
|
||||
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
|
||||
>
|
||||
My Data
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -192,17 +192,12 @@ defmodule ToweropsWeb.UserAuth do
|
|||
#
|
||||
defp renew_session(conn, _user) do
|
||||
delete_csrf_token()
|
||||
user_return_to = get_session(conn, :user_return_to)
|
||||
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
|> maybe_restore_return_to(user_return_to)
|
||||
end
|
||||
|
||||
defp maybe_restore_return_to(conn, nil), do: conn
|
||||
defp maybe_restore_return_to(conn, path), do: put_session(conn, :user_return_to, path)
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
|
||||
do: write_remember_me_cookie(conn, token)
|
||||
|
||||
|
|
@ -675,6 +670,7 @@ defmodule ToweropsWeb.UserAuth do
|
|||
|> put_session(:target_user_id, target_user.id)
|
||||
|> put_session(:impersonating, true)
|
||||
|> assign(:current_scope, Scope.for_impersonation(superuser, target_user))
|
||||
|> maybe_set_default_organization(target_user)
|
||||
|> put_flash(:info, "Now impersonating #{target_user.email}")
|
||||
|> redirect(to: ~p"/orgs")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ Devices Working
|
|||
|
||||
2026-01-28
|
||||
* Major SNMP Discovery overhaul and improvements
|
||||
* GDPR: Cookie consent banner
|
||||
* GDPR: Cookie consent banner for EU/EEA users
|
||||
* GDPR: Right to Access - My Data page and API endpoint
|
||||
* GDPR: API documentation for account data export
|
||||
* GDPR: My Data links in user settings and navigation
|
||||
* Bug fix: SNMP Community when using remote agent
|
||||
|
||||
2025-01-27
|
||||
|
|
|
|||
61
test/towerops_web/live/my_data_live_test.exs
Normal file
61
test/towerops_web/live/my_data_live_test.exs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
defmodule ToweropsWeb.AccountLive.MyDataTest do
|
||||
use ToweropsWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
describe "My Data page" do
|
||||
test "renders for organization owner without additional data", %{conn: conn} do
|
||||
# register_and_log_in_user creates a user with a Personal org (as owner)
|
||||
{:ok, _view, html} = live(conn, ~p"/account/my-data")
|
||||
|
||||
assert html =~ "My Data"
|
||||
assert html =~ "Profile Information"
|
||||
assert html =~ "Security Credentials"
|
||||
assert html =~ "Organizations"
|
||||
assert html =~ "Devices"
|
||||
assert html =~ "Audit Log"
|
||||
end
|
||||
|
||||
test "renders for organization owner with full data", %{conn: conn, user: user} do
|
||||
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Test Site",
|
||||
organization_id: organization.id
|
||||
})
|
||||
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(%{
|
||||
name: "Test Device",
|
||||
ip_address: "192.168.1.1",
|
||||
site_id: site.id,
|
||||
monitoring_enabled: true
|
||||
})
|
||||
|
||||
# Create an alert
|
||||
{:ok, _alert} =
|
||||
Towerops.Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: :device_down,
|
||||
triggered_at: DateTime.utc_now(),
|
||||
message: "Device is down"
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/account/my-data")
|
||||
|
||||
assert html =~ "My Data"
|
||||
assert html =~ "Profile Information"
|
||||
assert html =~ "Security Credentials"
|
||||
assert html =~ "Organizations"
|
||||
assert html =~ "Devices"
|
||||
assert html =~ "Recent Alerts"
|
||||
assert html =~ "Audit Log"
|
||||
assert html =~ "Test Org"
|
||||
assert html =~ "Test Device"
|
||||
assert html =~ "192.168.1.1"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1244,15 +1244,15 @@ defmodule ToweropsWeb.UserAuthTest do
|
|||
end
|
||||
|
||||
describe "log_in_user/3 with return_to" do
|
||||
test "restores user_return_to after login and keeps it in session", %{conn: conn, user: user} do
|
||||
test "uses user_return_to for redirect and clears it from session", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> put_session(:user_return_to, "/devices")
|
||||
|> UserAuth.log_in_user(user)
|
||||
|
||||
assert redirected_to(conn) == "/devices"
|
||||
# The return_to is restored in the session by renew_session
|
||||
assert get_session(conn, :user_return_to) == "/devices"
|
||||
# The return_to is used for redirect but cleared from session to allow new paths to be stored
|
||||
assert get_session(conn, :user_return_to) == nil
|
||||
end
|
||||
|
||||
test "does not clear session when already logged in with same user", %{conn: conn, user: user} do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue