Merge pull request 'Groups Admin UI closes #372' (#382) from feature/372-groups-management into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing

Reviewed-on: #382
This commit is contained in:
simon 2026-01-28 10:51:44 +01:00
commit 1f8fa8a6fb
18 changed files with 2101 additions and 18 deletions

View file

@ -1010,6 +1010,10 @@ let liveSocket = new LiveSocket("/live", Socket, {
### 3.8 Code Quality: Credo ### 3.8 Code Quality: Credo
**Static Code Analysis:**
We use **Credo** for static code analysis to ensure code quality, consistency, and maintainability. Credo checks are **mandatory** and must pass before code can be merged.
**Run Credo Regularly:** **Run Credo Regularly:**
```bash ```bash
@ -1020,6 +1024,13 @@ mix credo
mix credo --strict mix credo --strict
``` ```
**CI Enforcement:**
- ✅ **All Credo checks must pass in CI pipeline**
- ✅ Pull requests will be blocked if Credo checks fail
- ✅ Run `mix credo --strict` locally before pushing to catch issues early
- ✅ Address all Credo warnings and errors before requesting code review
**Key Credo Checks Enabled:** **Key Credo Checks Enabled:**
- Consistency checks (spacing, line endings, parameter patterns) - Consistency checks (spacing, line endings, parameter patterns)

View file

@ -255,9 +255,9 @@ lib/
**Route:** `/groups` - Groups management index page **Route:** `/groups` - Groups management index page
**Features:** **Features:**
- List all groups in table - List all groups in table (sorted by name via database query)
- Create new group button - Create new group button (navigates to `/groups/new`)
- Edit group (inline or modal) - Edit group via separate form page (`/groups/:slug/edit`)
- Delete group with confirmation modal - Delete group with confirmation modal
- Show member count per group - Show member count per group
@ -268,11 +268,13 @@ lib/
- Actions (Edit, Delete) - Actions (Edit, Delete)
**Delete Confirmation Modal:** **Delete Confirmation Modal:**
- Warning: "X members are in this group" - Warning: "X members are in this group" (with proper pluralization)
- Confirmation: "All member-group associations will be permanently deleted" - Confirmation: "All member-group associations will be permanently deleted"
- Input field: Enter group name to confirm - Input field: Enter group name to confirm (with `phx-debounce="200"` for better UX)
- Delete button disabled until name matches - Delete button disabled until name matches
- Modal remains open on name mismatch (allows user to correct input)
- Cancel button - Cancel button
- Server-side authorization check in delete event handler (security best practice)
### Member Overview Integration ### Member Overview Integration
@ -304,18 +306,33 @@ lib/
- Add/remove groups inline - Add/remove groups inline
- Link to group detail page - Link to group detail page
### Group Detail View (`/groups/:id`) ### Group Detail View (`/groups/:slug`)
**Route:** `/groups/:id` - Group detail page (uses UUID, slug can be used for future `/groups/:slug` routes) **Route:** `/groups/:slug` - Group detail page (uses slug for URL-friendly routing)
**Features:** **Features:**
- Display group name and description - Display group name and description
- List all members in group - List all members in group
- Link to member detail pages - Link to member detail pages
- Edit group button - Edit group button (navigates to `/groups/:slug/edit`)
- Delete group button (with confirmation) - Delete group button (with confirmation modal)
**Note:** Currently uses UUID for routing. Slug is available for future URL-friendly routes (`/groups/:slug`). **Note:** Uses slug for routing to provide URL-friendly, readable group URLs (e.g., `/groups/board-members`).
### Group Form Pages
**Create Form:** `/groups/new`
- Separate LiveView page for creating new groups
- Form with name and description fields
- Slug is auto-generated and not editable
- Redirects to `/groups` on success
**Edit Form:** `/groups/:slug/edit`
- Separate LiveView page for editing existing groups
- Form pre-populated with current group data
- Slug is immutable (not displayed in form)
- Redirects to `/groups/:slug` on success
- `mount/3` performs authorization check, `handle_params/3` loads group once
### Accessibility (A11y) Considerations ### Accessibility (A11y) Considerations
@ -473,6 +490,7 @@ lib/
- Paginate member list for large groups (>50 members) - Paginate member list for large groups (>50 members)
- Load member count via calculation (not separate query) - Load member count via calculation (not separate query)
- Use `Ash.Query.load` for member details when displaying - Use `Ash.Query.load` for member details when displaying
- Sorting performed at database level (`Ash.Query.sort(:name)`) for efficiency
### Search Performance ### Search Performance
@ -1106,7 +1124,7 @@ Groups include automatic slug generation, following the same pattern as CustomFi
- Automatically generated from the `name` attribute on create - Automatically generated from the `name` attribute on create
- Immutable after creation (don't change when name is updated) - Immutable after creation (don't change when name is updated)
- Unique and URL-friendly - Unique and URL-friendly
- Available for future route enhancements (e.g., `/groups/:slug` instead of `/groups/:id`) - Used for routing (e.g., `/groups/:slug` for group detail pages)
The implementation reuses the existing `GenerateSlug` change from CustomFields, ensuring consistency across the codebase. The implementation reuses the existing `GenerateSlug` change from CustomFields, ensuring consistency across the codebase.

View file

@ -244,4 +244,48 @@ defmodule Mv.Membership do
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{}) |> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__)
end 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
load = Keyword.get(opts, :load, [])
require Ash.Query
query =
Mv.Membership.Group
|> Ash.Query.filter(slug == ^slug)
|> Ash.Query.load(load)
opts
|> Keyword.delete(:load)
|> Keyword.put_new(:domain, __MODULE__)
|> then(&Ash.read_one(query, &1))
end
end end

View file

@ -112,7 +112,10 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true}, %{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true},
# CustomField: Can read all (needed for forms) # 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: [ pages: [
# Home page # Home page
@ -141,7 +144,10 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
# CustomField: Can read all # 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: [ pages: [
"/", "/",
@ -154,7 +160,11 @@ defmodule Mv.Authorization.PermissionSets do
# Custom field values overview # Custom field values overview
"/custom_field_values", "/custom_field_values",
# Custom field value detail # Custom field value detail
"/custom_field_values/:id" "/custom_field_values/:id",
# Groups overview
"/groups",
# Group detail
"/groups/:slug"
] ]
} }
end end
@ -181,7 +191,10 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true}, %{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
# CustomField: Read only (admin manages definitions) # 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: [ pages: [
"/", "/",
@ -197,7 +210,11 @@ defmodule Mv.Authorization.PermissionSets do
# Custom field value detail # Custom field value detail
"/custom_field_values/:id", "/custom_field_values/:id",
"/custom_field_values/new", "/custom_field_values/new",
"/custom_field_values/:id/edit" "/custom_field_values/:id/edit",
# Groups overview
"/groups",
# Group detail
"/groups/:slug"
] ]
} }
end end
@ -233,7 +250,13 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "Role", action: :read, scope: :all, granted: true}, %{resource: "Role", action: :read, scope: :all, granted: true},
%{resource: "Role", action: :create, scope: :all, granted: true}, %{resource: "Role", action: :create, scope: :all, granted: true},
%{resource: "Role", action: :update, 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: [ pages: [
# Wildcard: Admin can access all pages # Wildcard: Admin can access all pages

View file

@ -85,6 +85,7 @@ defmodule MvWeb.Layouts.Sidebar do
<!-- Nested Admin Menu --> <!-- Nested Admin Menu -->
<.menu_group icon="hero-cog-6-tooth" label={gettext("Administration")}> <.menu_group icon="hero-cog-6-tooth" label={gettext("Administration")}>
<.menu_subitem href={~p"/users"} label={gettext("Users")} /> <.menu_subitem href={~p"/users"} label={gettext("Users")} />
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} /> <.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
<.menu_subitem <.menu_subitem
href={~p"/membership_fee_settings"} href={~p"/membership_fee_settings"}

View file

@ -0,0 +1,184 @@
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 based on whether we are creating or updating
action = if params["slug"], do: :update, else: :create
resource = Mv.Membership.Group
if can?(actor, action, resource) do
{:ok,
socket
|> assign(:actor, actor)
|> assign(:group, nil)
|> assign(:page_title, page_title_for_params(params))
|> assign(:return_to, return_to_for_params(params))}
else
{:ok, redirect(socket, to: ~p"/groups")}
end
end
@impl true
def handle_params(params, _url, socket) do
actor = socket.assigns.actor
case params do
%{"slug" => slug} when is_binary(slug) ->
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} ->
{:noreply,
socket
|> assign(:group, group)
|> assign(:page_title, gettext("Edit Group"))
|> assign(:return_to, :show)
|> assign_form(actor)}
{:error, _error} ->
{:noreply,
socket
|> put_flash(:error, gettext("Failed to load group."))
|> redirect(to: ~p"/groups")}
end
_ ->
# New group - ensure form is initialized for create
{:noreply, assign_form(socket, actor)}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="group-form" phx-change="validate" phx-submit="save">
<%!-- Header with Back button, Title, and Save button --%>
<div class="flex items-center justify-between gap-4 pb-4">
<.button navigate={return_path(@return_to, @group)} type="button">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h1 class="text-2xl font-bold text-center flex-1">
{@page_title}
</h1>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</div>
<div class="max-w-2xl space-y-4">
<.input field={@form[:name]} label={gettext("Name")} required />
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="4"
/>
</div>
</.form>
</Layouts.app>
"""
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 = socket.assigns.actor
case submit_form(socket.assigns.form, group_params, actor) do
{:ok, group} ->
notify_parent({:saved, group})
redirect_path =
case socket.assigns.return_to do
:show -> ~p"/groups/#{group.slug}"
_ -> ~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, actor) do
group = Map.get(assigns, :group)
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 page_title_for_params(%{"slug" => _slug}), do: gettext("Edit Group")
defp page_title_for_params(_params), do: gettext("Create Group")
defp return_to_for_params(%{"slug" => _slug}), do: :show
defp return_to_for_params(_params), do: :index
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

View file

@ -0,0 +1,133 @@
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 access the groups page (page permission check)
if can_access_page?(actor, "/groups") do
groups = load_groups(actor)
{:ok,
socket
|> assign(:page_title, gettext("Groups"))
|> assign(:groups, groups)}
else
{:ok, redirect(socket, to: ~p"/members")}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{gettext("Groups")}</h1>
<%= 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")}
</.button>
<% end %>
</div>
<%= if Enum.empty?(@groups) do %>
<div class="text-center py-12">
<p class="text-base-content/70">{gettext("No groups")}</p>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Description")}</th>
<th>{gettext("Members")}</th>
<th>{gettext("Actions")}</th>
</tr>
</thead>
<tbody>
<%= for group <- @groups do %>
<tr>
<td>
{group.name}
</td>
<td>
<%= if group.description do %>
{group.description}
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</td>
<td>
<%= if group.member_count do %>
{group.member_count}
<% else %>
0
<% end %>
</td>
<td>
<div class="flex gap-2">
<.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost">
{gettext("View")}
</.link>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<.link
navigate={~p"/groups/#{group.slug}/edit"}
class="btn btn-sm btn-ghost"
>
{gettext("Edit")}
</.link>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.app>
"""
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)
|> Ash.Query.sort(:name)
opts = ash_actor_opts(actor)
case Ash.read(query, opts) do
{:ok, groups} ->
groups
{:error, _error} ->
require Logger
Logger.warning("Failed to load groups in GroupLive.Index")
[]
end
end
end

View file

@ -0,0 +1,304 @@
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,
count: @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">
<input
id="group-name-confirmation"
name="name"
type="text"
value={@name_confirmation || ""}
placeholder={gettext("Enter the group name to confirm")}
autocomplete="off"
phx-debounce="200"
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)
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
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

View file

@ -74,6 +74,12 @@ defmodule MvWeb.Router do
live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new live "/membership_fee_types/new", MembershipFeeTypeLive.Form, :new
live "/membership_fee_types/:id/edit", MembershipFeeTypeLive.Form, :edit 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) # Role Management (Admin only)
live "/admin/roles", RoleLive.Index, :index live "/admin/roles", RoleLive.Index, :index
live "/admin/roles/new", RoleLive.Form, :new live "/admin/roles/new", RoleLive.Form, :new

View file

@ -11,6 +11,7 @@ msgstr ""
"Language: de\n" "Language: de\n"
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "Aktionen" msgstr "Aktionen"
@ -37,6 +38,7 @@ msgid "City"
msgstr "Stadt" msgstr "Stadt"
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
@ -46,6 +48,8 @@ msgid "Delete"
msgstr "Löschen" msgstr "Löschen"
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
@ -62,6 +66,7 @@ msgstr "Bearbeiten"
msgid "Edit Member" msgid "Edit Member"
msgstr "Mitglied bearbeiten" msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -166,6 +171,7 @@ msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
@ -257,6 +263,7 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -269,6 +276,9 @@ msgstr "Abbrechen"
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
@ -301,6 +311,8 @@ msgid "Listing Users"
msgstr "Benutzer*innen auflisten" msgstr "Benutzer*innen auflisten"
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
@ -310,6 +322,9 @@ msgstr "Mitglieder"
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
@ -752,6 +767,8 @@ msgstr "Alle"
msgid "Address" msgid "Address"
msgstr "Adresse" msgstr "Adresse"
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -791,6 +808,7 @@ msgstr "Zahlungen"
msgid "Personal Data" msgid "Personal Data"
msgstr "Persönliche Daten" msgstr "Persönliche Daten"
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1626,6 +1644,7 @@ msgstr "Hauptnavigation"
msgid "New Role" msgid "New Role"
msgstr "Neue Rolle" msgstr "Neue Rolle"
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2126,6 +2145,114 @@ msgstr "E-Mail"
msgid "email %{email} has already been taken" msgid "email %{email} has already been taken"
msgstr "E-Mail %{email} wurde bereits verwendet" msgstr "E-Mail %{email} wurde bereits verwendet"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Are you sure you want to delete this group? This action cannot be undone."
msgstr "Möchtest du diese Gruppe wirklich löschen?"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Back to groups list"
msgstr "Zurück zur Gruppenübersicht"
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Create Group"
msgstr "Gruppe erstellen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Group"
msgstr "Gruppe löschen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete group"
msgstr "Gruppe löschen"
#: lib/mv_web/live/group_live/form.ex
#, elixir-autogen, elixir-format
msgid "Edit Group"
msgstr "Gruppe bearbeiten"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Enter the group name to confirm"
msgstr "Gebe zur Bestätigung den Gruppennamen ein"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to delete group: %{error}"
msgstr "Gruppe konnte nicht gelöscht werden: %{error}"
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to load group."
msgstr "Gruppe konnte nicht geladen werden"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Group deleted successfully."
msgstr "Gruppe erfolgreich gelöscht"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Group name does not match."
msgstr "Gruppenname stimmt nicht überein"
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Group not found."
msgstr "Gruppe nicht gefunden."
#: lib/mv_web/live/group_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Group saved successfully."
msgstr "Gruppe erfolgreich gespeichert."
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "Groups"
msgstr "Gruppen"
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "No groups"
msgstr "Keine Gruppen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No members in this group"
msgstr "Keine Mitglieder in dieser Gruppe"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "This group has %{count} member. All member-group associations will be permanently deleted."
msgid_plural "This group has %{count} members. All member-group associations will be permanently deleted."
msgstr[0] "Diese Gruppe hat %{count} Mitglied. Alle Mitglied-Gruppen-Zuordnungen werden dauerhaft gelöscht."
msgstr[1] "Diese Gruppe hat %{count} Mitglieder. Alle Mitglied-Gruppen-Zuordnungen werden dauerhaft gelöscht."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "To confirm deletion, please enter the group name:"
msgstr "Um die Löschung zu bestätigen, gib bitte den Gruppennamen ein:"
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "View"
msgstr "Anzeigen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Total: %{count} member"
msgid_plural "Total: %{count} members"
msgstr[0] "Insgesamt: %{count} Mitglied"
msgstr[1] "Insgesamt: %{count} Mitglieder"
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This user cannot be edited." msgid "This user cannot be edited."

View file

@ -12,6 +12,7 @@ msgid ""
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
@ -38,6 +39,7 @@ msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
@ -47,6 +49,8 @@ msgid "Delete"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
@ -63,6 +67,7 @@ msgstr ""
msgid "Edit Member" msgid "Edit Member"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -167,6 +172,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
@ -258,6 +264,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -270,6 +277,9 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
@ -302,6 +312,8 @@ msgid "Listing Users"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
@ -311,6 +323,9 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
@ -753,6 +768,8 @@ msgstr ""
msgid "Address" msgid "Address"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -792,6 +809,7 @@ msgstr ""
msgid "Personal Data" msgid "Personal Data"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1627,6 +1645,7 @@ msgstr ""
msgid "New Role" msgid "New Role"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -2127,6 +2146,114 @@ msgstr ""
msgid "email %{email} has already been taken" msgid "email %{email} has already been taken"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Are you sure you want to delete this group? This action cannot be undone."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Back to groups list"
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "Create Group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete Group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Delete group"
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#, elixir-autogen, elixir-format
msgid "Edit Group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Enter the group name to confirm"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to delete group: %{error}"
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to load group."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Group deleted successfully."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Group name does not match."
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Group not found."
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#, elixir-autogen, elixir-format
msgid "Group saved successfully."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "Groups"
msgstr ""
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "No groups"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No members in this group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "This group has %{count} member. All member-group associations will be permanently deleted."
msgid_plural "This group has %{count} members. All member-group associations will be permanently deleted."
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "To confirm deletion, please enter the group name:"
msgstr ""
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "View"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Total: %{count} member"
msgid_plural "Total: %{count} members"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This user cannot be edited." msgid "This user cannot be edited."
@ -2136,3 +2263,8 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This user cannot be viewed." msgid "This user cannot be viewed."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Not authorized."
msgstr ""

View file

@ -12,6 +12,7 @@ msgstr ""
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
@ -38,6 +39,7 @@ msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
@ -47,6 +49,8 @@ msgid "Delete"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
@ -63,6 +67,7 @@ msgstr ""
msgid "Edit Member" msgid "Edit Member"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -167,6 +172,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
@ -258,6 +264,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
@ -270,6 +277,9 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
@ -302,6 +312,8 @@ msgid "Listing Users"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/membership_fee_type_live/index.ex #: lib/mv_web/live/membership_fee_type_live/index.ex
@ -311,6 +323,9 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex #: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
@ -753,6 +768,8 @@ msgstr ""
msgid "Address" msgid "Address"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -792,6 +809,7 @@ msgstr ""
msgid "Personal Data" msgid "Personal Data"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -1627,6 +1645,7 @@ msgstr ""
msgid "New Role" msgid "New Role"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -2127,6 +2146,114 @@ msgstr ""
msgid "email %{email} has already been taken" msgid "email %{email} has already been taken"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Are you sure you want to delete this group? This action cannot be undone."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Back to groups list"
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Create Group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete Group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Delete group"
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#, elixir-autogen, elixir-format
msgid "Edit Group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Enter the group name to confirm"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to delete group: %{error}"
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to load group."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Group deleted successfully."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Group name does not match."
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Group not found."
msgstr ""
#: lib/mv_web/live/group_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Group saved successfully."
msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "Groups"
msgstr ""
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "No groups"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No members in this group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "This group has %{count} member. All member-group associations will be permanently deleted."
msgid_plural "This group has %{count} members. All member-group associations will be permanently deleted."
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "To confirm deletion, please enter the group name:"
msgstr ""
#: lib/mv_web/live/group_live/index.ex
#, elixir-autogen, elixir-format
msgid "View"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Total: %{count} member"
msgid_plural "Total: %{count} members"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This user cannot be edited." msgid "This user cannot be edited."

View file

@ -0,0 +1,290 @@
defmodule MvWeb.GroupLive.FormTest do
@moduledoc """
Tests for the group create/edit form.
Tests cover:
- Creating groups
- Editing groups
- Form validations
- Edge cases
- Security
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Fixtures
describe "create form" do
test "form renders with empty fields", %{conn: conn} do
{:ok, view, html} = live(conn, "/groups/new")
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "Gruppe erstellen"
assert has_element?(view, "form")
end
test "creates group successfully with name and description", %{conn: conn} do
{:ok, view, _html} = live(conn, "/groups/new")
form_data = %{
"name" => "New Group",
"description" => "Group description"
}
view
|> form("#group-form", group: form_data)
|> render_submit()
# Should redirect to groups list or show page
assert_redirect(view, "/groups")
end
test "creates group successfully with name only (description optional)", %{conn: conn} do
{:ok, view, _html} = live(conn, "/groups/new")
form_data = %{
"name" => "Group Without Description"
}
view
|> form("#group-form", group: form_data)
|> render_submit()
assert_redirect(view, "/groups")
end
test "shows error when name is missing", %{conn: conn} do
{:ok, view, _html} = live(conn, "/groups/new")
form_data = %{
"description" => "Description without name"
}
html =
view
|> form("#group-form", group: form_data)
|> render_submit()
assert html =~ gettext("required") or html =~ "name" or html =~ "error" or
html =~ "erforderlich"
end
test "shows error when name exceeds 100 characters", %{conn: conn} do
{:ok, view, _html} = live(conn, "/groups/new")
long_name = String.duplicate("a", 101)
form_data = %{"name" => long_name}
html =
view
|> form("#group-form", group: form_data)
|> render_submit()
assert html =~ "100" or html =~ "length" or html =~ "error" or html =~ "Länge"
end
test "shows error when description exceeds 500 characters", %{conn: conn} do
{:ok, view, _html} = live(conn, "/groups/new")
long_description = String.duplicate("a", 501)
form_data = %{
"name" => "Test Group",
"description" => long_description
}
html =
view
|> form("#group-form", group: form_data)
|> render_submit()
assert html =~ "500" or html =~ "length" or html =~ "error" or html =~ "Länge"
end
test "shows error when name already exists (case-insensitive)", %{conn: conn} do
_existing_group = Fixtures.group_fixture(%{name: "Existing Group"})
{:ok, view, _html} = live(conn, "/groups/new")
form_data = %{
"name" => "EXISTING GROUP"
}
html =
view
|> form("#group-form", group: form_data)
|> render_submit()
# Check for a validation error on the name field in a robust way
assert html =~ "name" or html =~ gettext("has already been taken")
end
test "shows error when name generates empty slug", %{conn: conn} do
{:ok, view, _html} = live(conn, "/groups/new")
form_data = %{
"name" => "!!!"
}
html =
view
|> form("#group-form", group: form_data)
|> render_submit()
assert html =~ "error" or html =~ "invalid" or html =~ "Fehler" or html =~ "ungültig"
end
end
describe "edit form" do
test "form renders with existing group data", %{conn: conn} do
group =
Fixtures.group_fixture(%{name: "Original Name", description: "Original Description"})
{:ok, view, html} = live(conn, "/groups/#{group.slug}/edit")
assert html =~ "Original Name"
assert html =~ "Original Description"
assert has_element?(view, "form")
end
test "updates group name successfully (slug remains unchanged)", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Original Name"})
original_slug = group.slug
{:ok, view, _html} = live(conn, "/groups/#{group.slug}/edit")
form_data = %{
"name" => "Updated Name"
}
view
|> form("#group-form", group: form_data)
|> render_submit()
# Verify slug didn't change by checking redirect URL or reloading
assert_redirect(view, "/groups/#{original_slug}")
end
test "updates group description successfully", %{conn: conn} do
group = Fixtures.group_fixture(%{description: "Old Description"})
{:ok, view, _html} = live(conn, "/groups/#{group.slug}/edit")
form_data = %{
"description" => "New Description"
}
view
|> form("#group-form", group: form_data)
|> render_submit()
assert_redirect(view, "/groups/#{group.slug}")
end
test "shows error when updating to duplicate name (case-insensitive)", %{conn: conn} do
_group1 = Fixtures.group_fixture(%{name: "Group One"})
group2 = Fixtures.group_fixture(%{name: "Group Two"})
{:ok, view, _html} = live(conn, "/groups/#{group2.slug}/edit")
form_data = %{
"name" => "GROUP ONE"
}
html =
view
|> form("#group-form", group: form_data)
|> render_submit()
assert html =~ "already" or html =~ "taken" or html =~ "exists" or html =~ "error" or
html =~ "bereits" or html =~ "vergeben"
end
test "slug is not displayed in form (immutable)", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Test Group"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit")
# Slug should not be in form (it's immutable)
refute html =~ ~r/slug.*input/i or html =~ ~r/input.*slug/i
end
end
describe "edge cases" do
test "handles whitespace trimming correctly", %{conn: conn} do
{:ok, view, _html} = live(conn, "/groups/new")
form_data = %{
"name" => " Trimmed Group "
}
view
|> form("#group-form", group: form_data)
|> render_submit()
# Name should be trimmed
assert_redirect(view, "/groups")
end
test "handles umlauts in name correctly", %{conn: conn} do
{:ok, view, _html} = live(conn, "/groups/new")
form_data = %{
"name" => "Müller Gruppe"
}
view
|> form("#group-form", group: form_data)
|> render_submit()
assert_redirect(view, "/groups")
end
end
describe "security" do
@tag role: :member
test "non-admin users cannot access create form", %{conn: conn} do
result = live(conn, "/groups/new")
assert match?({:error, {:redirect, %{to: _}}}, result) ||
match?({:error, {:live_redirect, %{to: _}}}, result)
end
@tag role: :member
test "non-admin users cannot access edit form", %{conn: conn} do
group = Fixtures.group_fixture()
result = live(conn, "/groups/#{group.slug}/edit")
assert match?({:error, {:redirect, %{to: _}}}, result) ||
match?({:error, {:live_redirect, %{to: _}}}, result)
end
@tag role: :unauthenticated
test "unauthenticated users are redirected to login", %{conn: conn} do
result = live(conn, "/groups/new")
assert match?({:error, {:redirect, %{to: "/sign-in"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/sign-in"}}}, result)
end
test "user cannot edit group with non-existent slug", %{conn: conn} do
non_existent_slug = "non-existent-group-slug"
result = live(conn, "/groups/#{non_existent_slug}/edit")
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
end
test "user cannot edit group with wrong slug (case mismatch)", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Test Group"})
wrong_case_slug = String.upcase(group.slug)
result = live(conn, "/groups/#{wrong_case_slug}/edit")
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
end
end
end

View file

@ -0,0 +1,151 @@
defmodule MvWeb.GroupLive.IndexTest do
@moduledoc """
Tests for the groups overview page.
Tests cover:
- Displaying list of groups
- Creating new groups
- Deleting groups with confirmation
- Permission checks
- Edge cases
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "mount and display" do
test "page renders successfully for admin user", %{conn: conn} do
{:ok, _view, html} = live(conn, "/groups")
assert html =~ gettext("Groups")
end
test "lists all groups", %{conn: conn} do
group1 = Fixtures.group_fixture(%{name: "Group One"})
group2 = Fixtures.group_fixture(%{name: "Group Two"})
{:ok, _view, html} = live(conn, "/groups")
assert html =~ group1.name
assert html =~ group2.name
end
test "displays group name, description, and member count", %{conn: conn} do
_group = Fixtures.group_fixture(%{name: "Test Group", description: "Test description"})
{:ok, _view, html} = live(conn, "/groups")
assert html =~ "Test Group"
assert html =~ "Test description"
# Member count should be displayed (0 for empty group)
assert html =~ "0" or html =~ gettext("Members") or html =~ "Mitglieder"
end
test "displays 'Create Group' button for admin users", %{conn: conn} do
{:ok, _view, html} = live(conn, "/groups")
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "new" or
html =~ "Gruppe erstellen"
end
test "displays empty state when no groups exist", %{conn: conn} do
{:ok, _view, html} = live(conn, "/groups")
# Should show empty state or empty list message
assert html =~ gettext("No groups") or html =~ "0" or html =~ "empty" or
html =~ "Keine Gruppen"
end
end
describe "edge cases" do
test "handles very long group names correctly", %{conn: conn} do
long_name = String.duplicate("a", 100)
_group = Fixtures.group_fixture(%{name: long_name})
{:ok, _view, html} = live(conn, "/groups")
assert html =~ long_name
end
test "handles very long descriptions correctly", %{conn: conn} do
long_description = String.duplicate("a", 500)
_group = Fixtures.group_fixture(%{description: long_description})
{:ok, _view, html} = live(conn, "/groups")
assert html =~ long_description or html =~ String.slice(long_description, 0, 100)
end
end
describe "security" do
@tag role: :member
test "non-admin users cannot access groups page", %{conn: conn} do
# Should redirect or show 403
result = live(conn, "/groups")
assert match?({:error, {:redirect, %{to: _}}}, result) ||
match?({:error, {:live_redirect, %{to: _}}}, result)
end
@tag role: :unauthenticated
test "unauthenticated users are redirected to login", %{conn: conn} do
result = live(conn, "/groups")
assert match?({:error, {:redirect, %{to: "/sign-in"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/sign-in"}}}, result)
end
@tag role: :member
test "read-only users can view groups but not create", %{conn: conn} do
# Create read-only user
read_only_user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_password_user(conn, read_only_user)
{:ok, _view, html} = live(conn, "/groups")
# Should be able to see groups
assert html =~ gettext("Groups")
# Should NOT see create button
refute html =~ gettext("Create Group") or html =~ "create"
end
end
describe "performance" do
test "page loads efficiently with many groups", %{conn: conn} do
# Create multiple groups
Enum.each(1..10, fn _ -> Fixtures.group_fixture() end)
# Should load without N+1 queries
{:ok, _view, html} = live(conn, "/groups")
# Verify all groups are displayed
assert html =~ gettext("Groups")
end
test "member count is loaded efficiently via calculation", %{conn: conn} do
group = Fixtures.group_fixture()
member1 = Fixtures.member_fixture()
member2 = Fixtures.member_fixture()
# 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
)
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
actor: system_actor
)
{:ok, _view, html} = live(conn, "/groups")
# Member count should be displayed (should be 2)
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
end
end
end

View file

@ -0,0 +1,209 @@
defmodule MvWeb.GroupLive.IntegrationTest do
@moduledoc """
Integration tests for complete group management workflows.
Tests cover:
- Complete workflows (Create View Edit Delete)
- Slug-based navigation
- Member-group associations
- URL persistence
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
import Ash.Expr
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "complete workflow" do
test "create → view via slug → edit → view via slug (slug unchanged)", %{
conn: conn,
current_user: current_user
} do
# Create group
{:ok, view, _html} = live(conn, "/groups/new")
form_data = %{
"name" => "Workflow Test Group",
"description" => "Initial description"
}
view
|> form("#group-form", group: form_data)
|> render_submit()
# Get the created group by name using current user (tests authorization)
{:ok, groups} = Membership.list_groups(actor: current_user)
group = Enum.find(groups, &(&1.name == "Workflow Test Group"))
original_slug = group.slug
# View via slug
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Workflow Test Group"
assert html =~ "Initial description"
# Edit group
{:ok, view, _html} = live(conn, "/groups/#{group.slug}/edit")
form_data = %{
"name" => "Updated Workflow Test Group",
"description" => "Updated description"
}
view
|> form("#group-form", group: form_data)
|> render_submit()
# View again via slug (should still work with original slug)
{:ok, _view, html} = live(conn, "/groups/#{original_slug}")
assert html =~ "Updated Workflow Test Group"
assert html =~ "Updated description"
# Slug should remain unchanged
assert html =~ original_slug or html =~ "workflow-test-group"
end
test "create group → add members → view (member count updated)", %{
conn: conn,
current_user: current_user
} do
# Create group
{:ok, view, _html} = live(conn, "/groups/new")
form_data = %{
"name" => "Member Test Group"
}
view
|> form("#group-form", group: form_data)
|> render_submit()
# Get the created group using current user (tests authorization)
{:ok, groups} = Membership.list_groups(actor: current_user)
group = Enum.find(groups, &(&1.name == "Member Test Group"))
# Add members to group (use system_actor for test data setup)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
member1 = Fixtures.member_fixture()
member2 = Fixtures.member_fixture()
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
actor: system_actor
)
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
actor: system_actor
)
# View group via slug
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Member count should be 2
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
assert html =~ member1.first_name or html =~ member1.last_name
assert html =~ member2.first_name or html =~ member2.last_name
end
test "create group → add members → delete (cascade works)", %{
conn: conn,
current_user: current_user
} do
# Create group
{:ok, view, _html} = live(conn, "/groups/new")
form_data = %{
"name" => "Delete Test Group"
}
view
|> form("#group-form", group: form_data)
|> render_submit()
# Get the created group using current user (tests authorization)
{:ok, groups} = Membership.list_groups(actor: current_user)
group = Enum.find(groups, &(&1.name == "Delete Test Group"))
# Add member to group (use system_actor for test data setup)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
member = Fixtures.member_fixture()
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
# Delete group (via UI - would need delete confirmation modal test)
# For now, test via API that cascade works
# Use current_user to test authorization (admin can delete)
:ok = Membership.destroy_group(group, actor: current_user)
# Verify member still exists (use system_actor for verification - this is test verification, not user action)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, member.id, actor: system_actor)
assert member_reloaded != nil
# Verify member-group association is deleted
{:ok, mgs} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(expr(group_id == ^group.id)),
actor: system_actor,
domain: Mv.Membership
)
assert mgs == []
end
end
describe "slug-based navigation" do
test "links to group detail use slug (not ID)", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Navigation Test Group"})
{:ok, _view, html} = live(conn, "/groups")
# Links should use slug, not UUID
assert html =~ "/groups/#{group.slug}"
refute html =~ "/groups/#{group.id}"
end
test "deep links to group detail per slug work", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Deep Link Test"})
# Direct navigation to slug URL
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Deep Link Test"
end
test "browser back button works correctly", %{conn: conn} do
group = Fixtures.group_fixture()
# Navigate to group detail
{:ok, _view1, _html} = live(conn, "/groups/#{group.slug}")
# Navigate to edit
{:ok, view2, _html} = live(conn, "/groups/#{group.slug}/edit")
# Browser back should work (tested via navigation)
assert view2 != nil
end
end
describe "URL persistence" do
test "slug-based URLs persist across page reloads", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Persistent URL Test"})
# First visit
{:ok, _view1, html1} = live(conn, "/groups/#{group.slug}")
assert html1 =~ "Persistent URL Test"
# Simulate page reload (new connection)
{:ok, _view2, html2} = live(conn, "/groups/#{group.slug}")
assert html2 =~ "Persistent URL Test"
end
end
end

View file

@ -0,0 +1,287 @@
defmodule MvWeb.GroupLive.ShowTest do
@moduledoc """
Tests for the group detail page.
Tests cover:
- Displaying group information
- Slug-based routing
- Member list display
- Navigation
- Error handling
- Delete functionality
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "mount and display" do
test "page renders successfully", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name
end
test "displays group name", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Test Group Name"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Test Group Name"
end
test "displays group description when present", %{conn: conn} do
group = Fixtures.group_fixture(%{description: "This is a test description"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ "This is a test description"
end
test "displays member count", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Member count should be displayed (might be 0 or more)
assert html =~ "0" or html =~ gettext("Members") or html =~ "member" or html =~ "Mitglied"
end
test "displays list of members in group", %{conn: conn} do
group = Fixtures.group_fixture()
member1 = Fixtures.member_fixture(%{first_name: "Alice", last_name: "Smith"})
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
)
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
actor: system_actor
)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Alice" or html =~ "Smith"
assert html =~ "Bob" or html =~ "Jones"
end
test "displays edit button for admin users", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ gettext("Edit") or html =~ "edit" or html =~ "Bearbeiten"
end
test "displays delete button for admin users", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ gettext("Delete") or html =~ "delete" or html =~ "Löschen"
end
end
describe "slug-based routing" do
test "route /groups/:slug works correctly", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Board Members"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Board Members"
# Verify slug is in URL
assert html =~ group.slug or html =~ "board-members"
end
test "group is found by slug via unique_slug identity", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Test Group"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name
end
test "non-existent slug returns 404", %{conn: conn} do
non_existent_slug = "non-existent-group-slug"
result = live(conn, "/groups/#{non_existent_slug}")
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
end
test "slug is case-sensitive (exact match required)", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Test Group"})
# Slug should be lowercase
wrong_case_slug = String.upcase(group.slug)
result = live(conn, "/groups/#{wrong_case_slug}")
# Should not find group with wrong case
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
end
test "URL is readable and user-friendly", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Board Members"})
{:ok, _view, _html} = live(conn, "/groups/#{group.slug}")
# URL should contain readable slug, not UUID
assert group.slug == "board-members"
refute String.contains?(group.slug, "-") == false
end
end
describe "edge cases" do
test "displays empty group correctly (0 members)", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ "0" or html =~ gettext("No members") or html =~ "empty" or
html =~ "Keine Mitglieder"
end
test "handles group without description correctly", %{conn: conn} do
group = Fixtures.group_fixture(%{description: nil})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Should not crash, description should be optional
assert html =~ group.name
end
test "handles slug with special characters correctly", %{conn: conn} do
# Create group with name that generates slug with hyphens
group = Fixtures.group_fixture(%{name: "Test-Group-Name"})
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ "Test-Group-Name" or html =~ group.name
end
end
describe "security" do
@tag role: :member
test "read-only users can view group detail page", %{conn: conn} do
group = Fixtures.group_fixture()
read_only_user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_password_user(conn, read_only_user)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name
# Should NOT see edit/delete buttons
refute html =~ gettext("Edit") or html =~ gettext("Delete")
end
@tag role: :unauthenticated
test "unauthenticated users are redirected to login", %{conn: conn} do
group = Fixtures.group_fixture()
result = live(conn, "/groups/#{group.slug}")
assert match?({:error, {:redirect, %{to: "/sign-in"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/sign-in"}}}, result)
end
test "slug injection attempts are prevented", %{conn: conn} do
# Try to inject SQL or other malicious content in slug
malicious_slug = "'; DROP TABLE groups; --"
result = live(conn, "/groups/#{malicious_slug}")
# Should not execute SQL, should return 404 or error
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
end
test "user cannot delete group with non-existent slug", %{conn: conn} do
# This test verifies that delete action (if accessed via URL manipulation)
# cannot be performed with non-existent slug
# Note: Actual delete functionality will be tested when delete modal is implemented
non_existent_slug = "non-existent-group-slug"
# Attempting to access show page with non-existent slug should fail
result = live(conn, "/groups/#{non_existent_slug}")
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
end
end
describe "performance" do
test "member list is loaded efficiently (no N+1 queries)", %{conn: conn} do
group = Fixtures.group_fixture()
# Create multiple members
members = Enum.map(1..5, fn _ -> Fixtures.member_fixture() end)
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Add all members to group
Enum.each(members, fn member ->
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
end)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# All members should be displayed
Enum.each(members, fn member ->
assert html =~ member.first_name or html =~ member.last_name
end)
end
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
group = Fixtures.group_fixture()
# Should use index for fast lookup
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ group.name
end
end
describe "navigation" do
test "back button navigates to groups list", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert {:error, {:live_redirect, %{to: to}}} =
view
|> element(
"a[aria-label*='Back'], button[aria-label*='Back'], a[aria-label*='groups list'], button[aria-label*='groups list']"
)
|> render_click()
assert to == "/groups"
end
test "edit button navigates to edit form", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Button with navigate is rendered as <a> tag via <.link>
# Use element with text content to find the link
assert {:error, {:live_redirect, %{to: to}}} =
view
|> element("a", gettext("Edit"))
|> render_click()
assert to == "/groups/#{group.slug}/edit"
end
end
end

View file

@ -170,7 +170,8 @@ defmodule MvWeb.ConnCase do
:member -> :member ->
# Create member user for role-based testing # Create member user for role-based testing
member_user = Mv.Fixtures.user_with_role_fixture("member") # "member" role uses "own_data" permission set (Mitglied role)
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
authenticated_conn = conn_with_password_user(conn, member_user) authenticated_conn = conn_with_password_user(conn, member_user)
{authenticated_conn, member_user} {authenticated_conn, member_user}

View file

@ -262,4 +262,39 @@ defmodule Mv.Fixtures do
{:error, error} -> raise "Failed to create member: #{inspect(error)}" {:error, error} -> raise "Failed to create member: #{inspect(error)}"
end end
end end
@doc """
Creates a group with default or custom attributes.
Uses system_actor for authorization to bypass permission checks in tests.
## Parameters
- `attrs` - Map or keyword list of attributes to override defaults
## Returns
- Group struct
## Examples
iex> group_fixture()
%Mv.Membership.Group{name: "Test Group", slug: "test-group", ...}
iex> group_fixture(%{name: "Board Members", description: "Board members group"})
%Mv.Membership.Group{name: "Board Members", slug: "board-members", ...}
"""
def group_fixture(attrs \\ %{}) do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
attrs
|> Enum.into(%{
name: "Test Group #{System.unique_integer([:positive])}",
description: "Test description"
})
|> Mv.Membership.create_group(actor: system_actor)
|> case do
{:ok, group} -> group
{:error, error} -> raise "Failed to create group: #{inspect(error)}"
end
end
end end