Packages

phoenix_kit

1.7.205
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 permissions_matrix.ex
Raw

lib/phoenix_kit_web/live/users/permissions_matrix.ex

defmodule PhoenixKitWeb.Live.Users.PermissionsMatrix do
@moduledoc """
Interactive permissions matrix view for PhoenixKit admin panel.
Displays a matrix of roles vs module permission keys, showing which
roles have access to which sections. Owner column shows "always" badge.
Cells are clickable to toggle permissions directly.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.Admin.Events
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Users.Permissions
alias PhoenixKit.Users.Roles
def mount(_params, _session, socket) do
if connected?(socket) do
Events.subscribe_to_roles()
Events.subscribe_to_permissions()
Events.subscribe_to_modules()
end
project_title = Settings.get_project_title()
socket =
socket
|> assign(:page_title, gettext("Permissions"))
|> assign(:project_title, project_title)
|> load_matrix()
{:ok, socket}
end
# --- PubSub Handlers ---
def handle_info({:role_created, _role}, socket) do
{:noreply, load_matrix(socket)}
end
def handle_info({:role_updated, _role}, socket) do
{:noreply, load_matrix(socket)}
end
def handle_info({:role_deleted, _role}, socket) do
{:noreply, load_matrix(socket)}
end
def handle_info({:permission_granted, _role_uuid, _key}, socket) do
{:noreply, refresh_matrix(socket)}
end
def handle_info({:permission_revoked, _role_uuid, _key}, socket) do
{:noreply, refresh_matrix(socket)}
end
def handle_info({:permissions_synced, _role_uuid, _keys}, socket) do
{:noreply, refresh_matrix(socket)}
end
def handle_info({:module_enabled, _key}, socket) do
{:noreply, load_matrix(socket)}
end
def handle_info({:module_disabled, _key}, socket) do
{:noreply, load_matrix(socket)}
end
def handle_info(_msg, socket), do: {:noreply, socket}
# --- Events ---
def handle_event("toggle_permission", %{"role_uuid" => role_uuid, "key" => key}, socket) do
scope = socket.assigns[:phoenix_kit_current_scope]
with role when not is_nil(role) <-
Enum.find(socket.assigns.roles, &(to_string(&1.uuid) == role_uuid)),
:ok <- Permissions.can_edit_role_permissions?(scope, role),
true <- scope != nil && Scope.has_module_access?(scope, "users") do
grantable =
if Scope.owner?(scope),
do: MapSet.new(Permissions.all_module_keys()),
else: Scope.accessible_modules(scope)
role_key_set = Map.get(socket.assigns.matrix, to_string(role.uuid), MapSet.new())
# The editor must hold every key the toggle actually TOUCHES, not just the
# clicked one. Granting a sub auto-grants its base; revoking a base
# cascade-revokes the target's subs — so the editor must hold those subs
# too, or an editor without "calendar.edit_others" could strip it from a
# role by revoking the "calendar" base.
if MapSet.subset?(affected_keys(key, role_key_set), grantable) do
toggle_role_permission(socket, role, key, scope, grantable)
else
{:noreply, put_flash(socket, :error, gettext("You can only manage permissions you have"))}
end
else
{:error, reason} ->
{:noreply, put_flash(socket, :error, permission_error_message(reason))}
nil ->
{:noreply, put_flash(socket, :error, gettext("Role not found"))}
false ->
{:noreply,
put_flash(socket, :error, gettext("You don't have permission to manage permissions"))}
end
end
# --- Helpers ---
# Keys an editor's toggle of `key` actually affects, given the role's current
# keys. Grant (key absent) pulls in the base via `expand_with_parents`; revoke
# (key present) cascade-revokes the role's existing sub-keys of that base, so
# those must be authorized too. A sub-key has no children, so revoking it only
# affects itself.
defp affected_keys(key, role_key_set) do
if MapSet.member?(role_key_set, key) do
child_keys =
key
|> Permissions.sub_permissions_for()
|> Enum.map(& &1.key)
|> MapSet.new()
role_key_set
|> MapSet.intersection(child_keys)
|> MapSet.put(key)
else
Permissions.expand_with_parents([key])
end
end
defp toggle_role_permission(socket, role, key, scope, grantable) do
granted_by_uuid = Scope.user_uuid(scope)
role_uuid = to_string(role.uuid)
role_keys = Map.get(socket.assigns.matrix, role_uuid, MapSet.new())
label = Permissions.module_label(key)
if MapSet.member?(role_keys, key) do
# Authoritative, race-free revoke authorization: the context re-checks the
# role's actual held keys under a lock (the cached matrix above can be
# stale). `:unauthorized` means a concurrently-granted sub-key would have
# been cascaded — refresh so the editor sees the current state.
case Permissions.revoke_permission(role_uuid, key, authorized_keys: grantable) do
:ok ->
{:noreply,
socket
|> put_flash(
:info,
gettext("Revoked %{label} from %{role_name}",
label: label,
role_name: role.name
)
)
|> refresh_matrix()}
{:error, :unauthorized} ->
{:noreply,
socket
|> put_flash(:error, gettext("You can only manage permissions you have"))
|> refresh_matrix()}
{:error, _reason} ->
{:noreply,
socket
|> put_flash(
:error,
gettext("Failed to revoke %{label} from %{role_name}",
label: label,
role_name: role.name
)
)
|> refresh_matrix()}
end
else
case Permissions.grant_permission(role_uuid, key, granted_by_uuid) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(
:info,
gettext("Granted %{label} to %{role_name}",
label: label,
role_name: role.name
)
)
|> refresh_matrix()}
{:error, _reason} ->
{:noreply,
socket
|> put_flash(
:error,
gettext("Failed to grant %{label} to %{role_name}",
label: label,
role_name: role.name
)
)
|> refresh_matrix()}
end
end
end
defp load_matrix(socket) do
roles = Roles.list_roles()
matrix = Permissions.get_permissions_matrix()
all_count = length(Permissions.all_module_keys())
# Sort: Owner first, then by permission count descending, then name
sorted_roles =
Enum.sort_by(roles, fn role ->
count =
if role.name == "Owner",
do: all_count,
else: Map.get(matrix, to_string(role.uuid), MapSet.new()) |> MapSet.size()
{role.name != "Owner", -count, role.name}
end)
enabled = Permissions.enabled_module_keys()
enabled_feature_keys =
Enum.filter(Permissions.feature_module_keys(), &MapSet.member?(enabled, &1))
# Sub-permissions render as indented rows under their (enabled) module row
sub_permissions =
enabled_feature_keys
|> Enum.map(&{&1, Permissions.sub_permissions_for(&1)})
|> Enum.reject(fn {_key, subs} -> subs == [] end)
|> Map.new()
enabled_sub_keys =
sub_permissions |> Map.values() |> List.flatten() |> Enum.map(& &1.key)
core_keys = Permissions.core_section_keys()
custom_keys = Permissions.custom_keys()
visible_keys =
MapSet.new(core_keys ++ enabled_feature_keys ++ enabled_sub_keys ++ custom_keys)
# Determine which roles can't be edited by the current user
scope = socket.assigns[:phoenix_kit_current_scope]
uneditable_role_uuids =
sorted_roles
|> Enum.filter(fn role ->
role.name == "Owner" or Permissions.can_edit_role_permissions?(scope, role) != :ok
end)
|> MapSet.new(fn role -> to_string(role.uuid) end)
socket
|> assign(:roles, sorted_roles)
|> assign(:matrix, matrix)
|> assign(:core_keys, core_keys)
|> assign(:feature_keys, enabled_feature_keys)
|> assign(:sub_permissions, sub_permissions)
|> assign(:custom_keys, custom_keys)
|> assign(:visible_keys, visible_keys)
|> assign(:uneditable_role_uuids, uneditable_role_uuids)
end
# Refresh matrix data only, keep existing role order stable
defp refresh_matrix(socket) do
matrix = Permissions.get_permissions_matrix()
assign(socket, :matrix, matrix)
end
# --- Template Components ---
attr :key, :string, required: true
attr :label, :string, required: true
attr :sub, :boolean, default: false
attr :roles, :list, required: true
attr :matrix, :map, required: true
attr :uneditable_role_uuids, :any, required: true
# One matrix row: label cell + a toggle/read-only cell per role.
# `sub` renders the indented variant used for sub-permission keys.
defp permission_row(assigns) do
~H"""
<tr>
<td class={[
"sticky left-0 z-[1] whitespace-nowrap",
(@sub && "pl-8 text-sm text-base-content/80") || "font-medium"
]}>
<.icon
:if={@sub}
name="hero-arrow-turn-down-right"
class="w-3.5 h-3.5 text-base-content/40 mr-1"
/>{@label}
</td>
<td :for={role <- @roles} class="text-center">
<%= if role.name == "Owner" do %>
<span class="badge badge-sm badge-primary badge-outline">
{gettext("always")}
</span>
<% else %>
<%= if MapSet.member?(@uneditable_role_uuids, to_string(role.uuid)) do %>
<%!-- Read-only: user's own role or Admin (non-Owner) --%>
<%= if granted?(@matrix, role, @key) do %>
<.icon name="hero-check-circle" class="w-5 h-5 text-success/50" />
<% else %>
<.icon name="hero-x-circle" class="w-5 h-5 text-base-content/10" />
<% end %>
<% else %>
<button
phx-click="toggle_permission"
phx-value-role_uuid={role.uuid}
phx-value-key={@key}
class="cursor-pointer hover:opacity-70 transition-opacity"
>
<%= if granted?(@matrix, role, @key) do %>
<.icon name="hero-check-circle" class="w-5 h-5 text-success" />
<% else %>
<.icon name="hero-x-circle" class="w-5 h-5 text-base-content/20" />
<% end %>
</button>
<% end %>
<% end %>
</td>
</tr>
"""
end
defp granted?(matrix, role, key) do
Map.get(matrix, to_string(role.uuid), MapSet.new()) |> MapSet.member?(key)
end
defp permission_error_message(:not_authenticated), do: gettext("Not authenticated")
defp permission_error_message(:owner_immutable),
do: gettext("Owner role always has full access and cannot be modified")
defp permission_error_message(:self_role),
do: gettext("You cannot edit permissions for your own role")
defp permission_error_message(:admin_owner_only),
do: gettext("Only the Owner can edit Admin permissions")
defp permission_error_message(_), do: gettext("Permission denied")
end