308 lines
10 KiB
Elixir
308 lines
10 KiB
Elixir
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}
|
|
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
|
|
case Membership.get_group_by_slug(slug, actor: actor, load: [:members, :member_count]) 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"""
|
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
|
<%!-- Header with Back button, Name, and Edit/Delete buttons --%>
|
|
<div class="flex items-center justify-between gap-4 pb-4">
|
|
<.button navigate={~p"/groups"} aria-label={gettext("Back to groups list")}>
|
|
<.icon name="hero-arrow-left" class="size-4" />
|
|
{gettext("Back")}
|
|
</.button>
|
|
|
|
<h1 class="text-2xl font-bold text-center flex-1">
|
|
{@group.name}
|
|
</h1>
|
|
|
|
<div class="flex gap-2">
|
|
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
|
<.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}>
|
|
{gettext("Edit")}
|
|
</.button>
|
|
<% end %>
|
|
<%= if can?(@current_user, :destroy, Mv.Membership.Group) do %>
|
|
<.button class="btn-error" phx-click="open_delete_modal">
|
|
{gettext("Delete")}
|
|
</.button>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Group Information --%>
|
|
<div class="max-w-2xl space-y-6 mb-6">
|
|
<div>
|
|
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
|
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
|
<%= if @group.description && String.trim(@group.description) != "" do %>
|
|
<p class="whitespace-pre-wrap">{@group.description}</p>
|
|
<% else %>
|
|
<p class="text-base-content/50 italic">{gettext("No description")}</p>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
|
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
|
<p class="mb-4">
|
|
{ngettext(
|
|
"Total: %{count} member",
|
|
"Total: %{count} members",
|
|
@group.member_count || 0,
|
|
count: @group.member_count || 0
|
|
)}
|
|
</p>
|
|
|
|
<%= if Enum.empty?(@group.members || []) do %>
|
|
<p class="text-base-content/50 italic">{gettext("No members in this group")}</p>
|
|
<% else %>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra">
|
|
<thead>
|
|
<tr>
|
|
<th>{gettext("Name")}</th>
|
|
<th>{gettext("Email")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<%= for member <- @group.members do %>
|
|
<tr>
|
|
<td>
|
|
<.link
|
|
navigate={~p"/members/#{member.id}"}
|
|
class="link link-primary"
|
|
>
|
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
|
</.link>
|
|
</td>
|
|
<td>
|
|
<%= if member.email do %>
|
|
<a
|
|
href={"mailto:#{member.email}"}
|
|
class="text-blue-700 hover:text-blue-800 underline"
|
|
>
|
|
{member.email}
|
|
</a>
|
|
<% else %>
|
|
<span class="text-base-content/50 italic">—</span>
|
|
<% end %>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Delete Confirmation Modal --%>
|
|
<%= if assigns[:show_delete_modal] do %>
|
|
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
|
|
<div class="modal-box">
|
|
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
|
|
<p class="mb-4">
|
|
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
|
|
</p>
|
|
<%= if @group.member_count && @group.member_count > 0 do %>
|
|
<div class="alert alert-warning mb-4">
|
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
|
<span>
|
|
{ngettext(
|
|
"This group has %{count} member. All member-group associations will be permanently deleted.",
|
|
"This group has %{count} members. All member-group associations will be permanently deleted.",
|
|
@group.member_count
|
|
)}
|
|
</span>
|
|
</div>
|
|
<% end %>
|
|
<div>
|
|
<label for="group-name-confirmation" class="label">
|
|
<span class="label-text">
|
|
{gettext("To confirm deletion, please enter the group name:")}
|
|
</span>
|
|
</label>
|
|
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
|
|
{@group.name}
|
|
</div>
|
|
<form phx-change="update_name_confirmation" phx-debounce="200">
|
|
<input
|
|
id="group-name-confirmation"
|
|
name="name"
|
|
type="text"
|
|
value={@name_confirmation || ""}
|
|
placeholder={gettext("Enter the group name to confirm")}
|
|
autocomplete="off"
|
|
class="w-full input input-bordered"
|
|
/>
|
|
</form>
|
|
</div>
|
|
<div class="modal-action">
|
|
<button
|
|
type="button"
|
|
class="btn"
|
|
phx-click="cancel_delete"
|
|
aria-label={gettext("Cancel")}
|
|
>
|
|
{gettext("Cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-error"
|
|
phx-click="confirm_delete"
|
|
phx-value-slug={@group.slug}
|
|
disabled={(@name_confirmation || "") != @group.name}
|
|
aria-label={gettext("Delete group")}
|
|
>
|
|
{gettext("Delete")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
<% end %>
|
|
</Layouts.app>
|
|
"""
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("open_delete_modal", _params, socket) do
|
|
{:noreply, assign(socket, show_delete_modal: true, name_confirmation: "")}
|
|
end
|
|
|
|
def handle_event("cancel_delete", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_delete_modal, false)
|
|
|> assign(:name_confirmation, "")}
|
|
end
|
|
|
|
def handle_event("update_name_confirmation", %{"name" => name}, socket) do
|
|
{:noreply, assign(socket, :name_confirmation, name)}
|
|
end
|
|
|
|
def handle_event("confirm_delete", %{"slug" => slug}, socket) do
|
|
actor = current_actor(socket)
|
|
|
|
# Server-side authorization check to prevent unauthorized delete attempts
|
|
if can?(actor, :destroy, Mv.Membership.Group) do
|
|
case Membership.get_group_by_slug(slug, actor: actor, load: []) do
|
|
{:ok, nil} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, gettext("Group not found."))
|
|
|> redirect(to: ~p"/groups")}
|
|
|
|
{:ok, group} ->
|
|
handle_delete_confirmation(socket, group, actor)
|
|
|
|
{:error, _error} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, gettext("Failed to load group."))
|
|
|> redirect(to: ~p"/groups")}
|
|
end
|
|
else
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, gettext("Not authorized."))
|
|
|> redirect(to: ~p"/groups")}
|
|
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
|