Packages

phoenix_kit

2.0.0
2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web live users sessions.ex
Raw

lib/phoenix_kit_web/live/users/sessions.ex

defmodule PhoenixKitWeb.Live.Users.Sessions do
@moduledoc """
Live component for managing active user sessions in the PhoenixKit admin panel.
This module provides functionality to:
- View all active sessions across the system
- See session details including user info, creation time, and age
- Revoke individual sessions
- Revoke all sessions for a specific user
- View session statistics
Only accessible to users with Owner or Admin roles.
"""
use PhoenixKitWeb, :live_view
# Search, filter and page live in the query string, so a filtered list is a
# real URL: shareable, reload-proof, and Back returns to the previous query
# instead of leaving the page. `filter_user_status` defaults to "all", which
# is therefore what gets omitted from the URL.
use PhoenixKitWeb.Live.UrlState,
params: [
search_query: [default: "", url_key: "q"],
filter_user_status: [default: "all", url_key: "status"],
page: [default: 1, cast: :integer, min: 1]
]
alias PhoenixKit.Admin.Events
alias PhoenixKit.Settings
alias PhoenixKit.Users.{Auth, Sessions}
alias PhoenixKit.Utils.Date, as: UtilsDate
@per_page 20
def mount(params, _session, socket) do
# Set locale for LiveView process
locale =
params["locale"] || socket.assigns[:current_locale]
# Subscribe to session events for real-time updates
if connected?(socket) do
Events.subscribe_to_sessions()
end
# Get project title from settings
project_title = Settings.get_project_title()
# :page, :search_query and :filter_user_status are assigned from the
# query string by UrlState before mount/3 runs — re-assigning them here
# would overwrite a shared link's state with the defaults.
socket =
socket
|> assign(:per_page, @per_page)
|> assign(:page_title, gettext("Session Management"))
|> assign(:project_title, project_title)
|> assign(:current_locale, locale)
|> assign(:show_revoke_modal, false)
|> assign(:selected_session, nil)
|> assign(:revoke_type, nil)
|> load_stats()
{:ok, socket}
end
# The list is loaded here rather than in mount/3: UrlState calls this after
# mount and on every change to the query string, so one code path serves the
# first render, a shared link, and the Back button alike.
#
# Deliberately not annotated with @impl — a single @impl anywhere in a module
# makes Elixir demand it on every other callback too, and this LiveView's
# mount/handle_event/handle_params/handle_info carry none.
def handle_url_state(_state, socket), do: load_sessions(socket)
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
# `replace: true` — the box is debounced, so a typed-out query would
# otherwise leave one history entry per pause and Back would walk the search
# string backwards instead of leaving the page.
def handle_event("search", %{"search" => search_query}, socket) do
{:noreply, push_url_state(socket, [search_query: search_query], replace: true)}
end
def handle_event("filter_by_user_status", %{"status" => status}, socket) do
{:noreply, push_url_state(socket, filter_user_status: status)}
end
def handle_event("change_page", %{"page" => page}, socket) do
case Integer.parse(page) do
{page, ""} when page > 0 -> {:noreply, push_url_state(socket, page: page)}
_ -> {:noreply, socket}
end
end
def handle_event("show_revoke_session", %{"token_uuid" => token_uuid}, socket) do
session_info = Sessions.get_session_info(token_uuid)
socket =
socket
|> assign(:selected_session, session_info)
|> assign(:revoke_type, :single)
|> assign(:show_revoke_modal, true)
{:noreply, socket}
end
def handle_event("show_revoke_user_sessions", %{"user_uuid" => user_uuid}, socket) do
user = Auth.get_user!(user_uuid)
user_sessions = Sessions.list_user_sessions(user)
socket =
socket
|> assign(:selected_user, user)
|> assign(:user_sessions_count, length(user_sessions))
|> assign(:revoke_type, :user_all)
|> assign(:show_revoke_modal, true)
{:noreply, socket}
end
def handle_event("hide_revoke_modal", _params, socket) do
socket =
socket
|> assign(:show_revoke_modal, false)
|> assign(:selected_session, nil)
|> assign(:selected_user, nil)
|> assign(:revoke_type, nil)
{:noreply, socket}
end
def handle_event("confirm_revoke_session", _params, socket) do
case socket.assigns.revoke_type do
:single ->
handle_single_session_revoke(socket)
:user_all ->
handle_user_sessions_revoke(socket)
_ ->
{:noreply, put_flash(socket, :error, gettext("Invalid revoke operation"))}
end
end
def handle_event("refresh_sessions", _params, socket) do
socket =
socket
|> load_sessions()
|> load_stats()
|> put_flash(:info, gettext("Sessions refreshed successfully"))
{:noreply, socket}
end
defp handle_single_session_revoke(socket) do
session = socket.assigns.selected_session
case Sessions.revoke_session(session.token_uuid) do
:ok ->
socket =
socket
|> put_flash(:info, gettext("Session revoked successfully"))
|> assign(:show_revoke_modal, false)
|> assign(:selected_session, nil)
|> assign(:revoke_type, nil)
|> load_sessions()
|> load_stats()
{:noreply, socket}
{:error, :not_found} ->
socket =
socket
|> put_flash(:error, gettext("Session not found or already expired"))
|> assign(:show_revoke_modal, false)
|> load_sessions()
{:noreply, socket}
end
end
defp handle_user_sessions_revoke(socket) do
user = socket.assigns.selected_user
revoked_count = Sessions.revoke_user_sessions(user)
socket =
socket
|> put_flash(
:info,
ngettext(
"1 session revoked for %{email}",
"%{count} sessions revoked for %{email}",
revoked_count,
email: user.email
)
)
|> assign(:show_revoke_modal, false)
|> assign(:selected_user, nil)
|> assign(:revoke_type, nil)
|> load_sessions()
|> load_stats()
{:noreply, socket}
end
defp load_sessions(socket) do
sessions = Sessions.list_active_sessions()
# Apply filtering
filtered_sessions =
sessions
|> filter_by_search(socket.assigns.search_query)
|> filter_by_user_status(socket.assigns.filter_user_status)
# Apply pagination
total_count = length(filtered_sessions)
total_pages = div(total_count + @per_page - 1, @per_page)
page = max(1, min(socket.assigns.page, total_pages))
offset = (page - 1) * @per_page
paginated_sessions =
filtered_sessions
|> Enum.drop(offset)
|> Enum.take(@per_page)
socket
|> assign(:sessions, paginated_sessions)
|> assign(:total_count, total_count)
|> assign(:total_pages, total_pages)
|> assign(:page, page)
end
defp load_stats(socket) do
stats = Sessions.get_session_stats()
socket
|> assign(:stats, stats)
end
defp filter_by_search(sessions, ""), do: sessions
defp filter_by_search(sessions, query) do
query_lower = String.downcase(query)
Enum.filter(sessions, fn session ->
String.contains?(String.downcase(session.user_email), query_lower) ||
String.contains?(String.downcase(session.token_preview), query_lower)
end)
end
defp filter_by_user_status(sessions, "all"), do: sessions
defp filter_by_user_status(sessions, "active") do
Enum.filter(sessions, & &1.user_is_active)
end
defp filter_by_user_status(sessions, "inactive") do
Enum.filter(sessions, &(!&1.user_is_active))
end
defp filter_by_user_status(sessions, "confirmed") do
Enum.filter(sessions, &(!is_nil(&1.user_confirmed_at)))
end
defp filter_by_user_status(sessions, "pending") do
Enum.filter(sessions, &is_nil(&1.user_confirmed_at))
end
## Live Event Handlers for Sessions
def handle_info({:session_created, _user, _token_info}, socket) do
socket =
socket
|> load_sessions()
|> load_stats()
{:noreply, socket}
end
def handle_info({:session_revoked, _token_uuid}, socket) do
socket =
socket
|> load_sessions()
|> load_stats()
{:noreply, socket}
end
def handle_info({:user_sessions_revoked, _user_uuid, _count}, socket) do
socket =
socket
|> load_sessions()
|> load_stats()
{:noreply, socket}
end
def handle_info({:sessions_stats_updated, stats}, socket) do
socket =
socket
|> assign(:stats, stats)
{:noreply, socket}
end
@doc """
Readable "Browser · OS" for a session row, or an "Unknown device" fallback
for sessions created before the device name was captured (pre-V148).
"""
def device_name(session) do
[session.browser, session.os]
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|> Enum.join(" · ")
|> case do
"" -> gettext("Unknown device")
label -> label
end
end
end