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
308 lines
11 KiB
Elixir
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
|