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:
parent
56093bb493
commit
9b613f7a40
8 changed files with 814 additions and 349 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -46,3 +46,6 @@ k8s/.envrc
|
|||
|
||||
# TODO tracking (personal notes, not for version control)
|
||||
TODOS.md
|
||||
|
||||
# SNMP profiles export (temporary/local data)
|
||||
profiles.json
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue