diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index b2316d8..344d582 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -4,7 +4,7 @@ **Feature:** Groups Management **Version:** 1.0 **Last Updated:** 2025-01-XX -**Status:** Architecture Design - Ready for Implementation +**Status:** ✅ Implemented (authorization: see [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)) --- @@ -412,12 +412,14 @@ lib/ ## Authorization +**Status:** ✅ Implemented. Group and MemberGroup resource policies and PermissionSets are in place. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns. + ### Permission Model (MVP) -**Resource:** `groups` +**Resource:** `Group` (and `MemberGroup`) **Actions:** -- `read` - View groups (all users with member read permission) +- `read` - View groups (all permission sets) - `create` - Create groups (admin only) - `update` - Edit groups (admin only) - `destroy` - Delete groups (admin only) diff --git a/docs/membership-fee-architecture.md b/docs/membership-fee-architecture.md index 4a290b7..fa82be3 100644 --- a/docs/membership-fee-architecture.md +++ b/docs/membership-fee-architecture.md @@ -334,20 +334,18 @@ lib/ ### Permission System Integration -**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) +**Status:** ✅ Implemented. See [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) for the full permission matrix and policy patterns. -**Required Permissions:** +**PermissionSets (lib/mv/authorization/permission_sets.ex):** -- `MembershipFeeType.create/update/destroy` - Admin only -- `MembershipFeeType.read` - Admin, Treasurer, Board -- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer -- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member +- **MembershipFeeType:** All permission sets can read (:all); only admin has create/update/destroy (:all). +- **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all). +- **Manual "Regenerate Cycles" (UI):** The "Regenerate Cycles" button in the member detail view is shown to users who have MembershipFeeCycle create permission (normal_user and admin). Regeneration runs with system actor; UI access is gated by `can_create_cycle`. -**Policy Patterns:** +**Resource Policies:** -- Use existing HasPermission check -- Leverage existing roles (Admin, Kassenwart) -- Member can read own cycles (linked via member_id) +- **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy. +- **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid. ### LiveView Integration @@ -357,7 +355,7 @@ lib/ 2. MembershipFeeCycle table component (member detail view) - Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent` - Displays all cycles in a table with status management - - Allows changing cycle status, editing amounts, and regenerating cycles + - Allows changing cycle status, editing amounts, and manually regenerating cycles (normal_user and admin) 3. Settings form section (admin) 4. Member list column (membership fee status) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index c4fd57d..4ea98e1 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -958,7 +958,6 @@ msgid "Last name" msgstr "Nachname" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "None" msgstr "Keine" @@ -1670,6 +1669,7 @@ msgstr "Profil" #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Role" msgstr "Rolle" @@ -1965,11 +1965,6 @@ msgstr "Bezahlstatus" msgid "Reset" msgstr "Zurücksetzen" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Only administrators can regenerate cycles" -msgstr "Nur Administrator*innen können Zyklen regenerieren" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid " (Field: %{field})" @@ -2302,3 +2297,18 @@ msgstr "Unbekannte Spalte '%{header}' wird ignoriert. Falls dies ein Datenfeld i #, elixir-autogen, elixir-format, fuzzy msgid "Only administrators or the linked user can change the email for members linked to users" msgstr "Nur Administrator*innen oder die verknüpfte Benutzer*in können die E-Mail von Mitgliedern ändern, die mit Benutzer*innen verknüpft sind." + +#: lib/mv_web/live/user_live/form.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Select role..." +msgstr "Keine auswählen" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "You are not allowed to perform this action." +msgstr "Du hast keine Berechtigung, diese Aktion auszuführen." + +#: lib/mv_web/live/member_live/form.ex +#, elixir-autogen, elixir-format +msgid "Select a membership fee type" +msgstr "Mitgliedsbeitragstyp auswählen" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 0908fd8..483f65f 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -959,7 +959,6 @@ msgid "Last name" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "None" msgstr "" @@ -1671,6 +1670,7 @@ msgstr "" #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Role" msgstr "" @@ -1966,11 +1966,6 @@ msgstr "" msgid "Reset" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Only administrators can regenerate cycles" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid " (Field: %{field})" @@ -2303,3 +2298,18 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Only administrators or the linked user can change the email for members linked to users" msgstr "" + +#: lib/mv_web/live/user_live/form.ex +#, elixir-autogen, elixir-format +msgid "Select role..." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "You are not allowed to perform this action." +msgstr "" + +#: lib/mv_web/live/member_live/form.ex +#, elixir-autogen, elixir-format +msgid "Select a membership fee type" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 6faa102..383dacd 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -959,7 +959,6 @@ msgid "Last name" msgstr "" #: lib/mv_web/components/core_components.ex -#: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format, fuzzy msgid "None" msgstr "" @@ -1671,6 +1670,7 @@ msgstr "" #: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/show.ex +#: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Role" msgstr "" @@ -1966,11 +1966,6 @@ msgstr "" msgid "Reset" msgstr "" -#: lib/mv_web/live/member_live/show/membership_fees_component.ex -#, elixir-autogen, elixir-format -msgid "Only administrators can regenerate cycles" -msgstr "" - #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid " (Field: %{field})" @@ -2303,3 +2298,18 @@ msgstr "Unknown column '%{header}' will be ignored. If this is a custom field, c #, elixir-autogen, elixir-format, fuzzy msgid "Only administrators or the linked user can change the email for members linked to users" msgstr "Only administrators or the linked user can change the email for members linked to users" + +#: lib/mv_web/live/user_live/form.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Select role..." +msgstr "" + +#: lib/mv_web/live/member_live/show/membership_fees_component.ex +#, elixir-autogen, elixir-format +msgid "You are not allowed to perform this action." +msgstr "" + +#: lib/mv_web/live/member_live/form.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Select a membership fee type" +msgstr "" diff --git a/test/mv_web/helpers/membership_fee_helpers_test.exs b/test/mv_web/helpers/membership_fee_helpers_test.exs index 530143f..6726091 100644 --- a/test/mv_web/helpers/membership_fee_helpers_test.exs +++ b/test/mv_web/helpers/membership_fee_helpers_test.exs @@ -134,8 +134,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do # Load cycles with membership_fee_type relationship member = member - |> Ash.load!(membership_fee_cycles: [:membership_fee_type]) - |> Ash.load!(:membership_fee_type) + |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor) + |> Ash.load!(:membership_fee_type, actor: actor) # Use a fixed date in 2024 to ensure 2023 is last completed today = ~D[2024-06-15] @@ -180,8 +180,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do # Load cycles and fee type (will be empty) member = member - |> Ash.load!(membership_fee_cycles: [:membership_fee_type]) - |> Ash.load!(:membership_fee_type) + |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor) + |> Ash.load!(:membership_fee_type, actor: actor) last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, Date.utc_today()) assert last_cycle == nil @@ -245,8 +245,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do # Load cycles with membership_fee_type relationship member = member - |> Ash.load!(membership_fee_cycles: [:membership_fee_type]) - |> Ash.load!(:membership_fee_type) + |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor) + |> Ash.load!(:membership_fee_type, actor: actor) result = MembershipFeeHelpers.get_current_cycle(member, today) diff --git a/test/mv_web/member_live/index/membership_fee_status_test.exs b/test/mv_web/member_live/index/membership_fee_status_test.exs index 950b65f..aa729ef 100644 --- a/test/mv_web/member_live/index/membership_fee_status_test.exs +++ b/test/mv_web/member_live/index/membership_fee_status_test.exs @@ -127,10 +127,12 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid}) # Load cycles with membership_fee_type relationship + system_actor = Mv.Helpers.SystemActor.get_system_actor() + member = member - |> Ash.load!(membership_fee_cycles: [:membership_fee_type]) - |> Ash.load!(:membership_fee_type) + |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) + |> Ash.load!(:membership_fee_type, actor: system_actor) # Use fixed date in 2024 to ensure 2023 is last completed # We need to manually set the date for the helper function @@ -183,8 +185,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do # Load cycles with membership_fee_type relationship member = member - |> Ash.load!(membership_fee_cycles: [:membership_fee_type]) - |> Ash.load!(:membership_fee_type) + |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) + |> Ash.load!(:membership_fee_type, actor: system_actor) status = MembershipFeeStatus.get_cycle_status_for_member(member, true) @@ -222,8 +224,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do # Load cycles and fee type first (will be empty) member = member - |> Ash.load!(membership_fee_cycles: [:membership_fee_type]) - |> Ash.load!(:membership_fee_type) + |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) + |> Ash.load!(:membership_fee_type, actor: system_actor) status = MembershipFeeStatus.get_cycle_status_for_member(member, false) @@ -273,12 +275,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do member2 = create_member(%{membership_fee_type_id: fee_type.id}) create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid}) + system_actor = Mv.Helpers.SystemActor.get_system_actor() + members = [member1, member2] |> Enum.map(fn m -> m - |> Ash.load!(membership_fee_cycles: [:membership_fee_type]) - |> Ash.load!(:membership_fee_type) + |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) + |> Ash.load!(:membership_fee_type, actor: system_actor) end) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false) @@ -300,12 +304,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do member2 = create_member(%{membership_fee_type_id: fee_type.id}) create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid}) + system_actor = Mv.Helpers.SystemActor.get_system_actor() + members = [member1, member2] |> Enum.map(fn m -> m - |> Ash.load!(membership_fee_cycles: [:membership_fee_type]) - |> Ash.load!(:membership_fee_type) + |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) + |> Ash.load!(:membership_fee_type, actor: system_actor) end) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false) @@ -327,12 +333,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do member2 = create_member(%{membership_fee_type_id: fee_type.id}) create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid}) + system_actor = Mv.Helpers.SystemActor.get_system_actor() + members = [member1, member2] |> Enum.map(fn m -> m - |> Ash.load!(membership_fee_cycles: [:membership_fee_type]) - |> Ash.load!(:membership_fee_type) + |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) + |> Ash.load!(:membership_fee_type, actor: system_actor) end) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true) @@ -354,12 +362,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do member2 = create_member(%{membership_fee_type_id: fee_type.id}) create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid}) + system_actor = Mv.Helpers.SystemActor.get_system_actor() + members = [member1, member2] |> Enum.map(fn m -> m - |> Ash.load!(membership_fee_cycles: [:membership_fee_type]) - |> Ash.load!(:membership_fee_type) + |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) + |> Ash.load!(:membership_fee_type, actor: system_actor) end) filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true) @@ -373,12 +383,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do member1 = create_member(%{membership_fee_type_id: fee_type.id}) member2 = create_member(%{membership_fee_type_id: fee_type.id}) + system_actor = Mv.Helpers.SystemActor.get_system_actor() + members = [member1, member2] |> Enum.map(fn m -> m - |> Ash.load!(membership_fee_cycles: [:membership_fee_type]) - |> Ash.load!(:membership_fee_type) + |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor) + |> Ash.load!(:membership_fee_type, actor: system_actor) end) # filter_unpaid_members should still work for backwards compatibility