Complete Permissions for Groups, Membership Fees, and User Role Assignment closes #404 #405

Merged
moritz merged 26 commits from feature/404_permission_completeness into main 2026-02-04 11:47:19 +01:00
7 changed files with 96 additions and 54 deletions
Showing only changes of commit c4459ebb92 - Show all commits

View file

@ -4,7 +4,7 @@
**Feature:** Groups Management **Feature:** Groups Management
**Version:** 1.0 **Version:** 1.0
**Last Updated:** 2025-01-XX **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 ## 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) ### Permission Model (MVP)
**Resource:** `groups` **Resource:** `Group` (and `MemberGroup`)
**Actions:** **Actions:**
- `read` - View groups (all users with member read permission) - `read` - View groups (all permission sets)
- `create` - Create groups (admin only) - `create` - Create groups (admin only)
- `update` - Edit groups (admin only) - `update` - Edit groups (admin only)
- `destroy` - Delete groups (admin only) - `destroy` - Delete groups (admin only)

View file

@ -334,20 +334,18 @@ lib/
### Permission System Integration ### 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:** All permission sets can read (:all); only admin has create/update/destroy (:all).
- `MembershipFeeType.read` - Admin, Treasurer, Board - **MembershipFeeCycle:** All can read (:all); read_only has read only; normal_user and admin have read + create + update + destroy (:all).
- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer - **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`.
- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member
**Policy Patterns:** **Resource Policies:**
- Use existing HasPermission check - **MembershipFeeType** (`lib/membership_fees/membership_fee_type.ex`): `authorizers: [Ash.Policy.Authorizer]`, single policy with `HasPermission` for read/create/update/destroy.
- Leverage existing roles (Admin, Kassenwart) - **MembershipFeeCycle** (`lib/membership_fees/membership_fee_cycle.ex`): Same pattern; update includes mark_as_paid, mark_as_suspended, mark_as_unpaid.
- Member can read own cycles (linked via member_id)
### LiveView Integration ### LiveView Integration
@ -357,7 +355,7 @@ lib/
2. MembershipFeeCycle table component (member detail view) 2. MembershipFeeCycle table component (member detail view)
- Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent` - Implemented as `MvWeb.MemberLive.Show.MembershipFeesComponent`
- Displays all cycles in a table with status management - 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) 3. Settings form section (admin)
4. Member list column (membership fee status) 4. Member list column (membership fee status)

View file

@ -958,7 +958,6 @@ msgid "Last name"
msgstr "Nachname" msgstr "Nachname"
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "None" msgid "None"
msgstr "Keine" msgstr "Keine"
@ -1670,6 +1669,7 @@ msgstr "Profil"
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role" msgid "Role"
msgstr "Rolle" msgstr "Rolle"
@ -1965,11 +1965,6 @@ msgstr "Bezahlstatus"
msgid "Reset" msgid "Reset"
msgstr "Zurücksetzen" 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 #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" msgid " (Field: %{field})"
@ -2302,3 +2297,18 @@ msgstr "Unbekannte Spalte '%{header}' wird ignoriert. Falls dies ein Datenfeld i
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Only administrators or the linked user can change the email for members linked to users" 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." 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"

View file

@ -959,7 +959,6 @@ msgid "Last name"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "None" msgid "None"
msgstr "" msgstr ""
@ -1671,6 +1670,7 @@ msgstr ""
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role" msgid "Role"
msgstr "" msgstr ""
@ -1966,11 +1966,6 @@ msgstr ""
msgid "Reset" msgid "Reset"
msgstr "" 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 #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" msgid " (Field: %{field})"
@ -2303,3 +2298,18 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Only administrators or the linked user can change the email for members linked to users" msgid "Only administrators or the linked user can change the email for members linked to users"
msgstr "" 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 ""

View file

@ -959,7 +959,6 @@ msgid "Last name"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "None" msgid "None"
msgstr "" msgstr ""
@ -1671,6 +1670,7 @@ msgstr ""
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Role" msgid "Role"
msgstr "" msgstr ""
@ -1966,11 +1966,6 @@ msgstr ""
msgid "Reset" msgid "Reset"
msgstr "" 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 #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid " (Field: %{field})" 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 #, elixir-autogen, elixir-format, fuzzy
msgid "Only administrators or the linked user can change the email for members linked to users" 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" 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 ""

View file

@ -134,8 +134,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
# Load cycles with membership_fee_type relationship # Load cycles with membership_fee_type relationship
member = member =
member member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: actor)
# Use a fixed date in 2024 to ensure 2023 is last completed # Use a fixed date in 2024 to ensure 2023 is last completed
today = ~D[2024-06-15] today = ~D[2024-06-15]
@ -180,8 +180,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
# Load cycles and fee type (will be empty) # Load cycles and fee type (will be empty)
member = member =
member member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: actor)
last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, Date.utc_today()) last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, Date.utc_today())
assert last_cycle == nil assert last_cycle == nil
@ -245,8 +245,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
# Load cycles with membership_fee_type relationship # Load cycles with membership_fee_type relationship
member = member =
member member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: actor)
result = MembershipFeeHelpers.get_current_cycle(member, today) result = MembershipFeeHelpers.get_current_cycle(member, today)

View file

@ -127,10 +127,12 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid}) create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
# Load cycles with membership_fee_type relationship # Load cycles with membership_fee_type relationship
system_actor = Mv.Helpers.SystemActor.get_system_actor()
member = member =
member member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
# Use fixed date in 2024 to ensure 2023 is last completed # Use fixed date in 2024 to ensure 2023 is last completed
# We need to manually set the date for the helper function # 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 # Load cycles with membership_fee_type relationship
member = member =
member member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
status = MembershipFeeStatus.get_cycle_status_for_member(member, true) 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) # Load cycles and fee type first (will be empty)
member = member =
member member
|> Ash.load!(membership_fee_cycles: [:membership_fee_type]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
status = MembershipFeeStatus.get_cycle_status_for_member(member, false) 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}) member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid}) create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = members =
[member1, member2] [member1, member2]
|> Enum.map(fn m -> |> Enum.map(fn m ->
m m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false) 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}) member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid}) create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = members =
[member1, member2] [member1, member2]
|> Enum.map(fn m -> |> Enum.map(fn m ->
m m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false) 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}) member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid}) create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = members =
[member1, member2] [member1, member2]
|> Enum.map(fn m -> |> Enum.map(fn m ->
m m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true) 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}) member2 = create_member(%{membership_fee_type_id: fee_type.id})
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid}) create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
members = members =
[member1, member2] [member1, member2]
|> Enum.map(fn m -> |> Enum.map(fn m ->
m m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
end) end)
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true) 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}) member1 = create_member(%{membership_fee_type_id: fee_type.id})
member2 = 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 = members =
[member1, member2] [member1, member2]
|> Enum.map(fn m -> |> Enum.map(fn m ->
m m
|> Ash.load!(membership_fee_cycles: [:membership_fee_type]) |> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|> Ash.load!(:membership_fee_type) |> Ash.load!(:membership_fee_type, actor: system_actor)
end) end)
# filter_unpaid_members should still work for backwards compatibility # filter_unpaid_members should still work for backwards compatibility