Merge branch 'main' into feature/223_memberfields_settings
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
carla 2026-01-12 13:15:40 +01:00
commit 922f9f93d0
19 changed files with 2939 additions and 356 deletions

View file

@ -0,0 +1,237 @@
defmodule MvWeb.RoleLive.Form do
@moduledoc """
LiveView form for creating and editing roles.
## Features
- Create new roles
- Edit existing roles (name, description, permission_set_name)
- Custom dropdown for permission_set_name with badges
- Form validation
## Security
Only admins can access this page (enforced by authorization).
"""
use MvWeb, :live_view
alias Mv.Authorization.PermissionSets
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>{gettext("Use this form to manage roles in your database.")}</:subtitle>
</.header>
<.form class="max-w-xl" for={@form} id="role-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label={gettext("Name")} required />
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="3"
/>
<div class="form-control">
<label class="label" for="role-form_permission_set_name">
<span class="label-text font-semibold">
{gettext("Permission Set")}
<span class="text-red-700">*</span>
</span>
</label>
<select
class={[
"select select-bordered w-full",
@form.errors[:permission_set_name] && "select-error"
]}
name="role[permission_set_name]"
id="role-form_permission_set_name"
required
aria-label={gettext("Permission Set")}
>
<option value="">{gettext("Select permission set")}</option>
<%= for permission_set <- all_permission_sets() do %>
<option
value={permission_set}
selected={@form[:permission_set_name].value == permission_set}
>
{format_permission_set_option(permission_set)}
</option>
<% end %>
</select>
<%= if @form.errors[:permission_set_name] do %>
<%= for error <- List.wrap(@form.errors[:permission_set_name]) do %>
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{msg}
</p>
<% end %>
<% end %>
</div>
<div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Role")}
</.button>
<.button navigate={return_path(@return_to, @role)} type="button">
{gettext("Cancel")}
</.button>
</div>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
case params["id"] do
nil ->
action = gettext("New")
page_title = action <> " " <> gettext("Role")
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:role, nil)
|> assign(:page_title, page_title)
|> assign_form()}
id ->
try do
case Ash.get(
Mv.Authorization.Role,
id,
domain: Mv.Authorization,
actor: socket.assigns[:current_user]
) do
{:ok, role} ->
action = gettext("Edit")
page_title = action <> " " <> gettext("Role")
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:role, role)
|> assign(:page_title, page_title)
|> assign_form()}
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
{:ok,
socket
|> put_flash(:error, gettext("Role not found."))
|> redirect(to: ~p"/admin/roles")}
{:error, error} ->
{:ok,
socket
|> put_flash(:error, format_error(error))
|> redirect(to: ~p"/admin/roles")}
end
rescue
e in [Ash.Error.Invalid] ->
# Handle exceptions that Ash.get might throw (e.g., policy violations)
case e do
%Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]} ->
{:ok,
socket
|> put_flash(:error, gettext("Role not found."))
|> redirect(to: ~p"/admin/roles")}
_ ->
reraise e, __STACKTRACE__
end
end
end
end
@spec return_to(String.t() | nil) :: String.t()
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
@impl true
def handle_event("validate", %{"role" => role_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, role_params)
{:noreply, assign(socket, form: validated_form)}
end
def handle_event("save", %{"role" => role_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: role_params) do
{:ok, role} ->
notify_parent({:saved, role})
redirect_path =
if socket.assigns.return_to == "show" do
~p"/admin/roles/#{role.id}"
else
~p"/admin/roles"
end
socket =
socket
|> put_flash(:info, gettext("Role saved successfully."))
|> push_navigate(to: redirect_path)
{:noreply, socket}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
@spec notify_parent(any()) :: any()
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp assign_form(%{assigns: %{role: role, current_user: actor}} = socket) do
form =
if role do
AshPhoenix.Form.for_update(role, :update_role,
domain: Mv.Authorization,
as: "role",
actor: actor
)
else
AshPhoenix.Form.for_create(
Mv.Authorization.Role,
:create_role,
domain: Mv.Authorization,
as: "role",
actor: actor
)
end
assign(socket, form: to_form(form))
end
defp all_permission_sets do
PermissionSets.all_permission_sets() |> Enum.map(&Atom.to_string/1)
end
defp format_permission_set_option("own_data"),
do: gettext("own_data - Access only to own data")
defp format_permission_set_option("read_only"),
do: gettext("read_only - Read access to all data")
defp format_permission_set_option("normal_user"),
do: gettext("normal_user - Create/Read/Update access")
defp format_permission_set_option("admin"),
do: gettext("admin - Unrestricted access")
defp format_permission_set_option(set), do: set
@spec return_path(String.t(), Mv.Authorization.Role.t() | nil) :: String.t()
defp return_path("index", _role), do: ~p"/admin/roles"
defp return_path("show", role) when not is_nil(role), do: ~p"/admin/roles/#{role.id}"
defp return_path("show", _role), do: ~p"/admin/roles"
defp return_path(_, role) when not is_nil(role), do: ~p"/admin/roles/#{role.id}"
defp return_path(_, _role), do: ~p"/admin/roles"
end

View file

@ -0,0 +1,44 @@
defmodule MvWeb.RoleLive.Helpers do
@moduledoc """
Shared helper functions for RoleLive modules.
"""
use Gettext, backend: MvWeb.Gettext
@doc """
Formats an error for display to the user.
Extracts error messages from Ash.Error.Invalid and joins them.
"""
@spec format_error(Ash.Error.Invalid.t() | String.t() | any()) :: String.t()
def format_error(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
def format_error(error) when is_binary(error), do: error
def format_error(_error), do: gettext("An error occurred")
@doc """
Returns the CSS badge class for a permission set name.
"""
@spec permission_set_badge_class(String.t()) :: String.t()
def permission_set_badge_class("own_data"), do: "badge badge-neutral badge-sm"
def permission_set_badge_class("read_only"), do: "badge badge-info badge-sm"
def permission_set_badge_class("normal_user"), do: "badge badge-success badge-sm"
def permission_set_badge_class("admin"), do: "badge badge-error badge-sm"
def permission_set_badge_class(_), do: "badge badge-ghost badge-sm"
@doc """
Builds Ash options with actor and domain, ensuring actor is never nil in real paths.
"""
@spec opts_with_actor(keyword(), map() | nil, atom()) :: keyword()
def opts_with_actor(base_opts \\ [], actor, domain) do
opts = Keyword.put(base_opts, :domain, domain)
if actor do
Keyword.put(opts, :actor, actor)
else
require Logger
Logger.warning("opts_with_actor called with nil actor - this may bypass policies")
opts
end
end
end

View file

@ -0,0 +1,170 @@
defmodule MvWeb.RoleLive.Index do
@moduledoc """
LiveView for displaying and managing the role list.
## Features
- List all roles with name, description, permission_set_name, is_system_role
- Create new roles
- Navigate to role details and edit forms
- Delete non-system roles
## Events
- `delete` - Remove a role from the database (only non-system roles)
## Security
Only admins can access this page (enforced by authorization).
"""
use MvWeb, :live_view
alias Mv.Accounts
alias Mv.Authorization
require Ash.Query
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(_params, _session, socket) do
actor = socket.assigns[:current_user]
roles = load_roles(actor)
user_counts = load_user_counts(roles, actor)
{:ok,
socket
|> assign(:page_title, gettext("Listing Roles"))
|> assign(:roles, roles)
|> assign(:user_counts, user_counts)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
case Authorization.get_role(id, actor: socket.assigns.current_user) do
{:ok, role} ->
handle_delete_role(role, id, socket)
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Role not found.")
)}
{:error, error} ->
error_message = format_error(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
end
end
defp handle_delete_role(role, id, socket) do
if role.is_system_role do
{:noreply,
put_flash(
socket,
:error,
gettext("System roles cannot be deleted.")
)}
else
user_count = recalculate_user_count(role, socket.assigns.current_user)
if user_count > 0 do
{:noreply,
put_flash(
socket,
:error,
gettext(
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
count: user_count
)
)}
else
perform_role_deletion(role, id, socket)
end
end
end
defp perform_role_deletion(role, id, socket) do
case Authorization.destroy_role(role, actor: socket.assigns.current_user) do
:ok ->
updated_roles = Enum.reject(socket.assigns.roles, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.user_counts, id)
{:noreply,
socket
|> assign(:roles, updated_roles)
|> assign(:user_counts, updated_counts)
|> put_flash(:info, gettext("Role deleted successfully."))}
{:error, error} ->
error_message = format_error(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
end
end
@spec load_roles(map() | nil) :: [Mv.Authorization.Role.t()]
defp load_roles(actor) do
opts = if actor, do: [actor: actor], else: []
case Authorization.list_roles(opts) do
{:ok, roles} -> Enum.sort_by(roles, & &1.name)
{:error, _} -> []
end
end
# Loads all user counts for roles using DB-side aggregation for better performance
@spec load_user_counts([Mv.Authorization.Role.t()], map() | nil) :: %{
Ecto.UUID.t() => non_neg_integer()
}
defp load_user_counts(roles, _actor) do
role_ids = Enum.map(roles, & &1.id)
# Use Ecto directly for efficient GROUP BY COUNT query
# This is much more performant than loading all users and counting in Elixir
# Note: We bypass Ash here for performance, but this is a simple read-only query
import Ecto.Query
query =
from u in Accounts.User,
where: u.role_id in ^role_ids,
group_by: u.role_id,
select: {u.role_id, count(u.id)}
results = Mv.Repo.all(query)
results
|> Enum.into(%{}, fn {role_id, count} -> {role_id, count} end)
end
# Gets user count from preloaded assigns map
@spec get_user_count(Mv.Authorization.Role.t(), %{Ecto.UUID.t() => non_neg_integer()}) ::
non_neg_integer()
defp get_user_count(role, user_counts) do
Map.get(user_counts, role.id, 0)
end
# Recalculates user count for a specific role (used before deletion)
@spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
defp recalculate_user_count(role, actor) do
opts = opts_with_actor([], actor, Mv.Accounts)
case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do
{:ok, count} -> count
_ -> 0
end
end
end

View file

@ -0,0 +1,97 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Listing Roles")}
<:subtitle>
{gettext("Manage user roles and their permission sets.")}
</:subtitle>
<:actions>
<%= if can?(@current_user, :create, Mv.Authorization.Role) do %>
<.button variant="primary" navigate={~p"/admin/roles/new"}>
<.icon name="hero-plus" /> {gettext("New Role")}
</.button>
<% end %>
</:actions>
</.header>
<.table
id="roles"
rows={@roles}
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
>
<:col :let={role} label={gettext("Name")}>
<div class="flex items-center gap-2">
<span class="font-medium">{role.name}</span>
<%= if role.is_system_role do %>
<span class="badge badge-warning badge-sm">{gettext("System Role")}</span>
<% end %>
</div>
</:col>
<:col :let={role} label={gettext("Description")}>
<%= if role.description do %>
<span class="text-sm">{role.description}</span>
<% else %>
<span class="text-base-content/70">{gettext("No description")}</span>
<% end %>
</:col>
<:col :let={role} label={gettext("Permission Set")}>
<span class={permission_set_badge_class(role.permission_set_name)}>
{role.permission_set_name}
</span>
</:col>
<:col :let={role} label={gettext("Type")}>
<%= if role.is_system_role do %>
<span class="badge badge-warning badge-sm">{gettext("System")}</span>
<% else %>
<span class="badge badge-ghost badge-sm">{gettext("Custom")}</span>
<% end %>
</:col>
<:col :let={role} label={gettext("Users")}>
<span class="badge badge-ghost">{get_user_count(role, @user_counts)}</span>
</:col>
<:action :let={role}>
<div class="sr-only">
<.link navigate={~p"/admin/roles/#{role}"}>{gettext("Show")}</.link>
</div>
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
<.link navigate={~p"/admin/roles/#{role}/edit"} class="btn btn-ghost btn-sm">
<.icon name="hero-pencil" class="size-4" />
{gettext("Edit")}
</.link>
<% end %>
</:action>
<:action :let={role}>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not role.is_system_role do %>
<.link
phx-click={JS.push("delete", value: %{id: role.id}) |> hide("#row-#{role.id}")}
data-confirm={gettext("Are you sure?")}
class="btn btn-ghost btn-sm text-error"
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</.link>
<% else %>
<div
:if={role.is_system_role}
class="tooltip tooltip-left"
data-tip={gettext("System roles cannot be deleted")}
>
<button
class="btn btn-ghost btn-sm text-error opacity-50 cursor-not-allowed"
disabled={true}
aria-label={gettext("Cannot delete system role")}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete")}
</button>
</div>
<% end %>
</:action>
</.table>
</Layouts.app>

View file

@ -0,0 +1,216 @@
defmodule MvWeb.RoleLive.Show do
@moduledoc """
LiveView for displaying a single role's details.
## Features
- Display role information (name, description, permission_set_name, is_system_role)
- Navigate to edit form
- Return to role list
## Security
Only admins can access this page (enforced by authorization).
"""
use MvWeb, :live_view
alias Mv.Accounts
require Ash.Query
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true
def mount(%{"id" => id}, _session, socket) do
try do
case Ash.get(
Mv.Authorization.Role,
id,
domain: Mv.Authorization,
actor: socket.assigns[:current_user]
) do
{:ok, role} ->
user_count = load_user_count(role, socket.assigns[:current_user])
{:ok,
socket
|> assign(:page_title, gettext("Show Role"))
|> assign(:role, role)
|> assign(:user_count, user_count)}
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
{:ok,
socket
|> put_flash(:error, gettext("Role not found."))
|> redirect(to: ~p"/admin/roles")}
{:error, error} ->
{:ok,
socket
|> put_flash(:error, format_error(error))
|> redirect(to: ~p"/admin/roles")}
end
rescue
e in [Ash.Error.Invalid] ->
# Handle exceptions that Ash.get might throw (e.g., policy violations)
case e do
%Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]} ->
{:ok,
socket
|> put_flash(:error, gettext("Role not found."))
|> redirect(to: ~p"/admin/roles")}
_ ->
reraise e, __STACKTRACE__
end
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
case Mv.Authorization.get_role(id, actor: socket.assigns.current_user) do
{:ok, role} ->
handle_delete_role(role, socket)
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("Role not found.")
)
|> push_navigate(to: ~p"/admin/roles")}
{:error, error} ->
error_message = format_error(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
end
end
defp handle_delete_role(role, socket) do
if role.is_system_role do
{:noreply,
put_flash(
socket,
:error,
gettext("System roles cannot be deleted.")
)}
else
user_count = recalculate_user_count(role, socket.assigns.current_user)
if user_count > 0 do
{:noreply,
put_flash(
socket,
:error,
gettext(
"Cannot delete role. %{count} user(s) are still assigned to this role. Please assign them to another role first.",
count: user_count
)
)}
else
perform_role_deletion(role, socket)
end
end
end
defp perform_role_deletion(role, socket) do
case Mv.Authorization.destroy_role(role, actor: socket.assigns.current_user) do
:ok ->
{:noreply,
socket
|> put_flash(:info, gettext("Role deleted successfully."))
|> push_navigate(to: ~p"/admin/roles")}
{:error, error} ->
error_message = format_error(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to delete role: %{error}", error: error_message)
)}
end
end
# Recalculates user count for a specific role (used before deletion)
@spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
defp recalculate_user_count(role, actor) do
opts = opts_with_actor([], actor, Mv.Accounts)
case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do
{:ok, count} -> count
_ -> 0
end
end
# Loads user count for initial display (uses same logic as recalculate)
@spec load_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
defp load_user_count(role, actor) do
recalculate_user_count(role, actor)
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Role")} {@role.name}
<:subtitle>{gettext("Role details and permissions.")}</:subtitle>
<:actions>
<.button navigate={~p"/admin/roles"} aria-label={gettext("Back to roles list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to roles list")}</span>
</.button>
<%= if can?(@current_user, :update, Mv.Authorization.Role) do %>
<.button variant="primary" navigate={~p"/admin/roles/#{@role}/edit"}>
<.icon name="hero-pencil-square" /> {gettext("Edit Role")}
</.button>
<% end %>
<%= if can?(@current_user, :destroy, Mv.Authorization.Role) and not @role.is_system_role do %>
<.link
phx-click={JS.push("delete", value: %{id: @role.id})}
data-confirm={gettext("Are you sure?")}
class="btn btn-error"
>
<.icon name="hero-trash" /> {gettext("Delete Role")}
</.link>
<% end %>
</:actions>
</.header>
<.list>
<:item title={gettext("Name")}>{@role.name}</:item>
<:item title={gettext("Description")}>
<%= if @role.description do %>
{@role.description}
<% else %>
<span class="text-base-content/70 italic">{gettext("No description")}</span>
<% end %>
</:item>
<:item title={gettext("Permission Set")}>
<span class={permission_set_badge_class(@role.permission_set_name)}>
{@role.permission_set_name}
</span>
</:item>
<:item title={gettext("System Role")}>
<%= if @role.is_system_role do %>
<span class="badge badge-warning">{gettext("Yes")}</span>
<% else %>
<span class="badge badge-ghost">{gettext("No")}</span>
<% end %>
</:item>
</.list>
</Layouts.app>
"""
end
end