Merge remote-tracking branch 'origin/main' into feature/ui-for-adding-members-groups

This commit is contained in:
Simon 2026-02-03 16:15:53 +01:00
commit 03f27a5938
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
33 changed files with 2765 additions and 501 deletions

View file

@ -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()

View file

@ -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()

View file

@ -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">

View file

@ -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) ->

View file

@ -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

View file

@ -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]

View 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

View file

@ -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