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,239 @@
defmodule Mv.Authorization.Checks.HasPermission do
@moduledoc """
Custom Ash Policy Check that evaluates permissions from the PermissionSets module.
This check:
1. Reads the actor's role and permission_set_name
2. Looks up permissions from PermissionSets.get_permissions/1
3. Finds matching permission for current resource + action
4. Applies scope filter (:own, :linked, :all)
## Usage in Ash Resource
policies do
policy action_type(:read) do
authorize_if Mv.Authorization.Checks.HasPermission
end
end
## Scope Behavior
- **:all** - Authorizes without filtering (returns all records)
- **:own** - Filters to records where record.id == actor.id
- **:linked** - Filters based on resource type:
- Member: member.user.id == actor.id (via has_one :user relationship)
- CustomFieldValue: custom_field_value.member.user.id == actor.id (traverses member user relationship!)
## Error Handling
Returns `false` for:
- Missing actor
- Actor without role
- Invalid permission_set_name
- No matching permission found
All errors result in Forbidden (policy fails).
## Examples
# In a resource policy
policies do
policy action_type([:read, :create, :update, :destroy]) do
authorize_if Mv.Authorization.Checks.HasPermission
end
end
"""
use Ash.Policy.Check
require Ash.Query
import Ash.Expr
alias Mv.Authorization.PermissionSets
require Logger
@impl true
def describe(_opts) do
"checks if actor has permission via their role's permission set"
end
@impl true
def strict_check(actor, authorizer, _opts) do
resource = authorizer.resource
action = get_action_from_authorizer(authorizer)
cond do
is_nil(actor) ->
log_auth_failure(actor, resource, action, "no actor")
{:ok, false}
is_nil(action) ->
log_auth_failure(
actor,
resource,
action,
"authorizer subject shape unsupported (no action)"
)
{:ok, false}
true ->
strict_check_with_permissions(actor, resource, action)
end
end
# Helper function to reduce nesting depth
defp strict_check_with_permissions(actor, resource, action) do
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom),
resource_name <- get_resource_name(resource) do
case check_permission(
permissions.resources,
resource_name,
action,
actor,
resource_name
) do
:authorized -> {:ok, true}
{:filter, _} -> {:ok, :unknown}
false -> {:ok, false}
end
else
%{role: nil} ->
log_auth_failure(actor, resource, action, "no role assigned")
{:ok, false}
%{role: %{permission_set_name: nil}} ->
log_auth_failure(actor, resource, action, "role has no permission_set_name")
{:ok, false}
{:error, :invalid_permission_set} ->
log_auth_failure(actor, resource, action, "invalid permission_set_name")
{:ok, false}
_ ->
log_auth_failure(actor, resource, action, "missing data")
{:ok, false}
end
end
@impl true
def auto_filter(actor, authorizer, _opts) do
resource = authorizer.resource
action = get_action_from_authorizer(authorizer)
cond do
is_nil(actor) -> nil
is_nil(action) -> nil
true -> auto_filter_with_permissions(actor, resource, action)
end
end
# Helper function to reduce nesting depth
defp auto_filter_with_permissions(actor, resource, action) do
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom),
resource_name <- get_resource_name(resource) do
case check_permission(
permissions.resources,
resource_name,
action,
actor,
resource_name
) do
:authorized -> nil
{:filter, filter_expr} -> filter_expr
false -> nil
end
else
_ -> nil
end
end
# Helper to extract action from authorizer
defp get_action_from_authorizer(authorizer) do
case authorizer.subject do
%{action: %{name: action}} -> action
%{action: action} when is_atom(action) -> action
_ -> nil
end
end
# Extract resource name from module (e.g., Mv.Membership.Member -> "Member")
defp get_resource_name(resource) when is_atom(resource) do
resource |> Module.split() |> List.last()
end
# Find matching permission and apply scope
defp check_permission(resource_perms, resource_name, action, actor, resource_name_for_logging) do
case Enum.find(resource_perms, fn perm ->
perm.resource == resource_name and perm.action == action and perm.granted
end) do
nil ->
log_auth_failure(actor, resource_name_for_logging, action, "no matching permission found")
false
perm ->
apply_scope(perm.scope, actor, resource_name)
end
end
# Scope: all - No filtering, access to all records
defp apply_scope(:all, _actor, _resource) do
:authorized
end
# Scope: own - Filter to records where record.id == actor.id
# Used for User resource (users can access their own user record)
defp apply_scope(:own, actor, _resource) do
{:filter, expr(id == ^actor.id)}
end
# Scope: linked - Filter based on user relationship (resource-specific!)
# Uses Ash relationships: Member has_one :user, CustomFieldValue belongs_to :member
defp apply_scope(:linked, actor, resource_name) do
case resource_name do
"Member" ->
# Member has_one :user → filter by user.id == actor.id
{:filter, expr(user.id == ^actor.id)}
"CustomFieldValue" ->
# CustomFieldValue belongs_to :member → member has_one :user
# Traverse: custom_field_value.member.user.id == actor.id
{:filter, expr(member.user.id == ^actor.id)}
_ ->
# Fallback for other resources: try user relationship first, then user_id
{:filter, expr(user.id == ^actor.id or user_id == ^actor.id)}
end
end
# Log authorization failures for debugging (lazy evaluation)
defp log_auth_failure(actor, resource, action, reason) do
Logger.debug(fn ->
actor_id = if is_map(actor), do: Map.get(actor, :id), else: "nil"
resource_name = get_resource_name_for_logging(resource)
"""
Authorization failed:
Actor: #{actor_id}
Resource: #{resource_name}
Action: #{inspect(action)}
Reason: #{reason}
"""
end)
end
# Helper to extract resource name for logging (handles both atoms and strings)
defp get_resource_name_for_logging(resource) when is_atom(resource) do
resource |> Module.split() |> List.last()
end
defp get_resource_name_for_logging(resource) when is_binary(resource) do
resource
end
defp get_resource_name_for_logging(_resource) do
"unknown"
end
end

View file

@ -89,6 +89,9 @@ defmodule MvWeb do
# Core UI components
import MvWeb.CoreComponents
# Authorization helpers
import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]
# Common modules used in templates
alias Phoenix.LiveView.JS
alias MvWeb.Layouts

206
lib/mv_web/authorization.ex Normal file
View file

@ -0,0 +1,206 @@
defmodule MvWeb.Authorization do
@moduledoc """
UI-level authorization helpers for LiveView templates.
These functions check if the current user has permission to perform actions
or access pages. They use the same PermissionSets module as the backend policies,
ensuring UI and backend authorization are consistent.
## Usage in Templates
<!-- Conditional button rendering -->
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
<.link patch={~p"/members/new"}>New Member</.link>
<% end %>
<!-- Record-level check -->
<%= if can?(@current_user, :update, @member) do %>
<.button>Edit</.button>
<% end %>
<!-- Page access check -->
<%= if can_access_page?(@current_user, "/admin/roles") do %>
<.link navigate="/admin/roles">Manage Roles</.link>
<% end %>
## Performance
All checks are pure function calls using the hardcoded PermissionSets module.
No database queries, < 1 microsecond per check.
"""
alias Mv.Authorization.PermissionSets
@doc """
Checks if user has permission for an action on a resource.
This function has two variants:
1. Resource atom: Checks if user has permission for action on resource type
2. Record struct: Checks if user has permission for action on specific record (with scope checking)
## Examples
# Resource-level check (atom)
iex> admin = %{role: %{permission_set_name: "admin"}}
iex> can?(admin, :create, Mv.Membership.Member)
true
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
iex> can?(mitglied, :create, Mv.Membership.Member)
false
# Record-level check (struct with scope)
iex> user = %{id: "user-123", role: %{permission_set_name: "own_data"}}
iex> member = %Member{id: "member-456", user: %User{id: "user-123"}}
iex> can?(user, :update, member)
true
"""
@spec can?(map() | nil, atom(), atom() | struct()) :: boolean()
def can?(nil, _action, _resource), do: false
def can?(user, action, resource) when is_atom(action) and is_atom(resource) do
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom) do
resource_name = get_resource_name(resource)
Enum.any?(permissions.resources, fn perm ->
perm.resource == resource_name and perm.action == action and perm.granted
end)
else
_ -> false
end
end
def can?(user, action, %resource{} = record) when is_atom(action) do
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom) do
resource_name = get_resource_name(resource)
# Find matching permission
matching_perm =
Enum.find(permissions.resources, fn perm ->
perm.resource == resource_name and perm.action == action and perm.granted
end)
case matching_perm do
nil -> false
perm -> check_scope(perm.scope, user, record, resource_name)
end
else
_ -> false
end
end
@doc """
Checks if user can access a specific page.
## Examples
iex> admin = %{role: %{permission_set_name: "admin"}}
iex> can_access_page?(admin, "/admin/roles")
true
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
iex> can_access_page?(mitglied, "/members")
false
"""
@spec can_access_page?(map() | nil, String.t() | Phoenix.VerifiedRoutes.unverified_path()) ::
boolean()
def can_access_page?(nil, _page_path), do: false
def can_access_page?(user, page_path) do
# Convert verified route to string if needed
page_path_str = if is_binary(page_path), do: page_path, else: to_string(page_path)
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom) do
page_matches?(permissions.pages, page_path_str)
else
_ -> false
end
end
# Check if scope allows access to record
defp check_scope(:all, _user, _record, _resource_name), do: true
defp check_scope(:own, user, record, _resource_name) do
record.id == user.id
end
defp check_scope(:linked, user, record, resource_name) do
case resource_name do
"Member" -> check_member_linked(user, record)
"CustomFieldValue" -> check_custom_field_value_linked(user, record)
_ -> check_fallback_linked(user, record)
end
end
defp check_member_linked(user, record) do
# Member has_one :user (inverse of User belongs_to :member)
# Check if member.user.id == user.id (user must be preloaded)
case Map.get(record, :user) do
%{id: user_id} -> user_id == user.id
_ -> false
end
end
defp check_custom_field_value_linked(user, record) do
# Need to traverse: custom_field_value.member.user.id
# Note: In UI, custom_field_value should have member.user preloaded
case Map.get(record, :member) do
%{user: %{id: member_user_id}} -> member_user_id == user.id
_ -> false
end
end
defp check_fallback_linked(user, record) do
# Fallback: try user_id or user relationship
case Map.get(record, :user_id) do
nil -> check_user_relationship_linked(user, record)
user_id -> user_id == user.id
end
end
defp check_user_relationship_linked(user, record) do
# Try user relationship
case Map.get(record, :user) do
%{id: user_id} -> user_id == user.id
_ -> false
end
end
# Check if page path matches any allowed pattern
defp page_matches?(allowed_pages, requested_path) do
Enum.any?(allowed_pages, fn pattern ->
cond do
pattern == "*" -> true
pattern == requested_path -> true
String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path)
true -> false
end
end)
end
# Match dynamic route pattern
defp match_pattern?(pattern, path) do
pattern_segments = String.split(pattern, "/", trim: true)
path_segments = String.split(path, "/", trim: true)
if length(pattern_segments) == length(path_segments) do
Enum.zip(pattern_segments, path_segments)
|> Enum.all?(fn {pattern_seg, path_seg} ->
String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
end)
else
false
end
end
# Extract resource name from module
defp get_resource_name(resource) when is_atom(resource) do
resource |> Module.split() |> List.last()
end
end

View file

@ -7,6 +7,7 @@ defmodule MvWeb.Layouts.Navbar do
use MvWeb, :verified_routes
alias Mv.Membership
import MvWeb.Authorization
attr :current_user, :map,
required: true,
@ -26,7 +27,21 @@ defmodule MvWeb.Layouts.Navbar do
<a href="/members" class="btn btn-ghost text-xl">{@club_name}</a>
<ul class="menu menu-horizontal bg-base-200">
<li><.link navigate="/members">{gettext("Members")}</.link></li>
<li><.link navigate="/settings">{gettext("Settings")}</.link></li>
<li>
<details>
<summary>{gettext("Settings")}</summary>
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
<li>
<.link navigate="/settings">{gettext("Global Settings")}</.link>
</li>
<%= if can_access_page?(@current_user, ~p"/admin/roles") do %>
<li>
<.link navigate={~p"/admin/roles"}>{gettext("Roles")}</.link>
</li>
<% end %>
</ul>
</details>
</li>
<li><.link navigate="/users">{gettext("Users")}</.link></li>
<li>
<details>

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

View file

@ -4,16 +4,59 @@ defmodule MvWeb.LiveHelpers do
## on_mount Hooks
- `:default` - Sets the user's locale from session (defaults to "de")
- `:ensure_user_role_loaded` - Ensures current_user has role relationship loaded
## Usage
Add to LiveView modules via:
```elixir
on_mount {MvWeb.LiveHelpers, :default}
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
```
"""
import Phoenix.Component
def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "de"
Gettext.put_locale(locale)
{:cont, socket}
end
def on_mount(:ensure_user_role_loaded, _params, _session, socket) do
socket = ensure_user_role_loaded(socket)
{:cont, socket}
end
defp ensure_user_role_loaded(socket) do
if socket.assigns[:current_user] do
user = socket.assigns.current_user
user_with_role = load_user_role(user)
assign(socket, :current_user, user_with_role)
else
socket
end
end
defp load_user_role(user) do
case Map.get(user, :role) do
%Ash.NotLoaded{} -> load_role_safely(user)
nil -> load_role_safely(user)
_role -> user
end
end
defp load_role_safely(user) do
# Use self as actor for loading own role relationship
opts = [domain: Mv.Accounts, actor: user]
case Ash.load(user, :role, opts) do
{:ok, loaded_user} ->
loaded_user
{:error, error} ->
# Log warning if role loading fails - this can cause authorization issues
require Logger
Logger.warning("Failed to load role for user #{user.id}: #{inspect(error)}")
user
end
end
end

View file

@ -46,7 +46,10 @@ defmodule MvWeb.Router do
AshAuthentication-specific: We define that all routes can only be accessed when the user is signed in.
"""
ash_authentication_live_session :authentication_required,
on_mount: {MvWeb.LiveUserAuth, :live_user_required} do
on_mount: [
{MvWeb.LiveUserAuth, :live_user_required},
{MvWeb.LiveHelpers, :ensure_user_role_loaded}
] do
live "/", MemberLive.Index, :index
live "/members", MemberLive.Index, :index
@ -81,6 +84,12 @@ defmodule MvWeb.Router do
live "/contribution_types", ContributionTypeLive.Index, :index
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
# Role Management (Admin only)
live "/admin/roles", RoleLive.Index, :index
live "/admin/roles/new", RoleLive.Form, :new
live "/admin/roles/:id", RoleLive.Show, :show
live "/admin/roles/:id/edit", RoleLive.Form, :edit
post "/set_locale", LocaleController, :set_locale
end