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 :if={@label} for={@id} class="block text-sm/6 font-medium text-gray-900 dark:text-white">
{@label}
</span>
</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"
>
<tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class="hover:bg-zinc-50 dark:hover:bg-zinc-900"
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)}>
<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>

View file

@ -17,6 +17,11 @@ defmodule ToweropsWeb.UserSettingsLive do
|> assign_changesets()
|> assign_credentials()
|> assign_mobile_sessions()
|> assign_api_tokens()
|> assign_organizations()
|> assign_default_organization()
|> assign(:show_token_modal, false)
|> assign(:created_token, nil)
{:ok, socket}
end
@ -101,6 +106,62 @@ defmodule ToweropsWeb.UserSettingsLive do
end
end
@impl true
def handle_event("create_api_token", %{"name" => name, "organization_id" => org_id}, socket) do
user = socket.assigns.current_scope.user
case Towerops.ApiTokens.create_api_token(%{
name: name,
organization_id: org_id,
user_id: user.id
}) do
{:ok, {_token, raw_token}} ->
socket =
socket
|> assign(:created_token, raw_token)
|> assign(:show_token_modal, true)
|> assign_api_tokens()
{:noreply, socket}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to create API token.")}
end
end
@impl true
def handle_event("close_token_modal", _params, socket) do
socket =
socket
|> assign(:show_token_modal, false)
|> assign(:created_token, nil)
{:noreply, socket}
end
@impl true
def handle_event("delete_api_token", %{"token-id" => token_id}, socket) do
token = Towerops.ApiTokens.get_api_token!(token_id)
user = socket.assigns.current_scope.user
if token.user_id == user.id do
case Towerops.ApiTokens.delete_api_token(token) do
{:ok, _} ->
socket =
socket
|> put_flash(:info, "API token deleted successfully.")
|> assign_api_tokens()
{:noreply, socket}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to delete API token.")}
end
else
{:noreply, put_flash(socket, :error, "You can only delete your own tokens.")}
end
end
defp assign_changesets(socket) do
user = socket.assigns.current_scope.user
@ -122,191 +183,379 @@ defmodule ToweropsWeb.UserSettingsLive do
assign(socket, :mobile_sessions, MobileSessions.list_user_sessions(user.id))
end
defp assign_api_tokens(socket) do
user = socket.assigns.current_scope.user
assign(socket, :api_tokens, Towerops.ApiTokens.list_user_api_tokens(user.id))
end
defp assign_organizations(socket) do
user = socket.assigns.current_scope.user
assign(socket, :organizations, Towerops.Organizations.list_user_organizations(user.id))
end
defp assign_default_organization(socket) do
organizations = socket.assigns.organizations
default_org = List.first(organizations)
assign(socket, :default_organization, default_org)
end
@impl true
def render(assigns) do
~H"""
<Layouts.authenticated flash={@flash} current_scope={@current_scope} current_organization={nil}>
<div class="text-center">
<.header>
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@default_organization}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Manage your account email address, password, and security settings
</p>
</div>
<div class="divide-y divide-gray-200 dark:divide-white/10">
<!-- Email Address Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">Email Address</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Update your email address. You'll receive a confirmation link to verify the new address.
</p>
</div>
<.form
for={@email_form}
phx-submit="update_email"
id="update_email"
class="space-y-4"
class="md:col-span-2"
>
<.input
field={@email_form[:email]}
<div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div class="col-span-full">
<label for="email" class="block text-sm/6 font-medium text-gray-900 dark:text-white">
Email address
</label>
<div class="mt-2">
<input
type="email"
label="Email"
name={@email_form[:email].name}
id="email"
value={@email_form[:email].value}
autocomplete="email"
required
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
</div>
</div>
</div>
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
<div class="mt-8 flex">
<button
type="submit"
phx-disable-with="Changing..."
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
>
Change Email
</button>
</div>
</.form>
</div>
<div class="divider" />
<!-- Change Password Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">Change Password</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Update your password. You'll be logged out and need to sign in again with your new password.
</p>
</div>
<.form
for={@password_form}
phx-submit="update_password"
id="update_password"
class="space-y-4"
class="md:col-span-2"
>
<.input
field={@password_form[:password]}
<div class="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div class="col-span-full">
<label
for="new-password"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
New password
</label>
<div class="mt-2">
<input
type="password"
label="New password"
name={@password_form[:password].name}
id="new-password"
autocomplete="new-password"
required
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
<.input
field={@password_form[:password_confirmation]}
</div>
</div>
<div class="col-span-full">
<label
for="confirm-password"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Confirm password
</label>
<div class="mt-2">
<input
type="password"
label="Confirm new password"
name={@password_form[:password_confirmation].name}
id="confirm-password"
autocomplete="new-password"
required
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
<.button variant="primary" phx-disable-with="Changing...">
</div>
</div>
</div>
<div class="mt-8 flex">
<button
type="submit"
phx-disable-with="Changing..."
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
>
Save Password
</.button>
</button>
</div>
</.form>
<div class="divider" />
</div>
<!-- API Tokens Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h3 class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100">
Alert Notification Devices
</h3>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Manage mobile devices that receive push notifications for alerts
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">API Tokens</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Create API tokens to access Towerops programmatically. Tokens are scoped to a specific organization.
</p>
</div>
<div class="mt-6 space-y-4">
<%= if Enum.empty?(@mobile_sessions) do %>
<div class="rounded-lg border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
<.icon name="hero-device-phone-mobile" class="mx-auto h-12 w-12 text-zinc-400" />
<h3 class="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
No mobile devices registered
<div class="md:col-span-2">
<%= if Enum.empty?(@api_tokens) do %>
<div class="text-center rounded-lg border border-dashed border-gray-300 px-6 py-10 dark:border-gray-700">
<.icon name="hero-code-bracket" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No API tokens
</h3>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Add a mobile device to receive push notifications for alerts
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Get started by creating a new API token.
</p>
<div class="mt-6">
<button
type="button"
id="add-api-token-btn"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> New Token
</button>
</div>
</div>
<% else %>
<div class="space-y-3">
<%= for session <- @mobile_sessions do %>
<div class="flex items-center justify-between rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<div class="flex items-center gap-3">
<.icon name="hero-device-phone-mobile" class="h-5 w-5 text-zinc-400" />
<ul role="list" class="divide-y divide-gray-200 dark:divide-white/10">
<%= for token <- @api_tokens do %>
<li class="flex items-center justify-between gap-x-6 py-5">
<div class="min-w-0">
<div class="flex items-start gap-x-3">
<p class="text-sm/6 font-semibold text-gray-900 dark:text-white">
{token.name}
</p>
</div>
<div class="mt-1 flex items-center gap-x-2 text-xs/5 text-gray-500 dark:text-gray-400">
<p class="whitespace-nowrap">{token.organization.name}</p>
<svg viewBox="0 0 2 2" class="h-0.5 w-0.5 fill-current">
<circle r="1" cx="1" cy="1" />
</svg>
<p class="whitespace-nowrap">
<%= if token.last_used_at do %>
Last used {Calendar.strftime(token.last_used_at, "%B %d, %Y")}
<% else %>
Never used
<% end %>
</p>
</div>
</div>
<button
type="button"
phx-click="delete_api_token"
phx-value-token-id={token.id}
data-confirm="Are you sure you want to delete this API token? This action cannot be undone."
class="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-100 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/10 dark:hover:bg-white/20"
>
Delete
</button>
</li>
<% end %>
</ul>
<div class="mt-6 flex">
<button
type="button"
id="add-api-token-btn"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> New Token
</button>
</div>
<% end %>
</div>
</div>
<!-- Mobile Devices Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<p class="font-medium text-zinc-900 dark:text-zinc-100">
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Alert Notification Devices
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Manage mobile devices that receive push notifications for alerts.
</p>
</div>
<div class="md:col-span-2">
<%= if Enum.empty?(@mobile_sessions) do %>
<div class="text-center rounded-lg border border-dashed border-gray-300 px-6 py-10 dark:border-gray-700">
<.icon name="hero-device-phone-mobile" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No mobile devices
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Add a mobile device to receive push notifications for alerts.
</p>
<div class="mt-6">
<.link
navigate={~p"/mobile/qr-login"}
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-qr-code" class="-ml-0.5 h-5 w-5" /> Add Device
</.link>
</div>
</div>
<% else %>
<ul role="list" class="divide-y divide-gray-200 dark:divide-white/10">
<%= for session <- @mobile_sessions do %>
<li class="flex items-center justify-between gap-x-6 py-5">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-x-3">
<p class="text-sm/6 font-semibold text-gray-900 dark:text-white">
{session.device_name || "Unknown Device"}
</p>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
{session.device_os} {session.app_version}
</p>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Last used {Calendar.strftime(session.last_used_at, "%B %d, %Y")}
</p>
<%= if session.alerts_enabled do %>
<span class="inline-flex items-center gap-x-0.5 rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 inset-ring-1 inset-ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:inset-ring-green-500/20">
<.icon name="hero-bell" class="h-3 w-3" /> Alerts On
</span>
<% else %>
<span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 inset-ring-1 inset-ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:inset-ring-gray-400/20">
<.icon name="hero-bell-slash" class="h-3 w-3" /> Alerts Off
</span>
<% end %>
</div>
<div class="mt-1 flex items-center gap-x-2 text-xs/5 text-gray-500 dark:text-gray-400">
<p>{session.device_os} {session.app_version}</p>
<svg viewBox="0 0 2 2" class="h-0.5 w-0.5 fill-current">
<circle r="1" cx="1" cy="1" />
</svg>
<p>Last used {Calendar.strftime(session.last_used_at, "%B %d, %Y")}</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex flex-none items-center gap-x-4">
<button
type="button"
phx-click="toggle_device_alerts"
phx-value-session-id={session.id}
phx-value-enabled={if session.alerts_enabled, do: "false", else: "true"}
class={[
"inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold shadow-sm",
if(session.alerts_enabled,
do:
"bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/20 dark:text-green-400 dark:hover:bg-green-900/30",
else:
"bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
)
]}
class="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-100 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/10 dark:hover:bg-white/20"
>
<%= if session.alerts_enabled do %>
<.icon name="hero-bell" class="h-4 w-4" /> Alerts On
<% else %>
<.icon name="hero-bell-slash" class="h-4 w-4" /> Alerts Off
<% end %>
Toggle
</button>
<button
type="button"
phx-click="revoke_mobile_device"
phx-value-session-id={session.id}
data-confirm="Are you sure you want to remove this device?"
class="text-sm font-semibold text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300"
class="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-100 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/10 dark:hover:bg-white/20"
>
Remove
</button>
</div>
</div>
<% end %>
</div>
</li>
<% end %>
</ul>
<div class="mt-6 flex">
<.link
navigate={~p"/mobile/qr-login"}
class="inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-300"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-qr-code" class="h-4 w-4" /> Add Mobile Device
<.icon name="hero-qr-code" class="-ml-0.5 h-5 w-5" /> Add Device
</.link>
</div>
<% end %>
</div>
</div>
<div class="divider" />
<!-- Passkeys Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h3 class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100">
Passkeys
</h3>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">Passkeys</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in.
</p>
</div>
<div class="md:col-span-2">
<%= if !@can_register_passkey do %>
<div class="mt-4 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<p class="text-sm text-amber-800 dark:text-amber-200">
Please confirm your email address before registering a passkey. Check your inbox for the confirmation link.
</p>
</div>
<% end %>
<div class="mt-6 space-y-4">
<%= if Enum.empty?(@credentials) do %>
<div class="rounded-lg border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
<.icon name="hero-key" class="mx-auto h-12 w-12 text-zinc-400" />
<h3 class="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
No passkeys registered
</h3>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
Add a passkey to enable quick, secure sign-in with biometrics
<div class={"text-center rounded-lg border border-dashed border-gray-300 px-6 py-10 dark:border-gray-700 #{if !@can_register_passkey, do: "mt-4"}"}>
<.icon name="hero-key" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">No passkeys</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Add a passkey to enable quick, secure sign-in with biometrics.
</p>
<%= if @can_register_passkey do %>
<div class="mt-6">
<button
type="button"
id="add-passkey-btn"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> Add Passkey
</button>
</div>
<% end %>
</div>
<% else %>
<div class="space-y-3">
<ul
role="list"
class={"divide-y divide-gray-200 dark:divide-white/10 #{if !@can_register_passkey, do: "mt-4"}"}
>
<%= for credential <- @credentials do %>
<div class="flex items-center justify-between rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<div class="flex items-center gap-3">
<.icon name="hero-key" class="h-5 w-5 text-zinc-400" />
<div>
<p class="font-medium text-zinc-900 dark:text-zinc-100">
<li class="flex items-center justify-between gap-x-6 py-5">
<div class="min-w-0">
<div class="flex items-start gap-x-3">
<p class="text-sm/6 font-semibold text-gray-900 dark:text-white">
{credential.name}
</p>
</div>
<div class="mt-1 text-xs/5 text-gray-500 dark:text-gray-400">
<%= if credential.last_used_at do %>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")}
</p>
<% else %>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Never used</p>
Never used
<% end %>
</div>
</div>
@ -314,23 +563,176 @@ defmodule ToweropsWeb.UserSettingsLive do
href={~p"/users/credentials/#{credential.id}"}
method="delete"
data-confirm="Are you sure you want to delete this passkey?"
class="text-sm font-semibold text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300"
class="rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-100 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/10 dark:hover:bg-white/20"
>
Delete
</.link>
</div>
<% end %>
</div>
</li>
<% end %>
</ul>
<%= if @can_register_passkey do %>
<div class="mt-6 flex">
<button
type="button"
id="add-passkey-btn"
class="inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-300"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-plus" class="h-4 w-4" /> Add Passkey
<.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> Add Passkey
</button>
</div>
<% end %>
<% end %>
</div>
</div>
</div>
<!-- API Token Creation Modal -->
<div
id="api-token-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div
class="fixed inset-0 z-0 bg-zinc-500 bg-opacity-75 transition-opacity dark:bg-zinc-950 dark:bg-opacity-75"
aria-hidden="true"
>
</div>
<span
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
aria-hidden="true"
>
&#8203;
</span>
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-zinc-900 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
<.icon name="hero-code-bracket" class="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100"
id="modal-title"
>
Create API Token
</h3>
<div class="mt-2">
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Give this token a name and select the organization it will have access to.
</p>
</div>
<div class="mt-4 space-y-4">
<input
type="text"
id="api-token-name-input"
placeholder="e.g., Production API"
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
<div class="grid grid-cols-1">
<select
id="api-token-org-select"
class="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 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
>
<%= for org <- @organizations do %>
<option value={org.id}>{org.name}</option>
<% end %>
</select>
<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>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
type="button"
id="confirm-create-api-token"
class="inline-flex w-full justify-center rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed sm:col-start-2"
>
Create Token
</button>
<button
type="button"
id="cancel-create-api-token"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-zinc-900 shadow-sm ring-1 ring-inset ring-zinc-300 hover:bg-zinc-50 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700 dark:hover:bg-zinc-700 sm:col-start-1 sm:mt-0"
>
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Token Created Success Modal -->
<%= if @show_token_modal && @created_token do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div
phx-click="close_token_modal"
class="fixed inset-0 bg-zinc-500 bg-opacity-75 transition-opacity dark:bg-zinc-950 dark:bg-opacity-75"
aria-hidden="true"
>
</div>
<span class="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
&#8203;
</span>
<div class="inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-zinc-900 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<.icon name="hero-check" class="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<div class="mt-3 text-center sm:mt-5">
<h3 class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100">
API Token Created
</h3>
<div class="mt-2">
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Copy this token now. You won't be able to see it again.
</p>
</div>
<div class="mt-4">
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-800">
<code class="text-sm text-zinc-900 dark:text-zinc-100 break-all">
{@created_token}
</code>
</div>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
type="button"
phx-click="close_token_modal"
class="inline-flex w-full justify-center rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Done
</button>
</div>
</div>
</div>
</div>
<% end %>
<!-- Passkey Registration Modal -->
<div
@ -341,14 +743,11 @@ defmodule ToweropsWeb.UserSettingsLive do
aria-modal="true"
>
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div
class="fixed inset-0 z-0 bg-zinc-500 bg-opacity-75 transition-opacity dark:bg-zinc-950 dark:bg-opacity-75"
aria-hidden="true"
>
</div>
<!-- Center modal -->
<span
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
aria-hidden="true"
@ -408,10 +807,52 @@ defmodule ToweropsWeb.UserSettingsLive do
</div>
</div>
</div>
<% end %>
</div>
</div>
</Layouts.authenticated>
<script>
// API Token Modal
const addTokenBtn = document.getElementById('add-api-token-btn');
const tokenModal = document.getElementById('api-token-modal');
const confirmCreateToken = document.getElementById('confirm-create-api-token');
const cancelCreateToken = document.getElementById('cancel-create-api-token');
const tokenNameInput = document.getElementById('api-token-name-input');
const tokenOrgSelect = document.getElementById('api-token-org-select');
if (addTokenBtn) {
addTokenBtn.addEventListener('click', () => {
tokenModal.classList.remove('hidden');
tokenNameInput.focus();
});
}
if (cancelCreateToken) {
cancelCreateToken.addEventListener('click', () => {
tokenModal.classList.add('hidden');
tokenNameInput.value = '';
});
}
if (confirmCreateToken) {
confirmCreateToken.addEventListener('click', () => {
const name = tokenNameInput.value.trim();
const orgId = tokenOrgSelect.value;
if (name && orgId) {
window.liveSocket.execJS(confirmCreateToken, [["push", {"event": "create_api_token", "value": {"name": name, "organization_id": orgId}}]]);
tokenModal.classList.add('hidden');
tokenNameInput.value = '';
}
});
}
if (tokenNameInput) {
tokenNameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
confirmCreateToken.click();
}
});
}
</script>
"""
end
end

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.