towerops/lib/towerops_web/live/user_settings_live.ex
Graham McIntire 90ae39091c
refactor user settings to use tab navigation
- 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
2026-01-18 10:35:29 -06:00

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"
>
&#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
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"
>
&#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-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