Merge branch 'main' into feature/223_memberfields_settings
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
commit
922f9f93d0
19 changed files with 2939 additions and 356 deletions
239
lib/mv/authorization/checks/has_permission.ex
Normal file
239
lib/mv/authorization/checks/has_permission.ex
Normal 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
|
||||
|
|
@ -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
206
lib/mv_web/authorization.ex
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
237
lib/mv_web/live/role_live/form.ex
Normal file
237
lib/mv_web/live/role_live/form.ex
Normal 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
|
||||
44
lib/mv_web/live/role_live/helpers.ex
Normal file
44
lib/mv_web/live/role_live/helpers.ex
Normal 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
|
||||
170
lib/mv_web/live/role_live/index.ex
Normal file
170
lib/mv_web/live/role_live/index.ex
Normal 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
|
||||
97
lib/mv_web/live/role_live/index.html.heex
Normal file
97
lib/mv_web/live/role_live/index.html.heex
Normal 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>
|
||||
216
lib/mv_web/live/role_live/show.ex
Normal file
216
lib/mv_web/live/role_live/show.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue