From 06d6531569fc485fd3cb6957aef3e062f6ab96dc Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 30 Jan 2026 11:13:28 +0100 Subject: [PATCH] UserLive.Form: gate Member-Linking to admin, use :update for non-admin - Show Member-Linking UI only when can_manage_member_linking (admin) - perform_member_link_action runs only for admin - assign_form: non-admin uses :update (email), admin uses :update_user - Load members for linking only when can_manage_member_linking --- lib/mv_web/live/user_live/form.ex | 290 ++++++++++++++++-------------- 1 file changed, 160 insertions(+), 130 deletions(-) diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 0a286c9..b24b214 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -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 @@ -125,129 +126,133 @@ defmodule MvWeb.UserLive.Form do <% end %> - -
-

{gettext("Linked Member")}

+ + <%= if @can_manage_member_linking do %> +
+

{gettext("Linked Member")}

- <%= if @user && @user.member && !@unlink_member do %> - -
-
-
-

- {MvWeb.Helpers.MemberHelpers.display_name(@user.member)} -

-

{@user.member.email}

-
- -
-
- <% else %> - <%= if @unlink_member do %> - -
-

- {gettext("Unlinking scheduled")}: {gettext( - "Member will be unlinked when you save. Cannot select new member until saved." - )} -

-
- <% end %> - -
-
- - - <%= if length(@available_members) > 0 do %> -
- <%= for {member, index} <- Enum.with_index(@available_members) do %> -
-

{MvWeb.Helpers.MemberHelpers.display_name(member)}

-

{member.email}

-
- <% end %> + <%= if @user && @user.member && !@unlink_member do %> + +
+
+
+

+ {MvWeb.Helpers.MemberHelpers.display_name(@user.member)} +

+

{@user.member.email}

- <% end %> + +
- - <%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %> -
+ <% else %> + <%= if @unlink_member do %> + +

- {gettext("Note")}: {gettext( - "A member with this email already exists. To link with a different member, please change one of the email addresses first." + {gettext("Unlinking scheduled")}: {gettext( + "Member will be unlinked when you save. Cannot select new member until saved." )}

<% end %> + +
+
+ - <%= if @selected_member_id && @selected_member_name do %> -
-

- {gettext("Selected")}: {@selected_member_name} -

-

- {gettext("Save to confirm linking.")} -

+ <%= if length(@available_members) > 0 do %> +
+ <%= for {member, index} <- Enum.with_index(@available_members) do %> +
+

+ {MvWeb.Helpers.MemberHelpers.display_name(member)} +

+

{member.email}

+
+ <% end %> +
+ <% end %>
- <% end %> -
- <% end %> -
+ + <%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %> +
+

+ {gettext("Note")}: {gettext( + "A member with this email already exists. To link with a different member, please change one of the email addresses first." + )} +

+
+ <% end %> + + <%= if @selected_member_id && @selected_member_name do %> +
+

+ {gettext("Selected")}: {@selected_member_name} +

+

+ {gettext("Save to confirm linking.")} +

+
+ <% end %> +
+ <% end %> +
+ <% end %>
<.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