Merge remote-tracking branch 'origin/main' into feature/ui-for-adding-members-groups
This commit is contained in:
commit
03f27a5938
33 changed files with 2765 additions and 501 deletions
|
|
@ -103,6 +103,7 @@ defmodule Mv.Accounts.User do
|
|||
# the specialized :update_user action below.
|
||||
update :update do
|
||||
primary? true
|
||||
accept [:email]
|
||||
|
||||
# Required because custom validation functions (email validation, member relationship validation)
|
||||
# cannot be executed atomically. These validations need to query the database and perform
|
||||
|
|
@ -310,6 +311,14 @@ defmodule Mv.Accounts.User do
|
|||
authorize_if expr(id == ^actor(:id))
|
||||
end
|
||||
|
||||
# update_user allows :member argument (link/unlink). Only admins may use it to prevent
|
||||
# privilege escalation (own_data could otherwise link to any member and get :linked scope).
|
||||
policy action(:update_user) do
|
||||
description "Only admins can update user with member link/unlink"
|
||||
forbid_unless Mv.Authorization.Checks.ActorIsAdmin
|
||||
authorize_if Mv.Authorization.Checks.ActorIsAdmin
|
||||
end
|
||||
|
||||
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
|
||||
policy action_type([:read, :create, :update, :destroy]) do
|
||||
description "Check permissions from user's role and permission set"
|
||||
|
|
|
|||
22
lib/mv/authorization/checks/actor_is_admin.ex
Normal file
22
lib/mv/authorization/checks/actor_is_admin.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
defmodule Mv.Authorization.Checks.ActorIsAdmin do
|
||||
@moduledoc """
|
||||
Policy check: true when the actor's role has permission_set_name "admin".
|
||||
|
||||
Used to restrict actions (e.g. User.update_user for member link/unlink) to admins only.
|
||||
"""
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
@impl true
|
||||
def describe(_opts), do: "actor has admin permission set"
|
||||
|
||||
@impl true
|
||||
def match?(nil, _context, _opts), do: false
|
||||
|
||||
def match?(actor, _context, _opts) do
|
||||
ps_name =
|
||||
get_in(actor, [Access.key(:role), Access.key(:permission_set_name)]) ||
|
||||
get_in(actor, [Access.key("role"), Access.key("permission_set_name")])
|
||||
|
||||
ps_name == "admin"
|
||||
end
|
||||
end
|
||||
|
|
@ -118,12 +118,15 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
# Home page
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
# Linked member detail (filtered by policy)
|
||||
"/members/:id"
|
||||
# No "/" - Mitglied must not see member index at root (same content as /members).
|
||||
# Own profile (sidebar links to /users/:id) and own user edit
|
||||
"/users/:id",
|
||||
"/users/:id/edit",
|
||||
"/users/:id/show/edit",
|
||||
# Linked member detail and edit (data access filtered by policy scope: :linked)
|
||||
"/members/:id",
|
||||
"/members/:id/edit",
|
||||
"/members/:id/show/edit"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
|
@ -151,8 +154,10 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
],
|
||||
pages: [
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||
"/users/:id",
|
||||
"/users/:id/edit",
|
||||
"/users/:id/show/edit",
|
||||
# Member list
|
||||
"/members",
|
||||
# Member detail
|
||||
|
|
@ -198,14 +203,17 @@ defmodule Mv.Authorization.PermissionSets do
|
|||
],
|
||||
pages: [
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||
"/users/:id",
|
||||
"/users/:id/edit",
|
||||
"/users/:id/show/edit",
|
||||
"/members",
|
||||
# Create member
|
||||
"/members/new",
|
||||
"/members/:id",
|
||||
# Edit member
|
||||
"/members/:id/edit",
|
||||
"/members/:id/show/edit",
|
||||
"/custom_field_values",
|
||||
# Custom field value detail
|
||||
"/custom_field_values/:id",
|
||||
|
|
|
|||
|
|
@ -21,4 +21,99 @@ defmodule Mv.Config do
|
|||
def sql_sandbox? do
|
||||
Application.get_env(:mv, :sql_sandbox, false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the maximum file size for CSV imports in bytes.
|
||||
|
||||
Reads the `max_file_size_mb` value from the CSV import configuration
|
||||
and converts it to bytes.
|
||||
|
||||
## Returns
|
||||
|
||||
- Maximum file size in bytes (default: 10_485_760 bytes = 10 MB)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Config.csv_import_max_file_size_bytes()
|
||||
10_485_760
|
||||
"""
|
||||
@spec csv_import_max_file_size_bytes() :: non_neg_integer()
|
||||
def csv_import_max_file_size_bytes do
|
||||
max_file_size_mb = get_csv_import_config(:max_file_size_mb, 10)
|
||||
max_file_size_mb * 1024 * 1024
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the maximum number of rows allowed in CSV imports.
|
||||
|
||||
Reads the `max_rows` value from the CSV import configuration.
|
||||
|
||||
## Returns
|
||||
|
||||
- Maximum number of rows (default: 1000)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Config.csv_import_max_rows()
|
||||
1000
|
||||
"""
|
||||
@spec csv_import_max_rows() :: pos_integer()
|
||||
def csv_import_max_rows do
|
||||
get_csv_import_config(:max_rows, 1000)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the maximum file size for CSV imports in megabytes.
|
||||
|
||||
Reads the `max_file_size_mb` value from the CSV import configuration.
|
||||
|
||||
## Returns
|
||||
|
||||
- Maximum file size in megabytes (default: 10)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Config.csv_import_max_file_size_mb()
|
||||
10
|
||||
"""
|
||||
@spec csv_import_max_file_size_mb() :: pos_integer()
|
||||
def csv_import_max_file_size_mb do
|
||||
get_csv_import_config(:max_file_size_mb, 10)
|
||||
end
|
||||
|
||||
# Helper function to get CSV import config values
|
||||
defp get_csv_import_config(key, default) do
|
||||
Application.get_env(:mv, :csv_import, [])
|
||||
|> Keyword.get(key, default)
|
||||
|> parse_and_validate_integer(default)
|
||||
end
|
||||
|
||||
# Parses and validates integer configuration values.
|
||||
#
|
||||
# Accepts:
|
||||
# - Integer values (passed through)
|
||||
# - String integers (e.g., "1000") - parsed to integer
|
||||
# - Invalid values (e.g., "abc", nil) - falls back to default
|
||||
#
|
||||
# Always clamps the result to a minimum of 1 to ensure positive values.
|
||||
#
|
||||
# Note: We don't log warnings for unparseable values because:
|
||||
# - These functions may be called frequently (e.g., on every request)
|
||||
# - Logging would create excessive log spam
|
||||
# - The fallback to default provides a safe behavior
|
||||
# - Configuration errors should be caught during deployment/testing
|
||||
defp parse_and_validate_integer(value, _default) when is_integer(value) do
|
||||
max(1, value)
|
||||
end
|
||||
|
||||
defp parse_and_validate_integer(value, default) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int, _remainder} -> max(1, int)
|
||||
:error -> default
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_and_validate_integer(_value, default) do
|
||||
default
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
chunks: list(list({pos_integer(), map()})),
|
||||
column_map: %{atom() => non_neg_integer()},
|
||||
custom_field_map: %{String.t() => non_neg_integer()},
|
||||
custom_field_lookup: %{String.t() => %{id: String.t(), value_type: atom()}},
|
||||
custom_field_lookup: %{
|
||||
String.t() => %{id: String.t(), value_type: atom(), name: String.t()}
|
||||
},
|
||||
warnings: list(String.t())
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +81,11 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
alias Mv.Helpers.SystemActor
|
||||
|
||||
# Import FieldTypes for human-readable type labels
|
||||
alias MvWeb.Translations.FieldTypes
|
||||
|
||||
# Configuration constants
|
||||
@default_max_errors 50
|
||||
@default_chunk_size 200
|
||||
|
|
@ -102,6 +109,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
- `opts` - Optional keyword list:
|
||||
- `:max_rows` - Maximum number of data rows allowed (default: 1000)
|
||||
- `:chunk_size` - Number of rows per chunk (default: 200)
|
||||
- `:actor` - Actor for authorization (default: system actor for systemic operations)
|
||||
|
||||
## Returns
|
||||
|
||||
|
|
@ -120,9 +128,10 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
def prepare(file_content, opts \\ []) do
|
||||
max_rows = Keyword.get(opts, :max_rows, @default_max_rows)
|
||||
chunk_size = Keyword.get(opts, :chunk_size, @default_chunk_size)
|
||||
actor = Keyword.get(opts, :actor, SystemActor.get_system_actor())
|
||||
|
||||
with {:ok, headers, rows} <- CsvParser.parse(file_content),
|
||||
{:ok, custom_fields} <- load_custom_fields(),
|
||||
{:ok, custom_fields} <- load_custom_fields(actor),
|
||||
{:ok, maps, warnings} <- build_header_maps(headers, custom_fields),
|
||||
:ok <- validate_row_count(rows, max_rows) do
|
||||
chunks = chunk_rows(rows, maps, chunk_size)
|
||||
|
|
@ -142,10 +151,10 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
end
|
||||
|
||||
# Loads all custom fields from the database
|
||||
defp load_custom_fields do
|
||||
defp load_custom_fields(actor) do
|
||||
custom_fields =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.read!()
|
||||
|> Ash.read!(actor: actor)
|
||||
|
||||
{:ok, custom_fields}
|
||||
rescue
|
||||
|
|
@ -158,7 +167,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
custom_fields
|
||||
|> Enum.reduce(%{}, fn cf, acc ->
|
||||
id_str = to_string(cf.id)
|
||||
Map.put(acc, id_str, %{id: cf.id, value_type: cf.value_type})
|
||||
Map.put(acc, id_str, %{id: cf.id, value_type: cf.value_type, name: cf.name})
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
@ -182,8 +191,10 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
normalized != "" && not member_field?(normalized)
|
||||
end)
|
||||
|> Enum.map(fn header ->
|
||||
"Unknown column '#{header}' will be ignored. " <>
|
||||
"If this is a custom field, create it in Mila before importing."
|
||||
gettext(
|
||||
"Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing.",
|
||||
header: header
|
||||
)
|
||||
end)
|
||||
|
||||
{:ok, %{member: member_map, custom: custom_map}, warnings}
|
||||
|
|
@ -224,17 +235,20 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
# Builds a row map from raw row values using column maps
|
||||
defp build_row_map(row_values, maps) do
|
||||
row_tuple = List.to_tuple(row_values)
|
||||
tuple_size = tuple_size(row_tuple)
|
||||
|
||||
member_map =
|
||||
maps.member
|
||||
|> Enum.reduce(%{}, fn {field, index}, acc ->
|
||||
value = Enum.at(row_values, index, "")
|
||||
value = if index < tuple_size, do: elem(row_tuple, index), else: ""
|
||||
Map.put(acc, field, value)
|
||||
end)
|
||||
|
||||
custom_map =
|
||||
maps.custom
|
||||
|> Enum.reduce(%{}, fn {custom_field_id, index}, acc ->
|
||||
value = Enum.at(row_values, index, "")
|
||||
value = if index < tuple_size, do: elem(row_tuple, index), else: ""
|
||||
Map.put(acc, custom_field_id, value)
|
||||
end)
|
||||
|
||||
|
|
@ -299,7 +313,7 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
custom_field_lookup = Keyword.get(opts, :custom_field_lookup, %{})
|
||||
existing_error_count = Keyword.get(opts, :existing_error_count, 0)
|
||||
max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
|
||||
actor = Keyword.fetch!(opts, :actor)
|
||||
actor = Keyword.get(opts, :actor, SystemActor.get_system_actor())
|
||||
|
||||
{inserted, failed, errors, _collected_error_count, truncated?} =
|
||||
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false}, fn {line_number, row_map},
|
||||
|
|
@ -508,32 +522,19 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
|
||||
{:ok, %{member: trimmed_member_attrs, custom: custom_attrs}} ->
|
||||
# Prepare custom field values for Ash
|
||||
custom_field_values = prepare_custom_field_values(custom_attrs, custom_field_lookup)
|
||||
case prepare_custom_field_values(custom_attrs, custom_field_lookup) do
|
||||
{:error, validation_errors} ->
|
||||
# Custom field validation errors - return first error
|
||||
first_error = List.first(validation_errors)
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: first_error}}
|
||||
|
||||
# Create member with custom field values
|
||||
member_attrs_with_cf =
|
||||
trimmed_member_attrs
|
||||
|> Map.put(:custom_field_values, custom_field_values)
|
||||
|
||||
# Only include custom_field_values if not empty
|
||||
final_attrs =
|
||||
if Enum.empty?(custom_field_values) do
|
||||
Map.delete(member_attrs_with_cf, :custom_field_values)
|
||||
else
|
||||
member_attrs_with_cf
|
||||
end
|
||||
|
||||
case Mv.Membership.create_member(final_attrs, actor: actor) do
|
||||
{:ok, member} ->
|
||||
{:ok, member}
|
||||
|
||||
{:error, %Ash.Error.Invalid{} = error} ->
|
||||
# Extract email from final_attrs for better error messages
|
||||
email = Map.get(final_attrs, :email) || Map.get(trimmed_member_attrs, :email)
|
||||
{:error, format_ash_error(error, line_number, email)}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
|
||||
{:ok, custom_field_values} ->
|
||||
create_member_with_custom_fields(
|
||||
trimmed_member_attrs,
|
||||
custom_field_values,
|
||||
line_number,
|
||||
actor
|
||||
)
|
||||
end
|
||||
end
|
||||
rescue
|
||||
|
|
@ -541,71 +542,241 @@ defmodule Mv.Membership.Import.MemberCSV do
|
|||
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
|
||||
end
|
||||
|
||||
# Prepares custom field values from row map for Ash
|
||||
defp prepare_custom_field_values(custom_attrs, custom_field_lookup) when is_map(custom_attrs) do
|
||||
custom_attrs
|
||||
|> Enum.filter(fn {_id, value} -> value != nil && value != "" end)
|
||||
|> Enum.map(fn {custom_field_id_str, value} ->
|
||||
case Map.get(custom_field_lookup, custom_field_id_str) do
|
||||
nil ->
|
||||
# Custom field not found, skip
|
||||
nil
|
||||
# Creates a member with custom field values, handling errors appropriately
|
||||
defp create_member_with_custom_fields(
|
||||
trimmed_member_attrs,
|
||||
custom_field_values,
|
||||
line_number,
|
||||
actor
|
||||
) do
|
||||
# Create member with custom field values
|
||||
member_attrs_with_cf =
|
||||
trimmed_member_attrs
|
||||
|> Map.put(:custom_field_values, custom_field_values)
|
||||
|
||||
%{id: custom_field_id, value_type: value_type} ->
|
||||
%{
|
||||
"custom_field_id" => to_string(custom_field_id),
|
||||
"value" => format_custom_field_value(value, value_type)
|
||||
}
|
||||
# Only include custom_field_values if not empty
|
||||
final_attrs =
|
||||
if Enum.empty?(custom_field_values) do
|
||||
Map.delete(member_attrs_with_cf, :custom_field_values)
|
||||
else
|
||||
member_attrs_with_cf
|
||||
end
|
||||
end)
|
||||
|> Enum.filter(&(&1 != nil))
|
||||
|
||||
case Mv.Membership.create_member(final_attrs, actor: actor) do
|
||||
{:ok, member} ->
|
||||
{:ok, member}
|
||||
|
||||
{:error, %Ash.Error.Invalid{} = error} ->
|
||||
# Extract email from final_attrs for better error messages
|
||||
email = Map.get(final_attrs, :email) || Map.get(trimmed_member_attrs, :email)
|
||||
{:error, format_ash_error(error, line_number, email)}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, %Error{csv_line_number: line_number, field: nil, message: inspect(error)}}
|
||||
end
|
||||
end
|
||||
|
||||
defp prepare_custom_field_values(_, _), do: []
|
||||
# Prepares custom field values from row map for Ash
|
||||
# Returns {:ok, [custom_field_value_maps]} or {:error, [validation_errors]}
|
||||
defp prepare_custom_field_values(custom_attrs, custom_field_lookup) when is_map(custom_attrs) do
|
||||
{values, errors} =
|
||||
custom_attrs
|
||||
|> Enum.filter(fn {_id, value} -> value != nil && value != "" end)
|
||||
|> Enum.reduce({[], []}, fn {custom_field_id_str, value}, {acc_values, acc_errors} ->
|
||||
process_single_custom_field(
|
||||
custom_field_id_str,
|
||||
value,
|
||||
custom_field_lookup,
|
||||
acc_values,
|
||||
acc_errors
|
||||
)
|
||||
end)
|
||||
|
||||
if Enum.empty?(errors) do
|
||||
{:ok, Enum.reverse(values)}
|
||||
else
|
||||
{:error, Enum.reverse(errors)}
|
||||
end
|
||||
end
|
||||
|
||||
defp prepare_custom_field_values(_, _), do: {:ok, []}
|
||||
|
||||
# Processes a single custom field value and returns updated accumulator
|
||||
defp process_single_custom_field(
|
||||
custom_field_id_str,
|
||||
value,
|
||||
custom_field_lookup,
|
||||
acc_values,
|
||||
acc_errors
|
||||
) do
|
||||
# Trim value early and skip if empty
|
||||
trimmed_value = if is_binary(value), do: String.trim(value), else: value
|
||||
|
||||
# Skip empty values (after trimming) - don't create CFV
|
||||
if trimmed_value == "" or trimmed_value == nil do
|
||||
{acc_values, acc_errors}
|
||||
else
|
||||
process_non_empty_custom_field(
|
||||
custom_field_id_str,
|
||||
trimmed_value,
|
||||
custom_field_lookup,
|
||||
acc_values,
|
||||
acc_errors
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Processes a non-empty custom field value
|
||||
defp process_non_empty_custom_field(
|
||||
custom_field_id_str,
|
||||
trimmed_value,
|
||||
custom_field_lookup,
|
||||
acc_values,
|
||||
acc_errors
|
||||
) do
|
||||
case Map.get(custom_field_lookup, custom_field_id_str) do
|
||||
nil ->
|
||||
# Custom field not found, skip
|
||||
{acc_values, acc_errors}
|
||||
|
||||
%{id: custom_field_id, value_type: value_type, name: custom_field_name} ->
|
||||
case format_custom_field_value(trimmed_value, value_type, custom_field_name) do
|
||||
{:ok, formatted_value} ->
|
||||
value_map = %{
|
||||
"custom_field_id" => to_string(custom_field_id),
|
||||
"value" => formatted_value
|
||||
}
|
||||
|
||||
{[value_map | acc_values], acc_errors}
|
||||
|
||||
{:error, reason} ->
|
||||
{acc_values, [reason | acc_errors]}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Formats a custom field value according to its type
|
||||
# Uses _union_type and _union_value format as expected by Ash
|
||||
defp format_custom_field_value(value, :string) when is_binary(value) do
|
||||
%{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
# Returns {:ok, formatted_value} or {:error, error_message}
|
||||
defp format_custom_field_value(value, :string, _custom_field_name) when is_binary(value) do
|
||||
{:ok, %{"_union_type" => "string", "_union_value" => String.trim(value)}}
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :integer) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{int_value, _} -> %{"_union_type" => "integer", "_union_value" => int_value}
|
||||
:error -> %{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
defp format_custom_field_value(value, :integer, custom_field_name) when is_binary(value) do
|
||||
trimmed = String.trim(value)
|
||||
|
||||
case Integer.parse(trimmed) do
|
||||
{int_value, ""} ->
|
||||
# Fully consumed - valid integer
|
||||
{:ok, %{"_union_type" => "integer", "_union_value" => int_value}}
|
||||
|
||||
{_int_value, _remaining} ->
|
||||
# Not fully consumed - invalid
|
||||
{:error, format_custom_field_error(custom_field_name, :integer, trimmed)}
|
||||
|
||||
:error ->
|
||||
{:error, format_custom_field_error(custom_field_name, :integer, trimmed)}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :boolean) when is_binary(value) do
|
||||
bool_value =
|
||||
value
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
|> case do
|
||||
"true" -> true
|
||||
"1" -> true
|
||||
"yes" -> true
|
||||
"ja" -> true
|
||||
_ -> false
|
||||
end
|
||||
defp format_custom_field_value(value, :boolean, custom_field_name) when is_binary(value) do
|
||||
trimmed = String.trim(value)
|
||||
|
||||
%{"_union_type" => "boolean", "_union_value" => bool_value}
|
||||
end
|
||||
case parse_boolean_value(trimmed) do
|
||||
{:ok, bool_value} ->
|
||||
{:ok, %{"_union_type" => "boolean", "_union_value" => bool_value}}
|
||||
|
||||
defp format_custom_field_value(value, :date) when is_binary(value) do
|
||||
case Date.from_iso8601(String.trim(value)) do
|
||||
{:ok, date} -> %{"_union_type" => "date", "_union_value" => date}
|
||||
{:error, _} -> %{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
:error ->
|
||||
{:error,
|
||||
format_custom_field_error_with_details(
|
||||
custom_field_name,
|
||||
:boolean,
|
||||
trimmed,
|
||||
gettext("(true/false/1/0/yes/no/ja/nein)")
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :email) when is_binary(value) do
|
||||
%{"_union_type" => "email", "_union_value" => String.trim(value)}
|
||||
defp format_custom_field_value(value, :date, custom_field_name) when is_binary(value) do
|
||||
trimmed = String.trim(value)
|
||||
|
||||
case Date.from_iso8601(trimmed) do
|
||||
{:ok, date} ->
|
||||
{:ok, %{"_union_type" => "date", "_union_value" => date}}
|
||||
|
||||
{:error, _} ->
|
||||
{:error,
|
||||
format_custom_field_error_with_details(
|
||||
custom_field_name,
|
||||
:date,
|
||||
trimmed,
|
||||
gettext("(ISO-8601 format: YYYY-MM-DD)")
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, _type) when is_binary(value) do
|
||||
defp format_custom_field_value(value, :email, custom_field_name) when is_binary(value) do
|
||||
trimmed = String.trim(value)
|
||||
|
||||
# Use EctoCommons.EmailValidator for consistency with Member email validation
|
||||
changeset =
|
||||
{%{}, %{email: :string}}
|
||||
|> Ecto.Changeset.cast(%{email: trimmed}, [:email])
|
||||
|> EctoCommons.EmailValidator.validate_email(:email,
|
||||
checks: Mv.Constants.email_validator_checks()
|
||||
)
|
||||
|
||||
if changeset.valid? do
|
||||
{:ok, %{"_union_type" => "email", "_union_value" => trimmed}}
|
||||
else
|
||||
{:error, format_custom_field_error(custom_field_name, :email, trimmed)}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, _type, _custom_field_name) when is_binary(value) do
|
||||
# Default to string if type is unknown
|
||||
%{"_union_type" => "string", "_union_value" => String.trim(value)}
|
||||
{:ok, %{"_union_type" => "string", "_union_value" => String.trim(value)}}
|
||||
end
|
||||
|
||||
# Parses a boolean value from a string, supporting multiple formats
|
||||
defp parse_boolean_value(value) when is_binary(value) do
|
||||
lower = String.downcase(value)
|
||||
parse_boolean_value_lower(lower)
|
||||
end
|
||||
|
||||
# Helper function with pattern matching for boolean values
|
||||
defp parse_boolean_value_lower("true"), do: {:ok, true}
|
||||
defp parse_boolean_value_lower("1"), do: {:ok, true}
|
||||
defp parse_boolean_value_lower("yes"), do: {:ok, true}
|
||||
defp parse_boolean_value_lower("ja"), do: {:ok, true}
|
||||
defp parse_boolean_value_lower("false"), do: {:ok, false}
|
||||
defp parse_boolean_value_lower("0"), do: {:ok, false}
|
||||
defp parse_boolean_value_lower("no"), do: {:ok, false}
|
||||
defp parse_boolean_value_lower("nein"), do: {:ok, false}
|
||||
defp parse_boolean_value_lower(_), do: :error
|
||||
|
||||
# Generates a consistent error message for custom field validation failures
|
||||
# Uses human-readable field type labels (e.g., "Number" instead of "integer")
|
||||
defp format_custom_field_error(custom_field_name, value_type, value) do
|
||||
type_label = FieldTypes.label(value_type)
|
||||
|
||||
gettext("custom_field: %{name} – expected %{type}, got: %{value}",
|
||||
name: custom_field_name,
|
||||
type: type_label,
|
||||
value: value
|
||||
)
|
||||
end
|
||||
|
||||
# Generates an error message with additional details (e.g., format hints)
|
||||
defp format_custom_field_error_with_details(custom_field_name, value_type, value, details) do
|
||||
type_label = FieldTypes.label(value_type)
|
||||
|
||||
gettext("custom_field: %{name} – expected %{type} %{details}, got: %{value}",
|
||||
name: custom_field_name,
|
||||
type: type_label,
|
||||
details: details,
|
||||
value: value
|
||||
)
|
||||
end
|
||||
|
||||
# Trims all string values in member attributes
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ defmodule MvWeb.Authorization do
|
|||
"""
|
||||
|
||||
alias Mv.Authorization.PermissionSets
|
||||
alias MvWeb.Plugs.CheckPagePermission
|
||||
|
||||
@doc """
|
||||
Checks if user has permission for an action on a resource.
|
||||
|
|
@ -111,16 +112,9 @@ defmodule MvWeb.Authorization do
|
|||
def can_access_page?(nil, _page_path), do: false
|
||||
|
||||
def can_access_page?(user, page_path) do
|
||||
# Convert verified route to string if needed
|
||||
# Delegate to plug logic so UI uses same rules (reserved "new", own/linked path checks).
|
||||
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
|
||||
CheckPagePermission.user_can_access_page?(user, page_path_str, router: MvWeb.Router)
|
||||
end
|
||||
|
||||
# Check if scope allows access to record
|
||||
|
|
@ -172,33 +166,6 @@ defmodule MvWeb.Authorization do
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -125,9 +125,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
|||
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_last_completed_cycle(member)
|
||||
# => %MembershipFeeCycle{cycle_start: ~D[2024-01-01], ...}
|
||||
"""
|
||||
@spec get_last_completed_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
||||
@spec get_last_completed_cycle(Member.t() | nil, Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
||||
def get_last_completed_cycle(member, today \\ nil)
|
||||
|
||||
def get_last_completed_cycle(nil, _today), do: nil
|
||||
|
||||
def get_last_completed_cycle(%Member{} = member, today) do
|
||||
today = today || Date.utc_today()
|
||||
|
||||
|
|
@ -174,9 +176,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
|||
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_current_cycle(member)
|
||||
# => %MembershipFeeCycle{cycle_start: ~D[2024-04-01], ...}
|
||||
"""
|
||||
@spec get_current_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
||||
@spec get_current_cycle(Member.t() | nil, Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
||||
def get_current_cycle(member, today \\ nil)
|
||||
|
||||
def get_current_cycle(nil, _today), do: nil
|
||||
|
||||
def get_current_cycle(%Member{} = member, today) do
|
||||
today = today || Date.utc_today()
|
||||
|
||||
|
|
|
|||
|
|
@ -50,66 +50,69 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
|||
</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("Required")}
|
||||
class="max-w-[9.375rem] text-center"
|
||||
<div :if={!@show_form} id="custom_fields">
|
||||
<.table
|
||||
id="custom_fields_table"
|
||||
rows={@streams.custom_fields}
|
||||
row_click={
|
||||
fn {_id, custom_field} ->
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
end
|
||||
}
|
||||
>
|
||||
<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("Name")}>{custom_field.name}</: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>
|
||||
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
|
||||
{@field_type_label.(custom_field.value_type)}
|
||||
</: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>
|
||||
<:col :let={{_id, custom_field}} label={gettext("Description")}>
|
||||
{custom_field.description}
|
||||
</: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("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>
|
||||
</div>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
### Limits
|
||||
|
||||
- Maximum file size: 10 MB
|
||||
- Maximum rows: 1,000 rows (excluding header)
|
||||
- Maximum file size: configurable via `config :mv, csv_import: [max_file_size_mb: ...]`
|
||||
- Maximum rows: configurable via `config :mv, csv_import: [max_rows: ...]` (excluding header)
|
||||
- Processing: chunks of 200 rows
|
||||
- Errors: capped at 50 per import
|
||||
|
||||
|
|
@ -54,8 +54,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
|
||||
# CSV Import configuration constants
|
||||
# 10 MB
|
||||
@max_file_size_bytes 10_485_760
|
||||
@max_errors 50
|
||||
|
||||
@impl true
|
||||
|
|
@ -76,13 +74,15 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|> assign(:import_status, :idle)
|
||||
|> assign(:locale, locale)
|
||||
|> assign(:max_errors, @max_errors)
|
||||
|> assign(:csv_import_max_rows, Config.csv_import_max_rows())
|
||||
|> assign(:csv_import_max_file_size_mb, Config.csv_import_max_file_size_mb())
|
||||
|> assign_form()
|
||||
# Configure file upload with auto-upload enabled
|
||||
# Files are uploaded automatically when selected, no need for manual trigger
|
||||
|> allow_upload(:csv_file,
|
||||
accept: ~w(.csv),
|
||||
max_entries: 1,
|
||||
max_file_size: @max_file_size_bytes,
|
||||
max_file_size: Config.csv_import_max_file_size_bytes(),
|
||||
auto_upload: true
|
||||
)
|
||||
|
||||
|
|
@ -138,16 +138,21 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
<%= if Authorization.can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||
<.form_section title={gettext("Import Members (CSV)")}>
|
||||
<div role="note" class="alert alert-info mb-4">
|
||||
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="font-semibold">
|
||||
<p class="text-sm mb-2">
|
||||
{gettext(
|
||||
"Custom fields must be created in Mila before importing CSV files with custom field columns"
|
||||
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, so they must be listed in the list of memberdate (like e-mail or first name). Unknown data field columns will be ignored with a warning."
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm mt-2">
|
||||
{gettext(
|
||||
"Use the custom field name as the CSV column header (same normalization as member fields applies)"
|
||||
)}
|
||||
<p class="text-sm">
|
||||
<.link
|
||||
href="#custom_fields"
|
||||
class="link"
|
||||
data-testid="custom-fields-link"
|
||||
>
|
||||
{gettext("Manage Memberdata")}
|
||||
</.link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -200,7 +205,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
/>
|
||||
<label class="label" id="csv_file_help">
|
||||
<span class="label-text-alt">
|
||||
{gettext("CSV files only, maximum 10 MB")}
|
||||
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -408,8 +413,11 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
|
||||
# Processes CSV upload and starts import
|
||||
defp process_csv_upload(socket) do
|
||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||
|
||||
with {:ok, content} <- consume_and_read_csv(socket),
|
||||
{:ok, import_state} <- MemberCSV.prepare(content) do
|
||||
{:ok, import_state} <-
|
||||
MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do
|
||||
start_import(socket, import_state)
|
||||
else
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
require Jason
|
||||
|
||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||
import MvWeb.Authorization, only: [can?: 3]
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
|
|
@ -94,7 +95,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<%= if @user do %>
|
||||
<%= if @user && @can_manage_member_linking do %>
|
||||
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
||||
<p class="text-sm text-orange-800">
|
||||
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
||||
|
|
@ -125,129 +126,133 @@ defmodule MvWeb.UserLive.Form do
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Member Linking Section -->
|
||||
<div class="mt-6">
|
||||
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
||||
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
|
||||
<%= if @can_manage_member_linking do %>
|
||||
<div class="mt-6">
|
||||
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
||||
|
||||
<%= if @user && @user.member && !@unlink_member do %>
|
||||
<!-- Show linked member with unlink button -->
|
||||
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-green-900">
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
||||
</p>
|
||||
<p class="text-sm text-green-700">{@user.member.email}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="unlink_member"
|
||||
class="btn btn-sm btn-error"
|
||||
>
|
||||
{gettext("Unlink Member")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= if @unlink_member do %>
|
||||
<!-- Show unlink pending message -->
|
||||
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- Show member search/selection for unlinked users -->
|
||||
<div class="space-y-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="member-search-input"
|
||||
role="combobox"
|
||||
phx-hook="ComboBox"
|
||||
phx-focus="show_member_dropdown"
|
||||
phx-change="search_members"
|
||||
phx-debounce="300"
|
||||
phx-window-keydown="member_dropdown_keydown"
|
||||
value={@member_search_query}
|
||||
placeholder={gettext("Search for a member to link...")}
|
||||
class="w-full input"
|
||||
name="member_search"
|
||||
disabled={@unlink_member}
|
||||
aria-label={gettext("Search for member to link")}
|
||||
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="member-dropdown"
|
||||
aria-expanded={to_string(@show_member_dropdown)}
|
||||
aria-activedescendant={
|
||||
if @focused_member_index,
|
||||
do: "member-option-#{@focused_member_index}",
|
||||
else: nil
|
||||
}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<%= if length(@available_members) > 0 do %>
|
||||
<div
|
||||
id="member-dropdown"
|
||||
role="listbox"
|
||||
aria-label={gettext("Available members")}
|
||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
||||
phx-click-away="hide_member_dropdown"
|
||||
>
|
||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||
<div
|
||||
id={"member-option-#{index}"}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={to_string(@focused_member_index == index)}
|
||||
phx-click="select_member"
|
||||
phx-value-id={member.id}
|
||||
data-member-id={member.id}
|
||||
class={[
|
||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||
if(@focused_member_index == index,
|
||||
do: "bg-base-300",
|
||||
else: "hover:bg-base-200"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<p class="font-medium">{MvWeb.Helpers.MemberHelpers.display_name(member)}</p>
|
||||
<p class="text-sm text-base-content/70">{member.email}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @user && @user.member && !@unlink_member do %>
|
||||
<!-- Show linked member with unlink button -->
|
||||
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-green-900">
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
||||
</p>
|
||||
<p class="text-sm text-green-700">{@user.member.email}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="unlink_member"
|
||||
class="btn btn-sm btn-error"
|
||||
>
|
||||
{gettext("Unlink Member")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
||||
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
||||
<% else %>
|
||||
<%= if @unlink_member do %>
|
||||
<!-- Show unlink pending message -->
|
||||
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Note")}:</strong> {gettext(
|
||||
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- Show member search/selection for unlinked users -->
|
||||
<div class="space-y-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="member-search-input"
|
||||
role="combobox"
|
||||
phx-hook="ComboBox"
|
||||
phx-focus="show_member_dropdown"
|
||||
phx-change="search_members"
|
||||
phx-debounce="300"
|
||||
phx-window-keydown="member_dropdown_keydown"
|
||||
value={@member_search_query}
|
||||
placeholder={gettext("Search for a member to link...")}
|
||||
class="w-full input"
|
||||
name="member_search"
|
||||
disabled={@unlink_member}
|
||||
aria-label={gettext("Search for member to link")}
|
||||
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="member-dropdown"
|
||||
aria-expanded={to_string(@show_member_dropdown)}
|
||||
aria-activedescendant={
|
||||
if @focused_member_index,
|
||||
do: "member-option-#{@focused_member_index}",
|
||||
else: nil
|
||||
}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<%= if @selected_member_id && @selected_member_name do %>
|
||||
<div
|
||||
id="member-selected"
|
||||
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
||||
>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-blue-600">
|
||||
{gettext("Save to confirm linking.")}
|
||||
</p>
|
||||
<%= if length(@available_members) > 0 do %>
|
||||
<div
|
||||
id="member-dropdown"
|
||||
role="listbox"
|
||||
aria-label={gettext("Available members")}
|
||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
||||
phx-click-away="hide_member_dropdown"
|
||||
>
|
||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||
<div
|
||||
id={"member-option-#{index}"}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={to_string(@focused_member_index == index)}
|
||||
phx-click="select_member"
|
||||
phx-value-id={member.id}
|
||||
data-member-id={member.id}
|
||||
class={[
|
||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||
if(@focused_member_index == index,
|
||||
do: "bg-base-300",
|
||||
else: "hover:bg-base-200"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<p class="font-medium">
|
||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/70">{member.email}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
||||
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Note")}:</strong> {gettext(
|
||||
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @selected_member_id && @selected_member_name do %>
|
||||
<div
|
||||
id="member-selected"
|
||||
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
||||
>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-blue-600">
|
||||
{gettext("Save to confirm linking.")}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-4">
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
|
|
@ -289,14 +294,19 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
|
||||
defp mount_continue(user, params, socket) do
|
||||
actor = current_actor(socket)
|
||||
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
||||
page_title = action <> " " <> gettext("User")
|
||||
|
||||
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
|
||||
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(user: user)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign(:can_manage_member_linking, can_manage_member_linking)
|
||||
|> assign(:show_password_fields, false)
|
||||
|> assign(:member_search_query, "")
|
||||
|> assign(:available_members, [])
|
||||
|
|
@ -329,9 +339,9 @@ defmodule MvWeb.UserLive.Form do
|
|||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
|
||||
|
||||
# Reload members if email changed (for email-match priority)
|
||||
# Reload members if email changed (for email-match priority; only when member linking UI is shown)
|
||||
socket =
|
||||
if Map.has_key?(user_params, "email") do
|
||||
if Map.has_key?(user_params, "email") and socket.assigns[:can_manage_member_linking] do
|
||||
user_email = user_params["email"]
|
||||
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
|
||||
|
||||
|
|
@ -480,20 +490,25 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
|
||||
defp perform_member_link_action(socket, user, actor) do
|
||||
cond do
|
||||
# Selected member ID takes precedence (new link)
|
||||
socket.assigns.selected_member_id ->
|
||||
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
|
||||
actor: actor
|
||||
)
|
||||
# Only admins may link/unlink (backend policy also restricts update_user; UI must not call it).
|
||||
if can?(actor, :destroy, Mv.Accounts.User) do
|
||||
cond do
|
||||
# Selected member ID takes precedence (new link)
|
||||
socket.assigns.selected_member_id ->
|
||||
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Unlink flag is set
|
||||
socket.assigns[:unlink_member] ->
|
||||
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
|
||||
# Unlink flag is set
|
||||
socket.assigns[:unlink_member] ->
|
||||
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
|
||||
|
||||
# No changes to member relationship
|
||||
true ->
|
||||
{:ok, user}
|
||||
# No changes to member relationship
|
||||
true ->
|
||||
{:ok, user}
|
||||
end
|
||||
else
|
||||
{:ok, user}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -552,13 +567,28 @@ defmodule MvWeb.UserLive.Form do
|
|||
end
|
||||
|
||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
||||
defp assign_form(
|
||||
%{
|
||||
assigns: %{
|
||||
user: user,
|
||||
show_password_fields: show_password_fields,
|
||||
can_manage_member_linking: can_manage_member_linking
|
||||
}
|
||||
} = socket
|
||||
) do
|
||||
actor = current_actor(socket)
|
||||
|
||||
form =
|
||||
if user do
|
||||
# For existing users, use admin password action if password fields are shown
|
||||
action = if show_password_fields, do: :admin_set_password, else: :update_user
|
||||
# For existing users: admin uses update_user (email + member); non-admin uses update (email only).
|
||||
# Password change uses admin_set_password for both.
|
||||
action =
|
||||
cond do
|
||||
show_password_fields -> :admin_set_password
|
||||
can_manage_member_linking -> :update_user
|
||||
true -> :update
|
||||
end
|
||||
|
||||
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
||||
else
|
||||
# For new users, use password registration if password fields are shown
|
||||
|
|
|
|||
|
|
@ -5,15 +5,18 @@ 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
|
||||
- `:check_page_permission_on_params` - Attaches handle_params hook to enforce page permission on client-side navigation (push_patch)
|
||||
|
||||
## Usage
|
||||
Add to LiveView modules via:
|
||||
```elixir
|
||||
on_mount {MvWeb.LiveHelpers, :default}
|
||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
on_mount {MvWeb.LiveHelpers, :check_page_permission_on_params}
|
||||
```
|
||||
"""
|
||||
import Phoenix.Component
|
||||
alias MvWeb.Plugs.CheckPagePermission
|
||||
|
||||
def on_mount(:default, _params, session, socket) do
|
||||
locale = session["locale"] || "de"
|
||||
|
|
@ -26,6 +29,40 @@ defmodule MvWeb.LiveHelpers do
|
|||
{:cont, socket}
|
||||
end
|
||||
|
||||
def on_mount(:check_page_permission_on_params, _params, _session, socket) do
|
||||
{:cont,
|
||||
Phoenix.LiveView.attach_hook(
|
||||
socket,
|
||||
:check_page_permission,
|
||||
:handle_params,
|
||||
&check_page_permission_handle_params/3
|
||||
)}
|
||||
end
|
||||
|
||||
defp check_page_permission_handle_params(_params, uri, socket) do
|
||||
path = uri |> URI.parse() |> Map.get(:path, "/") || "/"
|
||||
|
||||
if CheckPagePermission.public_path?(path) do
|
||||
{:cont, socket}
|
||||
else
|
||||
user = socket.assigns[:current_user]
|
||||
host = uri |> URI.parse() |> Map.get(:host) || "localhost"
|
||||
|
||||
if CheckPagePermission.user_can_access_page?(user, path, router: MvWeb.Router, host: host) do
|
||||
{:cont, socket}
|
||||
else
|
||||
redirect_to = CheckPagePermission.redirect_target_for_user(user)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> Phoenix.LiveView.put_flash(:error, "You don't have permission to access this page.")
|
||||
|> Phoenix.LiveView.push_navigate(to: redirect_to)
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_user_role_loaded(socket) do
|
||||
user = socket.assigns[:current_user]
|
||||
|
||||
|
|
|
|||
315
lib/mv_web/plugs/check_page_permission.ex
Normal file
315
lib/mv_web/plugs/check_page_permission.ex
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
defmodule MvWeb.Plugs.CheckPagePermission do
|
||||
@moduledoc """
|
||||
Plug that checks if the current user has permission to access the requested page.
|
||||
|
||||
Runs in the router pipeline before LiveView mounts. Uses PermissionSets page list
|
||||
and matches the current route template (or request path) against allowed patterns.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Public paths (e.g. /auth, /register) are exempt and pass through.
|
||||
2. Extracts page path from conn via `Phoenix.Router.route_info/4` (route template
|
||||
like "/members/:id") or falls back to `conn.request_path`.
|
||||
3. Gets current user from `conn.assigns[:current_user]`.
|
||||
4. Gets user's permission_set_name from role and calls `PermissionSets.get_permissions/1`.
|
||||
5. Matches requested path against allowed patterns (exact, dynamic `:param`, wildcard "*").
|
||||
6. If unauthorized: redirects to "/sign-in" (no user) or "/users/:id" (user profile) with flash error and halts.
|
||||
|
||||
## Pattern Matching
|
||||
|
||||
- Exact: "/members" == "/members"
|
||||
- Dynamic: "/members/:id" matches "/members/123"
|
||||
- Wildcard: "*" matches everything (admin)
|
||||
- Reserved: the segment "new" is never matched by `:id` or `:slug` (e.g. `/members/new` and `/groups/new` require an explicit page permission).
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
alias Mv.Authorization.PermissionSets
|
||||
require Logger
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
if public_path?(conn.request_path) do
|
||||
conn
|
||||
else
|
||||
# Ensure role is loaded (load_from_session does not load it; required for permission check)
|
||||
user =
|
||||
conn.assigns[:current_user]
|
||||
|> Mv.Authorization.Actor.ensure_loaded()
|
||||
|
||||
conn = Plug.Conn.assign(conn, :current_user, user)
|
||||
page_path = get_page_path(conn)
|
||||
request_path = conn.request_path
|
||||
|
||||
if has_page_permission?(user, page_path, request_path) do
|
||||
conn
|
||||
else
|
||||
log_page_access_denied(user, page_path)
|
||||
|
||||
redirect_to = redirect_target(user)
|
||||
|
||||
conn
|
||||
|> fetch_session()
|
||||
|> fetch_flash()
|
||||
|> put_flash(:error, "You don't have permission to access this page.")
|
||||
|> redirect(to: redirect_to)
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the redirect URL for an unauthorized user (for LiveView push_redirect).
|
||||
"""
|
||||
def redirect_target_for_user(nil), do: "/sign-in"
|
||||
|
||||
def redirect_target_for_user(user) when is_map(user) or is_struct(user) do
|
||||
id = Map.get(user, :id) || Map.get(user, "id")
|
||||
if id, do: "/users/#{to_string(id)}", else: "/sign-in"
|
||||
end
|
||||
|
||||
def redirect_target_for_user(_), do: "/sign-in"
|
||||
|
||||
defp redirect_target(user), do: redirect_target_for_user(user)
|
||||
|
||||
@doc """
|
||||
Returns true if the path is public (no auth/permission check).
|
||||
Used by LiveView hook to skip redirect on sign-in etc.
|
||||
"""
|
||||
def public_path?(path) when is_binary(path) do
|
||||
path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out"] or
|
||||
String.starts_with?(path, "/auth") or
|
||||
String.starts_with?(path, "/confirm") or
|
||||
String.starts_with?(path, "/password-reset")
|
||||
end
|
||||
|
||||
defp get_page_path(conn) do
|
||||
router = conn.private[:phoenix_router]
|
||||
get_page_path_from_router(router, conn.method, conn.request_path, conn.host)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns whether the user is allowed to access the given request path.
|
||||
Used by the plug and by LiveView on_mount/handle_params for client-side navigation.
|
||||
|
||||
Options: `:router` (default MvWeb.Router), `:host` (default "localhost").
|
||||
"""
|
||||
def user_can_access_page?(user, request_path, opts \\ []) do
|
||||
router = Keyword.get(opts, :router, MvWeb.Router)
|
||||
host = Keyword.get(opts, :host, "localhost")
|
||||
page_path = get_page_path_from_router(router, "GET", request_path, host)
|
||||
has_page_permission?(user, page_path, request_path)
|
||||
end
|
||||
|
||||
defp get_page_path_from_router(router, method, request_path, host) do
|
||||
case Phoenix.Router.route_info(router, method, request_path, host) do
|
||||
%{route: route} -> route
|
||||
_ -> request_path
|
||||
end
|
||||
end
|
||||
|
||||
defp has_page_permission?(nil, _page_path, _request_path), do: false
|
||||
|
||||
defp has_page_permission?(user, page_path, request_path) do
|
||||
with ps_name when is_binary(ps_name) <- permission_set_name_from_user(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, request_path, user)
|
||||
else
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp permission_set_name_from_user(user) when is_map(user) or is_struct(user) do
|
||||
get_in(user, [Access.key(:role), Access.key(:permission_set_name)]) ||
|
||||
get_in(user, [Access.key("role"), Access.key("permission_set_name")])
|
||||
end
|
||||
|
||||
defp permission_set_name_from_user(_), do: nil
|
||||
|
||||
defp user_id_from_user(user) when is_map(user) or is_struct(user) do
|
||||
id = Map.get(user, :id) || Map.get(user, "id")
|
||||
if id, do: to_string(id), else: nil
|
||||
end
|
||||
|
||||
defp user_id_from_user(_), do: nil
|
||||
|
||||
# Reserved path segments that must not match a single :id param (e.g. /members/new, /users/new).
|
||||
@reserved_id_segments ["new"]
|
||||
|
||||
# For "/users/:id" with own_data we only allow when the id in the path equals the current user's id.
|
||||
# For "/members/:id" we reject when the segment is reserved (e.g. "new") so /members/new is not allowed.
|
||||
defp page_matches?(allowed_pages, requested_path, request_path, user) do
|
||||
Enum.any?(allowed_pages, fn pattern ->
|
||||
pattern_match?(pattern, requested_path, request_path, user)
|
||||
end)
|
||||
end
|
||||
|
||||
defp pattern_match?("*", _requested_path, _request_path, _user), do: true
|
||||
|
||||
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||
when pattern == "/users/:id" do
|
||||
match_dynamic_route?(pattern, request_path) and
|
||||
path_param_equals(pattern, request_path, "id", user_id_from_user(user))
|
||||
end
|
||||
|
||||
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||
when pattern in ["/users/:id/edit", "/users/:id/show/edit"] do
|
||||
match_dynamic_route?(pattern, request_path) and
|
||||
path_param_equals(pattern, request_path, "id", user_id_from_user(user))
|
||||
end
|
||||
|
||||
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||
when pattern == "/members/:id" do
|
||||
match_dynamic_route?(pattern, request_path) and
|
||||
path_param_not_reserved(pattern, request_path, "id", @reserved_id_segments) and
|
||||
members_show_allowed?(pattern, request_path, user)
|
||||
end
|
||||
|
||||
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||
when pattern in ["/members/:id/edit", "/members/:id/show/edit"] do
|
||||
match_dynamic_route?(pattern, request_path) and
|
||||
members_edit_allowed?(pattern, request_path, user)
|
||||
end
|
||||
|
||||
defp pattern_match?(pattern, _requested_path, request_path, _user)
|
||||
when pattern == "/groups/:slug" do
|
||||
match_dynamic_route?(pattern, request_path) and
|
||||
path_param_not_reserved(pattern, request_path, "slug", @reserved_id_segments)
|
||||
end
|
||||
|
||||
defp pattern_match?(pattern, requested_path, _request_path, _user)
|
||||
when pattern == requested_path do
|
||||
true
|
||||
end
|
||||
|
||||
defp pattern_match?(pattern, _requested_path, request_path, _user) do
|
||||
if String.contains?(pattern, ":") do
|
||||
match_dynamic_route?(pattern, request_path)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp path_param_not_reserved(pattern, request_path, param_name, reserved)
|
||||
when is_list(reserved) do
|
||||
segments = String.split(request_path, "/", trim: true)
|
||||
idx = param_index(pattern, param_name)
|
||||
|
||||
if idx < 0 do
|
||||
false
|
||||
else
|
||||
value = Enum.at(segments, idx)
|
||||
value not in reserved
|
||||
end
|
||||
end
|
||||
|
||||
defp path_param_equals(pattern, request_path, param_name, expected_value)
|
||||
when is_binary(expected_value) do
|
||||
segments = String.split(request_path, "/", trim: true)
|
||||
idx = param_index(pattern, param_name)
|
||||
|
||||
if idx < 0 do
|
||||
false
|
||||
else
|
||||
value = Enum.at(segments, idx)
|
||||
value == expected_value
|
||||
end
|
||||
end
|
||||
|
||||
defp path_param_equals(_, _, _, _), do: false
|
||||
|
||||
# For own_data: only allow show/edit when :id is the user's linked member. For other permission sets: allow when not reserved.
|
||||
defp members_show_allowed?(pattern, request_path, user) do
|
||||
if permission_set_name_from_user(user) == "own_data" do
|
||||
path_param_equals(pattern, request_path, "id", user_member_id(user))
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
defp members_edit_allowed?(pattern, request_path, user) do
|
||||
if permission_set_name_from_user(user) == "own_data" do
|
||||
path_param_equals(pattern, request_path, "id", user_member_id(user))
|
||||
else
|
||||
path_param_not_reserved(pattern, request_path, "id", @reserved_id_segments)
|
||||
end
|
||||
end
|
||||
|
||||
defp user_member_id(user) when is_map(user) or is_struct(user) do
|
||||
member_id = Map.get(user, :member_id) || Map.get(user, "member_id")
|
||||
|
||||
if is_nil(member_id) do
|
||||
load_member_id_for_user(user)
|
||||
else
|
||||
to_string(member_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp user_member_id(_), do: nil
|
||||
|
||||
defp load_member_id_for_user(user) do
|
||||
id = user_id_from_user(user)
|
||||
|
||||
if id do
|
||||
case Ash.get(Mv.Accounts.User, id, load: [:member], domain: Mv.Accounts, authorize?: false) do
|
||||
{:ok, loaded} when not is_nil(loaded.member_id) -> to_string(loaded.member_id)
|
||||
_ -> nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp param_index(pattern, param_name) do
|
||||
pattern
|
||||
|> String.split("/", trim: true)
|
||||
|> Enum.find_index(fn seg ->
|
||||
String.starts_with?(seg, ":") and String.trim_leading(seg, ":") == param_name
|
||||
end)
|
||||
|> case do
|
||||
nil -> -1
|
||||
i -> i
|
||||
end
|
||||
end
|
||||
|
||||
defp match_dynamic_route?(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
|
||||
|
||||
defp log_page_access_denied(user, page_path) do
|
||||
user_id =
|
||||
if user do
|
||||
Map.get(user, :id) || Map.get(user, "id") || "nil"
|
||||
else
|
||||
"nil"
|
||||
end
|
||||
|
||||
role_name =
|
||||
if user do
|
||||
get_in(user, [Access.key(:role), Access.key(:name)]) ||
|
||||
get_in(user, [Access.key("role"), Access.key("name")]) || "nil"
|
||||
else
|
||||
"nil"
|
||||
end
|
||||
|
||||
Logger.info("""
|
||||
Page access denied:
|
||||
User: #{user_id}
|
||||
Role: #{role_name}
|
||||
Page: #{page_path}
|
||||
""")
|
||||
end
|
||||
end
|
||||
|
|
@ -14,6 +14,7 @@ defmodule MvWeb.Router do
|
|||
plug :put_secure_browser_headers
|
||||
plug :load_from_session
|
||||
plug :set_locale
|
||||
plug MvWeb.Plugs.CheckPagePermission
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
|
|
@ -48,7 +49,8 @@ defmodule MvWeb.Router do
|
|||
ash_authentication_live_session :authentication_required,
|
||||
on_mount: [
|
||||
{MvWeb.LiveUserAuth, :live_user_required},
|
||||
{MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||
{MvWeb.LiveHelpers, :ensure_user_role_loaded},
|
||||
{MvWeb.LiveHelpers, :check_page_permission_on_params}
|
||||
] do
|
||||
live "/", MemberLive.Index, :index
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue