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:
parent
14fa873640
commit
06d6531569
1 changed files with 160 additions and 130 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue