1586 lines
74 KiB
Text
1586 lines
74 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"
|
|
>
|
|
​
|
|
</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"
|
|
>
|
|
​
|
|
</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"
|
|
>
|
|
​
|
|
</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"
|
|
>
|
|
​
|
|
</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"
|
|
>
|
|
​
|
|
</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"
|
|
>
|
|
​
|
|
</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 %>
|
|
</Layouts.authenticated>
|