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 :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))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
​
|
||||
</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">
|
||||
​
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -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