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:
Graham McIntire 2026-01-18 10:46:43 -06:00
parent 243c687d35
commit b781246abd
No known key found for this signature in database
2 changed files with 161 additions and 149 deletions

View file

@ -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

View file

@ -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"
>
&#8203;
</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"
>
&#8203;
</span>
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-zinc-900 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
<.icon name="hero-code-bracket" class="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100"
id="modal-title"
>
Create API Token
</h3>
<div class="mt-2">
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Give this token a name and select the organization it will have access to.
</p>
</div>
<div class="mt-4 space-y-4">
<input
type="text"
id="api-token-name-input"
placeholder="e.g., Production API"
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
/>
<div class="grid grid-cols-1">
<select
id="api-token-org-select"
class="col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-1.5 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500"
<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