0 && "px-3 text-gray-500 dark:text-gray-400",
@row_click && "cursor-pointer"
]}
>
{render_slot(col, @row_item.(row))}
-
+
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex
index d6b85096..36e220fb 100644
--- a/lib/towerops_web/components/layouts.ex
+++ b/lib/towerops_web/components/layouts.ex
@@ -176,22 +176,34 @@ defmodule ToweropsWeb.Layouts do
type="button"
id="org-menu-button"
phx-click={JS.toggle(to: "#org-menu")}
- class="flex items-center gap-2 text-sm font-medium text-zinc-700 hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-100"
+ class="inline-flex items-center justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
>
{if @current_organization, do: @current_organization.name, else: "Menu"}
- <.icon name="hero-chevron-down" class="h-4 w-4" />
+
+
+
<.link
:if={@current_organization}
navigate={~p"/orgs"}
- class="block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
+ class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
Switch Org
@@ -199,7 +211,7 @@ defmodule ToweropsWeb.Layouts do
<.link
:if={@current_organization}
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
- class="flex items-center gap-2 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
+ class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-cog-6-tooth" class="w-4 h-4" /> Organization Settings
@@ -207,7 +219,7 @@ defmodule ToweropsWeb.Layouts do
<%= if @current_scope && @current_scope.user && @current_scope.user.is_superuser do %>
<.link
navigate={~p"/admin"}
- class="flex items-center gap-2 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
+ class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-shield-check" class="w-4 h-4" /> Admin Panel
@@ -215,7 +227,7 @@ defmodule ToweropsWeb.Layouts do
<% end %>
<.link
navigate={~p"/users/settings"}
- class="flex items-center gap-2 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
+ class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-user-circle" class="w-4 h-4" /> User Settings
@@ -223,7 +235,7 @@ defmodule ToweropsWeb.Layouts do
<.link
href={~p"/users/log-out"}
method="delete"
- class="block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
+ class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
>
Log out
diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex
index b3d89ac7..a007b570 100644
--- a/lib/towerops_web/live/user_settings_live.ex
+++ b/lib/towerops_web/live/user_settings_live.ex
@@ -17,6 +17,11 @@ defmodule ToweropsWeb.UserSettingsLive do
|> assign_changesets()
|> assign_credentials()
|> assign_mobile_sessions()
+ |> assign_api_tokens()
+ |> assign_organizations()
+ |> assign_default_organization()
+ |> assign(:show_token_modal, false)
+ |> assign(:created_token, nil)
{:ok, socket}
end
@@ -101,6 +106,62 @@ defmodule ToweropsWeb.UserSettingsLive do
end
end
+ @impl true
+ def handle_event("create_api_token", %{"name" => name, "organization_id" => org_id}, socket) do
+ user = socket.assigns.current_scope.user
+
+ case Towerops.ApiTokens.create_api_token(%{
+ name: name,
+ organization_id: org_id,
+ user_id: user.id
+ }) do
+ {:ok, {_token, raw_token}} ->
+ socket =
+ socket
+ |> assign(:created_token, raw_token)
+ |> assign(:show_token_modal, true)
+ |> assign_api_tokens()
+
+ {:noreply, socket}
+
+ {:error, _changeset} ->
+ {:noreply, put_flash(socket, :error, "Failed to create API token.")}
+ end
+ end
+
+ @impl true
+ def handle_event("close_token_modal", _params, socket) do
+ socket =
+ socket
+ |> assign(:show_token_modal, false)
+ |> assign(:created_token, nil)
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("delete_api_token", %{"token-id" => token_id}, socket) do
+ token = Towerops.ApiTokens.get_api_token!(token_id)
+ user = socket.assigns.current_scope.user
+
+ if token.user_id == user.id do
+ case Towerops.ApiTokens.delete_api_token(token) do
+ {:ok, _} ->
+ socket =
+ socket
+ |> put_flash(:info, "API token deleted successfully.")
+ |> assign_api_tokens()
+
+ {:noreply, socket}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Failed to delete API token.")}
+ end
+ else
+ {:noreply, put_flash(socket, :error, "You can only delete your own tokens.")}
+ end
+ end
+
defp assign_changesets(socket) do
user = socket.assigns.current_scope.user
@@ -122,296 +183,676 @@ defmodule ToweropsWeb.UserSettingsLive do
assign(socket, :mobile_sessions, MobileSessions.list_user_sessions(user.id))
end
+ defp assign_api_tokens(socket) do
+ user = socket.assigns.current_scope.user
+ assign(socket, :api_tokens, Towerops.ApiTokens.list_user_api_tokens(user.id))
+ end
+
+ defp assign_organizations(socket) do
+ user = socket.assigns.current_scope.user
+ assign(socket, :organizations, Towerops.Organizations.list_user_organizations(user.id))
+ end
+
+ defp assign_default_organization(socket) do
+ organizations = socket.assigns.organizations
+ default_org = List.first(organizations)
+ assign(socket, :default_organization, default_org)
+ end
+
@impl true
def render(assigns) do
~H"""
-
-
- <.header>
+
+
+
Account Settings
- <:subtitle>Manage your account email address and password settings
-
+
+
+ Manage your account email address, password, and security settings
+
- <.form
- for={@email_form}
- phx-submit="update_email"
- id="update_email"
- class="space-y-4"
- >
- <.input
- field={@email_form[:email]}
- type="email"
- label="Email"
- autocomplete="email"
- required
- />
-
- <.button variant="primary" phx-disable-with="Changing...">Change Email
-
-
-
-
- <.form
- for={@password_form}
- phx-submit="update_password"
- id="update_password"
- class="space-y-4"
- >
- <.input
- field={@password_form[:password]}
- type="password"
- label="New password"
- autocomplete="new-password"
- required
- />
- <.input
- field={@password_form[:password_confirmation]}
- type="password"
- label="Confirm new password"
- autocomplete="new-password"
- required
- />
- <.button variant="primary" phx-disable-with="Changing...">
- Save Password
-
-
-
-
-
-
-
- Alert Notification Devices
-
-
- Manage mobile devices that receive push notifications for alerts
-
-
-
- <%= if Enum.empty?(@mobile_sessions) do %>
-
- <.icon name="hero-device-phone-mobile" class="mx-auto h-12 w-12 text-zinc-400" />
-
- No mobile devices registered
-
-
- Add a mobile device to receive push notifications for alerts
-
-
- <% else %>
-
- <%= for session <- @mobile_sessions do %>
-
-
- <.icon name="hero-device-phone-mobile" class="h-5 w-5 text-zinc-400" />
-
-
- {session.device_name || "Unknown Device"}
-
-
- {session.device_os} • {session.app_version}
-
-
- Last used {Calendar.strftime(session.last_used_at, "%B %d, %Y")}
-
-
-
-
-
- <%= if session.alerts_enabled do %>
- <.icon name="hero-bell" class="h-4 w-4" /> Alerts On
- <% else %>
- <.icon name="hero-bell-slash" class="h-4 w-4" /> Alerts Off
- <% end %>
-
-
- Remove
-
-
-
- <% end %>
-
- <% end %>
-
- <.link
- navigate={~p"/mobile/qr-login"}
- class="inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-300"
- >
- <.icon name="hero-qr-code" class="h-4 w-4" /> Add Mobile Device
-
-
-
-
-
-
-
-
- Passkeys
-
-
- Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in
-
-
- <%= if !@can_register_passkey do %>
-
-
- Please confirm your email address before registering a passkey. Check your inbox for the confirmation link.
+
+
+
+
+
Email Address
+
+ Update your email address. You'll receive a confirmation link to verify the new address.
- <% end %>
-
- <%= if Enum.empty?(@credentials) do %>
-
- <.icon name="hero-key" class="mx-auto h-12 w-12 text-zinc-400" />
-
- No passkeys registered
-
-
- Add a passkey to enable quick, secure sign-in with biometrics
-
+ <.form
+ for={@email_form}
+ phx-submit="update_email"
+ id="update_email"
+ class="md:col-span-2"
+ >
+
+
+
+ Email address
+
+
+
+
+
- <% else %>
-
- <%= for credential <- @credentials do %>
-
-
- <.icon name="hero-key" class="h-5 w-5 text-zinc-400" />
-
-
- {credential.name}
-
- <%= if credential.last_used_at do %>
-
- Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")}
-
- <% else %>
-
Never used
- <% end %>
-
-
- <.link
- href={~p"/users/credentials/#{credential.id}"}
- method="delete"
- data-confirm="Are you sure you want to delete this passkey?"
- class="text-sm font-semibold text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300"
+
+
+
+ Change Email
+
+
+
+
+
+
+
+
+
Change Password
+
+ Update your password. You'll be logged out and need to sign in again with your new password.
+
+
+
+ <.form
+ for={@password_form}
+ phx-submit="update_password"
+ id="update_password"
+ class="md:col-span-2"
+ >
+
+
+
+ New password
+
+
+
+
+
+
+
+
+ Confirm password
+
+
+
+
+
+
+
+
+
+ Save Password
+
+
+
+
+
+
+
+
API Tokens
+
+ Create API tokens to access Towerops programmatically. Tokens are scoped to a specific organization.
+
+
+
+
+ <%= if Enum.empty?(@api_tokens) do %>
+
+ <.icon name="hero-code-bracket" class="mx-auto h-12 w-12 text-gray-400" />
+
+ No API tokens
+
+
+ Get started by creating a new API token.
+
+
+
- Delete
+ <.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> New Token
+
+
+
+ <% else %>
+
+ <%= for token <- @api_tokens do %>
+
+
+
+
+
{token.organization.name}
+
+
+
+
+ <%= if token.last_used_at do %>
+ Last used {Calendar.strftime(token.last_used_at, "%B %d, %Y")}
+ <% else %>
+ Never used
+ <% end %>
+
+
+
+
+ Delete
+
+
+ <% end %>
+
+
+
+
+ <.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> New Token
+
+
+ <% end %>
+
+
+
+
+
+
+
+ Alert Notification Devices
+
+
+ Manage mobile devices that receive push notifications for alerts.
+
+
+
+
+ <%= if Enum.empty?(@mobile_sessions) do %>
+
+ <.icon name="hero-device-phone-mobile" class="mx-auto h-12 w-12 text-gray-400" />
+
+ No mobile devices
+
+
+ Add a mobile device to receive push notifications for alerts.
+
+
+ <.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
- <% end %>
-
- <% end %>
-
- <%= if @can_register_passkey do %>
-
- <.icon name="hero-plus" class="h-4 w-4" /> Add Passkey
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- <.icon name="hero-key" class="h-6 w-6 text-blue-600 dark:text-blue-400" />
+
+ <% else %>
+
+ <%= for session <- @mobile_sessions do %>
+
+
+
+
+ {session.device_name || "Unknown Device"}
+
+ <%= if session.alerts_enabled do %>
+
+ <.icon name="hero-bell" class="h-3 w-3" /> Alerts On
+
+ <% else %>
+
+ <.icon name="hero-bell-slash" class="h-3 w-3" /> Alerts Off
+
+ <% end %>
+
+
+
{session.device_os} • {session.app_version}
+
+
+
+
Last used {Calendar.strftime(session.last_used_at, "%B %d, %Y")}
+
-
-
+
- Add Passkey
-
-
-
- Give this passkey a name to help you identify it later (e.g., "MacBook Touch ID", "iPhone").
+ Toggle
+
+
+ Remove
+
+
+
+ <% end %>
+
+
+
+ <.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
+
+
+ <% end %>
+
+
+
+
+
+
+
Passkeys
+
+ Use your device's biometrics (Face ID, Touch ID, Windows Hello) or security keys to sign in.
+
+
+
+
+ <%= if !@can_register_passkey do %>
+
+
+ Please confirm your email address before registering a passkey. Check your inbox for the confirmation link.
+
+
+ <% end %>
+
+ <%= if Enum.empty?(@credentials) do %>
+
+ <.icon name="hero-key" class="mx-auto h-12 w-12 text-gray-400" />
+
No passkeys
+
+ Add a passkey to enable quick, secure sign-in with biometrics.
+
+ <%= if @can_register_passkey do %>
+
+
+ <.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> Add Passkey
+
+
+ <% end %>
+
+ <% else %>
+
+ <%= for credential <- @credentials do %>
+
+
+
-
-
-
-
-
+
+ <%= if credential.last_used_at do %>
+ Last used {Calendar.strftime(credential.last_used_at, "%B %d, %Y")}
+ <% else %>
+ Never used
+ <% end %>
-
-
-
- Continue
-
-
+
+ <% end %>
+
+
+ <%= if @can_register_passkey do %>
+
+
+ <.icon name="hero-plus" class="-ml-0.5 h-5 w-5" /> Add Passkey
+
+
+ <% end %>
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <.icon name="hero-code-bracket" class="h-6 w-6 text-blue-600 dark:text-blue-400" />
+
+
+
+ Create API Token
+
+
+
+ Give this token a name and select the organization it will have access to.
+
+
+
+
+
+
- Cancel
-
+ <%= for org <- @organizations do %>
+ {org.name}
+ <% end %>
+
+
+
+
- <% end %>
+
+
+ Create Token
+
+
+ Cancel
+
+
+
+
+
+
+
+ <%= if @show_token_modal && @created_token do %>
+
+
+
+
+
+
+
+
+
+
+
+ <.icon name="hero-check" class="h-6 w-6 text-green-600 dark:text-green-400" />
+
+
+
+ API Token Created
+
+
+
+ Copy this token now. You won't be able to see it again.
+
+
+
+
+
+ {@created_token}
+
+
+
+
+
+
+
+ Done
+
+
+
+
+
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <.icon name="hero-key" class="h-6 w-6 text-blue-600 dark:text-blue-400" />
+
+
+
+ Add Passkey
+
+
+
+ Give this passkey a name to help you identify it later (e.g., "MacBook Touch ID", "iPhone").
+
+
+
+
+
+
+
+
+
+
+ Continue
+
+
+ Cancel
+
+
+
+
+
"""
end
end
diff --git a/lib/towerops_web/plugs/require_superuser.ex b/lib/towerops_web/plugs/require_superuser.ex
deleted file mode 100644
index 973f7c68..00000000
--- a/lib/towerops_web/plugs/require_superuser.ex
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule ToweropsWeb.Plugs.RequireSuperuser do
- @moduledoc """
- Plug that requires the current user to be a superuser.
-
- Must be used after authentication plug that sets :current_user assign.
- Returns 403 Forbidden if user is not a superuser.
- """
-
- import Phoenix.Controller, only: [json: 2]
- import Plug.Conn
-
- def init(opts), do: opts
-
- def call(conn, _opts) do
- user = conn.assigns[:current_user]
-
- if user && user.is_superuser do
- conn
- else
- conn
- |> put_status(:forbidden)
- |> json(%{error: "Superuser access required"})
- |> halt()
- end
- end
-end
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index 2865ec19..75f70b7f 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -16,6 +16,7 @@ defmodule ToweropsWeb.Router do
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
+ plug :store_return_to_for_liveview
end
pipeline :api do
diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex
index de46db6b..b020a8a2 100644
--- a/lib/towerops_web/user_auth.ex
+++ b/lib/towerops_web/user_auth.ex
@@ -185,12 +185,17 @@ defmodule ToweropsWeb.UserAuth do
#
defp renew_session(conn, _user) do
delete_csrf_token()
+ user_return_to = get_session(conn, :user_return_to)
conn
|> configure_session(renew: true)
|> clear_session()
+ |> maybe_restore_return_to(user_return_to)
end
+ defp maybe_restore_return_to(conn, nil), do: conn
+ defp maybe_restore_return_to(conn, path), do: put_session(conn, :user_return_to, path)
+
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
do: write_remember_me_cookie(conn, token)
@@ -292,6 +297,22 @@ defmodule ToweropsWeb.UserAuth do
defp maybe_store_return_to(conn), do: conn
+ @doc """
+ Plug to store the current path for LiveView routes.
+
+ This runs in the pipeline before LiveView mount, allowing us to store
+ the return path in the session before on_mount callbacks run. Only stores
+ if there's no existing return path to avoid overwriting.
+ """
+ def store_return_to_for_liveview(conn, _opts) do
+ # Only store for GET requests and only if there's no existing return path
+ if conn.method == "GET" && is_nil(get_session(conn, :user_return_to)) do
+ put_session(conn, :user_return_to, current_path(conn))
+ else
+ conn
+ end
+ end
+
@doc """
Plug to load the current organization from the URL slug.