towerops/lib/towerops_web/live/user_settings_live.html.heex
2026-02-01 17:25:16 -06:00

1660 lines
77 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
>
<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>
<.link
patch={~p"/users/settings?tab=personal"}
class={
if @active_tab == "personal",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
Personal
</.link>
</li>
<li>
<.link
patch={~p"/users/settings?tab=account"}
class={
if @active_tab == "account",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
Account
</.link>
</li>
<li>
<.link
patch={~p"/users/settings?tab=sessions"}
class={
if @active_tab == "sessions",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
Sessions
</.link>
</li>
<li>
<.link
patch={~p"/users/settings?tab=api"}
class={
if @active_tab == "api",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
API
</.link>
</li>
<li>
<.link
patch={~p"/users/settings?tab=notifications"}
class={
if @active_tab == "notifications",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
Notifications
</.link>
</li>
<li>
<.link
patch={~p"/users/settings?tab=security"}
class={
if @active_tab == "security",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
Security
</.link>
</li>
<li>
<.link
navigate={~p"/users/my-data"}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
My Data
</.link>
</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 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="sm:col-span-3">
<label
for="first_name"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
First name
</label>
<div class="mt-2">
<input
type="text"
name={@profile_form[:first_name].name}
id="first_name"
value={@profile_form[:first_name].value}
autocomplete="given-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="sm:col-span-3">
<label
for="last_name"
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Last name
</label>
<div class="mt-2">
<input
type="text"
name={@profile_form[:last_name].name}
id="last_name"
value={@profile_form[:last_name].value}
autocomplete="family-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="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"
phx-blur="check_password_breach"
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>
<%= if @password_breach_count && @password_breach_count > 0 do %>
<div class="mt-2 rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4">
<div class="flex">
<div class="flex-shrink-0">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Password found in data breaches
</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>
This password has appeared {@password_breach_count}
{if @password_breach_count == 1, do: "time", else: "times"} in known data breaches. We strongly recommend choosing a different,
more unique password to protect your account.
</p>
</div>
</div>
</div>
</div>
<% end %>
</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"
phx-click="show_add_token_modal"
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"
phx-click="show_add_token_modal"
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 %>
<!-- Sessions Tab -->
<%= if @active_tab == "sessions" do %>
<!-- Security Alert Banner -->
<%= if @show_security_alert do %>
<div class="px-4 py-6 sm:px-6 lg:px-8">
<div class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="flex">
<div class="flex-shrink-0">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
Security Alert
</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>
We detected {@failed_login_count} failed login attempts in the last 24 hours.
Please review your login history below and ensure all activity is authorized.
</p>
</div>
</div>
</div>
</div>
</div>
<% end %>
<!-- Active Sessions 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">
Active Sessions
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Manage browser sessions where you're currently logged in.
</p>
</div>
<div class="md:col-span-2">
<%= if Enum.empty?(@browser_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-computer-desktop" 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 active sessions
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Your current session will appear here after your next login.
</p>
</div>
<% else %>
<ul role="list" class="divide-y divide-gray-200 dark:divide-white/10">
<%= for session <- @browser_sessions do %>
<% is_current = current_session?(session, get_current_token_id(assigns)) %>
<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">
{format_browser_info(session)}
</p>
<%= if is_current do %>
<span class="inline-flex items-center 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">
Current Session
</span>
<% end %>
</div>
<div class="mt-1 flex flex-col gap-y-1 text-xs/5 text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-x-2">
<.icon name="hero-map-pin" class="h-3 w-3" />
<span>{format_location(session)}</span>
<span class="text-gray-400 dark:text-gray-600">•</span>
<span class="font-mono">{session.ip_address}</span>
</div>
<div class="flex items-center gap-x-2">
<.icon name="hero-clock" class="h-3 w-3" />
<span>Last active {format_relative_time(session.last_activity_at)}</span>
</div>
</div>
</div>
<button
type="button"
phx-click="revoke_session"
phx-value-session-id={session.id}
disabled={is_current}
data-confirm={
unless is_current,
do:
"Are you sure you want to revoke this session? You will be logged out from that device."
}
class={"rounded-md px-2.5 py-1.5 text-sm font-semibold shadow-xs inset-ring-1 " <>
if is_current do
"bg-gray-100 text-gray-400 inset-ring-gray-200 cursor-not-allowed dark:bg-gray-800 dark:text-gray-600 dark:inset-ring-gray-700"
else
"bg-white text-gray-900 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"
end
}
>
Revoke
</button>
</li>
<% end %>
</ul>
<%= if length(@browser_sessions) > 1 do %>
<div class="mt-6 flex">
<button
type="button"
phx-click="show_revoke_all_modal"
class="inline-flex items-center gap-x-1.5 rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 dark:bg-red-500 dark:hover:bg-red-400"
>
<.icon name="hero-x-mark" class="-ml-0.5 h-5 w-5" /> Revoke All Other Sessions
</button>
</div>
<% end %>
<% end %>
</div>
</div>
<!-- Login History 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 border-t border-gray-200 dark:border-white/10">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Login History
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Recent login attempts to your account, including successful and failed attempts.
</p>
</div>
<div class="md:col-span-2">
<%= if Enum.empty?(@login_history) do %>
<div class="text-center rounded-lg border border-dashed border-gray-300 px-6 py-10 dark:border-gray-700">
<.icon name="hero-clock" 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 login history
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Your login attempts will appear here.
</p>
</div>
<% else %>
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 dark:ring-white/10 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-white sm:pl-6"
>
Date & Time
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Status
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Method
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Location
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
IP Address
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900">
<%= for attempt <- @login_history do %>
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900 dark:text-white sm:pl-6">
{format_timestamp_in_timezone(
attempt.inserted_at,
@current_scope.timezone
)}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<%= if attempt.success do %>
<span class="inline-flex items-center gap-x-1 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-check-circle" class="h-3 w-3" /> Success
</span>
<% else %>
<span class="inline-flex items-center gap-x-1 rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 inset-ring-1 inset-ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:inset-ring-red-500/20">
<.icon name="hero-x-circle" class="h-3 w-3" /> Failed
</span>
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% {icon, text} = login_method_info(attempt.method) %>
<span class="inline-flex items-center gap-x-1">
<.icon name={icon} class="h-3 w-3" /> {text}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{format_location(attempt)}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm font-mono text-gray-500 dark:text-gray-400">
{attempt.ip_address}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%= if @login_history_pagination.total_pages > 1 do %>
<div class="mt-6 flex items-center justify-between border-t border-gray-200 dark:border-white/10 pt-6">
<!-- Mobile: Previous/Next only -->
<div class="flex flex-1 justify-between sm:hidden">
<.link
:if={@login_history_pagination.page > 1}
patch={
~p"/users/settings?tab=#{@active_tab}&page=#{@login_history_pagination.page - 1}"
}
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Previous
</.link>
<.link
:if={@login_history_pagination.page < @login_history_pagination.total_pages}
patch={
~p"/users/settings?tab=#{@active_tab}&page=#{@login_history_pagination.page + 1}"
}
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Next
</.link>
</div>
<!-- Desktop: Full pagination with page numbers -->
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 dark:text-gray-400">
Showing
<span class="font-medium">
{(@login_history_pagination.page - 1) * @login_history_pagination.per_page +
1}
</span>
to
<span class="font-medium">
{min(
@login_history_pagination.page * @login_history_pagination.per_page,
@login_history_pagination.total_count
)}
</span>
of <span class="font-medium">{@login_history_pagination.total_count}</span>
results
</p>
</div>
<div>
<nav
class="isolate inline-flex -space-x-px rounded-md shadow-sm"
aria-label="Pagination"
>
<!-- Previous button -->
<.link
:if={@login_history_pagination.page > 1}
patch={
~p"/users/settings?tab=#{@active_tab}&page=#{@login_history_pagination.page - 1}"
}
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:ring-white/10 dark:hover:bg-gray-800"
>
<span class="sr-only">Previous</span>
<.icon name="hero-chevron-left" class="h-5 w-5" />
</.link>
<!-- Page numbers with ellipsis -->
<%= for page_num <- pagination_range(@login_history_pagination.page, @login_history_pagination.total_pages) do %>
<%= if page_num == :ellipsis do %>
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300 dark:text-gray-400 dark:ring-white/10">
...
</span>
<% else %>
<.link
patch={~p"/users/settings?tab=#{@active_tab}&page=#{page_num}"}
class={[
"relative inline-flex items-center px-4 py-2 text-sm font-semibold ring-1 ring-inset ring-gray-300 dark:ring-white/10",
if @login_history_pagination.page == page_num do
"z-10 bg-blue-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
else
"text-gray-900 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
end
]}
>
{page_num}
</.link>
<% end %>
<% end %>
<!-- Next button -->
<.link
:if={
@login_history_pagination.page < @login_history_pagination.total_pages
}
patch={
~p"/users/settings?tab=#{@active_tab}&page=#{@login_history_pagination.page + 1}"
}
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:ring-white/10 dark:hover:bg-gray-800"
>
<span class="sr-only">Next</span>
<.icon name="hero-chevron-right" class="h-5 w-5" />
</.link>
</nav>
</div>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
<% end %>
<!-- Security Tab -->
<%= if @active_tab == "security" do %>
<!-- Authenticator Apps 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">
Authenticator Apps
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Manage TOTP devices for two-factor authentication. You must have at least one device configured.
</p>
</div>
<div class="md:col-span-2">
<%= if Enum.empty?(@totp_devices) 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 authenticator apps
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Add your first authenticator app to enable two-factor authentication.
</p>
<div class="mt-6">
<button
type="button"
phx-click="show_add_device_modal"
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 Device
</button>
</div>
</div>
<% else %>
<ul role="list" class="divide-y divide-gray-200 dark:divide-white/10">
<%= for device <- @totp_devices 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">
{device.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">
Added {Calendar.strftime(device.inserted_at, "%B %d, %Y")}
</p>
<%= if device.last_used_at do %>
<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">
Last used {format_relative_time(device.last_used_at)}
</p>
<% end %>
</div>
</div>
<button
type="button"
phx-click="delete_device"
phx-value-device-id={device.id}
disabled={length(@totp_devices) == 1}
data-confirm={
if length(@totp_devices) > 1,
do: "Are you sure you want to remove this device?"
}
class={
"rounded-md px-2.5 py-1.5 text-sm font-semibold shadow-xs inset-ring-1 " <>
if length(@totp_devices) == 1 do
"bg-gray-100 text-gray-400 inset-ring-gray-200 cursor-not-allowed dark:bg-gray-800 dark:text-gray-600 dark:inset-ring-gray-700"
else
"bg-white text-gray-900 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"
end
}
>
Remove
</button>
</li>
<% end %>
</ul>
<div class="mt-6 flex">
<button
type="button"
phx-click="show_add_device_modal"
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 Device
</button>
</div>
<% end %>
</div>
</div>
<!-- Recovery Codes 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 border-t border-gray-200 dark:border-white/10">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Recovery Codes
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Single-use backup codes for account access if you lose your authenticator app.
</p>
</div>
<div class="md:col-span-2">
<div class="rounded-lg border border-gray-200 bg-white px-6 py-5 dark:border-white/10 dark:bg-white/5">
<div class="flex items-center justify-between">
<div class="flex-1">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
Available Recovery Codes
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<%= if @unused_recovery_codes_count == 0 do %>
<span class="text-red-600 dark:text-red-400 font-medium">
No recovery codes available
</span>
- Generate new codes immediately
<% else %>
You have
<span class="font-medium text-gray-900 dark:text-white">
{@unused_recovery_codes_count}
</span>
unused recovery code{if @unused_recovery_codes_count != 1, do: "s"}
<% end %>
</p>
</div>
<div class="ml-4">
<button
type="button"
phx-click="regenerate_recovery_codes"
data-confirm="This will invalidate all existing unused recovery codes. Continue?"
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-arrow-path" class="-ml-0.5 h-5 w-5" /> Regenerate
</button>
</div>
</div>
</div>
<%= if @unused_recovery_codes_count == 0 do %>
<div class="mt-4 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="flex">
<div class="flex-shrink-0">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
No Recovery Codes
</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>
You have no recovery codes available. If you lose access to your authenticator app,
you won't be able to log in. Generate new codes now.
</p>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<!-- Revoke All Sessions Confirmation Modal -->
<%= if @show_revoke_all_modal do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="revoke-all-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="cancel_revoke_all"
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-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-gray-800/95 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-red-100 dark:bg-red-900">
<.icon
name="hero-exclamation-triangle"
class="h-6 w-6 text-red-600 dark:text-red-400"
/>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
id="revoke-all-modal-title"
>
Revoke All Other Sessions?
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
This will log you out from all other browsers and devices. Your current session will remain active.
</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"
phx-click="confirm_revoke_all"
class="inline-flex w-full justify-center rounded-lg bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 sm:col-start-2"
>
Revoke All
</button>
<button
type="button"
phx-click="cancel_revoke_all"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"
>
Cancel
</button>
</div>
</div>
</div>
</div>
<% end %>
<!-- API Token Creation Modal -->
<%= if @show_add_token_modal 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="cancel_add_token"
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-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-gray-800/95 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<.form for={%{}} as={:token} phx-submit="create_api_token">
<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-gray-900 dark:text-white"
id="create-api-token-modal-title"
>
Create API Token
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-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"
name="name"
placeholder="e.g., Production API"
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 class="grid grid-cols-1">
<select
name="organization_id"
required
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="submit"
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"
phx-click="cancel_add_token"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"
>
Cancel
</button>
</div>
</.form>
</div>
</div>
</div>
<% end %>
<!-- Token Created Success Modal -->
<%= if @show_token_modal && @created_token do %>
<div
id="token-success-modal"
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="token-success-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 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-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-gray-800/95 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
id="token-success-modal-title"
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
>
API Token Created
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Copy this token now. You won't be able to see it again.
</p>
</div>
<div class="mt-4">
<div class="relative">
<input
type="text"
readonly
value={String.trim(@created_token)}
class="w-full font-mono text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded px-3 py-2 pr-28 text-gray-900 dark:text-white select-all"
id="api-token-input"
onclick="this.select()"
/>
<button
type="button"
phx-hook="CopyToClipboard"
data-target="#api-token-input"
id="copy-api-token"
class="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded shadow-sm transition-colors flex items-center gap-1.5"
>
<.icon name="hero-clipboard" class="h-3.5 w-3.5" />
<span>Copy</span>
</button>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
Click the token to select all, or use the Copy button
</p>
</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 %>
<!-- Add TOTP Device Modal -->
<%= if @show_add_device_modal do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="add-device-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="cancel_add_device"
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-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-gray-800/95 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<.form for={%{}} as={:device} phx-submit="create_device">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 dark:bg-indigo-900">
<.icon
name="hero-device-phone-mobile"
class="h-6 w-6 text-indigo-600 dark:text-indigo-400"
/>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
id="add-device-modal-title"
>
Add Authenticator Device
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Give this device a name to help you identify it (e.g., "iPhone 15", "iPad Pro").
</p>
</div>
<div class="mt-4">
<input
type="text"
name="name"
placeholder="e.g., iPhone 15"
required
autofocus
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-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
type="submit"
class="inline-flex w-full justify-center rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 sm:col-start-2"
>
Continue
</button>
<button
type="button"
phx-click="cancel_add_device"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"
>
Cancel
</button>
</div>
</.form>
</div>
</div>
</div>
<% end %>
<!-- Device QR Code Modal -->
<%= if @show_device_qr_modal && @new_device_qr_code do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="device-qr-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_device_qr_modal"
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-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-gray-800/95 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-indigo-100 dark:bg-indigo-900">
<.icon name="hero-qr-code" class="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
id="device-qr-modal-title"
>
Scan QR Code
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.).
</p>
</div>
<!-- QR Code -->
<div class="mt-6 flex justify-center">
<div class="inline-block rounded-lg bg-white p-4">
<img src={@new_device_qr_code} alt="TOTP QR Code" class="h-48 w-48" />
</div>
</div>
<!-- Verification Code Input -->
<div class="mt-6">
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
Enter the 6-digit code from your app to verify
</p>
<.form for={%{}} as={:verification} phx-submit="verify_new_device">
<div class="flex justify-center gap-3">
<input
type="text"
name="code"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
inputmode="numeric"
autocomplete="one-time-code"
required
autofocus
class="block w-32 rounded-md bg-white px-3 py-2 text-center text-lg font-mono 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 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
<button
type="submit"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-4 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"
>
Verify
</button>
</div>
</.form>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
type="button"
phx-click="close_device_qr_modal"
class="inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800"
>
Cancel
</button>
</div>
</div>
</div>
</div>
<% end %>
<!-- Recovery Codes Display Modal -->
<%= if @show_recovery_codes_modal && @generated_recovery_codes do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="recovery-codes-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-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-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-gray-800/95 sm:my-8 sm:w-full sm:max-w-2xl 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-shield-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-gray-900 dark:text-white"
id="recovery-codes-modal-title"
>
Recovery Codes Generated
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Save these codes in a secure location. Each code can only be used once.
</p>
</div>
<!-- Recovery Codes Grid -->
<div class="mt-6">
<div class="rounded-lg bg-gray-50 p-6 dark:bg-gray-900/50">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<%= for code <- @generated_recovery_codes do %>
<div class="rounded-md bg-white px-3 py-2 font-mono text-sm text-gray-900 shadow-xs dark:bg-gray-800 dark:text-white">
{code}
</div>
<% end %>
</div>
</div>
</div>
<!-- Copy to Clipboard -->
<div class="mt-4 flex justify-center gap-3">
<button
type="button"
phx-hook="CopyToClipboard"
data-target="#recovery-codes-text"
id="copy-recovery-codes"
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"
>
<.icon name="hero-clipboard" class="-ml-0.5 h-5 w-5" /> Copy All
</button>
</div>
<!-- Hidden textarea for copy functionality -->
<textarea
id="recovery-codes-text"
class="sr-only"
readonly
>{Enum.join(@generated_recovery_codes, "\n")}</textarea>
<!-- Warning -->
<div class="mt-6 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="flex">
<div class="flex-shrink-0">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
Important
</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>
These codes won't be shown again. Store them securely (password manager, encrypted file, etc.).
If you lose your authenticator and these codes, you won't be able to access your account.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
type="button"
phx-click="close_recovery_codes_modal"
class="inline-flex w-full justify-center rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
I've Saved These Codes
</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-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-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-gray-800/95 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-gray-900 dark:text-white"
id="add-passkey-modal-title"
>
Add Passkey
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-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-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800/50 dark:text-white 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-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</Layouts.authenticated>