Add an Appearance section to the Personal tab in user settings with three clearly labelled options (Auto/Light/Dark). A ThemeSelector hook reads localStorage to highlight the active choice and updates it when selection changes. Remove the dropdown toggle from both the main nav and admin nav.
1853 lines
88 KiB
Text
1853 lines
88 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
>
|
|
<div class="pb-5">
|
|
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
|
{t("Account Settings")}
|
|
</h1>
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("Manage your account email address, password, and security settings")}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="border-b border-gray-200 dark:border-white/10">
|
|
<nav class="-mb-px flex space-x-8 overflow-x-auto">
|
|
<.link
|
|
patch={~p"/users/settings?tab=personal"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
|
|
if(@active_tab == "personal",
|
|
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
|
|
else:
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
|
|
)
|
|
]}
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-user" class="h-4 w-4" /> {t("Personal")}
|
|
</span>
|
|
</.link>
|
|
<.link
|
|
patch={~p"/users/settings?tab=account"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
|
|
if(@active_tab == "account",
|
|
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
|
|
else:
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
|
|
)
|
|
]}
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-envelope" class="h-4 w-4" /> {t("Account")}
|
|
</span>
|
|
</.link>
|
|
<.link
|
|
patch={~p"/users/settings?tab=sessions"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
|
|
if(@active_tab == "sessions",
|
|
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
|
|
else:
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
|
|
)
|
|
]}
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-computer-desktop" class="h-4 w-4" /> {t("Sessions")}
|
|
</span>
|
|
</.link>
|
|
<.link
|
|
patch={~p"/users/settings?tab=api"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
|
|
if(@active_tab == "api",
|
|
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
|
|
else:
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
|
|
)
|
|
]}
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-code-bracket" class="h-4 w-4" /> {t("API")}
|
|
</span>
|
|
</.link>
|
|
<.link
|
|
patch={~p"/users/settings?tab=notifications"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
|
|
if(@active_tab == "notifications",
|
|
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
|
|
else:
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
|
|
)
|
|
]}
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-bell" class="h-4 w-4" /> {t("Notifications")}
|
|
</span>
|
|
</.link>
|
|
<.link
|
|
patch={~p"/users/settings?tab=security"}
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors",
|
|
if(@active_tab == "security",
|
|
do: "border-indigo-500 text-indigo-600 dark:text-indigo-400 font-semibold",
|
|
else:
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium"
|
|
)
|
|
]}
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-shield-check" class="h-4 w-4" /> {t("Security")}
|
|
</span>
|
|
</.link>
|
|
<.link
|
|
navigate={~p"/users/my-data"}
|
|
class="whitespace-nowrap py-4 px-1 border-b-2 border-transparent text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> {t("My Data")}
|
|
</span>
|
|
</.link>
|
|
</nav>
|
|
</div>
|
|
|
|
<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">
|
|
{t("Personal Information")}
|
|
</h2>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
{t("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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("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"}>
|
|
{t("UTC (Coordinated Universal Time)")}
|
|
</option>
|
|
<option
|
|
value="America/New_York"
|
|
selected={@profile_form[:timezone].value == "America/New_York"}
|
|
>
|
|
{t("Eastern Time (US & Canada)")}
|
|
</option>
|
|
<option
|
|
value="America/Chicago"
|
|
selected={@profile_form[:timezone].value == "America/Chicago"}
|
|
>
|
|
{t("Central Time (US & Canada)")}
|
|
</option>
|
|
<option
|
|
value="America/Denver"
|
|
selected={@profile_form[:timezone].value == "America/Denver"}
|
|
>
|
|
{t("Mountain Time (US & Canada)")}
|
|
</option>
|
|
<option
|
|
value="America/Los_Angeles"
|
|
selected={@profile_form[:timezone].value == "America/Los_Angeles"}
|
|
>
|
|
{t("Pacific Time (US & Canada)")}
|
|
</option>
|
|
<option
|
|
value="Europe/London"
|
|
selected={@profile_form[:timezone].value == "Europe/London"}
|
|
>
|
|
{t("London")}
|
|
</option>
|
|
<option
|
|
value="Europe/Paris"
|
|
selected={@profile_form[:timezone].value == "Europe/Paris"}
|
|
>
|
|
{t("Paris")}
|
|
</option>
|
|
<option
|
|
value="Europe/Berlin"
|
|
selected={@profile_form[:timezone].value == "Europe/Berlin"}
|
|
>
|
|
{t("Berlin")}
|
|
</option>
|
|
<option
|
|
value="Asia/Tokyo"
|
|
selected={@profile_form[:timezone].value == "Asia/Tokyo"}
|
|
>
|
|
{t("Tokyo")}
|
|
</option>
|
|
<option
|
|
value="Asia/Shanghai"
|
|
selected={@profile_form[:timezone].value == "Asia/Shanghai"}
|
|
>
|
|
{t("Shanghai")}
|
|
</option>
|
|
<option
|
|
value="Australia/Sydney"
|
|
selected={@profile_form[:timezone].value == "Australia/Sydney"}
|
|
>
|
|
{t("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 class="col-span-full">
|
|
<label
|
|
for="time_format"
|
|
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
|
>
|
|
{t("Time Format")}
|
|
</label>
|
|
<div class="mt-2 grid grid-cols-1">
|
|
<select
|
|
name={@profile_form[:time_format].name}
|
|
id="time_format"
|
|
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="24h" selected={@profile_form[:time_format].value == "24h"}>
|
|
24-hour (14:30)
|
|
</option>
|
|
<option value="12h" selected={@profile_form[:time_format].value == "12h"}>
|
|
12-hour (2:30 PM)
|
|
</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 class="col-span-full">
|
|
<label
|
|
for="language"
|
|
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
|
>
|
|
{t("Language")}
|
|
</label>
|
|
<div class="mt-2 grid grid-cols-1">
|
|
<select
|
|
name={@profile_form[:language].name}
|
|
id="language"
|
|
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="en" selected={@profile_form[:language].value == "en"}>
|
|
English
|
|
</option>
|
|
<option value="es" selected={@profile_form[:language].value == "es"}>
|
|
Español
|
|
</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
|
|
fill-rule="evenodd"
|
|
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"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div :if={@organizations != []} class="col-span-full">
|
|
<label
|
|
for="default_organization_id"
|
|
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
|
>
|
|
{t("Default Organization")}
|
|
</label>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
"Choose which organization you see by default when logging in or navigating to pages without an organization context."
|
|
)}
|
|
</p>
|
|
<div class="mt-2 grid grid-cols-1">
|
|
<select
|
|
name={@profile_form[:default_organization_id].name}
|
|
id="default_organization_id"
|
|
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="">None (use first organization)</option>
|
|
<%= for org <- @organizations do %>
|
|
<option
|
|
value={org.id}
|
|
selected={@profile_form[:default_organization_id].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 class="mt-8 flex">
|
|
<button
|
|
type="submit"
|
|
phx-disable-with={t("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"
|
|
>
|
|
{t("Save")}
|
|
</button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
<% end %>
|
|
<!-- Appearance 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">
|
|
{t("Appearance")}
|
|
</h2>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
{t("Choose your preferred interface theme.")}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="md:col-span-2">
|
|
<div
|
|
id="theme-selector"
|
|
phx-hook="ThemeSelector"
|
|
phx-update="ignore"
|
|
class="flex gap-3"
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-pressed="false"
|
|
data-phx-theme="system"
|
|
phx-click={JS.dispatch("phx:set-theme")}
|
|
class="flex flex-1 flex-col items-center gap-2 rounded-lg border border-gray-200 px-4 py-5 text-sm font-medium text-gray-700 transition-colors hover:border-indigo-300 hover:bg-indigo-50 dark:border-white/10 dark:text-gray-300 dark:hover:border-indigo-500 dark:hover:bg-indigo-900/20"
|
|
>
|
|
<.icon name="hero-computer-desktop" class="h-6 w-6" />
|
|
{t("Auto")}
|
|
<span class="text-xs font-normal text-gray-400 dark:text-gray-500">
|
|
{t("System default")}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-pressed="false"
|
|
data-phx-theme="light"
|
|
phx-click={JS.dispatch("phx:set-theme")}
|
|
class="flex flex-1 flex-col items-center gap-2 rounded-lg border border-gray-200 px-4 py-5 text-sm font-medium text-gray-700 transition-colors hover:border-indigo-300 hover:bg-indigo-50 dark:border-white/10 dark:text-gray-300 dark:hover:border-indigo-500 dark:hover:bg-indigo-900/20"
|
|
>
|
|
<.icon name="hero-sun" class="h-6 w-6" />
|
|
{t("Light")}
|
|
<span class="text-xs font-normal text-gray-400 dark:text-gray-500">
|
|
{t("Always light")}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-pressed="false"
|
|
data-phx-theme="dark"
|
|
phx-click={JS.dispatch("phx:set-theme")}
|
|
class="flex flex-1 flex-col items-center gap-2 rounded-lg border border-gray-200 px-4 py-5 text-sm font-medium text-gray-700 transition-colors hover:border-indigo-300 hover:bg-indigo-50 dark:border-white/10 dark:text-gray-300 dark:hover:border-indigo-500 dark:hover:bg-indigo-900/20"
|
|
>
|
|
<.icon name="hero-moon" class="h-6 w-6" />
|
|
{t("Dark")}
|
|
<span class="text-xs font-normal text-gray-400 dark:text-gray-500">
|
|
{t("Always dark")}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</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">
|
|
{t("Email Address")}
|
|
</h2>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
"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">
|
|
{t("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={t("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"
|
|
>
|
|
{t("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">
|
|
{t("Change Password")}
|
|
</h2>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
"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"
|
|
>
|
|
{t("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">
|
|
{t("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"
|
|
>
|
|
{t("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={t("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"
|
|
>
|
|
{t("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">
|
|
{t("API Tokens")}
|
|
</h2>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
"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">
|
|
{t("No API tokens")}
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("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 {ToweropsWeb.TimeHelpers.format_date(
|
|
token.last_used_at,
|
|
@current_scope.timezone
|
|
)}
|
|
<% else %>
|
|
{t("Never used")}
|
|
<% end %>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
phx-click="delete_api_token"
|
|
phx-value-token-id={token.id}
|
|
data-confirm={
|
|
t(
|
|
"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"
|
|
>
|
|
{t("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">
|
|
{t("Mobile Devices")}
|
|
</h2>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
{t("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">
|
|
{t("No mobile devices")}
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("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 {ToweropsWeb.TimeHelpers.format_date(
|
|
session.last_used_at,
|
|
@current_scope.timezone
|
|
)}
|
|
</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"
|
|
>
|
|
{t("Toggle")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
phx-click="revoke_mobile_device"
|
|
phx-value-session-id={session.id}
|
|
data-confirm={t("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"
|
|
>
|
|
{t("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">
|
|
{t("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. {t(
|
|
"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">
|
|
{t("Active Sessions")}
|
|
</h2>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
{t("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">
|
|
{t("No active sessions")}
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("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="flex items-start gap-x-3 min-w-0 flex-1">
|
|
<div class="flex-shrink-0 mt-1">
|
|
<%= if String.contains?(String.downcase(session.user_agent || ""), ["mobile", "android", "iphone", "ipad"]) do %>
|
|
<.icon name="hero-device-phone-mobile" class="h-6 w-6 text-gray-400" />
|
|
<% else %>
|
|
<.icon name="hero-computer-desktop" class="h-6 w-6 text-gray-400" />
|
|
<% end %>
|
|
</div>
|
|
<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">
|
|
{t("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>
|
|
</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
|
|
}
|
|
>
|
|
{t("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">
|
|
{t("Login History")}
|
|
</h2>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
{t("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">
|
|
{t("No login history")}
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("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"
|
|
>
|
|
{t("Date & Time")}
|
|
</th>
|
|
<th
|
|
scope="col"
|
|
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
|
>
|
|
{t("Status")}
|
|
</th>
|
|
<th
|
|
scope="col"
|
|
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
|
>
|
|
{t("Method")}
|
|
</th>
|
|
<th
|
|
scope="col"
|
|
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
|
>
|
|
{t("Location")}
|
|
</th>
|
|
<th
|
|
scope="col"
|
|
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
|
|
>
|
|
{t("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,
|
|
@current_scope.time_format
|
|
)}
|
|
</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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("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">
|
|
{t("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={t("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">
|
|
{t("Authenticator Apps")}
|
|
</h2>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
"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">
|
|
{t("No authenticator apps")}
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("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 {ToweropsWeb.TimeHelpers.format_date(
|
|
device.inserted_at,
|
|
@current_scope.timezone
|
|
)}
|
|
</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
|
|
}
|
|
>
|
|
{t("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">
|
|
{t("Recovery Codes")}
|
|
</h2>
|
|
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
|
{t("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">
|
|
{t("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">
|
|
{t("No recovery codes available")}
|
|
</span>
|
|
- Generate new codes immediately
|
|
<% else %>
|
|
{t("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={
|
|
t("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">
|
|
{t("No Recovery Codes")}
|
|
</h3>
|
|
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
|
<p>
|
|
{t(
|
|
"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"
|
|
>
|
|
{t("Revoke All Other Sessions?")}
|
|
</h3>
|
|
<div class="mt-2">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
"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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("Create API Token")}
|
|
</h3>
|
|
<div class="mt-2">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
"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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("API Token Created")}
|
|
</h3>
|
|
<div class="mt-2">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
{t("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>{t("Copy")}</span>
|
|
</button>
|
|
</div>
|
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
|
{t("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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("Scan QR Code")}
|
|
</h3>
|
|
<div class="mt-4 text-left">
|
|
<div class="space-y-3">
|
|
<div class="flex items-start gap-3">
|
|
<span class="flex-shrink-0 flex h-6 w-6 items-center justify-center rounded-full bg-indigo-100 text-xs font-bold text-indigo-600 dark:bg-indigo-900 dark:text-indigo-400">
|
|
1
|
|
</span>
|
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
{t(
|
|
"Install an authenticator app (Google Authenticator, Authy, 1Password, etc.)"
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<span class="flex-shrink-0 flex h-6 w-6 items-center justify-center rounded-full bg-indigo-100 text-xs font-bold text-indigo-600 dark:bg-indigo-900 dark:text-indigo-400">
|
|
2
|
|
</span>
|
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
{t("Scan the QR code below with your authenticator app")}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<span class="flex-shrink-0 flex h-6 w-6 items-center justify-center rounded-full bg-indigo-100 text-xs font-bold text-indigo-600 dark:bg-indigo-900 dark:text-indigo-400">
|
|
3
|
|
</span>
|
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
{t("Enter the 6-digit code from your app to verify setup")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</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">
|
|
{t("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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("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"
|
|
>
|
|
{t("Recovery Codes Generated")}
|
|
</h3>
|
|
<div class="mt-2">
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
{t("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">
|
|
{t("Important")}
|
|
</h3>
|
|
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
|
<p>
|
|
{t(
|
|
"These codes won't be shown again. Store them securely (password manager, encrypted file, etc.)."
|
|
)}
|
|
{t(
|
|
"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"
|
|
>
|
|
{t("I've Saved These Codes")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</Layouts.authenticated>
|