convert API token modal to LiveView and add timezone support
- Replace JavaScript modal handlers with LiveView event bindings - Add phx-click events for show_add_token_modal and cancel_add_token - Convert plain HTML form to LiveView form with phx-submit - Remove outdated window.liveSocket.execJS JavaScript code - Add timezone parameter support to TimeHelpers functions: - format_datetime/2 now accepts timezone and shifts DateTime - format_date/2 now accepts timezone and shifts DateTime - format_iso8601/2 now accepts timezone and shifts DateTime - All helper functions fallback to UTC if timezone is invalid or nil
This commit is contained in:
parent
243c687d35
commit
b781246abd
2 changed files with 161 additions and 149 deletions
|
|
@ -54,53 +54,93 @@ defmodule ToweropsWeb.TimeHelpers do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Formats a DateTime into a full date and time string.
|
||||
Formats a DateTime into a full date and time string, converted to the given timezone.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> datetime = ~U[2026-01-15 14:34:00Z]
|
||||
iex> ToweropsWeb.TimeHelpers.format_datetime(datetime)
|
||||
iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "America/New_York")
|
||||
"Jan 15, 2026 at 09:34 AM EST"
|
||||
|
||||
iex> datetime = ~U[2026-01-15 14:34:00Z]
|
||||
iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "UTC")
|
||||
"Jan 15, 2026 at 02:34 PM UTC"
|
||||
|
||||
"""
|
||||
@spec format_datetime(DateTime.t() | nil) :: String.t()
|
||||
def format_datetime(nil), do: "Never"
|
||||
@spec format_datetime(DateTime.t() | nil, String.t() | nil) :: String.t()
|
||||
def format_datetime(nil, _timezone), do: "Never"
|
||||
|
||||
def format_datetime(datetime) do
|
||||
Calendar.strftime(datetime, "%b %d, %Y at %I:%M %p %Z")
|
||||
def format_datetime(datetime, nil), do: format_datetime(datetime, "UTC")
|
||||
|
||||
def format_datetime(datetime, timezone) when is_binary(timezone) do
|
||||
case DateTime.shift_zone(datetime, timezone) do
|
||||
{:ok, shifted_dt} ->
|
||||
Calendar.strftime(shifted_dt, "%b %d, %Y at %I:%M %p %Z")
|
||||
|
||||
{:error, _reason} ->
|
||||
# Fallback to UTC if timezone is invalid
|
||||
Calendar.strftime(datetime, "%b %d, %Y at %I:%M %p UTC")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats a DateTime into a short date string.
|
||||
Formats a DateTime into a short date string, converted to the given timezone.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> datetime = ~U[2026-01-15 14:34:00Z]
|
||||
iex> ToweropsWeb.TimeHelpers.format_date(datetime)
|
||||
iex> ToweropsWeb.TimeHelpers.format_date(datetime, "UTC")
|
||||
"Jan 15, 2026"
|
||||
|
||||
"""
|
||||
@spec format_date(DateTime.t() | nil) :: String.t()
|
||||
def format_date(nil), do: "Never"
|
||||
iex> datetime = ~U[2026-01-15 02:00:00Z]
|
||||
iex> ToweropsWeb.TimeHelpers.format_date(datetime, "America/Los_Angeles")
|
||||
"Jan 14, 2026"
|
||||
|
||||
def format_date(datetime) do
|
||||
Calendar.strftime(datetime, "%b %d, %Y")
|
||||
"""
|
||||
@spec format_date(DateTime.t() | nil, String.t() | nil) :: String.t()
|
||||
def format_date(nil, _timezone), do: "Never"
|
||||
|
||||
def format_date(datetime, nil), do: format_date(datetime, "UTC")
|
||||
|
||||
def format_date(datetime, timezone) when is_binary(timezone) do
|
||||
case DateTime.shift_zone(datetime, timezone) do
|
||||
{:ok, shifted_dt} ->
|
||||
Calendar.strftime(shifted_dt, "%b %d, %Y")
|
||||
|
||||
{:error, _reason} ->
|
||||
# Fallback to UTC if timezone is invalid
|
||||
Calendar.strftime(datetime, "%b %d, %Y")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats a DateTime into ISO 8601 format with UTC timezone.
|
||||
Formats a DateTime into ISO 8601 format, converted to the given timezone.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> datetime = ~U[2026-01-15 14:34:00Z]
|
||||
iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime)
|
||||
iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "UTC")
|
||||
"2026-01-15 14:34:00 UTC"
|
||||
|
||||
"""
|
||||
@spec format_iso8601(DateTime.t() | nil) :: String.t()
|
||||
def format_iso8601(nil), do: "Never"
|
||||
iex> datetime = ~U[2026-01-15 14:34:00Z]
|
||||
iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "America/New_York")
|
||||
"2026-01-15 09:34:00 EST"
|
||||
|
||||
def format_iso8601(datetime) do
|
||||
Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S UTC")
|
||||
"""
|
||||
@spec format_iso8601(DateTime.t() | nil, String.t() | nil) :: String.t()
|
||||
def format_iso8601(nil, _timezone), do: "Never"
|
||||
|
||||
def format_iso8601(datetime, nil), do: format_iso8601(datetime, "UTC")
|
||||
|
||||
def format_iso8601(datetime, timezone) when is_binary(timezone) do
|
||||
case DateTime.shift_zone(datetime, timezone) do
|
||||
{:ok, shifted_dt} ->
|
||||
tz_abbr = shifted_dt.zone_abbr || timezone
|
||||
Calendar.strftime(shifted_dt, "%Y-%m-%d %H:%M:%S #{tz_abbr}")
|
||||
|
||||
{:error, _reason} ->
|
||||
# Fallback to UTC if timezone is invalid
|
||||
Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S UTC")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
|> assign_api_tokens()
|
||||
|> assign_organizations()
|
||||
|> assign_default_organization()
|
||||
|> assign(:show_add_token_modal, false)
|
||||
|> assign(:show_token_modal, false)
|
||||
|> assign(:created_token, nil)
|
||||
|
||||
|
|
@ -33,6 +34,16 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
{:noreply, assign(socket, :active_tab, tab)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("show_add_token_modal", _params, socket) do
|
||||
{:noreply, assign(socket, :show_add_token_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_add_token", _params, socket) do
|
||||
{:noreply, assign(socket, :show_add_token_modal, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_profile", %{"user" => user_params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
|
@ -132,7 +143,7 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("create_api_token", %{"name" => name, "organization_id" => org_id}, socket) do
|
||||
def handle_event("create_api_token", %{"token" => %{"name" => name, "organization_id" => org_id}}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
case Towerops.ApiTokens.create_api_token(%{
|
||||
|
|
@ -143,6 +154,7 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
{:ok, {_token, raw_token}} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:show_add_token_modal, false)
|
||||
|> assign(:created_token, raw_token)
|
||||
|> assign(:show_token_modal, true)
|
||||
|> assign_api_tokens()
|
||||
|
|
@ -633,7 +645,7 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
<div class="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
id="add-api-token-btn"
|
||||
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
|
||||
|
|
@ -680,7 +692,7 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
<div class="mt-6 flex">
|
||||
<button
|
||||
type="button"
|
||||
id="add-api-token-btn"
|
||||
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
|
||||
|
|
@ -881,95 +893,100 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
</div>
|
||||
|
||||
<!-- API Token Creation Modal -->
|
||||
<div
|
||||
id="api-token-modal"
|
||||
class="hidden fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
|
||||
<div
|
||||
class="fixed inset-0 z-0 bg-zinc-500 bg-opacity-75 transition-opacity dark:bg-zinc-950 dark:bg-opacity-75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</span>
|
||||
<%= 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-zinc-500 bg-opacity-75 transition-opacity dark:bg-zinc-950 dark:bg-opacity-75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</span>
|
||||
|
||||
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-zinc-900 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
||||
<div>
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
|
||||
<.icon name="hero-code-bracket" class="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-5">
|
||||
<h3
|
||||
class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100"
|
||||
id="modal-title"
|
||||
>
|
||||
Create API Token
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Give this token a name and select the organization it will have access to.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
id="api-token-name-input"
|
||||
placeholder="e.g., Production API"
|
||||
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
|
||||
/>
|
||||
<div class="grid grid-cols-1">
|
||||
<select
|
||||
id="api-token-org-select"
|
||||
class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-1.5 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
|
||||
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-zinc-900 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
|
||||
<.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-zinc-900 dark:text-zinc-100"
|
||||
id="modal-title"
|
||||
>
|
||||
<%= 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"
|
||||
Create API Token
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Give this token a name and select the organization it will have access to.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
<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>
|
||||
</div>
|
||||
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
id="confirm-create-api-token"
|
||||
class="inline-flex w-full justify-center rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed sm:col-start-2"
|
||||
>
|
||||
Create Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="cancel-create-api-token"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-zinc-900 shadow-sm ring-1 ring-inset ring-zinc-300 hover:bg-zinc-50 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700 dark:hover:bg-zinc-700 sm:col-start-1 sm:mt-0"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div 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-zinc-900 shadow-sm ring-1 ring-inset ring-zinc-300 hover:bg-zinc-50 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700 dark:hover:bg-zinc-700 sm:col-start-1 sm:mt-0"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Token Created Success Modal -->
|
||||
<%= if @show_token_modal && @created_token do %>
|
||||
|
|
@ -1101,51 +1118,6 @@ defmodule ToweropsWeb.UserSettingsLive do
|
|||
</div>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
|
||||
<script>
|
||||
// API Token Modal
|
||||
const addTokenBtn = document.getElementById('add-api-token-btn');
|
||||
const tokenModal = document.getElementById('api-token-modal');
|
||||
const confirmCreateToken = document.getElementById('confirm-create-api-token');
|
||||
const cancelCreateToken = document.getElementById('cancel-create-api-token');
|
||||
const tokenNameInput = document.getElementById('api-token-name-input');
|
||||
const tokenOrgSelect = document.getElementById('api-token-org-select');
|
||||
|
||||
if (addTokenBtn) {
|
||||
addTokenBtn.addEventListener('click', () => {
|
||||
tokenModal.classList.remove('hidden');
|
||||
tokenNameInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelCreateToken) {
|
||||
cancelCreateToken.addEventListener('click', () => {
|
||||
tokenModal.classList.add('hidden');
|
||||
tokenNameInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmCreateToken) {
|
||||
confirmCreateToken.addEventListener('click', () => {
|
||||
const name = tokenNameInput.value.trim();
|
||||
const orgId = tokenOrgSelect.value;
|
||||
|
||||
if (name && orgId) {
|
||||
window.liveSocket.execJS(confirmCreateToken, [["push", {"event": "create_api_token", "value": {"name": name, "organization_id": orgId}}]]);
|
||||
tokenModal.classList.add('hidden');
|
||||
tokenNameInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenNameInput) {
|
||||
tokenNameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
confirmCreateToken.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue