Packages

phoenix_kit

2.4.0
2.6.0 2.5.0 2.4.0 2.3.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 dashboard overview.ex
Raw

lib/phoenix_kit_web/live/dashboard/overview.ex

defmodule PhoenixKitWeb.Live.Dashboard.Overview do
@moduledoc """
The LiveView half of the dashboard overview — the data and the message
handling that back `PhoenixKitWeb.Components.Core.DashboardOverview`.
Used by `/admin` (`PhoenixKitWeb.Live.Dashboard`), whose operator half the
overview is. The deprecated `/dashboard`
(`PhoenixKitWeb.Live.Dashboard.Index`) is a separate page and does NOT use
this.
## Gate the DATA, not only the markup
Hiding a card in HEEx is cosmetic — the operator aggregates would still have
run. `assign_scope_gates/1` therefore decides FIRST and works SECOND: a
visitor who may not see the statistics causes
* no `Roles.get_extended_stats/0`, `Sessions.get_session_stats/0` or
`Presence.get_presence_stats/0`,
* no migration-version read,
* and no `Events.subscribe_to_*` at all,
and the six statistics assigns are set to `nil`.
## One gate site, re-run on every scope change
Every scope-derived assign on this page — `:can_access_admin_area?`, the six
card gates, `:show_statistics`, the six statistics assigns — is computed in
`assign_scope_gates/1` and NOWHERE else. Mount calls it through
`assign_overview/3`; a mid-session permission change calls it through
`phoenix_kit_scope_changed/1`, the callback `use` injects and
`PhoenixKitWeb.Users.Auth`'s scope-refresh hook invokes right after it
reassigns `:phoenix_kit_current_scope`. A gate added to that function is
therefore recomputed for free, and cannot be the one that gets forgotten.
This matters because `/admin` is the guaranteed landing: a visitor is NOT
evicted from it when their rights change, so the page has to survive the
change in place. Without recomputation a revoked operator kept a card
pointing at a page that now rejects them, beside a sidebar (which re-derives
every render) that disagreed.
The statistics **subscriptions** move in both directions with the gate:
`sync_statistics_subscription/2` subscribes a visitor who gains the rights
mid-session and unsubscribes one who loses them. An unsubscribe, not just a
hidden card — a hidden card still costs three aggregates per broadcast.
`PhoenixKit.Admin.Events` had no unsubscribe counterpart before this page
needed one; it does now (`unsubscribe_from_stats/0` and friends), because
PhoenixKit runs its own PubSub instance and a caller cannot reach
`Phoenix.PubSub.unsubscribe/2` for it.
`refresh_statistics/1` re-derives the verdict from the CURRENT scope rather
than trusting `:show_statistics`, because `phx-click` events are
client-supplied — the button being hidden is not a guarantee that the event
cannot arrive.
## Visibility rules
| Block | Gate |
|---|---|
| Users / Roles / Sessions / Live Activity / Add User cards | `PhoenixKitWeb.Users.Auth.can_access_admin_view?/2` on each card's destination LiveView |
| Email card | the module being loaded and enabled, AND `can_access_admin_view?/2` on its admin LiveView |
| Platform Statistics, System Information, Refresh | `Scope.holds_all_enabled_permissions?/1` |
The card rule is derived, never restated: the card and the page it links to
ask the same function, so "a card is visible iff the visitor can open it"
holds by construction. The statistics have no destination LiveView to derive
from, so they use the role-agnostic "can reach everything" check — the same
posture the unmapped-admin-view fallback takes.
## Usage
use PhoenixKitWeb.Live.Dashboard.Overview
def mount(_params, session, socket) do
{:ok, Overview.assign_overview(socket, session, Routes.path("/admin"))}
end
`use` injects three things: `handle_event("refresh_stats", …)`, ONE
`handle_info/2` clause guarded by `is_overview_message/1`, and the
`phoenix_kit_scope_changed/1` callback described above.
That single guarded clause replaced nine shape-matched ones deliberately. A
socket can still be handed a message from a topic it has just left — the
unsubscribe and the broadcast race — and it will also see any FUTURE arity of
a message it does subscribe to. Nine exact-shape clauses turn either into an
unmatched `handle_info` and a crashed LiveView. The guard matches on the
message TAG alone and `apply_statistics_message/2` ends in a catch-all, so an
in-flight or reshaped message is a no-op rather than a crash — and, because
that function re-checks the gate first, never an operator query on behalf of
someone who just lost the permission.
The guard is deliberately narrow: it names ten tags and nothing else, so a
host LiveView keeps full control of every other message. It does mean any
additional `handle_event`/`handle_info` clauses must be defined together with
these (Elixir warns when clauses of one function are not grouped, and
`mix precommit` compiles with `--warnings-as-errors`).
The attrs are passed to the component one by one rather than via a
`Map.take/2` spread: an explicit attr keeps LiveView's change tracking, so a
presence update re-sends only the tile that changed instead of the whole
overview.
"""
use Gettext, backend: PhoenixKitWeb.Gettext
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [connected?: 1, get_connect_info: 2, put_flash: 3]
alias PhoenixKit.Admin.{Events, Presence}
alias PhoenixKit.Migrations.Postgres, as: Migrations
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Users.{Roles, Sessions}
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.IpAddress
alias PhoenixKitWeb.Users.Auth
# Assign name → the LiveView the card links to. The gate is the destination's
# own admin-view gate, so this list carries no permission knowledge of its own
# and cannot drift from what the destination enforces. Note `Add User` is
# `PhoenixKitWeb.Users.UserForm` — NOT under `Live.Users`.
@card_views [
show_users_card: PhoenixKitWeb.Live.Users.Users,
show_roles_card: PhoenixKitWeb.Live.Users.Roles,
show_sessions_card: PhoenixKitWeb.Live.Users.Sessions,
show_live_activity_card: PhoenixKitWeb.Live.Users.LiveSessions,
show_add_user_card: PhoenixKitWeb.Users.UserForm
]
# The Emails module ships as a separate package. Held as plain atoms (and
# called through `apply/3`) so core still compiles when it is absent.
@emails_module PhoenixKit.Modules.Emails
@emails_admin_view PhoenixKit.Modules.Emails.Web.Emails
# Assign tracking whether THIS socket currently holds the three statistics
# subscriptions. `Phoenix.PubSub` subscriptions are idempotent, but the
# unsubscribe direction is not knowable without it, and a bare
# "subscribe again on every scope change" would leave a revoked visitor
# subscribed forever.
@subscribed_assign :phoenix_kit_overview_subscribed?
# Every message tag the three statistics topics carry. The guard below matches
# on the tag ALONE — never on arity — so a broadcast that grows a field stays
# handled (as a no-op) instead of crashing the LiveView.
@overview_message_tags [
:stats_updated,
:sessions_stats_updated,
:session_created,
:session_revoked,
:user_sessions_revoked,
:presence_stats_updated,
:anonymous_session_connected,
:anonymous_session_disconnected,
:user_session_connected,
:user_session_disconnected
]
@doc """
Whether `message` is a broadcast from one of the three statistics topics.
Guards the single `handle_info/2` clause `use` injects. Tag-only by design:
see the "Usage" section of the module documentation for why matching the
full shape is a crash waiting for a downgrade or a schema change.
"""
defguard is_overview_message(message)
when is_tuple(message) and tuple_size(message) >= 1 and
elem(message, 0) in @overview_message_tags
@doc """
Whether `scope` may see the operator statistics — Platform Statistics, System
Information and the Refresh button.
These blocks have no destination LiveView to derive a gate from, so they use
the role-agnostic "holds every grantable permission" check rather than a role
name. `nil` scope → `false`.
"""
@spec statistics_visible?(Scope.t() | nil) :: boolean()
def statistics_visible?(scope), do: Scope.holds_all_enabled_permissions?(scope)
@doc """
Mount-time entry point: tracks the visitor's presence on `page_path`, then
computes every gate through `assign_scope_gates/1`.
`page_path` is the page the caller actually serves — it is reported to
`Presence` as the visitor's `current_page`, and the Live Activity page reads
it back, so it belongs to the caller rather than being hardcoded here. Pass
an already-resolved path (`PhoenixKit.Utils.Routes.path/1`).
Presence tracking is the one thing here that happens ONCE per mount rather
than on every scope change — it reports where the socket is, which a
permission change does not alter.
"""
@spec assign_overview(Phoenix.LiveView.Socket.t(), map(), String.t()) ::
Phoenix.LiveView.Socket.t()
def assign_overview(socket, session, page_path) do
if connected?(socket) do
track_authenticated_session(socket, session, page_path)
end
assign_scope_gates(socket)
end
@doc """
Computes EVERY scope-derived assign on the dashboard from
`:phoenix_kit_current_scope`, and reconciles the statistics subscriptions
with the verdict.
The single gate site. Called from `assign_overview/3` at mount and from
`phoenix_kit_scope_changed/1` whenever a permission change reassigns the
scope under a live socket, so the page never renders a gate that predates the
visitor's current rights. Idempotent: calling it with an unchanged scope
re-derives the same values and leaves the subscriptions alone.
"""
@spec assign_scope_gates(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
def assign_scope_gates(socket) do
scope = socket.assigns[:phoenix_kit_current_scope]
show_statistics = statistics_visible?(scope)
socket
|> sync_statistics_subscription(show_statistics)
|> assign_card_gates(scope)
# The page's coarsest gate: it drives the header subtitle and is what
# "this visitor sees the welcome block and nothing else" hangs off. It
# lives here rather than in the page's own `mount/3` so that there is
# exactly ONE function to add the next gate to.
|> assign(:can_access_admin_area?, Scope.can_access_admin_area?(scope))
|> assign(:show_statistics, show_statistics)
|> assign_statistics(show_statistics)
end
@doc """
Re-runs the operator aggregates behind the Refresh button.
Re-derives the verdict from the current scope rather than reading
`:show_statistics`: the button is hidden for everyone else, but a hidden
button does not stop a crafted `phx-click` from arriving, and these are
exactly the queries the gate exists to withhold. (The assign would answer the
same today — it is recomputed on every scope change — but a gate fed by
client-triggered input should not depend on that staying true.)
"""
@spec refresh_statistics(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
def refresh_statistics(socket) do
if statistics_visible?(socket.assigns[:phoenix_kit_current_scope]) do
socket
|> assign(:stats, Roles.get_extended_stats())
|> assign(:session_stats, Sessions.get_session_stats())
|> assign(:presence_stats, Presence.get_presence_stats())
|> put_flash(:info, gettext("Statistics refreshed successfully"))
else
socket
end
end
@doc """
Applies one statistics/session/presence broadcast to the socket.
The body of the single `handle_info/2` clause `use` injects. Two guarantees
a set of exact-shape `handle_info` clauses could not give:
* **it never crashes.** Any message the guard admits is handled — including
one whose shape changed and one that arrived after this socket left the
topic (unsubscribe races a broadcast already in flight). The fallback is
a stale tile, not a dead LiveView.
* **it never queries for someone who may not see the result.** The gate is
re-checked before anything runs, so a revoked operator's in-flight
messages cost nothing, and the three `reload_*` aggregates stop with the
permission rather than with the socket.
"""
@spec apply_statistics_message(Phoenix.LiveView.Socket.t(), tuple()) ::
Phoenix.LiveView.Socket.t()
def apply_statistics_message(socket, message) do
# `:show_statistics` is derived in exactly one place and recomputed on every
# scope change, so it is the current verdict — not the mount-time snapshot
# it used to be. Read here rather than re-deriving from the scope because
# presence broadcasts are frequent and `statistics_visible?/1` walks the
# enabled-module set.
if socket.assigns[:show_statistics] do
apply_visible_statistics_message(socket, message)
else
socket
end
end
defp apply_visible_statistics_message(socket, {:stats_updated, stats}),
do: assign(socket, :stats, stats)
defp apply_visible_statistics_message(socket, {:sessions_stats_updated, session_stats}),
do: assign(socket, :session_stats, session_stats)
defp apply_visible_statistics_message(socket, {:presence_stats_updated, presence_stats}),
do: assign(socket, :presence_stats, presence_stats)
# Individual session mutations (a new login, a single revoke, or a "revoke
# all/others") change the counts the dashboard shows but carry no aggregate of
# their own, so the tiles are re-read.
defp apply_visible_statistics_message(socket, {:session_created, _user, _token_info}),
do: reload_session_stats(socket)
defp apply_visible_statistics_message(socket, {:session_revoked, _token_uuid}),
do: reload_session_stats(socket)
defp apply_visible_statistics_message(socket, {:user_sessions_revoked, _user_uuid, _count}),
do: reload_session_stats(socket)
defp apply_visible_statistics_message(socket, {:anonymous_session_connected, _id, _metadata}),
do: reload_presence_stats(socket)
defp apply_visible_statistics_message(socket, {:anonymous_session_disconnected, _id}),
do: reload_presence_stats(socket)
defp apply_visible_statistics_message(socket, {:user_session_connected, _uuid, _metadata}),
do: reload_presence_stats(socket)
defp apply_visible_statistics_message(socket, {:user_session_disconnected, _uuid, _session_id}),
do: reload_presence_stats(socket)
# A tag we subscribe to, in a shape we do not know. Ignoring it leaves a tile
# one broadcast stale until the next one; matching nothing would end the
# LiveView.
defp apply_visible_statistics_message(socket, _message), do: socket
defp reload_session_stats(socket),
do: assign(socket, :session_stats, Sessions.get_session_stats())
defp reload_presence_stats(socket),
do: assign(socket, :presence_stats, Presence.get_presence_stats())
# Brings this socket's statistics subscriptions in line with
# `show_statistics`. Both directions matter and neither is the mount case:
#
# * a visitor GRANTED the rights mid-session subscribes here, so the tiles
# they can now see go live without a reload;
# * a visitor whose rights are REVOKED unsubscribes here — the messages stop
# arriving at all, rather than arriving and being dropped by a hidden card
# while three aggregates run per broadcast.
#
# Subscribing needs a connected socket (a dead render has no process worth
# subscribing); unsubscribing does not check, so a socket that holds the
# subscriptions always releases them.
#
# Exposed as `@doc false def` so both directions are unit-testable without a
# database — the granted direction otherwise reaches `assign_scope_gates/1`'s
# aggregates, which by definition need one. Not part of the public API.
@doc false
@spec sync_statistics_subscription(Phoenix.LiveView.Socket.t(), boolean()) ::
Phoenix.LiveView.Socket.t()
def sync_statistics_subscription(socket, show_statistics) do
subscribed? = socket.assigns[@subscribed_assign] == true
want? = show_statistics and connected?(socket)
cond do
want? and not subscribed? ->
Events.subscribe_to_stats()
Events.subscribe_to_sessions()
Events.subscribe_to_presence()
assign(socket, @subscribed_assign, true)
subscribed? and not want? ->
Events.unsubscribe_from_stats()
Events.unsubscribe_from_sessions()
Events.unsubscribe_from_presence()
assign(socket, @subscribed_assign, false)
true ->
assign(socket, @subscribed_assign, subscribed?)
end
end
defp assign_card_gates(socket, scope) do
socket =
Enum.reduce(@card_views, socket, fn {assign_key, view}, acc ->
assign(acc, assign_key, Auth.can_access_admin_view?(scope, view))
end)
assign(socket, :show_email_card, show_email_card?(scope))
end
# The module has to be present and switched on (the check this card has always
# carried), AND the visitor has to hold what its admin page requires.
#
# `apply/3` rather than a direct call (the form the template used, kept for
# the same reason): a compile-time remote call to a module core does not
# depend on warns "is not available or is yet to be defined", and
# `mix precommit` compiles with `--warnings-as-errors`. Binding the module to
# a variable first does NOT dodge it — the compiler constant-folds it back.
defp show_email_card?(scope) do
# credo:disable-for-next-line Credo.Check.Refactor.Apply
Code.ensure_loaded?(@emails_module) and apply(@emails_module, :enabled?, []) and
Auth.can_access_admin_view?(scope, @emails_admin_view)
end
defp assign_statistics(socket, true) do
socket
|> assign(:stats, Roles.get_extended_stats())
|> assign(:session_stats, Sessions.get_session_stats())
|> assign(:presence_stats, Presence.get_presence_stats())
|> assign(:phoenix_kit_version, to_string(Application.spec(:phoenix_kit, :vsn)))
|> assign(:migration_current, Migrations.current_version())
|> assign(:migration_db, Migrations.migrated_version_runtime(%{prefix: "public"}))
end
defp assign_statistics(socket, false) do
socket
|> assign(:stats, nil)
|> assign(:session_stats, nil)
|> assign(:presence_stats, nil)
|> assign(:phoenix_kit_version, nil)
|> assign(:migration_current, nil)
|> assign(:migration_db, nil)
end
defp track_authenticated_session(socket, session, page_path) do
scope = socket.assigns[:phoenix_kit_current_scope]
if scope && Scope.authenticated?(scope) do
# Create a user map for tracking (uuid required by SimplePresence)
user = %{uuid: Scope.user_uuid(scope), email: Scope.user_email(scope)}
session_id = session["live_socket_id"] || generate_session_id()
Presence.track_user(user, %{
connected_at: UtilsDate.utc_now(),
session_id: session_id,
ip_address: IpAddress.extract_from_socket(socket),
user_agent: get_connect_info(socket, :user_agent),
current_page: page_path
})
end
end
defp generate_session_id do
:crypto.strong_rand_bytes(16) |> Base.encode64()
end
@doc """
Injects the statistics event clause, the statistics message clause and the
scope-change callback into a dashboard LiveView.
See the module documentation for what this means for a host LiveView that
defines `handle_event/3` or `handle_info/2` clauses of its own.
"""
defmacro __using__(_opts) do
quote do
require unquote(__MODULE__)
# Invoked by `PhoenixKitWeb.Users.Auth`'s scope-refresh hook right after
# it reassigns `:phoenix_kit_current_scope`, so a permission change is
# reflected on the page the visitor is already sitting on. Exporting this
# function is the whole opt-in — the hook looks for nothing else.
@doc false
def phoenix_kit_scope_changed(socket) do
unquote(__MODULE__).assign_scope_gates(socket)
end
@impl true
def handle_event("refresh_stats", _params, socket) do
{:noreply, unquote(__MODULE__).refresh_statistics(socket)}
end
@impl true
def handle_info(message, socket)
when unquote(__MODULE__).is_overview_message(message) do
{:noreply, unquote(__MODULE__).apply_statistics_message(socket, message)}
end
end
end
end