Merge remote-tracking branch 'origin/main' into sidebar
This commit is contained in:
commit
e7515b5450
83 changed files with 8084 additions and 1276 deletions
|
|
@ -17,6 +17,10 @@ defmodule Mv.Accounts.User do
|
|||
# When a member is deleted, set the user's member_id to NULL
|
||||
# This allows users to continue existing even if their linked member is removed
|
||||
reference :member, on_delete: :nilify
|
||||
|
||||
# When a role is deleted, prevent deletion if users are assigned to it
|
||||
# This protects critical roles from accidental deletion
|
||||
reference :role, on_delete: :restrict
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -357,6 +361,12 @@ defmodule Mv.Accounts.User do
|
|||
# This automatically creates a `member_id` attribute in the User table
|
||||
# The relationship is optional (allow_nil? true by default)
|
||||
belongs_to :member, Mv.Membership.Member
|
||||
|
||||
# 1:1 relationship - User belongs to a Role
|
||||
# This automatically creates a `role_id` attribute in the User table
|
||||
# The relationship is optional (allow_nil? true by default)
|
||||
# Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users)
|
||||
belongs_to :role, Mv.Authorization.Role
|
||||
end
|
||||
|
||||
identities do
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ defmodule Mv.Membership.Member do
|
|||
## Overview
|
||||
Members are the core entity in the membership management system. Each member
|
||||
can have:
|
||||
- Personal information (name, email, phone, address)
|
||||
- Personal information (name, email, address)
|
||||
- Optional link to a User account (1:1 relationship)
|
||||
- Dynamic custom field values via CustomField system
|
||||
- Full-text searchable profile
|
||||
|
|
@ -20,9 +20,8 @@ defmodule Mv.Membership.Member do
|
|||
- `has_one :user` - Optional authentication account link
|
||||
|
||||
## Validations
|
||||
- Required: first_name, last_name, email
|
||||
- Required: email (all other fields are optional)
|
||||
- Email format validation (using EctoCommons.EmailValidator)
|
||||
- Phone number format: international format with 6-20 digits
|
||||
- Postal code format: exactly 5 digits (German format)
|
||||
- Date validations: join_date not in future, exit_date after join_date
|
||||
- Email uniqueness: prevents conflicts with unlinked users
|
||||
|
|
@ -31,7 +30,7 @@ defmodule Mv.Membership.Member do
|
|||
Members have a `search_vector` attribute (tsvector) that is automatically
|
||||
updated via database trigger. Search includes name, email, notes, contact fields,
|
||||
and all custom field values. Custom field values are automatically included in
|
||||
the search vector with weight 'C' (same as phone_number, city, etc.).
|
||||
the search vector with weight 'C' (same as city, etc.).
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
|
|
@ -41,6 +40,8 @@ defmodule Mv.Membership.Member do
|
|||
import Ash.Expr
|
||||
require Logger
|
||||
|
||||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
|
||||
# Module constants
|
||||
@member_search_limit 10
|
||||
|
||||
|
|
@ -343,9 +344,7 @@ defmodule Mv.Membership.Member do
|
|||
validations do
|
||||
# Required fields are covered by allow_nil? false
|
||||
|
||||
# First name and last name must not be empty
|
||||
validate present(:first_name)
|
||||
validate present(:last_name)
|
||||
# Email is required
|
||||
validate present(:email)
|
||||
|
||||
# Email uniqueness check for all actions that change the email attribute
|
||||
|
|
@ -396,11 +395,6 @@ defmodule Mv.Membership.Member do
|
|||
where: [present([:join_date, :exit_date])],
|
||||
message: "cannot be before join date"
|
||||
|
||||
# Phone number format (only if set)
|
||||
validate match(:phone_number, ~r/^\+?[0-9\- ]{6,20}$/),
|
||||
where: [present(:phone_number)],
|
||||
message: "is not a valid phone number"
|
||||
|
||||
# Postal code format (only if set)
|
||||
validate match(:postal_code, ~r/^\d{5}$/),
|
||||
where: [present(:postal_code)],
|
||||
|
|
@ -453,12 +447,12 @@ defmodule Mv.Membership.Member do
|
|||
uuid_v7_primary_key :id
|
||||
|
||||
attribute :first_name, :string do
|
||||
allow_nil? false
|
||||
allow_nil? true
|
||||
constraints min_length: 1
|
||||
end
|
||||
|
||||
attribute :last_name, :string do
|
||||
allow_nil? false
|
||||
allow_nil? true
|
||||
constraints min_length: 1
|
||||
end
|
||||
|
||||
|
|
@ -474,10 +468,6 @@ defmodule Mv.Membership.Member do
|
|||
constraints min_length: 5, max_length: 254
|
||||
end
|
||||
|
||||
attribute :phone_number, :string do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :join_date, :date do
|
||||
allow_nil? true
|
||||
end
|
||||
|
|
@ -612,18 +602,21 @@ defmodule Mv.Membership.Member do
|
|||
"""
|
||||
@spec show_in_overview?(atom()) :: boolean()
|
||||
def show_in_overview?(field) when is_atom(field) do
|
||||
# exit_date defaults to false (hidden) instead of true
|
||||
default_visibility = if field == :exit_date, do: false, else: true
|
||||
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
# Normalize map keys to atoms (JSONB may return string keys)
|
||||
normalized_config = normalize_visibility_config(visibility_config)
|
||||
normalized_config = VisibilityConfig.normalize(visibility_config)
|
||||
|
||||
# Get value from normalized config, default to true
|
||||
Map.get(normalized_config, field, true)
|
||||
# Get value from normalized config, use field-specific default
|
||||
Map.get(normalized_config, field, default_visibility)
|
||||
|
||||
{:error, _} ->
|
||||
# If settings can't be loaded, default to visible
|
||||
true
|
||||
# If settings can't be loaded, use field-specific default
|
||||
default_visibility
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -968,29 +961,6 @@ defmodule Mv.Membership.Member do
|
|||
defp error_type(error) when is_atom(error), do: error
|
||||
defp error_type(_), do: :unknown
|
||||
|
||||
# Normalizes visibility config map keys from strings to atoms.
|
||||
# JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||
defp normalize_visibility_config(config) when is_map(config) do
|
||||
Enum.reduce(config, %{}, fn
|
||||
{key, value}, acc when is_atom(key) ->
|
||||
Map.put(acc, key, value)
|
||||
|
||||
{key, value}, acc when is_binary(key) ->
|
||||
try do
|
||||
atom_key = String.to_existing_atom(key)
|
||||
Map.put(acc, atom_key, value)
|
||||
rescue
|
||||
ArgumentError ->
|
||||
acc
|
||||
end
|
||||
|
||||
_, acc ->
|
||||
acc
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_visibility_config(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Performs fuzzy search on members using PostgreSQL trigram similarity.
|
||||
|
||||
|
|
@ -1073,7 +1043,6 @@ defmodule Mv.Membership.Member do
|
|||
expr(
|
||||
contains(postal_code, ^query) or
|
||||
contains(house_number, ^query) or
|
||||
contains(phone_number, ^query) or
|
||||
contains(email, ^query) or
|
||||
contains(city, ^query)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ defmodule Mv.Membership do
|
|||
# Settings should be created via seed script
|
||||
define :update_settings, action: :update
|
||||
define :update_member_field_visibility, action: :update_member_field_visibility
|
||||
|
||||
define :update_single_member_field_visibility,
|
||||
action: :update_single_member_field_visibility
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -89,7 +92,10 @@ defmodule Mv.Membership do
|
|||
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
||||
|
||||
Mv.Membership.Setting
|
||||
|> Ash.Changeset.for_create(:create, %{club_name: default_club_name})
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
club_name: default_club_name,
|
||||
member_field_visibility: %{"exit_date" => false}
|
||||
})
|
||||
|> Ash.create!(domain: __MODULE__)
|
||||
|> then(fn settings -> {:ok, settings} end)
|
||||
|
||||
|
|
@ -183,4 +189,42 @@ defmodule Mv.Membership do
|
|||
})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Atomically updates a single field in the member field visibility configuration.
|
||||
|
||||
This action uses PostgreSQL's jsonb_set function to atomically update a single key
|
||||
in the JSONB map, preventing lost updates in concurrent scenarios. This is the
|
||||
preferred method for updating individual field visibility settings.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `field` - The member field name as a string (e.g., "street", "house_number")
|
||||
- `show_in_overview` - Boolean value indicating visibility
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_single_member_field_visibility(settings, field: "street", show_in_overview: false)
|
||||
iex> updated.member_field_visibility["street"]
|
||||
false
|
||||
|
||||
"""
|
||||
def update_single_member_field_visibility(settings,
|
||||
field: field,
|
||||
show_in_overview: show_in_overview
|
||||
) do
|
||||
settings
|
||||
|> Ash.Changeset.new()
|
||||
|> Ash.Changeset.set_argument(:field, field)
|
||||
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -91,6 +91,16 @@ defmodule Mv.Membership.Setting do
|
|||
accept [:member_field_visibility]
|
||||
end
|
||||
|
||||
update :update_single_member_field_visibility do
|
||||
description "Atomically updates a single field in the member_field_visibility JSONB map"
|
||||
require_atomic? false
|
||||
|
||||
argument :field, :string, allow_nil?: false
|
||||
argument :show_in_overview, :boolean, allow_nil?: false
|
||||
|
||||
change Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility
|
||||
end
|
||||
|
||||
update :update_membership_fee_settings do
|
||||
description "Updates the membership fee configuration"
|
||||
require_atomic? false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
|
||||
@moduledoc """
|
||||
Ash change that atomically updates a single field in the member_field_visibility JSONB map.
|
||||
|
||||
This change uses PostgreSQL's jsonb_set function to atomically update a single key
|
||||
in the JSONB map, preventing lost updates in concurrent scenarios.
|
||||
|
||||
## Arguments
|
||||
- `field` - The member field name as a string (e.g., "street", "house_number")
|
||||
- `show_in_overview` - Boolean value indicating visibility
|
||||
|
||||
## Example
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_single_member_field_visibility,
|
||||
%{},
|
||||
arguments: %{field: "street", show_in_overview: false}
|
||||
)
|
||||
|> Ash.update(domain: Mv.Membership)
|
||||
"""
|
||||
use Ash.Resource.Change
|
||||
|
||||
alias Ash.Error.Invalid
|
||||
alias Ecto.Adapters.SQL
|
||||
require Logger
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
with {:ok, field} <- get_and_validate_field(changeset),
|
||||
{:ok, show_in_overview} <- get_and_validate_boolean(changeset, :show_in_overview) do
|
||||
add_after_action(changeset, field, show_in_overview)
|
||||
else
|
||||
{:error, updated_changeset} -> updated_changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_validate_field(changeset) do
|
||||
case Ash.Changeset.get_argument(changeset, :field) do
|
||||
nil ->
|
||||
{:error,
|
||||
add_error(changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "field argument is required"
|
||||
)}
|
||||
|
||||
field ->
|
||||
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
if field in valid_fields do
|
||||
{:ok, field}
|
||||
else
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "Invalid member field: #{field}"
|
||||
)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_and_validate_boolean(changeset, arg_name) do
|
||||
case Ash.Changeset.get_argument(changeset, arg_name) do
|
||||
nil ->
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "#{arg_name} argument is required"
|
||||
)}
|
||||
|
||||
value when is_boolean(value) ->
|
||||
{:ok, value}
|
||||
|
||||
_ ->
|
||||
{:error,
|
||||
add_error(
|
||||
changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "#{arg_name} must be a boolean"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp add_error(changeset, opts) do
|
||||
Ash.Changeset.add_error(changeset, opts)
|
||||
end
|
||||
|
||||
defp add_after_action(changeset, field, show_in_overview) do
|
||||
# Use after_action to execute atomic SQL update
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, settings ->
|
||||
# Use PostgreSQL jsonb_set for atomic update
|
||||
# jsonb_set(target, path, new_value, create_missing?)
|
||||
# path is an array: ['field_name']
|
||||
# new_value must be JSON: to_jsonb(boolean)
|
||||
sql = """
|
||||
UPDATE settings
|
||||
SET member_field_visibility = jsonb_set(
|
||||
COALESCE(member_field_visibility, '{}'::jsonb),
|
||||
ARRAY[$1::text],
|
||||
to_jsonb($2::boolean),
|
||||
true
|
||||
)
|
||||
WHERE id = $3
|
||||
RETURNING member_field_visibility
|
||||
"""
|
||||
|
||||
# Convert UUID string to binary for PostgreSQL
|
||||
uuid_binary = Ecto.UUID.dump!(settings.id)
|
||||
|
||||
case SQL.query(Mv.Repo, sql, [field, show_in_overview, uuid_binary]) do
|
||||
{:ok, %{rows: [[updated_jsonb] | _]}} ->
|
||||
updated_visibility = normalize_jsonb_result(updated_jsonb)
|
||||
|
||||
# Update the settings struct with the new visibility
|
||||
updated_settings = %{settings | member_field_visibility: updated_visibility}
|
||||
{:ok, updated_settings}
|
||||
|
||||
{:ok, %{rows: []}} ->
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: :member_field_visibility,
|
||||
message: "Settings not found"
|
||||
)}
|
||||
|
||||
{:error, error} ->
|
||||
Logger.error("Failed to atomically update member_field_visibility: #{inspect(error)}")
|
||||
|
||||
{:error,
|
||||
Invalid.exception(
|
||||
field: :member_field_visibility,
|
||||
message: "Failed to update visibility"
|
||||
)}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_jsonb_result(updated_jsonb) do
|
||||
case updated_jsonb do
|
||||
map when is_map(map) ->
|
||||
# Convert atom keys to strings if needed
|
||||
Enum.reduce(map, %{}, fn
|
||||
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
|
||||
{k, v}, acc -> Map.put(acc, k, v)
|
||||
end)
|
||||
|
||||
binary when is_binary(binary) ->
|
||||
case Jason.decode(binary) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
decoded
|
||||
|
||||
# Not a map after decode
|
||||
{:ok, _} ->
|
||||
%{}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
|
||||
%{}
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
|
||||
%{}
|
||||
end
|
||||
end
|
||||
end
|
||||
31
lib/mv/authorization/authorization.ex
Normal file
31
lib/mv/authorization/authorization.ex
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
defmodule Mv.Authorization do
|
||||
@moduledoc """
|
||||
Ash Domain for authorization and role management.
|
||||
|
||||
## Resources
|
||||
- `Role` - User roles that reference permission sets
|
||||
|
||||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- Role CRUD: `create_role/1`, `list_roles/0`, `update_role/2`, `destroy_role/1`
|
||||
|
||||
## Admin Interface
|
||||
The domain is configured with AshAdmin for management UI.
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
|
||||
resources do
|
||||
resource Mv.Authorization.Role do
|
||||
define :create_role, action: :create_role
|
||||
define :list_roles, action: :read
|
||||
define :get_role, action: :read, get_by: [:id]
|
||||
define :update_role, action: :update_role
|
||||
define :destroy_role, action: :destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
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
|
||||
294
lib/mv/authorization/permission_sets.ex
Normal file
294
lib/mv/authorization/permission_sets.ex
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
defmodule Mv.Authorization.PermissionSets do
|
||||
@moduledoc """
|
||||
Defines the four hardcoded permission sets for the application.
|
||||
|
||||
Each permission set specifies:
|
||||
- Resource permissions (what CRUD operations on which resources)
|
||||
- Page permissions (which LiveView pages can be accessed)
|
||||
- Scopes (own, linked, all)
|
||||
|
||||
## Permission Sets
|
||||
|
||||
1. **own_data** - Default for "Mitglied" role
|
||||
- Can only access own user data and linked member/custom field values
|
||||
- Cannot create new members or manage system
|
||||
|
||||
2. **read_only** - For "Vorstand" and "Buchhaltung" roles
|
||||
- Can read all member data
|
||||
- Cannot create, update, or delete
|
||||
|
||||
3. **normal_user** - For "Kassenwart" role
|
||||
- Create/Read/Update members (no delete for safety), full CRUD on custom field values
|
||||
- Cannot manage custom fields or users
|
||||
|
||||
4. **admin** - For "Admin" role
|
||||
- Unrestricted access to all resources
|
||||
- Can manage users, roles, custom fields
|
||||
|
||||
## Usage
|
||||
|
||||
# Get permissions for a role's permission set
|
||||
permissions = PermissionSets.get_permissions(:admin)
|
||||
|
||||
# Check if a permission set name is valid
|
||||
PermissionSets.valid_permission_set?("read_only") # => true
|
||||
|
||||
# Convert string to atom safely
|
||||
{:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data")
|
||||
|
||||
## Performance
|
||||
|
||||
All functions are pure and intended to be constant-time. Permission lookups
|
||||
are very fast (typically < 1 microsecond in practice) as they are simple
|
||||
pattern matches and map lookups with no database queries or external calls.
|
||||
"""
|
||||
|
||||
@type scope :: :own | :linked | :all
|
||||
@type action :: :read | :create | :update | :destroy
|
||||
|
||||
@type resource_permission :: %{
|
||||
resource: String.t(),
|
||||
action: action(),
|
||||
scope: scope(),
|
||||
granted: boolean()
|
||||
}
|
||||
|
||||
@type permission_set :: %{
|
||||
resources: [resource_permission()],
|
||||
pages: [String.t()]
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns the list of all valid permission set names.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> PermissionSets.all_permission_sets()
|
||||
[:own_data, :read_only, :normal_user, :admin]
|
||||
"""
|
||||
@spec all_permission_sets() :: [atom()]
|
||||
def all_permission_sets do
|
||||
[:own_data, :read_only, :normal_user, :admin]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns permissions for the given permission set.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> permissions = PermissionSets.get_permissions(:admin)
|
||||
iex> Enum.any?(permissions.resources, fn p ->
|
||||
...> p.resource == "User" and p.action == :destroy
|
||||
...> end)
|
||||
true
|
||||
|
||||
iex> PermissionSets.get_permissions(:invalid)
|
||||
** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin]
|
||||
"""
|
||||
@spec get_permissions(atom()) :: permission_set()
|
||||
|
||||
def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do
|
||||
raise ArgumentError,
|
||||
"invalid permission set: #{inspect(set)}. Must be one of: #{inspect(all_permission_sets())}"
|
||||
end
|
||||
|
||||
def get_permissions(:own_data) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can always read/update own credentials
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Can read/update linked member
|
||||
%{resource: "Member", action: :read, scope: :linked, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :linked, granted: true},
|
||||
|
||||
# CustomFieldValue: Can read/update custom field values of linked member
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
|
||||
|
||||
# CustomField: Can read all (needed for forms)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
# Home page
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
# Linked member detail (filtered by policy)
|
||||
"/members/:id"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(:read_only) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can read/update own credentials only
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Can read all members, no modifications
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
|
||||
# CustomFieldValue: Can read all custom field values
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Can read all
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
# Member list
|
||||
"/members",
|
||||
# Member detail
|
||||
"/members/:id",
|
||||
# Custom field values overview
|
||||
"/custom_field_values",
|
||||
# Custom field value detail
|
||||
"/custom_field_values/:id"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(:normal_user) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can read/update own credentials only
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Full CRUD except destroy (safety)
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :all, granted: true},
|
||||
# Note: destroy intentionally omitted for safety
|
||||
|
||||
# CustomFieldValue: Full CRUD
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Read only (admin manages definitions)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
"/members",
|
||||
# Create member
|
||||
"/members/new",
|
||||
"/members/:id",
|
||||
# Edit member
|
||||
"/members/:id/edit",
|
||||
"/custom_field_values",
|
||||
# Custom field value detail
|
||||
"/custom_field_values/:id",
|
||||
"/custom_field_values/new",
|
||||
"/custom_field_values/:id/edit"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(:admin) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Full management including other users
|
||||
%{resource: "User", action: :read, scope: :all, granted: true},
|
||||
%{resource: "User", action: :create, scope: :all, granted: true},
|
||||
%{resource: "User", action: :update, scope: :all, granted: true},
|
||||
%{resource: "User", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# Member: Full CRUD
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomFieldValue: Full CRUD
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Full CRUD (admin manages custom field definitions)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# Role: Full CRUD (admin manages roles)
|
||||
%{resource: "Role", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :update, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :destroy, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
# Wildcard: Admin can access all pages
|
||||
"*"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(invalid) do
|
||||
raise ArgumentError,
|
||||
"invalid permission set: #{inspect(invalid)}. Must be one of: #{inspect(all_permission_sets())}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a permission set name (string or atom) is valid.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> PermissionSets.valid_permission_set?("admin")
|
||||
true
|
||||
|
||||
iex> PermissionSets.valid_permission_set?(:read_only)
|
||||
true
|
||||
|
||||
iex> PermissionSets.valid_permission_set?("invalid")
|
||||
false
|
||||
"""
|
||||
@spec valid_permission_set?(any()) :: boolean()
|
||||
def valid_permission_set?(name) when is_binary(name) do
|
||||
case permission_set_name_to_atom(name) do
|
||||
{:ok, _atom} -> true
|
||||
{:error, _} -> false
|
||||
end
|
||||
end
|
||||
|
||||
def valid_permission_set?(name) when is_atom(name) do
|
||||
name in all_permission_sets()
|
||||
end
|
||||
|
||||
def valid_permission_set?(_), do: false
|
||||
|
||||
@doc """
|
||||
Converts a permission set name string to atom safely.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> PermissionSets.permission_set_name_to_atom("admin")
|
||||
{:ok, :admin}
|
||||
|
||||
iex> PermissionSets.permission_set_name_to_atom("invalid")
|
||||
{:error, :invalid_permission_set}
|
||||
"""
|
||||
@spec permission_set_name_to_atom(String.t()) ::
|
||||
{:ok, atom()} | {:error, :invalid_permission_set}
|
||||
def permission_set_name_to_atom(name) when is_binary(name) do
|
||||
atom = String.to_existing_atom(name)
|
||||
|
||||
if valid_permission_set?(atom) do
|
||||
{:ok, atom}
|
||||
else
|
||||
{:error, :invalid_permission_set}
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> {:error, :invalid_permission_set}
|
||||
end
|
||||
end
|
||||
142
lib/mv/authorization/role.ex
Normal file
142
lib/mv/authorization/role.ex
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
defmodule Mv.Authorization.Role do
|
||||
@moduledoc """
|
||||
Represents a user role that references a permission set.
|
||||
|
||||
Roles are stored in the database and link users to permission sets.
|
||||
Each role has a `permission_set_name` that references one of the four
|
||||
hardcoded permission sets defined in `Mv.Authorization.PermissionSets`.
|
||||
|
||||
## Fields
|
||||
|
||||
- `name` - Unique role name (e.g., "Vorstand", "Admin")
|
||||
- `description` - Human-readable description of the role
|
||||
- `permission_set_name` - Must be one of: "own_data", "read_only", "normal_user", "admin"
|
||||
- `is_system_role` - If true, role cannot be deleted (protects critical roles like "Mitglied")
|
||||
|
||||
## Relationships
|
||||
|
||||
- `has_many :users` - Users assigned to this role
|
||||
|
||||
## Validations
|
||||
|
||||
- `permission_set_name` must be a valid permission set (checked against PermissionSets.all_permission_sets/0)
|
||||
- `name` must be unique
|
||||
- System roles cannot be deleted (enforced via validation)
|
||||
|
||||
## Examples
|
||||
|
||||
# Create a new role
|
||||
{:ok, role} = Mv.Authorization.create_role(%{
|
||||
name: "Vorstand",
|
||||
description: "Board member with read access",
|
||||
permission_set_name: "read_only"
|
||||
})
|
||||
|
||||
# List all roles
|
||||
{:ok, roles} = Mv.Authorization.list_roles()
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Authorization,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "roles"
|
||||
repo Mv.Repo
|
||||
|
||||
references do
|
||||
# Prevent deletion of roles that are assigned to users
|
||||
reference :users, on_delete: :restrict
|
||||
end
|
||||
end
|
||||
|
||||
code_interface do
|
||||
define :create_role
|
||||
define :list_roles, action: :read
|
||||
define :update_role
|
||||
define :destroy_role, action: :destroy
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read]
|
||||
|
||||
create :create_role do
|
||||
primary? true
|
||||
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
|
||||
accept [:name, :description, :permission_set_name]
|
||||
# Note: In Ash 3.0, require_atomic? is not available for create actions
|
||||
# Custom validations will still work
|
||||
end
|
||||
|
||||
update :update_role do
|
||||
primary? true
|
||||
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
|
||||
accept [:name, :description, :permission_set_name]
|
||||
# Required because custom validation functions cannot be executed atomically
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
destroy :destroy do
|
||||
# Required because custom validation functions cannot be executed atomically
|
||||
require_atomic? false
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate one_of(
|
||||
:permission_set_name,
|
||||
Mv.Authorization.PermissionSets.all_permission_sets()
|
||||
|> Enum.map(&Atom.to_string/1)
|
||||
),
|
||||
message:
|
||||
"must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
|
||||
|
||||
validate fn changeset, _context ->
|
||||
if changeset.data.is_system_role do
|
||||
{:error,
|
||||
field: :is_system_role,
|
||||
message:
|
||||
"Cannot delete system role. System roles are required for the application to function."}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:destroy]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_v7_primary_key :id
|
||||
|
||||
attribute :name, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :description, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :permission_set_name, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :is_system_role, :boolean do
|
||||
allow_nil? false
|
||||
default false
|
||||
public? true
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :users, Mv.Accounts.User do
|
||||
destination_attribute :role_id
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_name, [:name]
|
||||
end
|
||||
end
|
||||
|
|
@ -7,7 +7,6 @@ defmodule Mv.Constants do
|
|||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
|
|
|
|||
49
lib/mv/helpers/type_parsers.ex
Normal file
49
lib/mv/helpers/type_parsers.ex
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
defmodule Mv.Helpers.TypeParsers do
|
||||
@moduledoc """
|
||||
Helper functions for parsing various input types to common Elixir types.
|
||||
|
||||
Provides safe parsing functions for common type conversions, especially useful
|
||||
when dealing with form data or external APIs.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Parses various input types to boolean.
|
||||
|
||||
Handles: booleans, strings ("true"/"false"), integers (1/0), and other values (defaults to false).
|
||||
|
||||
## Parameters
|
||||
|
||||
- `value` - The value to parse (boolean, string, integer, or other)
|
||||
|
||||
## Returns
|
||||
|
||||
A boolean value
|
||||
|
||||
## Examples
|
||||
|
||||
iex> parse_boolean(true)
|
||||
true
|
||||
|
||||
iex> parse_boolean("true")
|
||||
true
|
||||
|
||||
iex> parse_boolean("false")
|
||||
false
|
||||
|
||||
iex> parse_boolean(1)
|
||||
true
|
||||
|
||||
iex> parse_boolean(0)
|
||||
false
|
||||
|
||||
iex> parse_boolean(nil)
|
||||
false
|
||||
"""
|
||||
@spec parse_boolean(any()) :: boolean()
|
||||
def parse_boolean(value) when is_boolean(value), do: value
|
||||
def parse_boolean("true"), do: true
|
||||
def parse_boolean("false"), do: false
|
||||
def parse_boolean(1), do: true
|
||||
def parse_boolean(0), do: false
|
||||
def parse_boolean(_), do: false
|
||||
end
|
||||
55
lib/mv/membership/helpers/visibility_config.ex
Normal file
55
lib/mv/membership/helpers/visibility_config.ex
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
defmodule Mv.Membership.Helpers.VisibilityConfig do
|
||||
@moduledoc """
|
||||
Helper functions for normalizing member field visibility configuration.
|
||||
|
||||
Handles conversion between string keys (from JSONB) and atom keys (Elixir convention).
|
||||
JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||
This module provides functions to normalize these back to atoms for Elixir usage.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Normalizes visibility config map keys from strings to atoms.
|
||||
|
||||
JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||
This function converts them back to atoms for Elixir usage.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `config` - A map with either string or atom keys
|
||||
|
||||
## Returns
|
||||
|
||||
A map with atom keys (where possible)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> normalize(%{"first_name" => true, "email" => false})
|
||||
%{first_name: true, email: false}
|
||||
|
||||
iex> normalize(%{first_name: true, email: false})
|
||||
%{first_name: true, email: false}
|
||||
|
||||
iex> normalize(%{"invalid_field" => true})
|
||||
%{}
|
||||
"""
|
||||
@spec normalize(map()) :: map()
|
||||
def normalize(config) when is_map(config) do
|
||||
Enum.reduce(config, %{}, fn
|
||||
{key, value}, acc when is_atom(key) ->
|
||||
Map.put(acc, key, value)
|
||||
|
||||
{key, value}, acc when is_binary(key) ->
|
||||
try do
|
||||
atom_key = String.to_existing_atom(key)
|
||||
Map.put(acc, atom_key, value)
|
||||
rescue
|
||||
ArgumentError -> acc
|
||||
end
|
||||
|
||||
_, acc ->
|
||||
acc
|
||||
end)
|
||||
end
|
||||
|
||||
def normalize(_), do: %{}
|
||||
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
|
||||
|
|
@ -692,7 +692,7 @@ defmodule MvWeb.CoreComponents do
|
|||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :class, :string, default: "size-4"
|
||||
attr :rest, :global, include: ~w[aria-hidden]
|
||||
attr :rest, :global, include: ~w(aria-hidden)
|
||||
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
Navbar that is used in the rootlayout shown on every page
|
||||
"""
|
||||
use MvWeb, :html
|
||||
import MvWeb.Authorization
|
||||
|
||||
attr :current_user, :map,
|
||||
required: true,
|
||||
|
|
@ -22,12 +23,26 @@ 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>
|
||||
<summary>{gettext("Contributions")}</summary>
|
||||
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
|
||||
<ul class="bg-base-200 rounded-t-none p-2 z-10">
|
||||
<li>
|
||||
<.link navigate="/membership_fee_types">{gettext("Membership Fee Types")}</.link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -78,6 +78,12 @@ defmodule MvWeb.AuthController do
|
|||
end
|
||||
end
|
||||
|
||||
# Catch-all clause for any other error types
|
||||
defp handle_rauthy_failure(conn, reason) do
|
||||
Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}")
|
||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||
end
|
||||
|
||||
# Handle generic AuthenticationFailed errors
|
||||
defp handle_authentication_failed(conn, %Ash.Error.Forbidden{errors: errors}) do
|
||||
if Enum.any?(errors, &match?(%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}, &1)) do
|
||||
|
|
|
|||
59
lib/mv_web/helpers/field_type_formatter.ex
Normal file
59
lib/mv_web/helpers/field_type_formatter.ex
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
defmodule MvWeb.Helpers.FieldTypeFormatter do
|
||||
@moduledoc """
|
||||
Helper functions for formatting field types for display.
|
||||
|
||||
Handles both Ash type modules (e.g., `Ash.Type.String`) and simple atoms (e.g., `:string`).
|
||||
"""
|
||||
|
||||
alias MvWeb.Translations.FieldTypes
|
||||
|
||||
@doc """
|
||||
Formats an Ash type for display.
|
||||
|
||||
Handles both Ash type modules (e.g., `Ash.Type.String`) and simple atoms (e.g., `:string`).
|
||||
|
||||
## Parameters
|
||||
|
||||
- `type` - An atom or module representing the field type
|
||||
|
||||
## Returns
|
||||
|
||||
A human-readable string representation of the type
|
||||
|
||||
## Examples
|
||||
|
||||
iex> format(:string)
|
||||
"String"
|
||||
|
||||
iex> format(Ash.Type.String)
|
||||
"String"
|
||||
|
||||
iex> format(Ash.Type.Date)
|
||||
"Date"
|
||||
"""
|
||||
@spec format(atom() | module()) :: String.t()
|
||||
def format(type) when is_atom(type) do
|
||||
type_string = to_string(type)
|
||||
|
||||
if String.contains?(type_string, "Ash.Type.") do
|
||||
type_string
|
||||
|> String.split(".")
|
||||
|> List.last()
|
||||
|> String.downcase()
|
||||
|> then(fn type_name ->
|
||||
try do
|
||||
type_atom = String.to_existing_atom(type_name)
|
||||
FieldTypes.label(type_atom)
|
||||
rescue
|
||||
ArgumentError -> FieldTypes.label(:string)
|
||||
end
|
||||
end)
|
||||
else
|
||||
FieldTypes.label(type)
|
||||
end
|
||||
end
|
||||
|
||||
def format(type) do
|
||||
to_string(type)
|
||||
end
|
||||
end
|
||||
64
lib/mv_web/helpers/member_helpers.ex
Normal file
64
lib/mv_web/helpers/member_helpers.ex
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
defmodule MvWeb.Helpers.MemberHelpers do
|
||||
@moduledoc """
|
||||
Helper functions for member-related operations in the web layer.
|
||||
|
||||
Provides utilities for formatting and displaying member information.
|
||||
"""
|
||||
|
||||
alias Mv.Membership.Member
|
||||
|
||||
@doc """
|
||||
Returns a display name for a member.
|
||||
|
||||
Combines first_name and last_name if available, otherwise falls back to email.
|
||||
This ensures that members without names still have a meaningful display name.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> member = %Member{first_name: "John", last_name: "Doe", email: "john@example.com"}
|
||||
iex> MvWeb.Helpers.MemberHelpers.display_name(member)
|
||||
"John Doe"
|
||||
|
||||
iex> member = %Member{first_name: nil, last_name: nil, email: "john@example.com"}
|
||||
iex> MvWeb.Helpers.MemberHelpers.display_name(member)
|
||||
"john@example.com"
|
||||
|
||||
iex> member = %Member{first_name: "John", last_name: nil, email: "john@example.com"}
|
||||
iex> MvWeb.Helpers.MemberHelpers.display_name(member)
|
||||
"John"
|
||||
"""
|
||||
def display_name(%Member{} = member) do
|
||||
name_parts =
|
||||
[member.first_name, member.last_name]
|
||||
|> Enum.reject(&blank?/1)
|
||||
|> Enum.map_join(" ", &String.trim/1)
|
||||
|
||||
if name_parts == "" do
|
||||
member.email
|
||||
else
|
||||
name_parts
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a value is blank (nil, empty string, or only whitespace).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.Helpers.MemberHelpers.blank?(nil)
|
||||
true
|
||||
|
||||
iex> MvWeb.Helpers.MemberHelpers.blank?("")
|
||||
true
|
||||
|
||||
iex> MvWeb.Helpers.MemberHelpers.blank?(" ")
|
||||
true
|
||||
|
||||
iex> MvWeb.Helpers.MemberHelpers.blank?("John")
|
||||
false
|
||||
"""
|
||||
def blank?(nil), do: true
|
||||
def blank?(""), do: true
|
||||
def blank?(value) when is_binary(value), do: String.trim(value) == ""
|
||||
def blank?(_), do: false
|
||||
end
|
||||
|
|
@ -8,9 +8,9 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
|||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.Membership.Member
|
||||
|
||||
@doc """
|
||||
Formats a decimal amount as currency string.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
|||
|
||||
use MvWeb, :live_component
|
||||
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UPDATE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -66,7 +68,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
|||
<.dropdown_menu
|
||||
id="field-visibility-menu"
|
||||
icon="hero-adjustments-horizontal"
|
||||
button_label={gettext("Columns")}
|
||||
button_label={gettext("Show/Hide Columns")}
|
||||
items={@all_items}
|
||||
checkboxes={true}
|
||||
selected={@selected_fields}
|
||||
|
|
@ -153,12 +155,12 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
|||
defp field_to_string(field) when is_binary(field), do: field
|
||||
|
||||
defp format_field_label(field) when is_atom(field) do
|
||||
MvWeb.Translations.MemberFields.label(field)
|
||||
MemberFields.label(field)
|
||||
end
|
||||
|
||||
defp format_field_label(field) when is_binary(field) do
|
||||
case safe_to_existing_atom(field) do
|
||||
{:ok, atom} -> MvWeb.Translations.MemberFields.label(atom)
|
||||
{:ok, atom} -> MemberFields.label(atom)
|
||||
:error -> fallback_label(field)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ defmodule MvWeb.ContributionPeriodLive.Show do
|
|||
<.mockup_warning />
|
||||
|
||||
<.header>
|
||||
{gettext("Contributions for %{name}", name: "#{@member.first_name} #{@member.last_name}")}
|
||||
{gettext("Contributions for %{name}", name: MvWeb.Helpers.MemberHelpers.display_name(@member))}
|
||||
<:subtitle>
|
||||
{gettext("Contribution type")}:
|
||||
<span class="font-semibold">{@member.contribution_type}</span>
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ defmodule MvWeb.ContributionTypeLive.Index do
|
|||
<div class="prose prose-sm max-w-none">
|
||||
<p>
|
||||
{gettext(
|
||||
"Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
"Contribution types define different membership fee structures. Each type has a fixed cycle (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
type="button"
|
||||
phx-click="cancel"
|
||||
phx-target={@myself}
|
||||
aria-label={gettext("Back to custom field overview")}
|
||||
aria-label={gettext("Back to settings")}
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="w-4 h-4" />
|
||||
</.button>
|
||||
<h3 class="card-title">
|
||||
{if @custom_field, do: gettext("Edit Custom Field"), else: gettext("New Custom Field")}
|
||||
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Custom Field")}
|
||||
{gettext("Save Data Field")}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
|
|
|
|||
|
|
@ -17,158 +17,170 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
|
||||
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<.form_section title={gettext("Custom Fields")}>
|
||||
<div class="flex">
|
||||
<p class="text-sm text-base-content/70">
|
||||
{gettext("These will appear in addition to other data when adding new members.")}
|
||||
</p>
|
||||
<div class="ml-auto">
|
||||
<.button
|
||||
class="ml-auto"
|
||||
variant="primary"
|
||||
phx-click="new_custom_field"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-plus" /> {gettext("New Custom Field")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<%!-- Show form when creating or editing --%>
|
||||
<div :if={@show_form} class="mb-8">
|
||||
<.live_component
|
||||
module={MvWeb.CustomFieldLive.FormComponent}
|
||||
id={@form_id}
|
||||
custom_field={@editing_custom_field}
|
||||
on_save={
|
||||
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
|
||||
}
|
||||
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Hide table when form is visible --%>
|
||||
<.table
|
||||
:if={!@show_form}
|
||||
id="custom_fields"
|
||||
rows={@streams.custom_fields}
|
||||
row_click={
|
||||
fn {_id, custom_field} ->
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
end
|
||||
}
|
||||
>
|
||||
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
|
||||
{@field_type_label.(custom_field.value_type)}
|
||||
</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label={gettext("Description")}>
|
||||
{custom_field.description}
|
||||
</:col>
|
||||
|
||||
<:col
|
||||
:let={{_id, custom_field}}
|
||||
label={gettext("Show in overview")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
<div id={@id} class="mt-8">
|
||||
<div class="flex">
|
||||
<p class="text-sm text-base-content/70">
|
||||
{gettext("These will appear in addition to other data when adding new members.")}
|
||||
</p>
|
||||
<div class="ml-auto">
|
||||
<.button
|
||||
class="ml-auto"
|
||||
variant="primary"
|
||||
phx-click="new_custom_field"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<span :if={custom_field.show_in_overview} class="badge badge-success">
|
||||
{gettext("Yes")}
|
||||
</span>
|
||||
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
|
||||
{gettext("No")}
|
||||
</span>
|
||||
</:col>
|
||||
<.icon name="hero-plus" /> {gettext("New Data Field")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<%!-- Show form when creating or editing --%>
|
||||
<div :if={@show_form} class="mb-8">
|
||||
<.live_component
|
||||
module={MvWeb.CustomFieldLive.FormComponent}
|
||||
id={@form_id}
|
||||
custom_field={@editing_custom_field}
|
||||
on_save={
|
||||
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
|
||||
}
|
||||
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
}>
|
||||
{gettext("Edit")}
|
||||
</.link>
|
||||
</:action>
|
||||
<%!-- Hide table when form is visible --%>
|
||||
<.table
|
||||
:if={!@show_form}
|
||||
id="custom_fields"
|
||||
rows={@streams.custom_fields}
|
||||
row_click={
|
||||
fn {_id, custom_field} ->
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
end
|
||||
}
|
||||
>
|
||||
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={
|
||||
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
|
||||
}>
|
||||
{gettext("Delete")}
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
|
||||
{@field_type_label.(custom_field.value_type)}
|
||||
</:col>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Delete Custom Field")}</h3>
|
||||
<:col :let={{_id, custom_field}} label={gettext("Description")}>
|
||||
{custom_field.description}
|
||||
</:col>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
||||
<div>
|
||||
<p class="font-semibold">
|
||||
{ngettext(
|
||||
"%{count} member has a value assigned for this custom field.",
|
||||
"%{count} members have values assigned for this custom field.",
|
||||
@custom_field_to_delete.assigned_members_count,
|
||||
count: @custom_field_to_delete.assigned_members_count
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{gettext(
|
||||
"All custom field values will be permanently deleted when you delete this custom field."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<:col
|
||||
:let={{_id, custom_field}}
|
||||
label={gettext("Required")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
>
|
||||
<span :if={custom_field.required} class="text-base-content font-semibold">
|
||||
{gettext("Required")}
|
||||
</span>
|
||||
<span :if={!custom_field.required} class="text-base-content/70">
|
||||
{gettext("Optional")}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:col
|
||||
:let={{_id, custom_field}}
|
||||
label={gettext("Show in overview")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
>
|
||||
<span :if={custom_field.show_in_overview} class="badge badge-success">
|
||||
{gettext("Yes")}
|
||||
</span>
|
||||
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
|
||||
{gettext("No")}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
}>
|
||||
{gettext("Edit")}
|
||||
</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
|
||||
{gettext("Delete")}
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Delete Data Field")}</h3>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
||||
<div>
|
||||
<label for="slug-confirmation" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("To confirm deletion, please enter this text:")}
|
||||
</span>
|
||||
</label>
|
||||
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
|
||||
{@custom_field_to_delete.slug}
|
||||
</div>
|
||||
<form phx-change="update_slug_confirmation" phx-target={@myself}>
|
||||
<input
|
||||
id="slug-confirmation"
|
||||
name="slug"
|
||||
type="text"
|
||||
value={@slug_confirmation}
|
||||
placeholder={gettext("Enter the text above to confirm")}
|
||||
autocomplete="off"
|
||||
phx-mounted={JS.focus()}
|
||||
class="w-full input input-bordered"
|
||||
/>
|
||||
</form>
|
||||
<p class="font-semibold">
|
||||
{ngettext(
|
||||
"%{count} member has a value assigned for this custom field.",
|
||||
"%{count} members have values assigned for this custom field.",
|
||||
@custom_field_to_delete.assigned_members_count,
|
||||
count: @custom_field_to_delete.assigned_members_count
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{gettext(
|
||||
"All custom field values will be permanently deleted when you delete this custom field."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="confirm_delete"
|
||||
phx-target={@myself}
|
||||
class="btn btn-error"
|
||||
disabled={@slug_confirmation != @custom_field_to_delete.slug}
|
||||
>
|
||||
{gettext("Delete Custom Field and All Values")}
|
||||
</button>
|
||||
<div>
|
||||
<label for="slug-confirmation" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("To confirm deletion, please enter this text:")}
|
||||
</span>
|
||||
</label>
|
||||
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
|
||||
{@custom_field_to_delete.slug}
|
||||
</div>
|
||||
<form phx-change="update_slug_confirmation" phx-target={@myself}>
|
||||
<input
|
||||
id="slug-confirmation"
|
||||
name="slug"
|
||||
type="text"
|
||||
value={@slug_confirmation}
|
||||
placeholder={gettext("Enter the text above to confirm")}
|
||||
autocomplete="off"
|
||||
phx-mounted={JS.focus()}
|
||||
class="w-full input input-bordered"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</.form_section>
|
||||
|
||||
<div class="modal-action">
|
||||
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="confirm_delete"
|
||||
phx-target={@myself}
|
||||
class="btn btn-error"
|
||||
disabled={@slug_confirmation != @custom_field_to_delete.slug}
|
||||
>
|
||||
{gettext("Delete Custom Field and All Values")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
# Track previous show_form state to detect when form is closed
|
||||
previous_show_form = Map.get(socket.assigns, :show_form, false)
|
||||
|
||||
# If show_form is explicitly provided in assigns, reset editing state
|
||||
socket =
|
||||
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
|
||||
|
|
@ -179,6 +191,13 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
socket
|
||||
end
|
||||
|
||||
# Detect when form is closed (show_form changes from true to false)
|
||||
new_show_form = Map.get(assigns, :show_form, false)
|
||||
|
||||
if previous_show_form and not new_show_form do
|
||||
send(self(), {:editing_section_changed, nil})
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|
|
@ -193,6 +212,11 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
|
||||
@impl true
|
||||
def handle_event("new_custom_field", _params, socket) do
|
||||
# Only send event if form was not already open
|
||||
if not socket.assigns[:show_form] do
|
||||
send(self(), {:editing_section_changed, :custom_fields})
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_form, true)
|
||||
|
|
@ -204,6 +228,11 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
def handle_event("edit_custom_field", %{"id" => id}, socket) do
|
||||
custom_field = Ash.get!(Mv.Membership.CustomField, id)
|
||||
|
||||
# Only send event if form was not already open
|
||||
if not socket.assigns[:show_form] do
|
||||
send(self(), {:editing_section_changed, :custom_fields})
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_form, true)
|
||||
|
|
|
|||
|
|
@ -289,6 +289,6 @@ defmodule MvWeb.CustomFieldValueLive.Form do
|
|||
end
|
||||
|
||||
defp member_options(members) do
|
||||
Enum.map(members, &{"#{&1.first_name} #{&1.last_name}", &1.id})
|
||||
Enum.map(members, &{MvWeb.Helpers.MemberHelpers.display_name(&1), &1.id})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ defmodule MvWeb.CustomFieldValueLive.Show do
|
|||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Custom field value {@custom_field_value.id}
|
||||
Data field value {@custom_field_value.id}
|
||||
<:subtitle>This is a custom_field_value record from your database.</:subtitle>
|
||||
|
||||
<:actions>
|
||||
|
|
@ -62,6 +62,6 @@ defmodule MvWeb.CustomFieldValueLive.Show do
|
|||
|> assign(:custom_field_value, Ash.get!(Mv.Membership.CustomFieldValue, id))}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: "Show Custom field value"
|
||||
defp page_title(:edit), do: "Edit Custom field value"
|
||||
defp page_title(:show), do: "Show data field value"
|
||||
defp page_title(:edit), do: "Edit data field value"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
socket
|
||||
|> assign(:page_title, gettext("Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
|
|
@ -62,11 +63,21 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
</.button>
|
||||
</.form>
|
||||
</.form_section>
|
||||
<%!-- Custom Fields Section --%>
|
||||
<.live_component
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
/>
|
||||
<%!-- Memberdata Section --%>
|
||||
<.form_section title={gettext("Memberdata")}>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :custom_fields}
|
||||
module={MvWeb.MemberFieldLive.IndexComponent}
|
||||
id="member-fields-component"
|
||||
settings={@settings}
|
||||
/>
|
||||
<%!-- Custom Fields Section --%>
|
||||
<.live_component
|
||||
:if={@active_editing_section != :member_fields}
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
/>
|
||||
</.form_section>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -105,12 +116,14 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
)
|
||||
|
||||
{:noreply,
|
||||
put_flash(socket, :info, gettext("Custom field %{action} successfully", action: action))}
|
||||
socket
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> put_flash(:info, gettext("Data field %{action} successfully", action: action))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_deleted, _custom_field}, socket) do
|
||||
{:noreply, put_flash(socket, :info, gettext("Custom field deleted successfully"))}
|
||||
{:noreply, put_flash(socket, :info, gettext("Data field deleted successfully"))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -119,7 +132,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to delete custom field: %{error}", error: inspect(error))
|
||||
gettext("Failed to delete data field: %{error}", error: inspect(error))
|
||||
)}
|
||||
end
|
||||
|
||||
|
|
@ -128,6 +141,43 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:editing_section_changed, section}, socket) do
|
||||
{:noreply, assign(socket, :active_editing_section, section)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:member_field_saved, _member_field, action}, socket) do
|
||||
# Reload settings to get updated member_field_visibility
|
||||
{:ok, updated_settings} = Membership.get_settings()
|
||||
|
||||
# Send update to member fields component to close form
|
||||
send_update(MvWeb.MemberFieldLive.IndexComponent,
|
||||
id: "member-fields-component",
|
||||
show_form: false,
|
||||
settings: updated_settings
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:settings, updated_settings)
|
||||
|> assign(:active_editing_section, nil)
|
||||
|> put_flash(:info, gettext("Member field %{action} successfully", action: action))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:member_field_visibility_updated}, socket) do
|
||||
# Legacy event - reload settings and update component
|
||||
{:ok, updated_settings} = Membership.get_settings()
|
||||
|
||||
send_update(MvWeb.MemberFieldLive.IndexComponent,
|
||||
id: "member-fields-component",
|
||||
settings: updated_settings
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, :settings, updated_settings)}
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
|
|
|
|||
338
lib/mv_web/live/member_field_live/form_component.ex
Normal file
338
lib/mv_web/live/member_field_live/form_component.ex
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||
@moduledoc """
|
||||
LiveComponent form for editing member field properties (embedded in settings).
|
||||
|
||||
## Features
|
||||
- Edit member field visibility (show_in_overview)
|
||||
- Display member field information from Member Resource (read-only)
|
||||
- Restrict editing for email field (only show_in_overview can be changed)
|
||||
- Real-time validation
|
||||
- Updates Settings.member_field_visibility atomically
|
||||
|
||||
## Props
|
||||
- `member_field` - The member field atom to edit (e.g., :first_name, :email)
|
||||
- `settings` - The current Settings resource
|
||||
- `on_save` - Callback function to call when form is saved
|
||||
- `on_cancel` - Callback function to call when form is cancelled
|
||||
|
||||
## Note
|
||||
Member fields are technical fields that cannot be changed (name, value_type, description, required).
|
||||
Only the visibility (show_in_overview) can be modified.
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
alias Mv.Helpers.TypeParsers
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
alias MvWeb.Helpers.FieldTypeFormatter
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
@required_fields [:first_name, :last_name, :email]
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:field_attributes, get_field_attributes(assigns.member_field))
|
||||
|> assign(:is_email_field?, assigns.member_field == :email)
|
||||
|> assign(:field_label, MemberFields.label(assigns.member_field))
|
||||
|
||||
~H"""
|
||||
<div id={@id} class="mb-8 border shadow-xl card border-base-300">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="cancel"
|
||||
phx-target={@myself}
|
||||
aria-label={gettext("Back to Settings")}
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="w-4 h-4" />
|
||||
</.button>
|
||||
<h3 class="card-title">
|
||||
{gettext("Edit Field: %{field}", field: @field_label)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
id={@id <> "-form"}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={gettext("This is a technical field and cannot be changed")}
|
||||
aria-label={gettext("This is a technical field and cannot be changed")}
|
||||
>
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span class="mb-1 label flex items-center gap-2">
|
||||
{gettext("Name")}
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name={@form[:name].name}
|
||||
id={@form[:name].id}
|
||||
value={@field_label}
|
||||
disabled
|
||||
readonly
|
||||
class="w-full input"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={gettext("This is a technical field and cannot be changed")}
|
||||
aria-label={gettext("This is a technical field and cannot be changed")}
|
||||
>
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span class="mb-1 label flex items-center gap-2">
|
||||
{gettext("Value type")}
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name={@form[:value_type].name}
|
||||
id={@form[:value_type].id}
|
||||
value={FieldTypeFormatter.format(@field_attributes.value_type)}
|
||||
disabled
|
||||
readonly
|
||||
class="w-full input"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@is_email_field?}
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={gettext("This is a technical field and cannot be changed")}
|
||||
aria-label={gettext("This is a technical field and cannot be changed")}
|
||||
>
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span class="mb-1 label flex items-center gap-2">
|
||||
{gettext("Description")}
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name={@form[:description].name}
|
||||
id={@form[:description].id}
|
||||
value={@form[:description].value}
|
||||
disabled
|
||||
readonly
|
||||
class="w-full input"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<.input
|
||||
:if={not @is_email_field?}
|
||||
field={@form[:description]}
|
||||
type="text"
|
||||
label={gettext("Description")}
|
||||
disabled={@is_email_field?}
|
||||
readonly={@is_email_field?}
|
||||
/>
|
||||
|
||||
<div
|
||||
:if={@is_email_field?}
|
||||
class="tooltip tooltip-right"
|
||||
data-tip={gettext("This is a technical field and cannot be changed")}
|
||||
aria-label={gettext("This is a technical field and cannot be changed")}
|
||||
>
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<input type="hidden" name={@form[:required].name} value="false" disabled />
|
||||
<span class="label flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={@form[:required].name}
|
||||
id={@form[:required].id}
|
||||
value="true"
|
||||
checked={@form[:required].value}
|
||||
disabled
|
||||
readonly
|
||||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
<span class="flex items-center gap-2">
|
||||
{gettext("Required")}
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<.input
|
||||
:if={not @is_email_field?}
|
||||
field={@form[:required]}
|
||||
type="checkbox"
|
||||
label={gettext("Required")}
|
||||
disabled={@is_email_field?}
|
||||
readonly={@is_email_field?}
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={@form[:show_in_overview]}
|
||||
type="checkbox"
|
||||
label={gettext("Show in overview")}
|
||||
/>
|
||||
|
||||
<div class="justify-end mt-4 card-actions">
|
||||
<.button type="button" phx-click="cancel" phx-target={@myself}>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Field")}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"member_field" => member_field_params}, socket) do
|
||||
# For member fields, we only validate show_in_overview
|
||||
# Other fields are read-only or derived from the Member Resource
|
||||
form = socket.assigns.form
|
||||
|
||||
updated_params =
|
||||
member_field_params
|
||||
|> Map.put(
|
||||
"show_in_overview",
|
||||
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
||||
)
|
||||
|> Map.put("name", form.source["name"])
|
||||
|> Map.put("value_type", form.source["value_type"])
|
||||
|> Map.put("description", form.source["description"])
|
||||
|> Map.put("required", form.source["required"])
|
||||
|
||||
updated_form =
|
||||
form
|
||||
|> Map.put(:value, updated_params)
|
||||
|> Map.put(:errors, [])
|
||||
|
||||
{:noreply, assign(socket, form: updated_form)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"member_field" => member_field_params}, socket) do
|
||||
# Only show_in_overview can be changed for member fields
|
||||
show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"])
|
||||
field_string = Atom.to_string(socket.assigns.member_field)
|
||||
|
||||
# Use atomic action to update only this single field
|
||||
# This prevents lost updates in concurrent scenarios
|
||||
case Membership.update_single_member_field_visibility(
|
||||
socket.assigns.settings,
|
||||
field: field_string,
|
||||
show_in_overview: show_in_overview
|
||||
) do
|
||||
{:ok, _updated_settings} ->
|
||||
socket.assigns.on_save.(socket.assigns.member_field, "update")
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, error} ->
|
||||
# Add error to form
|
||||
form =
|
||||
socket.assigns.form
|
||||
|> Map.put(:errors, [
|
||||
%{field: :show_in_overview, message: format_error(error)}
|
||||
])
|
||||
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel", _params, socket) do
|
||||
socket.assigns.on_cancel.()
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
|
||||
field_attributes = get_field_attributes(member_field)
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
normalized_config = VisibilityConfig.normalize(visibility_config)
|
||||
show_in_overview = Map.get(normalized_config, member_field, true)
|
||||
|
||||
# Create a manual form structure with string keys
|
||||
# Note: immutable is not included as it's not editable for member fields
|
||||
form_data = %{
|
||||
"name" => MemberFields.label(member_field),
|
||||
"value_type" => FieldTypeFormatter.format(field_attributes.value_type),
|
||||
"description" => field_attributes.description || "",
|
||||
"required" => field_attributes.required,
|
||||
"show_in_overview" => show_in_overview
|
||||
}
|
||||
|
||||
form = to_form(form_data, as: "member_field")
|
||||
|
||||
assign(socket, form: form)
|
||||
end
|
||||
|
||||
defp get_field_attributes(field) when is_atom(field) do
|
||||
# Get attribute info from Member Resource
|
||||
alias Ash.Resource.Info
|
||||
|
||||
case Info.attribute(Mv.Membership.Member, field) do
|
||||
nil ->
|
||||
# Fallback for fields not in resource (shouldn't happen with Constants)
|
||||
%{
|
||||
value_type: :string,
|
||||
description: nil,
|
||||
required: field in @required_fields
|
||||
}
|
||||
|
||||
attribute ->
|
||||
%{
|
||||
value_type: attribute.type,
|
||||
description: nil,
|
||||
required: not attribute.allow_nil?
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_error(%Ash.Error.Invalid{} = error) do
|
||||
Ash.ErrorKind.message(error)
|
||||
end
|
||||
|
||||
defp format_error(error) do
|
||||
inspect(error)
|
||||
end
|
||||
end
|
||||
219
lib/mv_web/live/member_field_live/index_component.ex
Normal file
219
lib/mv_web/live/member_field_live/index_component.ex
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
defmodule MvWeb.MemberFieldLive.IndexComponent do
|
||||
@moduledoc """
|
||||
LiveComponent for managing member field visibility in overview (embedded in settings).
|
||||
|
||||
## Features
|
||||
- List all member fields from Mv.Constants.member_fields()
|
||||
- Display show_in_overview status as badge (Yes/No)
|
||||
- Display required status based on actual attribute definitions (allow_nil? false)
|
||||
- Edit member field properties (expandable form like custom fields)
|
||||
- Updates Settings.member_field_visibility
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
alias Ash.Resource.Info
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
alias MvWeb.Helpers.FieldTypeFormatter
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:member_fields, get_member_fields_with_visibility(assigns.settings))
|
||||
|> assign(:required?, &required?/1)
|
||||
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
{gettext(
|
||||
"These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<%!-- Show form when editing --%>
|
||||
<div :if={@show_form} class="mb-8">
|
||||
<.live_component
|
||||
module={MvWeb.MemberFieldLive.FormComponent}
|
||||
id={@form_id}
|
||||
member_field={@editing_member_field}
|
||||
settings={@settings}
|
||||
on_save={
|
||||
fn member_field, action ->
|
||||
send(self(), {:member_field_saved, member_field, action})
|
||||
end
|
||||
}
|
||||
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Hide table when form is visible --%>
|
||||
<.table
|
||||
:if={!@show_form}
|
||||
id="member_fields"
|
||||
rows={@member_fields}
|
||||
>
|
||||
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
|
||||
{MemberFields.label(field_data.field)}
|
||||
</:col>
|
||||
|
||||
<:col :let={{_field_name, field_data}} label={gettext("Value Type")}>
|
||||
{format_value_type(field_data.field)}
|
||||
</:col>
|
||||
|
||||
<:col :let={{_field_name, field_data}} label={gettext("Description")}>
|
||||
{field_data.description || ""}
|
||||
</:col>
|
||||
|
||||
<:col
|
||||
:let={{_field_name, field_data}}
|
||||
label={gettext("Required")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
>
|
||||
<span
|
||||
:if={@required?.(field_data.field)}
|
||||
class="text-base-content font-semibold"
|
||||
>
|
||||
{gettext("Required")}
|
||||
</span>
|
||||
<span :if={!@required?.(field_data.field)} class="text-base-content/70">
|
||||
{gettext("Optional")}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:col
|
||||
:let={{_field_name, field_data}}
|
||||
label={gettext("Show in overview")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
>
|
||||
<span :if={field_data.show_in_overview} class="badge badge-success">
|
||||
{gettext("Yes")}
|
||||
</span>
|
||||
<span :if={!field_data.show_in_overview} class="badge badge-ghost">
|
||||
{gettext("No")}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={{_field_name, field_data}}>
|
||||
<.link
|
||||
phx-click="edit_member_field"
|
||||
phx-value-field={Atom.to_string(field_data.field)}
|
||||
phx-target={@myself}
|
||||
>
|
||||
{gettext("Edit")}
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
# Track previous show_form state to detect when form is closed
|
||||
previous_show_form = Map.get(socket.assigns, :show_form, false)
|
||||
|
||||
# If show_form is explicitly provided in assigns, reset editing state
|
||||
socket =
|
||||
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
|
||||
socket
|
||||
|> assign(:editing_member_field, nil)
|
||||
|> assign(:form_id, "member-field-form-new")
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
# Detect when form is closed (show_form changes from true to false)
|
||||
new_show_form = Map.get(assigns, :show_form, false)
|
||||
|
||||
if previous_show_form and not new_show_form do
|
||||
send(self(), {:editing_section_changed, nil})
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:settings, fn -> get_settings() end)
|
||||
|> assign_new(:show_form, fn -> false end)
|
||||
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|
||||
|> assign_new(:editing_member_field, fn -> nil end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("edit_member_field", %{"field" => field_string}, socket) do
|
||||
# Validate that the field is a valid member field before converting to atom
|
||||
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
if field_string in valid_fields do
|
||||
field_atom = String.to_existing_atom(field_string)
|
||||
|
||||
# Only send event if form was not already open
|
||||
if not socket.assigns[:show_form] do
|
||||
send(self(), {:editing_section_changed, :member_fields})
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_form, true)
|
||||
|> assign(:editing_member_field, field_atom)
|
||||
|> assign(:form_id, "member-field-form-#{field_string}")}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp get_settings do
|
||||
case Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
settings
|
||||
|
||||
{:error, _} ->
|
||||
# Return a minimal struct-like map for fallback
|
||||
# This is only used for initial rendering, actual settings will be loaded properly
|
||||
%{member_field_visibility: %{}}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_member_fields_with_visibility(settings) do
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
|
||||
# Normalize visibility config keys to atoms
|
||||
normalized_config = VisibilityConfig.normalize(visibility_config)
|
||||
|
||||
Enum.map(member_fields, fn field ->
|
||||
show_in_overview = Map.get(normalized_config, field, true)
|
||||
attribute = Info.attribute(Mv.Membership.Member, field)
|
||||
|
||||
%{
|
||||
field: field,
|
||||
show_in_overview: show_in_overview,
|
||||
value_type: (attribute && attribute.type) || :string,
|
||||
description: nil
|
||||
}
|
||||
end)
|
||||
|> Enum.map(fn field_data ->
|
||||
{Atom.to_string(field_data.field), field_data}
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_value_type(field) when is_atom(field) do
|
||||
case Info.attribute(Mv.Membership.Member, field) do
|
||||
nil -> FieldTypeFormatter.format(:string)
|
||||
attribute -> FieldTypeFormatter.format(attribute.type)
|
||||
end
|
||||
end
|
||||
|
||||
# Check if a field is required by checking the actual attribute definition
|
||||
defp required?(field) when is_atom(field) do
|
||||
case Info.attribute(Mv.Membership.Member, field) do
|
||||
nil -> false
|
||||
attribute -> not attribute.allow_nil?
|
||||
end
|
||||
end
|
||||
|
||||
defp required?(_), do: false
|
||||
end
|
||||
|
|
@ -43,7 +43,7 @@ defmodule MvWeb.MemberLive.Form do
|
|||
|
||||
<h1 class="text-2xl font-bold text-center flex-1">
|
||||
<%= if @member do %>
|
||||
{@member.first_name} {@member.last_name}
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||
<% else %>
|
||||
{gettext("New Member")}
|
||||
<% end %>
|
||||
|
|
@ -82,10 +82,10 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<%!-- Name Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input field={@form[:first_name]} label={gettext("First Name")} required />
|
||||
<.input field={@form[:first_name]} label={gettext("First Name")} />
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
|
||||
<.input field={@form[:last_name]} label={gettext("Last Name")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -110,11 +110,6 @@ defmodule MvWeb.MemberLive.Form do
|
|||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
</div>
|
||||
|
||||
<%!-- Phone --%>
|
||||
<div>
|
||||
<.input field={@form[:phone_number]} label={gettext("Phone")} type="tel" />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-36">
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@ defmodule MvWeb.MemberLive.Index do
|
|||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership
|
||||
alias MvWeb.MemberLive.Index.Formatter
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
alias MvWeb.MemberLive.Index.FieldSelection
|
||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||
alias MvWeb.MemberLive.Index.Formatter
|
||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||
|
||||
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
||||
|
|
|
|||
|
|
@ -239,24 +239,6 @@
|
|||
>
|
||||
{member.city}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:phone_number in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_phone_number}
|
||||
field={:phone_number}
|
||||
label={gettext("Phone Number")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.phone_number}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:join_date in @member_fields_visible}
|
||||
|
|
@ -275,6 +257,24 @@
|
|||
>
|
||||
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:exit_date in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_exit_date}
|
||||
field={:exit_date}
|
||||
label={gettext("Exit Date")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{MvWeb.MemberLive.Index.format_date(member.exit_date)}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
label={gettext("Membership Fee Status")}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
3. Default (all fields visible)
|
||||
"""
|
||||
|
||||
alias Mv.Membership.Helpers.VisibilityConfig
|
||||
|
||||
@doc """
|
||||
Gets all available fields for selection.
|
||||
|
||||
|
|
@ -177,13 +179,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
# Gets member field visibility from settings
|
||||
defp get_member_field_visibility_from_settings(settings) do
|
||||
visibility_config =
|
||||
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
|
||||
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
|
||||
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
Enum.reduce(member_fields, %{}, fn field, acc ->
|
||||
field_string = Atom.to_string(field)
|
||||
show_in_overview = Map.get(visibility_config, field, true)
|
||||
# exit_date defaults to false (hidden), all other fields default to true
|
||||
default_visibility = if field == :exit_date, do: false, else: true
|
||||
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
||||
Map.put(acc, field_string, show_in_overview)
|
||||
end)
|
||||
end
|
||||
|
|
@ -199,27 +203,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
end)
|
||||
end
|
||||
|
||||
# Normalizes visibility config map keys from strings to atoms
|
||||
defp normalize_visibility_config(config) when is_map(config) do
|
||||
Enum.reduce(config, %{}, fn
|
||||
{key, value}, acc when is_atom(key) ->
|
||||
Map.put(acc, key, value)
|
||||
|
||||
{key, value}, acc when is_binary(key) ->
|
||||
try do
|
||||
atom_key = String.to_existing_atom(key)
|
||||
Map.put(acc, atom_key, value)
|
||||
rescue
|
||||
ArgumentError -> acc
|
||||
end
|
||||
|
||||
_, acc ->
|
||||
acc
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_visibility_config(_), do: %{}
|
||||
|
||||
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
|
||||
defp to_field_identifier(field_string) when is_binary(field_string) do
|
||||
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ defmodule MvWeb.MemberLive.Show do
|
|||
</.button>
|
||||
|
||||
<h1 class="text-2xl font-bold text-center flex-1">
|
||||
{@member.first_name} {@member.last_name}
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||
</h1>
|
||||
|
||||
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
||||
|
|
@ -104,11 +104,6 @@ defmodule MvWeb.MemberLive.Show do
|
|||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Phone --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Phone")} value={@member.phone_number} />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
require Ash.Query
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.CalendarCycles
|
||||
alias Mv.MembershipFees.CycleGenerator
|
||||
alias Mv.MembershipFees.MembershipFeeCycle
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
|
|
@ -63,7 +63,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
phx-click="delete_all_cycles"
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete all cycles")}
|
||||
title={gettext("Delete All Cycles")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete All Cycles")}
|
||||
|
|
@ -168,7 +168,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
phx-value-cycle_id={cycle.id}
|
||||
phx-target={@myself}
|
||||
class="btn btn-sm btn-error btn-outline"
|
||||
title={gettext("Delete cycle")}
|
||||
title={gettext("Delete Cycle")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
{gettext("Delete")}
|
||||
|
|
@ -329,16 +329,14 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
|||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
{gettext(
|
||||
"The cycle period will be calculated based on this date and the interval."
|
||||
)}
|
||||
{gettext("The cycle will be calculated based on this date and the interval.")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<%= if @create_cycle_date do %>
|
||||
<div class="form-control w-full mt-4">
|
||||
<label class="label">
|
||||
<span class="label-text">{gettext("Cycle Period")}</span>
|
||||
<span class="label-text">{gettext("Cycle")}</span>
|
||||
</label>
|
||||
<div class="text-sm text-base-content/70">
|
||||
{format_create_cycle_period(
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership.Member
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.Member
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.MembershipFees.MembershipFeeType
|
||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||
|
||||
@impl true
|
||||
|
|
@ -115,7 +115,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
phx-value-id={mft.id}
|
||||
data-confirm={gettext("Are you sure?")}
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
aria-label={gettext("Delete membership fee type")}
|
||||
aria-label={gettext("Delete Membership Fee Type")}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
|
|
|
|||
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
|
||||
|
|
@ -131,7 +131,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-green-900">
|
||||
{@user.member.first_name} {@user.member.last_name}
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
||||
</p>
|
||||
<p class="text-sm text-green-700">{@user.member.email}</p>
|
||||
</div>
|
||||
|
|
@ -210,7 +210,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
)
|
||||
]}
|
||||
>
|
||||
<p class="font-medium">{member.first_name} {member.last_name}</p>
|
||||
<p class="font-medium">{MvWeb.Helpers.MemberHelpers.display_name(member)}</p>
|
||||
<p class="text-sm text-base-content/70">{member.email}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
@ -438,7 +438,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
member_name =
|
||||
if selected_member,
|
||||
do: "#{selected_member.first_name} #{selected_member.last_name}",
|
||||
do: MvWeb.Helpers.MemberHelpers.display_name(selected_member),
|
||||
else: ""
|
||||
|
||||
# Store the selected member ID and name in socket state and clear unlink flag
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
</:col>
|
||||
<:col :let={user} label={gettext("Linked Member")}>
|
||||
<%= if user.member do %>
|
||||
{user.member.first_name} {user.member.last_name}
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(user.member)}
|
||||
<% else %>
|
||||
<span class="text-base-content/50">{gettext("No member linked")}</span>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ defmodule MvWeb.UserLive.Show do
|
|||
class="text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
<.icon name="hero-users" class="inline w-4 h-4 mr-1" />
|
||||
{@user.member.first_name} {@user.member.last_name}
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="italic text-gray-500">{gettext("No member linked")}</span>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ defmodule MvWeb.Translations.MemberFields do
|
|||
def label(:first_name), do: gettext("First Name")
|
||||
def label(:last_name), do: gettext("Last Name")
|
||||
def label(:email), do: gettext("Email")
|
||||
def label(:phone_number), do: gettext("Phone")
|
||||
def label(:join_date), do: gettext("Join Date")
|
||||
def label(:exit_date), do: gettext("Exit Date")
|
||||
def label(:notes), do: gettext("Notes")
|
||||
|
|
@ -28,6 +27,7 @@ defmodule MvWeb.Translations.MemberFields do
|
|||
def label(:street), do: gettext("Street")
|
||||
def label(:house_number), do: gettext("House Number")
|
||||
def label(:postal_code), do: gettext("Postal Code")
|
||||
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
|
||||
|
||||
# Fallback for unknown fields
|
||||
def label(field) do
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue