diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index b42daa5..0f865e8 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -244,4 +244,48 @@ defmodule Mv.Membership do |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{}) |> Ash.update(domain: __MODULE__) end + + @doc """ + Gets a group by its slug. + + Uses `Ash.Query.filter` to efficiently find a group by its slug. + The unique index on `slug` ensures efficient lookup performance. + The slug lookup is case-sensitive (exact match required). + + ## Parameters + + - `slug` - The slug to search for (case-sensitive) + - `opts` - Options including `:actor` for authorization + + ## Returns + + - `{:ok, group}` - Found group (with members and member_count loaded) + - `{:ok, nil}` - Group not found + - `{:error, error}` - Error reading group + + ## Examples + + iex> {:ok, group} = Mv.Membership.get_group_by_slug("board-members", actor: actor) + iex> group.name + "Board Members" + + iex> {:ok, nil} = Mv.Membership.get_group_by_slug("non-existent", actor: actor) + {:ok, nil} + + """ + def get_group_by_slug(slug, opts \\ []) do + actor = Keyword.get(opts, :actor) + load_opts = Keyword.get(opts, :load, [:members, :member_count]) + + require Ash.Query + + query = + Mv.Membership.Group + |> Ash.Query.filter(slug == ^slug) + |> Ash.Query.load(load_opts) + + opts_with_actor = if actor, do: [actor: actor], else: [] + + Ash.read_one(query, opts_with_actor) + end end diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index e133ed7..1d5c87b 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -112,7 +112,10 @@ defmodule Mv.Authorization.PermissionSets do %{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true}, # CustomField: Can read all (needed for forms) - %{resource: "CustomField", action: :read, scope: :all, granted: true} + %{resource: "CustomField", action: :read, scope: :all, granted: true}, + + # Group: Can read all (needed for viewing groups) + %{resource: "Group", action: :read, scope: :all, granted: true} ], pages: [ # Home page @@ -141,7 +144,10 @@ defmodule Mv.Authorization.PermissionSets do %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true}, # CustomField: Can read all - %{resource: "CustomField", action: :read, scope: :all, granted: true} + %{resource: "CustomField", action: :read, scope: :all, granted: true}, + + # Group: Can read all + %{resource: "Group", action: :read, scope: :all, granted: true} ], pages: [ "/", @@ -154,7 +160,11 @@ defmodule Mv.Authorization.PermissionSets do # Custom field values overview "/custom_field_values", # Custom field value detail - "/custom_field_values/:id" + "/custom_field_values/:id", + # Groups overview + "/groups", + # Group detail + "/groups/:slug" ] } end @@ -181,7 +191,10 @@ defmodule Mv.Authorization.PermissionSets do %{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true}, # CustomField: Read only (admin manages definitions) - %{resource: "CustomField", action: :read, scope: :all, granted: true} + %{resource: "CustomField", action: :read, scope: :all, granted: true}, + + # Group: Can read all + %{resource: "Group", action: :read, scope: :all, granted: true} ], pages: [ "/", @@ -197,7 +210,11 @@ defmodule Mv.Authorization.PermissionSets do # Custom field value detail "/custom_field_values/:id", "/custom_field_values/new", - "/custom_field_values/:id/edit" + "/custom_field_values/:id/edit", + # Groups overview + "/groups", + # Group detail + "/groups/:slug" ] } end @@ -233,7 +250,13 @@ defmodule Mv.Authorization.PermissionSets do %{resource: "Role", action: :read, scope: :all, granted: true}, %{resource: "Role", action: :create, scope: :all, granted: true}, %{resource: "Role", action: :update, scope: :all, granted: true}, - %{resource: "Role", action: :destroy, scope: :all, granted: true} + %{resource: "Role", action: :destroy, scope: :all, granted: true}, + + # Group: Full CRUD (admin manages groups) + %{resource: "Group", action: :read, scope: :all, granted: true}, + %{resource: "Group", action: :create, scope: :all, granted: true}, + %{resource: "Group", action: :update, scope: :all, granted: true}, + %{resource: "Group", action: :destroy, scope: :all, granted: true} ], pages: [ # Wildcard: Admin can access all pages diff --git a/lib/mv_web/live/group_live/form.ex b/lib/mv_web/live/group_live/form.ex new file mode 100644 index 0000000..49351b1 --- /dev/null +++ b/lib/mv_web/live/group_live/form.ex @@ -0,0 +1,201 @@ +defmodule MvWeb.GroupLive.Form do + @moduledoc """ + LiveView form for creating and editing groups. + + ## Features + - Create new groups with name and description + - Edit existing group details (name and description) + - Slug is automatically generated and immutable + - Form validation with visual feedback + + ## Security + - Only admin users can create/edit groups + - Non-admin users are redirected + """ + use MvWeb, :live_view + + import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] + import MvWeb.Authorization + + alias Mv.Membership + + @impl true + def mount(params, _session, socket) do + actor = current_actor(socket) + + # Check authorization + action = if params["slug"], do: :update, else: :create + resource = Mv.Membership.Group + + unless can?(actor, action, resource) do + {:ok, redirect(socket, to: ~p"/groups")} + else + socket = + case params["slug"] do + nil -> + # New group + socket + |> assign(:group, nil) + |> assign(:page_title, gettext("Create Group")) + |> assign(:return_to, "index") + + slug -> + # Edit existing group + case Membership.get_group_by_slug(slug, actor: actor) do + {:ok, nil} -> + socket + |> put_flash(:error, gettext("Group not found.")) + |> redirect(to: ~p"/groups") + + {:ok, group} -> + socket + |> assign(:group, group) + |> assign(:page_title, gettext("Edit Group")) + |> assign(:return_to, "show") + + {:error, _error} -> + socket + |> put_flash(:error, gettext("Failed to load group.")) + |> redirect(to: ~p"/groups") + end + end + + {:ok, assign_form(socket)} + end + end + + @impl true + def handle_params(params, _url, socket) do + # Handle slug-based routing for edit + case params do + %{"slug" => slug} when is_binary(slug) -> + actor = current_actor(socket) + + case Membership.get_group_by_slug(slug, actor: actor) do + {:ok, nil} -> + {:noreply, + socket + |> put_flash(:error, gettext("Group not found.")) + |> redirect(to: ~p"/groups")} + + {:ok, group} -> + {:noreply, + socket + |> assign(:group, group) + |> assign(:page_title, gettext("Edit Group")) + |> assign(:return_to, "show") + |> assign_form()} + + {:error, _error} -> + {:noreply, + socket + |> put_flash(:error, gettext("Failed to load group.")) + |> redirect(to: ~p"/groups")} + end + + _ -> + {:noreply, socket} + end + end + + @impl true + def render(assigns) do + ~H""" + + <.form for={@form} id="group-form" phx-change="validate" phx-submit="save"> + <%!-- Header with Back button, Title, and Save button --%> +
+ <.button navigate={return_path(@return_to, @group)} type="button"> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + +

+ {@page_title} +

+ + <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save")} + +
+ +
+ <.input field={@form[:name]} label={gettext("Name")} required /> + <.input + field={@form[:description]} + type="textarea" + label={gettext("Description")} + rows="4" + /> +
+ +
+ """ + end + + @impl true + def handle_event("validate", %{"group" => group_params}, socket) do + validated_form = AshPhoenix.Form.validate(socket.assigns.form, group_params) + {:noreply, assign(socket, form: validated_form)} + end + + def handle_event("save", %{"group" => group_params}, socket) do + actor = current_actor(socket) + + case submit_form(socket.assigns.form, group_params, actor) do + {:ok, group} -> + notify_parent({:saved, group}) + + redirect_path = + if socket.assigns.return_to == "show" do + ~p"/groups/#{group.slug}" + else + ~p"/groups" + end + + socket = + socket + |> put_flash(:info, gettext("Group saved successfully.")) + |> push_navigate(to: redirect_path) + + {:noreply, socket} + + {:error, form} -> + {:noreply, assign(socket, form: form)} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + + defp assign_form(%{assigns: assigns} = socket) do + group = assigns.group + actor = assigns.current_user + + form = + if group do + AshPhoenix.Form.for_update( + group, + :update, + api: Membership, + as: "group", + actor: actor + ) + else + AshPhoenix.Form.for_create( + Mv.Membership.Group, + :create, + api: Membership, + as: "group", + actor: actor + ) + end + + assign(socket, form: to_form(form)) + end + + defp return_path("index", _group), do: ~p"/groups" + defp return_path("show", group) when not is_nil(group), do: ~p"/groups/#{group.slug}" + defp return_path("show", _group), do: ~p"/groups" + defp return_path(_, group) when not is_nil(group), do: ~p"/groups/#{group.slug}" + defp return_path(_, _group), do: ~p"/groups" +end diff --git a/lib/mv_web/live/group_live/index.ex b/lib/mv_web/live/group_live/index.ex new file mode 100644 index 0000000..5ae8ed4 --- /dev/null +++ b/lib/mv_web/live/group_live/index.ex @@ -0,0 +1,132 @@ +defmodule MvWeb.GroupLive.Index do + @moduledoc """ + LiveView for displaying and managing the groups list. + + ## Features + - List all groups with name, description, and member count + - Create new groups + - Navigate to group details and edit forms + - Delete groups (with confirmation) + + ## Security + - Admin users can create, edit, and delete groups + - Read-only users can view groups but not manage them + - Non-admin users are redirected + """ + use MvWeb, :live_view + + import MvWeb.LiveHelpers, only: [current_actor: 1, ash_actor_opts: 1] + import MvWeb.Authorization + + @impl true + def mount(_params, _session, socket) do + actor = current_actor(socket) + + # Check if user can read groups + unless can?(actor, :read, Mv.Membership.Group) do + {:ok, redirect(socket, to: ~p"/members")} + else + groups = load_groups(actor) + + {:ok, + socket + |> assign(:page_title, gettext("Groups")) + |> assign(:groups, groups)} + end + end + + @impl true + def render(assigns) do + ~H""" + +
+

{gettext("Groups")}

+ <%= if can?(@current_user, :create, Mv.Membership.Group) do %> + <.button navigate={~p"/groups/new"} variant="primary"> + <.icon name="hero-plus" class="size-4 mr-2" /> + {gettext("Create Group")} + + <% end %> +
+ + <%= if Enum.empty?(@groups) do %> +
+

{gettext("No groups")}

+
+ <% else %> +
+ + + + + + + + + + + <%= for group <- @groups do %> + + + + + + + <% end %> + +
{gettext("Name")}{gettext("Description")}{gettext("Members")}{gettext("Actions")}
+ <.link navigate={~p"/groups/#{group.slug}"} class="link link-primary"> + {group.name} + + + <%= if group.description do %> + {group.description} + <% else %> + + <% end %> + + <%= if group.member_count do %> + {group.member_count} + <% else %> + 0 + <% end %> + +
+ <.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost"> + {gettext("View")} + + <%= if can?(@current_user, :update, Mv.Membership.Group) do %> + <.link + navigate={~p"/groups/#{group.slug}/edit"} + class="btn btn-sm btn-ghost" + > + {gettext("Edit")} + + <% end %> +
+
+
+ <% end %> +
+ """ + end + + @spec load_groups(map() | nil) :: [Mv.Membership.Group.t()] + defp load_groups(actor) do + require Ash.Query + + query = + Mv.Membership.Group + |> Ash.Query.load(:member_count) + + opts = ash_actor_opts(actor) + + case Ash.read(query, opts) do + {:ok, groups} -> + Enum.sort_by(groups, & &1.name) + + {:error, _} -> + [] + end + end +end diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex new file mode 100644 index 0000000..28117a2 --- /dev/null +++ b/lib/mv_web/live/group_live/show.ex @@ -0,0 +1,284 @@ +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 + unless can?(actor, :read, Mv.Membership.Group) do + {:noreply, redirect(socket, to: ~p"/members")} + else + 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 + 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")}

+
+

+ {gettext("Total: %{count} member(s)", count: @group.member_count || 0)} +

+ + <%= if Enum.empty?(@group.members || []) do %> +

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

+ <% else %> +
+ + + + + + + + + <%= for member <- @group.members do %> + + + + + <% end %> + +
{gettext("Name")}{gettext("Email")}
+ <.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 + + @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) + + case Membership.get_group_by_slug(slug, actor: actor) do + {:ok, nil} -> + {:noreply, + socket + |> put_flash(:error, gettext("Group not found.")) + |> redirect(to: ~p"/groups")} + + {:ok, group} -> + if socket.assigns.name_confirmation == group.name 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 + else + {:noreply, + socket + |> put_flash(:error, gettext("Group name does not match.")) + |> assign(:show_delete_modal, false) + |> assign(:name_confirmation, "")} + end + + {:error, _error} -> + {:noreply, + socket + |> put_flash(:error, gettext("Failed to load group.")) + |> redirect(to: ~p"/groups")} + end + end + + defp format_error(%{message: message}) when is_binary(message), do: message + defp format_error(error), do: inspect(error) +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 79f4791..86e7413 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -74,6 +74,12 @@ defmodule MvWeb.Router do live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit + # Groups Management + live "/groups", GroupLive.Index, :index + live "/groups/new", GroupLive.Form, :new + live "/groups/:slug", GroupLive.Show, :show + live "/groups/:slug/edit", GroupLive.Form, :edit + # Role Management (Admin only) live "/admin/roles", RoleLive.Index, :index live "/admin/roles/new", RoleLive.Form, :new diff --git a/test/mv_web/live/group_live/form_test.exs b/test/mv_web/live/group_live/form_test.exs index ceca883..fd525e0 100644 --- a/test/mv_web/live/group_live/form_test.exs +++ b/test/mv_web/live/group_live/form_test.exs @@ -86,6 +86,7 @@ defmodule MvWeb.GroupLive.FormTest do {:ok, view, _html} = live(conn, "/groups/new") long_description = String.duplicate("a", 501) + form_data = %{ "name" => "Test Group", "description" => long_description @@ -134,7 +135,8 @@ defmodule MvWeb.GroupLive.FormTest do describe "edit form" do test "form renders with existing group data", %{conn: conn} do - group = Fixtures.group_fixture(%{name: "Original Name", description: "Original Description"}) + group = + Fixtures.group_fixture(%{name: "Original Name", description: "Original Description"}) {:ok, view, html} = live(conn, "/groups/#{group.slug}/edit") diff --git a/test/mv_web/live/group_live/index_test.exs b/test/mv_web/live/group_live/index_test.exs index c2c164f..7674d79 100644 --- a/test/mv_web/live/group_live/index_test.exs +++ b/test/mv_web/live/group_live/index_test.exs @@ -131,6 +131,7 @@ defmodule MvWeb.GroupLive.IndexTest do # Add members to group system_actor = Mv.Helpers.SystemActor.get_system_actor() + Membership.create_member_group(%{member_id: member1.id, group_id: group.id}, actor: system_actor ) diff --git a/test/mv_web/live/group_live/show_test.exs b/test/mv_web/live/group_live/show_test.exs index bf94e85..036e249 100644 --- a/test/mv_web/live/group_live/show_test.exs +++ b/test/mv_web/live/group_live/show_test.exs @@ -58,6 +58,7 @@ defmodule MvWeb.GroupLive.ShowTest do member2 = Fixtures.member_fixture(%{first_name: "Bob", last_name: "Jones"}) system_actor = Mv.Helpers.SystemActor.get_system_actor() + Membership.create_member_group(%{member_id: member1.id, group_id: group.id}, actor: system_actor )