feat: update UI to Tailwind Plus design system and fix auth redirects

- Update user settings page to use Tailwind Plus 3-column grid layout
- Add API token management section to user settings
- Implement organization name display in navigation menu
- Update table component to use Tailwind Plus styling across all tables
- Update flash notifications with Tailwind Plus design
- Update dropdown menus and select inputs with custom chevron styling
- Fix authentication redirect to return to original page after login
- Preserve user_return_to session key across session renewal
- Add store_return_to_for_liveview plug to browser pipeline
- Add profiles.json to gitignore
This commit is contained in:
Graham McIntire 2026-01-18 09:56:49 -06:00
parent 56093bb493
commit 9b613f7a40
No known key found for this signature in database
8 changed files with 814 additions and 349 deletions

3
.gitignore vendored
View file

@ -46,3 +46,6 @@ k8s/.envrc
# TODO tracking (personal notes, not for version control)
TODOS.md
# SNMP profiles export (temporary/local data)
profiles.json

View file

@ -58,6 +58,22 @@ defmodule Towerops.ApiTokens do
|> Repo.all()
end
@doc """
Lists all API tokens created by a user across all organizations.
## Examples
iex> list_user_api_tokens(user_id)
[%ApiToken{}, ...]
"""
def list_user_api_tokens(user_id) do
ApiToken
|> where([t], t.user_id == ^user_id)
|> order_by([t], desc: t.inserted_at)
|> preload(:organization)
|> Repo.all()
end
@doc """
Gets a single API token by ID.

View file

@ -54,62 +54,45 @@ defmodule ToweropsWeb.CoreComponents do
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
aria-live="assertive"
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6 z-50"
{@rest}
>
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
<div class={[
"pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5",
@kind == :info && "bg-blue-50 dark:bg-blue-950",
@kind == :error && "bg-red-50 dark:bg-red-950"
]}>
<div class="pointer-events-auto w-full max-w-sm translate-y-0 transform rounded-lg bg-white opacity-100 shadow-lg outline-1 outline-black/5 transition duration-300 ease-out sm:translate-x-0 dark:bg-gray-800 dark:-outline-offset-1 dark:outline-white/10 starting:translate-y-2 starting:opacity-0 starting:sm:translate-x-2 starting:sm:translate-y-0">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="shrink-0">
<.icon
:if={@kind == :info}
name="hero-information-circle"
class="h-6 w-6 text-blue-600 dark:text-blue-400"
name="hero-check-circle"
class="size-6 text-green-400"
/>
<.icon
:if={@kind == :error}
name="hero-exclamation-circle"
class="h-6 w-6 text-red-600 dark:text-red-400"
name="hero-x-circle"
class="size-6 text-red-400"
/>
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p
:if={@title}
class={[
"text-sm font-medium",
@kind == :info && "text-blue-900 dark:text-blue-100",
@kind == :error && "text-red-900 dark:text-red-100"
]}
>
<p :if={@title} class="text-sm font-medium text-gray-900 dark:text-white">
{@title}
</p>
<p class={[
"text-sm",
@title && "mt-1",
@kind == :info && "text-blue-700 dark:text-blue-200",
@kind == :error && "text-red-700 dark:text-red-200"
"text-sm text-gray-500 dark:text-gray-400",
@title && "mt-1"
]}>
{msg}
</p>
</div>
<div class="ml-4 flex flex-shrink-0">
<div class="ml-4 flex shrink-0">
<button
type="button"
class={[
"inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2",
@kind == :info &&
"text-blue-500 hover:text-blue-600 focus:ring-blue-500 dark:text-blue-400",
@kind == :error &&
"text-red-500 hover:text-red-600 focus:ring-red-500 dark:text-red-400"
]}
class="inline-flex rounded-md text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:hover:text-white dark:focus:outline-indigo-500"
aria-label={gettext("close")}
>
<.icon name="hero-x-mark" class="h-5 w-5" />
<span class="sr-only">Close</span>
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
</div>
@ -283,21 +266,21 @@ defmodule ToweropsWeb.CoreComponents do
def input(%{type: "select"} = assigns) do
~H"""
<div class="mb-4">
<label>
<span :if={@label} class="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-2">
{@label}
</span>
<label :if={@label} for={@id} class="block text-sm/6 font-medium text-gray-900 dark:text-white">
{@label}
</label>
<div class={["mt-2 grid grid-cols-1", @label && ""]}>
<select
id={@id}
name={@name}
class={[
@class ||
"block w-full rounded-lg border-0 py-2 px-3 text-zinc-900 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 dark:bg-zinc-800 dark:text-zinc-100",
"col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-1.5 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800",
@errors == [] &&
"ring-zinc-300 focus:ring-blue-600 dark:ring-zinc-700 dark:focus:ring-blue-500",
"outline-gray-300 focus:outline-indigo-600 dark:focus:outline-indigo-500",
@errors != [] &&
(@error_class ||
"ring-red-300 focus:ring-red-500 dark:ring-red-700 dark:focus:ring-red-500")
"outline-red-300 focus:outline-red-500 dark:outline-red-700 dark:focus:outline-red-500")
]}
multiple={@multiple}
{@rest}
@ -305,7 +288,20 @@ defmodule ToweropsWeb.CoreComponents do
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<svg
viewBox="0 0 16 16"
fill="currentColor"
data-slot="icon"
aria-hidden="true"
class="pointer-events-none col-start-1 row-start-1 mr-2 size-5 self-center justify-self-end text-gray-400 sm:size-4"
>
<path
d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
fill-rule="evenodd"
/>
</svg>
</div>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
@ -433,17 +429,17 @@ defmodule ToweropsWeb.CoreComponents do
end
~H"""
<div class="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-900">
<div class="overflow-hidden shadow-sm outline-1 outline-black/5 sm:rounded-lg dark:shadow-none dark:-outline-offset-1 dark:outline-white/10">
<table class="relative min-w-full divide-y divide-gray-300 dark:divide-white/15">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr>
<th
:for={col <- @col}
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-700 dark:text-zinc-300"
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-gray-900 sm:pl-6 dark:text-gray-200"
>
{col[:label]}
</th>
<th :if={@action != []} class="px-6 py-3">
<th :if={@action != []} class="py-3.5 pr-4 pl-3 sm:pr-6">
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
@ -451,24 +447,25 @@ defmodule ToweropsWeb.CoreComponents do
<tbody
id={@id}
phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}
class="divide-y divide-zinc-200 bg-white dark:divide-zinc-700 dark:bg-zinc-950"
class="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-gray-800/50"
>
<tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class="hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={[
"px-6 py-4 whitespace-nowrap text-sm text-zinc-900 dark:text-zinc-100",
"py-4 text-sm whitespace-nowrap",
i == 0 && "pr-3 pl-4 font-medium text-gray-900 sm:pl-6 dark:text-white",
i > 0 && "px-3 text-gray-500 dark:text-gray-400",
@row_click && "cursor-pointer"
]}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<td
:if={@action != []}
class="py-4 pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-6"
>
<div class="flex gap-4 justify-end">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}

View file

@ -176,22 +176,34 @@ defmodule ToweropsWeb.Layouts do
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"
class="inline-flex items-center justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
>
{if @current_organization, do: @current_organization.name, else: "Menu"}
<.icon name="hero-chevron-down" class="h-4 w-4" />
<svg
viewBox="0 0 20 20"
fill="currentColor"
data-slot="icon"
aria-hidden="true"
class="-mr-1 size-5 text-gray-400"
>
<path
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
fill-rule="evenodd"
/>
</svg>
</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"
class="hidden absolute top-full right-0 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg outline-1 outline-black/5 z-50 dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
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"
class="block 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")}
>
Switch Org
@ -199,7 +211,7 @@ defmodule ToweropsWeb.Layouts do
<.link
:if={@current_organization}
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
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"
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-cog-6-tooth" class="w-4 h-4" /> Organization Settings
@ -207,7 +219,7 @@ defmodule ToweropsWeb.Layouts do
<%= 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"
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-shield-check" class="w-4 h-4" /> Admin Panel
@ -215,7 +227,7 @@ defmodule ToweropsWeb.Layouts do
<% end %>
<.link
navigate={~p"/users/settings"}
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"
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-user-circle" class="w-4 h-4" /> User Settings
@ -223,7 +235,7 @@ defmodule ToweropsWeb.Layouts do
<.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"
class="block 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"
>
Log out
</.link>

File diff suppressed because it is too large Load diff

View file

@ -1,26 +0,0 @@
defmodule ToweropsWeb.Plugs.RequireSuperuser do
@moduledoc """
Plug that requires the current user to be a superuser.
Must be used after authentication plug that sets :current_user assign.
Returns 403 Forbidden if user is not a superuser.
"""
import Phoenix.Controller, only: [json: 2]
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
user = conn.assigns[:current_user]
if user && user.is_superuser do
conn
else
conn
|> put_status(:forbidden)
|> json(%{error: "Superuser access required"})
|> halt()
end
end
end

View file

@ -16,6 +16,7 @@ defmodule ToweropsWeb.Router do
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
plug :store_return_to_for_liveview
end
pipeline :api do

View file

@ -185,12 +185,17 @@ 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)
@ -292,6 +297,22 @@ defmodule ToweropsWeb.UserAuth do
defp maybe_store_return_to(conn), do: conn
@doc """
Plug to store the current path for LiveView routes.
This runs in the pipeline before LiveView mount, allowing us to store
the return path in the session before on_mount callbacks run. Only stores
if there's no existing return path to avoid overwriting.
"""
def store_return_to_for_liveview(conn, _opts) do
# Only store for GET requests and only if there's no existing return path
if conn.method == "GET" && is_nil(get_session(conn, :user_return_to)) do
put_session(conn, :user_return_to, current_path(conn))
else
conn
end
end
@doc """
Plug to load the current organization from the URL slug.