- Add tab navigation UI with Personal, Account, Security, API, Notifications tabs - Add active_tab state management in LiveView - Add switch_tab event handler - Show only active tab content (like Tailwind Plus template) - Remove section prefixes since each is now on separate tab - Default to Personal tab on mount
1151 lines
50 KiB
Elixir
1151 lines
50 KiB
Elixir
defmodule ToweropsWeb.UserSettingsLive do
|
|
@moduledoc """
|
|
LiveView for managing user account settings.
|
|
|
|
Allows users to update email, password, manage passkeys, and mobile sessions.
|
|
"""
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Accounts
|
|
alias Towerops.MobileSessions
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:page_title, "Account Settings")
|
|
|> assign(:active_tab, "personal")
|
|
|> assign_changesets()
|
|
|> assign_profile_form()
|
|
|> 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
|
|
|
|
@impl true
|
|
def handle_event("switch_tab", %{"tab" => tab}, socket) do
|
|
{:noreply, assign(socket, :active_tab, tab)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("update_profile", %{"user" => user_params}, socket) do
|
|
user = socket.assigns.current_scope.user
|
|
|
|
case Accounts.update_user_profile(user, user_params) do
|
|
{:ok, _updated_user} ->
|
|
socket =
|
|
socket
|
|
|> put_flash(:info, "Profile updated successfully.")
|
|
|> assign_profile_form()
|
|
|
|
{:noreply, socket}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply, assign(socket, :profile_form, to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("update_email", %{"user" => user_params}, socket) do
|
|
user = socket.assigns.current_scope.user
|
|
|
|
case Accounts.change_user_email(user, user_params) do
|
|
%{valid?: true} = changeset ->
|
|
_ =
|
|
Accounts.deliver_user_update_email_instructions(
|
|
Ecto.Changeset.apply_action!(changeset, :insert),
|
|
user.email,
|
|
&url(~p"/users/settings/confirm-email/#{&1}")
|
|
)
|
|
|
|
socket =
|
|
socket
|
|
|> put_flash(:info, "A link to confirm your email change has been sent to the new address.")
|
|
|> assign_changesets()
|
|
|
|
{:noreply, socket}
|
|
|
|
changeset ->
|
|
{:noreply, assign(socket, :email_form, to_form(%{changeset | action: :insert}))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("update_password", %{"user" => user_params}, socket) do
|
|
user = socket.assigns.current_scope.user
|
|
|
|
case Accounts.update_user_password(user, user_params) do
|
|
{:ok, {_updated_user, _}} ->
|
|
# Redirect to re-authenticate with new password
|
|
socket =
|
|
socket
|
|
|> put_flash(:info, "Password updated successfully. Please log in with your new password.")
|
|
|> redirect(to: ~p"/users/log-in")
|
|
|
|
{:noreply, socket}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply, assign(socket, :password_form, to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("revoke_mobile_device", %{"session-id" => session_id}, socket) do
|
|
case MobileSessions.revoke_session(session_id) do
|
|
{:ok, _} ->
|
|
socket =
|
|
socket
|
|
|> put_flash(:info, "Mobile device removed successfully.")
|
|
|> assign_mobile_sessions()
|
|
|
|
{:noreply, socket}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, "Failed to remove mobile device.")}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_device_alerts", %{"session-id" => session_id, "enabled" => enabled}, socket) do
|
|
enabled_bool = enabled == "true"
|
|
|
|
case MobileSessions.update_alert_preferences(session_id, %{alerts_enabled: enabled_bool}) do
|
|
{:ok, _} ->
|
|
message = if enabled_bool, do: "Alerts enabled for device", else: "Alerts disabled for device"
|
|
|
|
socket =
|
|
socket
|
|
|> put_flash(:info, message)
|
|
|> assign_mobile_sessions()
|
|
|
|
{:noreply, socket}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, "Failed to update alert preferences.")}
|
|
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_profile_form(socket) do
|
|
user = socket.assigns.current_scope.user
|
|
assign(socket, :profile_form, user |> Accounts.change_user_profile() |> to_form())
|
|
end
|
|
|
|
defp assign_changesets(socket) do
|
|
user = socket.assigns.current_scope.user
|
|
|
|
socket
|
|
|> assign(:email_form, user |> Accounts.change_user_email() |> to_form())
|
|
|> assign(:password_form, user |> Accounts.change_user_password() |> to_form())
|
|
end
|
|
|
|
defp assign_credentials(socket) do
|
|
user = socket.assigns.current_scope.user
|
|
|
|
socket
|
|
|> assign(:credentials, Accounts.list_user_credentials(user.id))
|
|
|> assign(:can_register_passkey, Accounts.passkey_registration_allowed?(user))
|
|
end
|
|
|
|
defp assign_mobile_sessions(socket) do
|
|
user = socket.assigns.current_scope.user
|
|
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={@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
|
|
</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>
|
|
|
|
<header class="border-b border-gray-200 dark:border-white/5">
|
|
<nav class="flex overflow-x-auto py-4">
|
|
<ul
|
|
role="list"
|
|
class="flex min-w-full flex-none gap-x-6 px-4 text-sm/6 font-semibold text-gray-500 sm:px-6 lg:px-8 dark:text-gray-400"
|
|
>
|
|
<li>
|
|
<a
|
|
href="#"
|
|
phx-click="switch_tab"
|
|
phx-value-tab="personal"
|
|
class={
|
|
if @active_tab == "personal",
|
|
do: "text-indigo-600 dark:text-indigo-400",
|
|
else: ""
|
|
}
|
|
>
|
|
Personal
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a
|
|
href="#"
|
|
phx-click="switch_tab"
|
|
phx-value-tab="account"
|
|
class={
|
|
if @active_tab == "account",
|
|
do: "text-indigo-600 dark:text-indigo-400",
|
|
else: ""
|
|
}
|
|
>
|
|
Account
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a
|
|
href="#"
|
|
phx-click="switch_tab"
|
|
phx-value-tab="security"
|
|
class={
|
|
if @active_tab == "security",
|
|
do: "text-indigo-600 dark:text-indigo-400",
|
|
else: ""
|
|
}
|
|
>
|
|
Security
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a
|
|
href="#"
|
|
phx-click="switch_tab"
|
|
phx-value-tab="api"
|
|
class={
|
|
if @active_tab == "api",
|
|
do: "text-indigo-600 dark:text-indigo-400",
|
|
else: ""
|
|
}
|
|
>
|
|
API
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a
|
|
href="#"
|
|
phx-click="switch_tab"
|
|
phx-value-tab="notifications"
|
|
class={
|
|
if @active_tab == "notifications",
|
|
do: "text-indigo-600 dark:text-indigo-400",
|
|
else: ""
|
|
}
|
|
>
|
|
Notifications
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
</header>
|
|
|
|
<div class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<!-- Personal Information Section -->
|
|
<%= if @active_tab == "personal" do %>
|
|
<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">
|
|
Personal Information
|
|
</h2>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
Update your name, avatar, and timezone preferences.
|
|
</p>
|
|
</div>
|
|
|
|
<.form
|
|
for={@profile_form}
|
|
phx-submit="update_profile"
|
|
id="update_profile"
|
|
class="md:col-span-2"
|
|
>
|
|
<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="name" class="block text-sm/6 font-medium text-gray-900 dark:text-white">
|
|
Name
|
|
</label>
|
|
<div class="mt-2">
|
|
<input
|
|
type="text"
|
|
name={@profile_form[:name].name}
|
|
id="name"
|
|
value={@profile_form[:name].value}
|
|
autocomplete="name"
|
|
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 class="col-span-full">
|
|
<label
|
|
for="avatar-url"
|
|
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
|
>
|
|
Avatar URL
|
|
</label>
|
|
<div class="mt-2">
|
|
<input
|
|
type="url"
|
|
name={@profile_form[:avatar_url].name}
|
|
id="avatar-url"
|
|
value={@profile_form[:avatar_url].value}
|
|
placeholder="https://example.com/avatar.jpg"
|
|
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>
|
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
Enter a URL to an avatar image or use a service like Gravatar.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="col-span-full">
|
|
<label
|
|
for="timezone"
|
|
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
|
>
|
|
Timezone
|
|
</label>
|
|
<div class="mt-2 grid grid-cols-1">
|
|
<select
|
|
name={@profile_form[:timezone].name}
|
|
id="timezone"
|
|
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"
|
|
>
|
|
<option value="UTC" selected={@profile_form[:timezone].value == "UTC"}>
|
|
UTC (Coordinated Universal Time)
|
|
</option>
|
|
<option
|
|
value="America/New_York"
|
|
selected={@profile_form[:timezone].value == "America/New_York"}
|
|
>
|
|
Eastern Time (US & Canada)
|
|
</option>
|
|
<option
|
|
value="America/Chicago"
|
|
selected={@profile_form[:timezone].value == "America/Chicago"}
|
|
>
|
|
Central Time (US & Canada)
|
|
</option>
|
|
<option
|
|
value="America/Denver"
|
|
selected={@profile_form[:timezone].value == "America/Denver"}
|
|
>
|
|
Mountain Time (US & Canada)
|
|
</option>
|
|
<option
|
|
value="America/Los_Angeles"
|
|
selected={@profile_form[:timezone].value == "America/Los_Angeles"}
|
|
>
|
|
Pacific Time (US & Canada)
|
|
</option>
|
|
<option
|
|
value="Europe/London"
|
|
selected={@profile_form[:timezone].value == "Europe/London"}
|
|
>
|
|
London
|
|
</option>
|
|
<option
|
|
value="Europe/Paris"
|
|
selected={@profile_form[:timezone].value == "Europe/Paris"}
|
|
>
|
|
Paris
|
|
</option>
|
|
<option
|
|
value="Europe/Berlin"
|
|
selected={@profile_form[:timezone].value == "Europe/Berlin"}
|
|
>
|
|
Berlin
|
|
</option>
|
|
<option
|
|
value="Asia/Tokyo"
|
|
selected={@profile_form[:timezone].value == "Asia/Tokyo"}
|
|
>
|
|
Tokyo
|
|
</option>
|
|
<option
|
|
value="Asia/Shanghai"
|
|
selected={@profile_form[:timezone].value == "Asia/Shanghai"}
|
|
>
|
|
Shanghai
|
|
</option>
|
|
<option
|
|
value="Australia/Sydney"
|
|
selected={@profile_form[:timezone].value == "Australia/Sydney"}
|
|
>
|
|
Sydney
|
|
</option>
|
|
</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 class="mt-8 flex">
|
|
<button
|
|
type="submit"
|
|
phx-disable-with="Saving..."
|
|
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
|
|
</button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Account Tab -->
|
|
<%= if @active_tab == "account" do %>
|
|
<!-- 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="md:col-span-2"
|
|
>
|
|
<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"
|
|
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>
|
|
|
|
<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>
|
|
|
|
<!-- 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="md:col-span-2"
|
|
>
|
|
<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"
|
|
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"
|
|
/>
|
|
</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"
|
|
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"
|
|
/>
|
|
</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>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- API Tab -->
|
|
<%= if @active_tab == "api" do %>
|
|
<!-- 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>
|
|
<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="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-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 %>
|
|
<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>
|
|
<% end %>
|
|
|
|
<!-- Notifications Tab -->
|
|
<%= if @active_tab == "notifications" do %>
|
|
<!-- 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>
|
|
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
|
Mobile 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>
|
|
<%= 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 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="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"
|
|
>
|
|
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="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>
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
|
|
<div class="mt-6 flex">
|
|
<.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>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Security Tab -->
|
|
<%= if @active_tab == "security" do %>
|
|
<!-- 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>
|
|
<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="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 %>
|
|
|
|
<%= if Enum.empty?(@credentials) do %>
|
|
<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 %>
|
|
<ul
|
|
role="list"
|
|
class={"divide-y divide-gray-200 dark:divide-white/10 #{if !@can_register_passkey, do: "mt-4"}"}
|
|
>
|
|
<%= for credential <- @credentials 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">
|
|
{credential.name}
|
|
</p>
|
|
</div>
|
|
<div class="mt-1 text-xs/5 text-gray-500 dark:text-gray-400">
|
|
<%= if credential.last_used_at do %>
|
|
Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")}
|
|
<% else %>
|
|
Never used
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<.link
|
|
href={~p"/users/credentials/#{credential.id}"}
|
|
method="delete"
|
|
data-confirm="Are you sure you want to delete this passkey?"
|
|
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>
|
|
</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-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 %>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</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
|
|
id="passkey-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-key" 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"
|
|
>
|
|
Add Passkey
|
|
</h3>
|
|
<div class="mt-2">
|
|
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
|
Give this passkey a name to help you identify it later (e.g., "MacBook Touch ID", "iPhone").
|
|
</p>
|
|
</div>
|
|
<div class="mt-4">
|
|
<input
|
|
type="text"
|
|
id="passkey-name-input"
|
|
placeholder="e.g., MacBook Touch ID"
|
|
class="block w-full rounded-lg border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div
|
|
id="passkey-error"
|
|
class="mt-3 hidden rounded-lg bg-red-50 p-3 dark:bg-red-900/20"
|
|
>
|
|
<p class="text-sm text-red-800 dark:text-red-200"></p>
|
|
</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-add-passkey"
|
|
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"
|
|
>
|
|
Continue
|
|
</button>
|
|
<button
|
|
type="button"
|
|
id="cancel-add-passkey"
|
|
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>
|
|
</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
|