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
This commit is contained in:
Moritz 2026-01-30 11:13:28 +01:00
parent 14fa873640
commit 06d6531569

View file

@ -36,6 +36,7 @@ defmodule MvWeb.UserLive.Form do
require Jason require Jason
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
import MvWeb.Authorization, only: [can?: 3]
@impl true @impl true
def render(assigns) do def render(assigns) do
@ -125,129 +126,133 @@ defmodule MvWeb.UserLive.Form do
<% end %> <% end %>
</div> </div>
<!-- Member Linking Section --> <!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
<div class="mt-6"> <%= if @can_manage_member_linking do %>
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2> <div class="mt-6">
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
<%= if @user && @user.member && !@unlink_member do %> <%= if @user && @user.member && !@unlink_member do %>
<!-- Show linked member with unlink button --> <!-- Show linked member with unlink button -->
<div class="p-4 border border-green-200 rounded-lg bg-green-50"> <div class="p-4 border border-green-200 rounded-lg bg-green-50">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="font-medium text-green-900"> <p class="font-medium text-green-900">
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)} {MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</p> </p>
<p class="text-sm text-green-700">{@user.member.email}</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 %>
</div> </div>
<% end %> <button
type="button"
phx-click="unlink_member"
class="btn btn-sm btn-error"
>
{gettext("Unlink Member")}
</button>
</div>
</div> </div>
<% else %>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %> <%= if @unlink_member do %>
<div class="p-3 border border-yellow-200 rounded bg-yellow-50"> <!-- Show unlink pending message -->
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800"> <p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext( <strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first." "Member will be unlinked when you save. Cannot select new member until saved."
)} )}
</p> </p>
</div> </div>
<% end %> <% 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 %> <%= if length(@available_members) > 0 do %>
<div <div
id="member-selected" id="member-dropdown"
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50" role="listbox"
> aria-label={gettext("Available members")}
<p class="text-sm text-blue-800"> 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"}"}
<strong>{gettext("Selected")}:</strong> {@selected_member_name} phx-click-away="hide_member_dropdown"
</p> >
<p class="mt-1 text-xs text-blue-600"> <%= for {member, index} <- Enum.with_index(@available_members) do %>
{gettext("Save to confirm linking.")} <div
</p> 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> </div>
<% end %>
</div> <%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<% end %> <div class="p-3 border border-yellow-200 rounded bg-yellow-50">
</div> <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"> <div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
@ -289,14 +294,19 @@ defmodule MvWeb.UserLive.Form do
end end
defp mount_continue(user, params, socket) do defp mount_continue(user, params, socket) do
actor = current_actor(socket)
action = if is_nil(user), do: gettext("New"), else: gettext("Edit") action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
page_title = action <> " " <> gettext("User") 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, {:ok,
socket socket
|> assign(:return_to, return_to(params["return_to"])) |> assign(:return_to, return_to(params["return_to"]))
|> assign(user: user) |> assign(user: user)
|> assign(:page_title, page_title) |> assign(:page_title, page_title)
|> assign(:can_manage_member_linking, can_manage_member_linking)
|> assign(:show_password_fields, false) |> assign(:show_password_fields, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
@ -329,9 +339,9 @@ defmodule MvWeb.UserLive.Form do
def handle_event("validate", %{"user" => user_params}, socket) do def handle_event("validate", %{"user" => user_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params) 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 = 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"] user_email = user_params["email"]
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket) members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
@ -480,20 +490,25 @@ defmodule MvWeb.UserLive.Form do
end end
defp perform_member_link_action(socket, user, actor) do defp perform_member_link_action(socket, user, actor) do
cond do # Only admins may link/unlink (backend policy also restricts update_user; UI must not call it).
# Selected member ID takes precedence (new link) if can?(actor, :destroy, Mv.Accounts.User) do
socket.assigns.selected_member_id -> cond do
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}}, # Selected member ID takes precedence (new link)
actor: actor socket.assigns.selected_member_id ->
) Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
actor: actor
)
# Unlink flag is set # Unlink flag is set
socket.assigns[:unlink_member] -> socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil}, actor: actor) Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
# No changes to member relationship # No changes to member relationship
true -> true ->
{:ok, user} {:ok, user}
end
else
{:ok, user}
end end
end end
@ -552,13 +567,28 @@ defmodule MvWeb.UserLive.Form do
end end
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() @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) actor = current_actor(socket)
form = form =
if user do if user do
# For existing users, use admin password action if password fields are shown # For existing users: admin uses update_user (email + member); non-admin uses update (email only).
action = if show_password_fields, do: :admin_set_password, else: :update_user # 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) AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
else else
# For new users, use password registration if password fields are shown # For new users, use password registration if password fields are shown