diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 4905b79..28c454b 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1010,6 +1010,10 @@ let liveSocket = new LiveSocket("/live", Socket, { ### 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:** ```bash @@ -1020,6 +1024,13 @@ mix credo 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:** - Consistency checks (spacing, line endings, parameter patterns) diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index 023df5b..b2316d8 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -255,9 +255,9 @@ lib/ **Route:** `/groups` - Groups management index page **Features:** -- List all groups in table -- Create new group button -- Edit group (inline or modal) +- List all groups in table (sorted by name via database query) +- Create new group button (navigates to `/groups/new`) +- Edit group via separate form page (`/groups/:slug/edit`) - Delete group with confirmation modal - Show member count per group @@ -268,11 +268,13 @@ lib/ - Actions (Edit, Delete) **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" -- 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 +- Modal remains open on name mismatch (allows user to correct input) - Cancel button +- Server-side authorization check in delete event handler (security best practice) ### Member Overview Integration @@ -304,18 +306,33 @@ lib/ - Add/remove groups inline - 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:** - Display group name and description - List all members in group - Link to member detail pages -- Edit group button -- Delete group button (with confirmation) +- Edit group button (navigates to `/groups/:slug/edit`) +- 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 @@ -473,6 +490,7 @@ lib/ - Paginate member list for large groups (>50 members) - Load member count via calculation (not separate query) - Use `Ash.Query.load` for member details when displaying +- Sorting performed at database level (`Ash.Query.sort(:name)`) for efficiency ### 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 - Immutable after creation (don't change when name is updated) - 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. diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index b42daa5..47d2f0e 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 + 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 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/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex index c464b66..1d564c1 100644 --- a/lib/mv_web/components/layouts/sidebar.ex +++ b/lib/mv_web/components/layouts/sidebar.ex @@ -85,6 +85,7 @@ defmodule MvWeb.Layouts.Sidebar do <.menu_group icon="hero-cog-6-tooth" label={gettext("Administration")}> <.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"/membership_fee_settings"} 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..0ffba09 --- /dev/null +++ b/lib/mv_web/live/group_live/form.ex @@ -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""" + + <.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 = 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 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..b0d428d --- /dev/null +++ b/lib/mv_web/live/group_live/index.ex @@ -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""" + +
+

{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")}
+ {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) + |> 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 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..8114fa5 --- /dev/null +++ b/lib/mv_web/live/group_live/show.ex @@ -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""" + + <%!-- Header with Back button, Name, and Edit/Delete buttons --%> +
+ <.button navigate={~p"/groups"} aria-label={gettext("Back to groups list")}> + <.icon name="hero-arrow-left" class="size-4" /> + {gettext("Back")} + + +

+ {@group.name} +

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

{gettext("Description")}

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

{@group.description}

+ <% else %> +

{gettext("No description")}

+ <% end %> +
+
+ +
+

{gettext("Members")}

+
+

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

+ + <%= if 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) + 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 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/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 865dff4..e97950d 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -11,6 +11,7 @@ msgstr "" "Language: de\n" #: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/group_live/index.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "Aktionen" @@ -37,6 +38,7 @@ msgid "City" msgstr "Stadt" #: 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/show/membership_fees_component.ex #: lib/mv_web/live/role_live/index.html.heex @@ -46,6 +48,8 @@ msgid "Delete" msgstr "Löschen" #: 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_live/index.html.heex #: lib/mv_web/live/role_live/form.ex @@ -62,6 +66,7 @@ msgstr "Bearbeiten" msgid "Edit Member" 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/index.html.heex #: 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/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_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/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_live/form.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/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/index_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex @@ -301,6 +311,8 @@ msgid "Listing Users" msgstr "Benutzer*innen auflisten" #: 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.html.heex #: 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/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/index_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex @@ -752,6 +767,8 @@ msgstr "Alle" msgid "Address" 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/show.ex #, elixir-autogen, elixir-format @@ -791,6 +808,7 @@ msgstr "Zahlungen" msgid "Personal Data" 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/show/membership_fees_component.ex #, elixir-autogen, elixir-format @@ -1626,6 +1644,7 @@ msgstr "Hauptnavigation" msgid "New Role" 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/show.ex #, elixir-autogen, elixir-format @@ -2126,6 +2145,114 @@ msgstr "E-Mail" msgid "email %{email} has already been taken" 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 #, elixir-autogen, elixir-format msgid "This user cannot be edited." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index e63621d..13b488c 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -12,6 +12,7 @@ msgid "" msgstr "" #: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/group_live/index.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "" @@ -38,6 +39,7 @@ msgid "City" msgstr "" #: 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/show/membership_fees_component.ex #: lib/mv_web/live/role_live/index.html.heex @@ -47,6 +49,8 @@ msgid "Delete" msgstr "" #: 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_live/index.html.heex #: lib/mv_web/live/role_live/form.ex @@ -63,6 +67,7 @@ msgstr "" msgid "Edit Member" msgstr "" +#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: 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/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_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/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_live/form.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/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/index_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex @@ -302,6 +312,8 @@ msgid "Listing Users" msgstr "" #: 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.html.heex #: 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/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/index_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex @@ -753,6 +768,8 @@ msgstr "" msgid "Address" 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/show.ex #, elixir-autogen, elixir-format @@ -792,6 +809,7 @@ msgstr "" msgid "Personal Data" msgstr "" +#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format @@ -1627,6 +1645,7 @@ msgstr "" msgid "New Role" msgstr "" +#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format @@ -2127,6 +2146,114 @@ msgstr "" msgid "email %{email} has already been taken" 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 #, elixir-autogen, elixir-format msgid "This user cannot be edited." @@ -2136,3 +2263,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "This user cannot be viewed." msgstr "" + +#: lib/mv_web/live/group_live/show.ex +#, elixir-autogen, elixir-format +msgid "Not authorized." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 15c6f9a..a85d2d7 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -12,6 +12,7 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: lib/mv_web/components/core_components.ex +#: lib/mv_web/live/group_live/index.ex #, elixir-autogen, elixir-format msgid "Actions" msgstr "" @@ -38,6 +39,7 @@ msgid "City" msgstr "" #: 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/show/membership_fees_component.ex #: lib/mv_web/live/role_live/index.html.heex @@ -47,6 +49,8 @@ msgid "Delete" msgstr "" #: 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_live/index.html.heex #: lib/mv_web/live/role_live/form.ex @@ -63,6 +67,7 @@ msgstr "" msgid "Edit Member" msgstr "" +#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: 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/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_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/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_live/form.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/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/index_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex @@ -302,6 +312,8 @@ msgid "Listing Users" msgstr "" #: 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.html.heex #: 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/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/index_component.ex #: lib/mv_web/live/membership_fee_type_live/form.ex @@ -753,6 +768,8 @@ msgstr "" msgid "Address" 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/show.ex #, elixir-autogen, elixir-format @@ -792,6 +809,7 @@ msgstr "" msgid "Personal Data" msgstr "" +#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show/membership_fees_component.ex #, elixir-autogen, elixir-format, fuzzy @@ -1627,6 +1645,7 @@ msgstr "" msgid "New Role" msgstr "" +#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/show.ex #, elixir-autogen, elixir-format, fuzzy @@ -2127,6 +2146,114 @@ msgstr "" msgid "email %{email} has already been taken" 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 #, elixir-autogen, elixir-format msgid "This user cannot be edited." diff --git a/test/mv_web/live/group_live/form_test.exs b/test/mv_web/live/group_live/form_test.exs new file mode 100644 index 0000000..9169dfe --- /dev/null +++ b/test/mv_web/live/group_live/form_test.exs @@ -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 diff --git a/test/mv_web/live/group_live/index_test.exs b/test/mv_web/live/group_live/index_test.exs new file mode 100644 index 0000000..7dabcab --- /dev/null +++ b/test/mv_web/live/group_live/index_test.exs @@ -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 diff --git a/test/mv_web/live/group_live/integration_test.exs b/test/mv_web/live/group_live/integration_test.exs new file mode 100644 index 0000000..a98de85 --- /dev/null +++ b/test/mv_web/live/group_live/integration_test.exs @@ -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 diff --git a/test/mv_web/live/group_live/show_test.exs b/test/mv_web/live/group_live/show_test.exs new file mode 100644 index 0000000..af298fd --- /dev/null +++ b/test/mv_web/live/group_live/show_test.exs @@ -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 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 diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 290b3ac..7dd118b 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -170,7 +170,8 @@ defmodule MvWeb.ConnCase do :member -> # 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, member_user} diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 23d4aa7..fd4166d 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -262,4 +262,39 @@ defmodule Mv.Fixtures do {:error, error} -> raise "Failed to create member: #{inspect(error)}" 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