defmodule MvWeb.GroupLive.Show do @moduledoc """ LiveView for displaying a single group's details. ## Features - Display group information (name, description, member count) - List all members in the group - Navigate to edit form - Return to groups list - Delete group (with confirmation) ## Security - All users with read permission can view groups - Only admin users can edit/delete groups """ use MvWeb, :live_view import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.Authorization alias Mv.Membership @impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:show_add_member_input, false) |> assign(:member_search_query, "") |> assign(:available_members, []) |> assign(:selected_member_ids, []) |> assign(:selected_members, []) |> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil)} end @impl true def handle_params(%{"slug" => slug}, _url, socket) do actor = current_actor(socket) # Check if user can read groups if can?(actor, :read, Mv.Membership.Group) do load_group_by_slug(socket, slug, actor) else {:noreply, redirect(socket, to: ~p"/members")} end end defp load_group_by_slug(socket, slug, actor) do # Load group with members and member_count # Using explicit load ensures efficient preloading of members relationship require Ash.Query query = Mv.Membership.Group |> Ash.Query.filter(slug == ^slug) |> Ash.Query.load([:members, :member_count]) case Ash.read_one(query, actor: actor, domain: Mv.Membership) do {:ok, nil} -> {:noreply, socket |> put_flash(:error, gettext("Group not found.")) |> redirect(to: ~p"/groups")} {:ok, group} -> {:noreply, socket |> assign(:page_title, group.name) |> assign(:group, group)} {:error, _error} -> {:noreply, socket |> put_flash(:error, gettext("Failed to load group.")) |> redirect(to: ~p"/groups")} end end @impl true def render(assigns) do ~H""" <%!-- Header with Back button, Name, and Edit/Delete buttons --%>
<.button navigate={~p"/groups"} aria-label={gettext("Back to groups list")}> <.icon name="hero-arrow-left" class="size-4" /> {gettext("Back")}

{@group.name}

<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}> {gettext("Edit")} <% end %> <%= if can?(@current_user, :destroy, Mv.Membership.Group) do %> <.button class="btn-error" phx-click="open_delete_modal"> {gettext("Delete")} <% end %>
<%!-- Group Information --%>

{gettext("Description")}

<%= if @group.description && String.trim(@group.description) != "" do %>

{@group.description}

<% else %>

{gettext("No description")}

<% end %>

{gettext("Members")}

{ngettext( "Total: %{count} member", "Total: %{count} members", @group.member_count || 0, count: @group.member_count || 0 )}

<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<%= if assigns[:show_add_member_input] do %>
<%= for member <- @selected_members do %> {MvWeb.Helpers.MemberHelpers.display_name(member)} <% end %>
<%= if length(@available_members) > 0 do %>
<%= for {member, index} <- Enum.with_index(@available_members) do %>

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

{member.email || gettext("No email")}

<% end %>
<% end %>
<% else %> <.button variant="primary" phx-click="show_add_member_input" aria-label={gettext("Add Member")} > {gettext("Add Member")} <% end %>
<% end %> <%= if Enum.empty?(@group.members || []) do %>

{gettext("No members in this group")}

<% else %>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <% end %> <%= for member <- @group.members do %> <%= if can?(@current_user, :update, Mv.Membership.Group) do %> <% end %> <% end %>
{gettext("Name")} {gettext("Email")}{gettext("Actions")}
<.link navigate={~p"/members/#{member.id}"} class="link link-primary" > {MvWeb.Helpers.MemberHelpers.display_name(member)} <%= if member.email do %> {member.email} <% else %> <% end %>
<% end %>
<%!-- Delete Confirmation Modal --%> <%= if assigns[:show_delete_modal] do %> <% end %>
""" end # Delete Modal Events @impl true def handle_event("open_delete_modal", _params, socket) do {:noreply, assign(socket, show_delete_modal: true, name_confirmation: "")} end @impl true def handle_event("cancel_delete", _params, socket) do {:noreply, socket |> assign(:show_delete_modal, false) |> assign(:name_confirmation, "")} end @impl true def handle_event("update_name_confirmation", %{"name" => name}, socket) do {:noreply, assign(socket, :name_confirmation, name)} end @impl true def handle_event("confirm_delete", %{"slug" => slug}, socket) do actor = current_actor(socket) group = socket.assigns.group # Verify slug matches the group in assigns (prevents tampering) if group.slug == slug do # Server-side authorization check on the specific group record if can?(actor, :destroy, group) do handle_delete_confirmation(socket, group, actor) else {:noreply, socket |> put_flash(:error, gettext("Not authorized.")) |> redirect(to: ~p"/groups")} end else {:noreply, socket |> put_flash(:error, gettext("Group not found.")) |> redirect(to: ~p"/groups")} end end # Add Member Events @impl true def handle_event("show_add_member_input", _params, socket) do # Reload group to ensure we have the latest members list actor = current_actor(socket) group = socket.assigns.group socket = reload_group(socket, group.slug, actor) {:noreply, socket |> assign(:show_add_member_input, true) |> assign(:member_search_query, "") |> assign(:available_members, []) |> assign(:selected_member_ids, []) |> assign(:selected_members, []) |> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil)} end @impl true def handle_event("show_member_dropdown", _params, socket) do # Reload group to ensure we have the latest members list before filtering actor = current_actor(socket) group = socket.assigns.group socket = reload_group(socket, group.slug, actor) # Load available members with empty query when input is focused socket = socket |> load_available_members("") |> assign(:show_member_dropdown, true) |> assign(:focused_member_index, nil) {:noreply, socket} end @impl true def handle_event("hide_member_dropdown", _params, socket) do {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} end @impl true def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do return_if_dropdown_closed(socket, fn -> max_index = length(socket.assigns.available_members) - 1 current = socket.assigns.focused_member_index new_index = case current do nil -> 0 index when index < max_index -> index + 1 _ -> current end {:noreply, assign(socket, focused_member_index: new_index)} end) end @impl true def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do return_if_dropdown_closed(socket, fn -> current = socket.assigns.focused_member_index new_index = case current do nil -> 0 0 -> 0 index -> index - 1 end {:noreply, assign(socket, focused_member_index: new_index)} end) end @impl true def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do return_if_dropdown_closed(socket, fn -> select_focused_member(socket) end) end @impl true def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do return_if_dropdown_closed(socket, fn -> {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} end) end @impl true def handle_event("member_dropdown_keydown", _params, socket) do # Ignore other keys {:noreply, socket} end @impl true def handle_event("search_members", %{"member_search" => query}, socket) do # Reload group to ensure we have the latest members list before filtering actor = current_actor(socket) group = socket.assigns.group socket = reload_group(socket, group.slug, actor) socket = socket |> assign(:member_search_query, query) |> load_available_members(query) |> assign(:show_member_dropdown, true) |> assign(:focused_member_index, nil) {:noreply, socket} end @impl true def handle_event("select_member", %{"id" => member_id}, socket) do # Check if member is already selected if member_id in socket.assigns.selected_member_ids do {:noreply, socket} else # Find the selected member selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id)) if selected_member do socket = socket |> assign(:selected_member_ids, [member_id | socket.assigns.selected_member_ids]) |> assign(:selected_members, [selected_member | socket.assigns.selected_members]) |> assign(:member_search_query, "") |> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil) {:noreply, socket} else {:noreply, socket} end end end @impl true def handle_event("remove_selected_member", %{"member_id" => member_id}, socket) do socket = socket |> assign(:selected_member_ids, List.delete(socket.assigns.selected_member_ids, member_id)) |> assign( :selected_members, Enum.reject(socket.assigns.selected_members, &(&1.id == member_id)) ) {:noreply, socket} end @impl true def handle_event("add_selected_members", _params, socket) do actor = current_actor(socket) group = socket.assigns.group # Server-side authorization check if can?(actor, :update, group) do perform_add_members(socket, group, socket.assigns.selected_member_ids, actor) else {:noreply, socket |> put_flash(:error, gettext("Not authorized.")) |> redirect(to: ~p"/groups/#{group.slug}")} end end @impl true def handle_event("remove_member", %{"member_id" => member_id}, socket) do actor = current_actor(socket) group = socket.assigns.group # Server-side authorization check if can?(actor, :update, group) do perform_remove_member(socket, group, member_id, actor) else {:noreply, socket |> put_flash(:error, gettext("Not authorized.")) |> redirect(to: ~p"/groups/#{group.slug}")} end end # Helper functions defp return_if_dropdown_closed(socket, fun) do if socket.assigns.show_member_dropdown do fun.() else {:noreply, socket} end end defp select_focused_member(socket) do case socket.assigns.focused_member_index do nil -> {:noreply, socket} index -> select_member_by_index(socket, index) end end defp select_member_by_index(socket, index) do case Enum.at(socket.assigns.available_members, index) do nil -> {:noreply, socket} member -> add_member_to_selection(socket, member) end end defp add_member_to_selection(socket, member) do # Check if member is already selected if member.id in socket.assigns.selected_member_ids do {:noreply, socket} else socket = socket |> assign(:selected_member_ids, [member.id | socket.assigns.selected_member_ids]) |> assign(:selected_members, [member | socket.assigns.selected_members]) |> assign(:member_search_query, "") |> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil) {:noreply, socket} end end defp load_available_members(socket, query) do require Ash.Query base_query = available_members_base_query(query) limited_query = Ash.Query.limit(base_query, 10) actor = current_actor(socket) case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do {:ok, members} -> current_member_ids = group_member_ids_set(socket.assigns.group) filtered_members = Enum.reject(members, fn member -> MapSet.member?(current_member_ids, member.id) end) assign(socket, available_members: filtered_members) {:error, _} -> assign(socket, available_members: []) end end defp available_members_base_query(query) do search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil if search_query do Mv.Membership.Member |> Ash.Query.for_read(:search, %{query: search_query}) else Mv.Membership.Member |> Ash.Query.new() end end defp group_member_ids_set(group) do cond do is_list(group.members) and group.members != [] -> group.members |> Enum.map(& &1.id) |> MapSet.new() is_list(group.members) -> MapSet.new() true -> MapSet.new() end end defp perform_add_members(socket, group, member_ids, actor) when is_list(member_ids) do # Add all members in a transaction-like manner results = Enum.map(member_ids, fn member_id -> Membership.create_member_group( %{member_id: member_id, group_id: group.id}, actor: actor ) end) # Check for errors errors = Enum.filter(results, &match?({:error, _}, &1)) if Enum.empty?(errors) do handle_successful_add_members(socket, group, actor) else handle_failed_add_members(socket, group, errors, actor) end end defp perform_add_members(socket, _group, _member_ids, _actor) do {:noreply, socket |> put_flash(:error, gettext("No members selected."))} end defp handle_successful_add_members(socket, group, actor) do socket = reload_group(socket, group.slug, actor) {:noreply, socket |> assign(:show_add_member_input, false) |> assign(:member_search_query, "") |> assign(:available_members, []) |> assign(:selected_member_ids, []) |> assign(:selected_members, []) |> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil)} end defp handle_failed_add_members(socket, group, errors, actor) do error_messages = extract_error_messages(errors) # Still reload to show any successful additions socket = reload_group(socket, group.slug, actor) {:noreply, socket |> put_flash( :error, gettext("Some members could not be added: %{errors}", errors: error_messages) ) |> assign(:show_add_member_input, true)} end defp extract_error_messages(errors) do Enum.map(errors, fn {:error, error} -> format_single_error(error) end) |> Enum.uniq() |> Enum.join(", ") end defp format_single_error(%{errors: [%{message: message}]}) when is_binary(message), do: message defp format_single_error(%{errors: [%{field: :member_id, message: message}]}) when is_binary(message), do: message defp format_single_error(error), do: format_error(error) defp perform_remove_member(socket, group, member_id, actor) do require Ash.Query # Find the MemberGroup association query = Mv.Membership.MemberGroup |> Ash.Query.filter(member_id == ^member_id and group_id == ^group.id) case Ash.read_one(query, actor: actor, domain: Mv.Membership) do {:ok, nil} -> {:noreply, socket |> put_flash(:error, gettext("Member is not in this group."))} {:ok, member_group} -> case Membership.destroy_member_group(member_group, actor: actor) do :ok -> # Reload group with members and member_count socket = reload_group(socket, group.slug, actor) {:noreply, socket} {:error, error} -> error_message = format_error(error) {:noreply, socket |> put_flash( :error, gettext("Failed to remove member: %{error}", error: error_message) )} end {:error, error} -> error_message = format_error(error) {:noreply, socket |> put_flash( :error, gettext("Failed to remove member: %{error}", error: error_message) )} end end defp reload_group(socket, slug, actor) do require Ash.Query query = Mv.Membership.Group |> Ash.Query.filter(slug == ^slug) |> Ash.Query.load([:members, :member_count]) case Ash.read_one(query, actor: actor, domain: Mv.Membership) do {:ok, group} -> assign(socket, :group, group) {:error, _} -> socket end end defp handle_delete_confirmation(socket, group, actor) do if socket.assigns.name_confirmation == group.name do perform_group_deletion(socket, group, actor) else {:noreply, socket |> put_flash(:error, gettext("Group name does not match.")) |> assign(:show_delete_modal, true)} end end defp perform_group_deletion(socket, group, actor) do case Membership.destroy_group(group, actor: actor) do :ok -> {:noreply, socket |> put_flash(:info, gettext("Group deleted successfully.")) |> redirect(to: ~p"/groups")} {:error, error} -> error_message = format_error(error) {:noreply, socket |> put_flash( :error, gettext("Failed to delete group: %{error}", error: error_message) ) |> assign(:show_delete_modal, false) |> assign(:name_confirmation, "")} end end defp format_error(%{message: message}) when is_binary(message), do: message defp format_error(error), do: inspect(error) end