Packages
phoenix_kit
2.22.3
2.22.5
2.22.4
2.22.3
2.22.2
2.22.1
2.22.0
2.21.5
2.21.4
2.21.3
2.21.2
2.21.1
2.21.0
2.20.0
2.19.0
2.18.1
2.18.0
2.17.0
2.16.0
2.15.1
2.15.0
2.14.2
2.14.1
2.14.0
2.13.19
2.13.18
2.13.17
2.13.16
2.13.15
2.13.13
2.13.12
2.13.11
2.13.10
2.13.9
2.13.8
2.13.7
2.13.6
2.13.5
2.13.4
2.13.3
2.13.2
2.13.1
2.13.0
2.12.1
2.12.0
2.11.0
2.10.0
2.9.0
2.8.1
2.8.0
2.7.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
Current section
Files
lib/phoenix_kit_web/live/users/roles.ex
defmodule PhoenixKitWeb.Live.Users.Roles do
@moduledoc """
Role management LiveView for PhoenixKit admin panel.
Provides interface for viewing and managing user roles and permissions.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
# Page and rows-per-page live in the query string, so a page of the list is
# a real URL: shareable, reload-proof, and Back returns to the previous
# page. `per_page` is allowlisted rather than bounded: an arbitrary integer
# out of the URL is a LIMIT clause.
use PhoenixKitWeb.Live.UrlState,
params: [
page: [default: 1, cast: :integer, min: 1],
per_page: [default: 25, cast: :integer, in: [10, 25, 50, 100]]
]
alias PhoenixKit.Admin.Events
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Users.Permissions
alias PhoenixKit.Users.{Role, Roles}
def mount(_params, _session, socket) do
# Attach locale hook for automatic locale handling
# Subscribe to role events for live updates
if connected?(socket) do
Events.subscribe_to_roles()
Events.subscribe_to_stats()
Events.subscribe_to_permissions()
Events.subscribe_to_modules()
end
# Load optimized role statistics once
role_stats = load_role_statistics()
# Get project title from settings
project_title = Settings.get_project_title()
# :page and :per_page are assigned from the query string by UrlState
# before mount/3 runs; the list itself is loaded in handle_url_state/2.
socket =
socket
|> assign(:roles, [])
|> assign(:show_create_form, false)
|> assign(:show_edit_form, false)
|> assign(:create_role_form, nil)
|> assign(:edit_role_form, nil)
|> assign(:editing_role, nil)
|> assign(:delete_confirmation, %{show: false})
|> assign(:show_permissions_editor, false)
|> assign(:permissions_role, nil)
|> assign(:permissions_role_keys, MapSet.new())
|> assign(:permissions_grantable_keys, MapSet.new())
|> assign(:permissions_preserved_keys, MapSet.new())
|> assign(:page_title, gettext("Role Management"))
|> assign(:role_stats, role_stats)
|> assign(:project_title, project_title)
|> assign(:can_manage_permissions, false)
{: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_info carry none.
def handle_url_state(_state, socket), do: load_roles(socket)
# `push_url_state` resets the page along with the size: page 3 of 25 rows
# is not page 3 of 100.
def handle_event("change_per_page", %{"per_page" => per_page}, socket) do
case Integer.parse(per_page) do
{per_page, ""} -> {:noreply, push_url_state(socket, per_page: per_page)}
_ -> {:noreply, socket}
end
end
def handle_event("show_create_form", _params, socket) do
form = to_form(Role.changeset(%Role{}, %{}))
socket =
socket
|> assign(:show_create_form, true)
|> assign(:create_role_form, form)
{:noreply, socket}
end
def handle_event("show_edit_role", %{"role_uuid" => role_uuid}, socket) do
role = find_role_by_uuid(socket.assigns.roles, role_uuid)
if role && !role.is_system_role do
form = to_form(Role.changeset(role, %{}))
socket =
socket
|> assign(:show_edit_form, true)
|> assign(:edit_role_form, form)
|> assign(:editing_role, role)
{:noreply, socket}
else
socket = put_flash(socket, :error, gettext("System roles cannot be edited"))
{:noreply, socket}
end
end
def handle_event("hide_form", _params, socket) do
socket =
socket
|> assign(:show_create_form, false)
|> assign(:show_edit_form, false)
|> assign(:create_role_form, nil)
|> assign(:edit_role_form, nil)
|> assign(:editing_role, nil)
{:noreply, socket}
end
def handle_event("create_role", %{"role" => role_params}, socket) do
case Roles.create_role(role_params) do
{:ok, role} ->
flash_msg =
if can_manage_permissions?(socket),
do:
gettext(
"Role \"%{role_name}\" created. Click Permissions to configure access.",
role_name: role.name
),
else: gettext("Role created successfully")
socket =
socket
|> put_flash(:info, flash_msg)
|> assign(:show_create_form, false)
|> assign(:create_role_form, nil)
|> load_roles()
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, :create_role_form, to_form(changeset))}
end
end
def handle_event("update_role", %{"role" => role_params}, socket) do
editing_role = socket.assigns.editing_role
if is_nil(editing_role) do
{:noreply,
socket
|> put_flash(:error, gettext("Role no longer exists"))
|> assign(:show_edit_form, false)
|> assign(:edit_role_form, nil)
|> assign(:editing_role, nil)}
else
case Roles.update_role(editing_role, role_params) do
{:ok, _role} ->
socket =
socket
|> put_flash(:info, gettext("Role updated successfully"))
|> assign(:show_edit_form, false)
|> assign(:edit_role_form, nil)
|> assign(:editing_role, nil)
|> load_roles()
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, :edit_role_form, to_form(changeset))}
end
end
end
# New events for confirmation modal
def handle_event(
"request_delete_role",
%{"role_uuid" => role_uuid, "role_name" => role_name},
socket
) do
delete_confirmation = %{
show: true,
role_uuid: role_uuid,
role_name: role_name
}
{:noreply, assign(socket, :delete_confirmation, delete_confirmation)}
end
def handle_event("cancel_delete", _params, socket) do
{:noreply, assign(socket, :delete_confirmation, %{show: false})}
end
def handle_event("confirm_delete_role", _params, socket) do
role_uuid = Map.get(socket.assigns.delete_confirmation, :role_uuid)
if is_nil(role_uuid) do
{:noreply, assign(socket, :delete_confirmation, %{show: false})}
else
# Close modal first
socket = assign(socket, :delete_confirmation, %{show: false})
# Execute the deletion
handle_delete_role(role_uuid, socket)
end
end
# Keep old handler for backward compatibility
def handle_event("delete_role", %{"role_uuid" => role_uuid}, socket) do
handle_delete_role(role_uuid, socket)
end
# --- Permission Editor Events ---
def handle_event("show_permissions_editor", %{"role_uuid" => role_uuid}, socket) do
if can_manage_permissions?(socket) do
role = find_role_by_uuid(socket.assigns.roles, role_uuid)
scope = socket.assigns[:phoenix_kit_current_scope]
with true <- role != nil,
:ok <- Permissions.can_edit_role_permissions?(scope, role) do
grantable = grantable_keys(socket)
enabled = Permissions.enabled_module_keys()
# Only show keys that are both grantable AND enabled
displayable = MapSet.intersection(grantable, enabled)
current_keys = Permissions.get_permissions_for_role(role.uuid) |> MapSet.new()
editable_checked = MapSet.intersection(current_keys, displayable)
# Preserve: keys outside displayable set (not grantable OR disabled modules)
preserved = MapSet.difference(current_keys, displayable)
socket =
socket
|> assign(:show_permissions_editor, true)
|> assign(:permissions_role, role)
|> assign(:permissions_role_keys, editable_checked)
|> assign(:permissions_grantable_keys, displayable)
|> assign(:permissions_preserved_keys, preserved)
{:noreply, socket}
else
{:error, reason} ->
{:noreply, put_flash(socket, :error, reason)}
false ->
{:noreply, put_flash(socket, :error, gettext("Role not found"))}
end
else
{:noreply,
put_flash(socket, :error, gettext("You don't have permission to manage permissions"))}
end
end
def handle_event("hide_permissions_editor", _params, socket) do
socket =
socket
|> assign(:show_permissions_editor, false)
|> assign(:permissions_role, nil)
|> assign(:permissions_role_keys, MapSet.new())
|> assign(:permissions_grantable_keys, MapSet.new())
|> assign(:permissions_preserved_keys, MapSet.new())
{:noreply, socket}
end
def handle_event("toggle_permission", %{"key" => key}, socket) do
if can_manage_permissions?(socket) do
grantable = socket.assigns.permissions_grantable_keys
keys = socket.assigns.permissions_role_keys
cond do
# Unchecking: drop the key and (for a base module key) the
# sub-permissions it carries — a sub-key must not outlive its module.
MapSet.member?(keys, key) and MapSet.member?(grantable, key) ->
implied_subs = key |> Permissions.sub_permissions_for() |> Enum.map(& &1.key)
new_keys = MapSet.difference(keys, MapSet.new([key | implied_subs]))
{:noreply, assign(socket, :permissions_role_keys, new_keys)}
# Checking: pull in the base key a sub-permission implies. The editor
# must hold EVERY key the check implies, not just the clicked one —
# otherwise holding only the sub would smuggle in a base grant.
not MapSet.member?(keys, key) and
MapSet.subset?(Permissions.expand_with_parents([key]), grantable) ->
new_keys = MapSet.union(keys, Permissions.expand_with_parents([key]))
{:noreply, assign(socket, :permissions_role_keys, new_keys)}
true ->
{:noreply,
put_flash(socket, :error, gettext("You can only manage permissions you have"))}
end
else
{:noreply,
put_flash(socket, :error, gettext("You don't have permission to manage permissions"))}
end
end
def handle_event("grant_all_permissions", _params, socket) do
if can_manage_permissions?(socket) do
{:noreply,
assign(socket, :permissions_role_keys, socket.assigns.permissions_grantable_keys)}
else
{:noreply,
put_flash(socket, :error, gettext("You don't have permission to manage permissions"))}
end
end
def handle_event("revoke_all_permissions", _params, socket) do
if can_manage_permissions?(socket) do
{:noreply, assign(socket, :permissions_role_keys, MapSet.new())}
else
{:noreply,
put_flash(socket, :error, gettext("You don't have permission to manage permissions"))}
end
end
def handle_event("save_permissions", _params, socket) do
role = socket.assigns.permissions_role
cond do
is_nil(role) ->
{:noreply, put_flash(socket, :error, gettext("No role selected"))}
!can_manage_permissions?(socket) ->
{:noreply,
put_flash(socket, :error, gettext("You don't have permission to manage permissions"))}
Permissions.can_edit_role_permissions?(socket.assigns[:phoenix_kit_current_scope], role) !=
:ok ->
{:noreply,
put_flash(socket, :error, gettext("You cannot edit permissions for this role"))}
true ->
preserved = socket.assigns.permissions_preserved_keys
editor_keys = socket.assigns.permissions_role_keys
final_keys = MapSet.union(preserved, editor_keys) |> MapSet.to_list()
scope = socket.assigns[:phoenix_kit_current_scope]
granted_by_uuid = if scope, do: Scope.user_uuid(scope), else: nil
case Permissions.set_permissions(role.uuid, final_keys, granted_by_uuid) do
:ok ->
socket =
socket
|> put_flash(
:info,
gettext("Permissions updated for %{role_name}", role_name: role.name)
)
|> assign(:show_permissions_editor, false)
|> assign(:permissions_role, nil)
|> assign(:permissions_role_keys, MapSet.new())
|> assign(:permissions_grantable_keys, MapSet.new())
|> assign(:permissions_preserved_keys, MapSet.new())
{:noreply, socket}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Failed to update permissions"))}
end
end
end
# Keep the old handler for backward compatibility and make it private
defp handle_delete_role(role_uuid, socket) when is_binary(role_uuid) do
role = find_role_by_uuid(socket.assigns.roles, role_uuid)
if role && !role.is_system_role do
case Roles.delete_role(role) do
{:ok, _role} ->
socket =
socket
|> put_flash(:info, gettext("Role deleted successfully"))
|> load_roles()
{:noreply, socket}
{:error, :role_in_use} ->
socket =
put_flash(
socket,
:error,
gettext("Cannot delete role: it is currently assigned to users")
)
{:noreply, socket}
{:error, _changeset} ->
socket = put_flash(socket, :error, gettext("Failed to delete role"))
{:noreply, socket}
end
else
socket = put_flash(socket, :error, gettext("System roles cannot be deleted"))
{:noreply, socket}
end
end
defp load_roles(socket) do
%{roles: roles, total_count: total_count, total_pages: total_pages, page: page} =
Roles.list_roles_paginated(page: socket.assigns.page, per_page: socket.assigns.per_page)
scope = socket.assigns[:phoenix_kit_current_scope]
uneditable_role_uuids =
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, roles)
|> assign(:total_count, total_count)
|> assign(:total_pages, total_pages)
|> assign(:page, page)
|> assign(:role_counts, Roles.count_roles())
|> assign(:uneditable_role_uuids, uneditable_role_uuids)
|> assign(:can_manage_permissions, can_manage_permissions?(socket))
end
# Load role statistics: count of users per role name
defp load_role_statistics do
Roles.list_roles()
|> Map.new(fn role -> {role.name, Roles.count_users_with_role(role.name)} end)
end
# Optimized function using cached statistics
## Live Event Handlers
def handle_info({:role_created, _role}, socket) do
socket =
socket
|> load_roles()
|> assign(:role_stats, load_role_statistics())
{:noreply, socket}
end
def handle_info({:role_updated, _role}, socket) do
socket =
socket
|> load_roles()
|> assign(:role_stats, load_role_statistics())
{:noreply, socket}
end
def handle_info({:role_deleted, role}, socket) do
socket =
socket
|> close_modals_for_deleted_role(role)
|> load_roles()
|> assign(:role_stats, load_role_statistics())
{:noreply, socket}
end
def handle_info({:stats_updated, _stats}, socket) do
socket =
socket
|> assign(:role_stats, load_role_statistics())
{:noreply, socket}
end
def handle_info({:permission_granted, role_uuid, _key}, socket) do
{:noreply, refresh_permissions_if_editing(socket, role_uuid)}
end
def handle_info({:permission_revoked, role_uuid, _key}, socket) do
{:noreply, refresh_permissions_if_editing(socket, role_uuid)}
end
def handle_info({:permissions_synced, role_uuid, _keys}, socket) do
{:noreply, refresh_permissions_if_editing(socket, role_uuid)}
end
def handle_info({:module_enabled, _key}, socket) do
{:noreply, refresh_permissions_if_editing_any(socket)}
end
def handle_info({:module_disabled, _key}, socket) do
{:noreply, refresh_permissions_if_editing_any(socket)}
end
def handle_info(_msg, socket), do: {:noreply, socket}
# --- Helpers ---
defp can_manage_permissions?(socket) do
scope = socket.assigns[:phoenix_kit_current_scope]
scope && Scope.has_module_access?(scope, "users")
end
defp grantable_keys(socket) do
scope = socket.assigns[:phoenix_kit_current_scope]
if scope && Scope.owner?(scope) do
MapSet.new(Permissions.all_module_keys())
else
Scope.accessible_modules(scope)
end
end
defp find_role_by_uuid(roles, uuid_str) when is_binary(uuid_str) do
Enum.find(roles, &(to_string(&1.uuid) == uuid_str))
end
defp close_modals_for_deleted_role(socket, deleted_role) do
socket
|> then(fn s ->
if s.assigns.show_permissions_editor &&
s.assigns.permissions_role &&
s.assigns.permissions_role.uuid == deleted_role.uuid do
s
|> assign(:show_permissions_editor, false)
|> assign(:permissions_role, nil)
|> assign(:permissions_role_keys, MapSet.new())
|> assign(:permissions_grantable_keys, MapSet.new())
|> assign(:permissions_preserved_keys, MapSet.new())
|> put_flash(
:info,
gettext("Role \"%{role_name}\" was deleted", role_name: deleted_role.name)
)
else
s
end
end)
|> then(fn s ->
if s.assigns.editing_role && s.assigns.editing_role.uuid == deleted_role.uuid do
s
|> assign(:show_edit_form, false)
|> assign(:edit_role_form, nil)
|> assign(:editing_role, nil)
|> put_flash(
:info,
gettext("Role \"%{role_name}\" was deleted", role_name: deleted_role.name)
)
else
s
end
end)
end
defp refresh_permissions_if_editing(socket, role_uuid) do
role = socket.assigns[:permissions_role]
if socket.assigns.show_permissions_editor && role &&
to_string(role.uuid) == to_string(role_uuid) do
reload_permission_editor_data(socket)
else
socket
end
end
defp refresh_permissions_if_editing_any(socket) do
if socket.assigns.show_permissions_editor && socket.assigns.permissions_role do
reload_permission_editor_data(socket)
else
socket
end
end
defp reload_permission_editor_data(socket) do
role = socket.assigns.permissions_role
grantable = grantable_keys(socket)
enabled = Permissions.enabled_module_keys()
displayable = MapSet.intersection(grantable, enabled)
current_keys = Permissions.get_permissions_for_role(role.uuid) |> MapSet.new()
editable_checked = MapSet.intersection(current_keys, displayable)
preserved = MapSet.difference(current_keys, displayable)
socket
|> assign(:permissions_grantable_keys, displayable)
|> assign(:permissions_role_keys, editable_checked)
|> assign(:permissions_preserved_keys, preserved)
end
end