towerops/lib/towerops_web/components/layouts.ex
Graham McIntire 853d548f82
Add superuser system with user impersonation for admin support
Implement comprehensive admin interface allowing designated superusers to view all users and organizations, impersonate users for debugging, and perform administrative operations. All superuser actions are tracked in audit logs for compliance.

Features:
- Superuser authentication with dedicated admin routes at /admin
- User impersonation with session state preservation
- Admin dashboard with system statistics
- User and organization management interfaces
- Comprehensive audit logging with IP tracking
- Visual impersonation banner with exit capability
- Security controls preventing self-impersonation and superuser-to-superuser impersonation

Database:
- Add is_superuser boolean field to users table
- Create audit_logs table for tracking sensitive operations
- Set graham@mcintire.me as initial superuser
2026-01-06 12:50:10 -06:00

308 lines
11 KiB
Elixir

defmodule ToweropsWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use ToweropsWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
slot :inner_block, required: true
def app(assigns) do
~H"""
<main class="min-h-screen bg-zinc-50 px-4 py-12 sm:px-6 lg:px-8 dark:bg-zinc-950">
<div class="mx-auto max-w-2xl">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
@doc """
Renders the authenticated app layout with navigation.
Use this for pages that require organization context.
## Examples
<Layouts.authenticated flash={@flash} current_organization={@current_organization} active_page="dashboard">
<h1>Content</h1>
</Layouts.authenticated>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current scope (contains user and impersonation state)"
attr :current_organization, :any,
default: nil,
doc: "the current organization (can be nil for pages without org context)"
attr :active_page, :string,
default: nil,
doc: "the currently active page (dashboard, sites, equipment, or alerts)"
slot :inner_block, required: true
def authenticated(assigns) do
~H"""
<div class="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<!-- Impersonation Banner -->
<%= if @current_scope && @current_scope.impersonating? do %>
<div class="bg-yellow-400 border-b-2 border-yellow-600">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span class="font-medium">
Impersonating: {@current_scope.user.email}
</span>
</div>
<form action={~p"/admin/impersonate"} method="post">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<input type="hidden" name="_method" value="delete" />
<button
type="submit"
class="px-3 py-1 bg-white text-yellow-900 hover:bg-yellow-50 rounded font-medium text-sm"
>
Exit Impersonation
</button>
</form>
</div>
</div>
</div>
<% end %>
<nav class="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<div class="flex flex-shrink-0 items-center">
<.link navigate={~p"/orgs"} class="text-xl font-bold text-zinc-900 dark:text-zinc-100">
Towerops
</.link>
</div>
<div :if={@current_organization} class="ml-10 flex space-x-8">
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}"}
active={@active_page == "dashboard"}
>
Dashboard
</.nav_link>
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}/sites"}
active={@active_page == "sites"}
>
Sites
</.nav_link>
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}/equipment"}
active={@active_page == "equipment"}
>
Equipment
</.nav_link>
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
active={@active_page == "alerts"}
>
Alerts
</.nav_link>
</div>
</div>
<div class="relative flex items-center">
<button
type="button"
id="org-menu-button"
phx-click={JS.toggle(to: "#org-menu")}
class="flex items-center gap-2 text-sm font-medium text-zinc-700 hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-100"
>
{if @current_organization, do: @current_organization.name, else: "Menu"}
<.icon name="hero-chevron-down" class="h-4 w-4" />
</button>
<div
id="org-menu"
class="hidden absolute top-full right-0 mt-2 w-48 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 z-50 dark:bg-zinc-800 dark:ring-zinc-700"
phx-click-away={JS.hide(to: "#org-menu")}
>
<div class="py-1">
<.link
:if={@current_organization}
navigate={~p"/orgs"}
class="block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
phx-click={JS.hide(to: "#org-menu")}
>
Switch Org
</.link>
<%= if @current_scope && @current_scope.user && @current_scope.user.is_superuser do %>
<.link
navigate={~p"/admin"}
class="flex items-center gap-2 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-shield-check" class="w-4 h-4" /> Admin Panel
</.link>
<% end %>
<.link
navigate={~p"/users/settings"}
class="block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
phx-click={JS.hide(to: "#org-menu")}
>
Settings
</.link>
<.link
href={~p"/users/log-out"}
method="delete"
class="block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
>
Log out
</.link>
</div>
</div>
</div>
</div>
</div>
</nav>
<main class="py-10">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{render_slot(@inner_block)}
</div>
</main>
</div>
<.flash_group flash={@flash} />
"""
end
attr :navigate, :string, required: true
attr :active, :boolean, default: false
slot :inner_block, required: true
defp nav_link(assigns) do
~H"""
<.link
navigate={@navigate}
class={[
"inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium",
if(@active,
do: "border-zinc-900 text-zinc-900 dark:border-zinc-100 dark:text-zinc-100",
else:
"border-transparent text-zinc-700 hover:border-zinc-300 hover:text-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:text-zinc-100"
)
]}
>
{render_slot(@inner_block)}
</.link>
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.
See <head> in root.html.heex which applies the theme before page load.
"""
def theme_toggle(assigns) do
~H"""
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="system"
>
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="light"
>
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="dark"
>
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
</div>
"""
end
end