diff --git a/docker-compose.yml b/docker-compose.yml
index 626f353..4c169b5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -25,7 +25,7 @@ services:
rauthy:
container_name: rauthy-dev
- image: ghcr.io/sebadob/rauthy:0.34.2
+ image: ghcr.io/sebadob/rauthy:0.33.4
environment:
- LOCAL_TEST=true
- SMTP_URL=mailcrab
diff --git a/docs/email-sync.md b/docs/email-sync.md
index 2f765f0..c191ff4 100644
--- a/docs/email-sync.md
+++ b/docs/email-sync.md
@@ -4,7 +4,6 @@
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
-5. **Linked member email change** - When a member is linked, only administrators or the linked user may change that member's email (Member resource validation `EmailChangePermission`). Because User.email wins on link and changes sync Member → User, allowing anyone to change a linked member's email would overwrite that user's account email; this rule keeps sync under control.
---
diff --git a/lib/membership/member.ex b/lib/membership/member.ex
index 8213ecb..7b49c86 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -25,7 +25,6 @@ defmodule Mv.Membership.Member do
- Postal code format: exactly 5 digits (German format)
- Date validations: join_date not in future, exit_date after join_date
- Email uniqueness: prevents conflicts with unlinked users
- - Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`)
## Full-Text Search
Members have a `search_vector` attribute (tsvector) that is automatically
@@ -382,9 +381,6 @@ defmodule Mv.Membership.Member do
# Validates that member email is not already used by another (unlinked) user
validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser
- # Only admins or the linked user may change a linked member's email (prevents breaking sync)
- validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]
-
# Prevent linking to a user that already has a member
# This validation prevents "stealing" users from other members by checking
# if the target user is already linked to a different member
diff --git a/lib/mv/authorization/actor.ex b/lib/mv/authorization/actor.ex
index edc6b8b..3482043 100644
--- a/lib/mv/authorization/actor.ex
+++ b/lib/mv/authorization/actor.ex
@@ -1,7 +1,6 @@
defmodule Mv.Authorization.Actor do
@moduledoc """
- Helper functions for ensuring User actors have required data loaded
- and for querying actor capabilities (e.g. admin, permission set).
+ Helper functions for ensuring User actors have required data loaded.
## Actor Invariant
@@ -28,11 +27,8 @@ defmodule Mv.Authorization.Actor do
assign(socket, :current_user, user)
end
- # Check if actor is admin (policy checks, validations)
- if Actor.admin?(actor), do: ...
-
- # Get permission set name (string or nil)
- ps_name = Actor.permission_set_name(actor)
+ # In tests
+ user = Actor.ensure_loaded(user)
## Security Note
@@ -51,8 +47,6 @@ defmodule Mv.Authorization.Actor do
require Logger
- alias Mv.Helpers.SystemActor
-
@doc """
Ensures the actor (User) has their `:role` relationship loaded.
@@ -102,45 +96,4 @@ defmodule Mv.Authorization.Actor do
actor
end
end
-
- @doc """
- Returns the actor's permission set name (string or atom) from their role, or nil.
-
- Ensures role is loaded (including when role is nil). Supports both atom and
- string keys for session/socket assigns. Use for capability checks consistent
- with `ActorIsAdmin` and `HasPermission`.
- """
- @spec permission_set_name(Mv.Accounts.User.t() | map() | nil) :: String.t() | atom() | nil
- def permission_set_name(nil), do: nil
-
- def permission_set_name(actor) do
- actor = actor |> ensure_loaded() |> maybe_load_role()
-
- get_in(actor, [Access.key(:role), Access.key(:permission_set_name)]) ||
- get_in(actor, [Access.key("role"), Access.key("permission_set_name")])
- end
-
- @doc """
- Returns true if the actor is the system user or has the admin permission set.
-
- Use for validations and policy checks that require admin capability (e.g.
- changing a linked member's email). Consistent with `ActorIsAdmin` policy check.
- """
- @spec admin?(Mv.Accounts.User.t() | map() | nil) :: boolean()
- def admin?(nil), do: false
-
- def admin?(actor) do
- SystemActor.system_user?(actor) or permission_set_name(actor) in ["admin", :admin]
- end
-
- # Load role only when it is nil (e.g. actor from session without role). ensure_loaded/1
- # already handles %Ash.NotLoaded{}, so we do not double-load in the normal Ash path.
- defp maybe_load_role(%Mv.Accounts.User{role: nil} = user) do
- case Ash.load(user, :role, domain: Mv.Accounts, authorize?: false) do
- {:ok, loaded} -> loaded
- _ -> user
- end
- end
-
- defp maybe_load_role(actor), do: actor
end
diff --git a/lib/mv/authorization/checks/actor_is_admin.ex b/lib/mv/authorization/checks/actor_is_admin.ex
index 413c6c7..2328876 100644
--- a/lib/mv/authorization/checks/actor_is_admin.ex
+++ b/lib/mv/authorization/checks/actor_is_admin.ex
@@ -1,18 +1,22 @@
defmodule Mv.Authorization.Checks.ActorIsAdmin do
@moduledoc """
- Policy check: true when the actor is the system user or has permission_set_name "admin".
+ Policy check: true when the actor's role has permission_set_name "admin".
Used to restrict actions (e.g. User.update_user for member link/unlink) to admins only.
- Delegates to `Mv.Authorization.Actor.admin?/1`, which returns true for the system actor
- or for a user whose role has permission_set_name "admin".
"""
use Ash.Policy.SimpleCheck
- alias Mv.Authorization.Actor
-
@impl true
def describe(_opts), do: "actor has admin permission set"
@impl true
- def match?(actor, _context, _opts), do: Actor.admin?(actor)
+ def match?(nil, _context, _opts), do: false
+
+ def match?(actor, _context, _opts) do
+ ps_name =
+ get_in(actor, [Access.key(:role), Access.key(:permission_set_name)]) ||
+ get_in(actor, [Access.key("role"), Access.key("permission_set_name")])
+
+ ps_name == "admin"
+ end
end
diff --git a/lib/mv/email_sync/loader.ex b/lib/mv/email_sync/loader.ex
index 31e0468..98f85df 100644
--- a/lib/mv/email_sync/loader.ex
+++ b/lib/mv/email_sync/loader.ex
@@ -3,15 +3,13 @@ defmodule Mv.EmailSync.Loader do
Helper functions for loading linked records in email synchronization.
Centralizes the logic for retrieving related User/Member entities.
- ## Authorization-independent link checks
+ ## Authorization
- All functions use the **system actor** for the load. Link existence
- (linked vs not linked) is therefore determined **independently of the
- current request actor**. This is required so that validations (e.g.
- `EmailChangePermission`, `EmailNotUsedByOtherUser`) can correctly decide
- "member is linked" even when the current user would not have read permission
- on the related User. Using the request actor would otherwise allow
- treating a linked member as unlinked and bypass the permission rule.
+ This module runs systemically and uses the system actor for all operations.
+ This ensures that email synchronization always works, regardless of user permissions.
+
+ All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
+ user permission checks, as email sync is a mandatory side effect.
"""
alias Mv.Helpers
alias Mv.Helpers.SystemActor
diff --git a/lib/mv/membership/member/validations/email_change_permission.ex b/lib/mv/membership/member/validations/email_change_permission.ex
deleted file mode 100644
index 2b1c041..0000000
--- a/lib/mv/membership/member/validations/email_change_permission.ex
+++ /dev/null
@@ -1,75 +0,0 @@
-defmodule Mv.Membership.Member.Validations.EmailChangePermission do
- @moduledoc """
- Validates that only admins or the linked user may change a linked member's email.
-
- This validation runs on member update when the email attribute is changing.
- It allows the change only if:
- - The member is not linked to a user, or
- - The actor has the admin permission set (via `Mv.Authorization.Actor.admin?/1`), or
- - The actor is the user linked to this member (actor.member_id == member.id).
-
- This prevents non-admins from changing another user's linked member email,
- which would sync to that user's account and break email synchronization.
-
- Missing actor is not allowed; the system actor counts as admin (via `Actor.admin?/1`).
- """
- use Ash.Resource.Validation
- use Gettext, backend: MvWeb.Gettext, otp_app: :mv
-
- alias Mv.Authorization.Actor
- alias Mv.EmailSync.Loader
-
- @doc """
- Validates that the actor may change the member's email when the member is linked.
-
- Only runs when the email attribute is changing (checked inside). Skips when
- member is not linked. Allows when actor is admin or owns the linked member.
- """
- @impl true
- def validate(changeset, _opts, context) do
- if Ash.Changeset.changing_attribute?(changeset, :email) do
- validate_linked_member_email_change(changeset, context)
- else
- :ok
- end
- end
-
- defp validate_linked_member_email_change(changeset, context) do
- linked_user = Loader.get_linked_user(changeset.data)
-
- if is_nil(linked_user) do
- :ok
- else
- actor = resolve_actor(changeset, context)
- member_id = changeset.data.id
-
- if Actor.admin?(actor) or actor_owns_member?(actor, member_id) do
- :ok
- else
- msg =
- dgettext(
- "default",
- "Only administrators or the linked user can change the email for members linked to users"
- )
-
- {:error, field: :email, message: msg}
- end
- end
- end
-
- # Ash stores actor in changeset.context.private.actor; validation context has .actor; some callsites use context.actor
- defp resolve_actor(changeset, context) do
- ctx = changeset.context || %{}
-
- get_in(ctx, [:private, :actor]) ||
- Map.get(ctx, :actor) ||
- (context && Map.get(context, :actor))
- end
-
- defp actor_owns_member?(nil, _member_id), do: false
-
- defp actor_owns_member?(actor, member_id) do
- actor_member_id = Map.get(actor, :member_id) || Map.get(actor, "member_id")
- actor_member_id == member_id
- end
-end
diff --git a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex
index 1ee8ab0..f9fba1b 100644
--- a/lib/mv/membership/member/validations/email_not_used_by_other_user.ex
+++ b/lib/mv/membership/member/validations/email_not_used_by_other_user.ex
@@ -8,8 +8,6 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
This allows creating members with the same email as unlinked users.
"""
use Ash.Resource.Validation
-
- alias Mv.EmailSync.Loader
alias Mv.Helpers
require Logger
@@ -34,8 +32,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
- linked_user = Loader.get_linked_user(changeset.data)
- linked_user_id = if linked_user, do: linked_user.id, else: nil
+ linked_user_id = get_linked_user_id(changeset.data)
is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing
@@ -79,4 +76,16 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
+
+ defp get_linked_user_id(member_data) do
+ alias Mv.Helpers.SystemActor
+
+ system_actor = SystemActor.get_system_actor()
+ opts = Helpers.ash_actor_opts(system_actor)
+
+ case Ash.load(member_data, :user, opts) do
+ {:ok, %{user: %{id: id}}} -> id
+ _ -> nil
+ end
+ end
end
diff --git a/lib/mv_web/authorization.ex b/lib/mv_web/authorization.ex
index d821416..d20be7d 100644
--- a/lib/mv_web/authorization.ex
+++ b/lib/mv_web/authorization.ex
@@ -97,18 +97,12 @@ defmodule MvWeb.Authorization do
@doc """
Checks if user can access a specific page.
- Nil-safe: returns false when user is nil (e.g. unauthenticated or layout
- assigns regression), so callers do not need to guard.
-
## Examples
iex> admin = %{role: %{permission_set_name: "admin"}}
iex> can_access_page?(admin, "/admin/roles")
true
- iex> can_access_page?(nil, "/members")
- false
-
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
iex> can_access_page?(mitglied, "/members")
false
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 59e300e..45bcae0 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -97,13 +97,12 @@ defmodule MvWeb.CoreComponents do
<.button navigate={~p"/"}>Home
<.button disabled={true}>Disabled
"""
- attr :rest, :global, include: ~w(href navigate patch method data-testid)
+ attr :rest, :global, include: ~w(href navigate patch method)
attr :variant, :string, values: ~w(primary)
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true
- def button(assigns) do
- rest = assigns.rest
+ def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
diff --git a/lib/mv_web/components/layouts/sidebar.ex b/lib/mv_web/components/layouts/sidebar.ex
index 26c0d7a..1d564c1 100644
--- a/lib/mv_web/components/layouts/sidebar.ex
+++ b/lib/mv_web/components/layouts/sidebar.ex
@@ -4,8 +4,6 @@ defmodule MvWeb.Layouts.Sidebar do
"""
use MvWeb, :html
- alias MvWeb.PagePaths
-
attr :current_user, :map, default: nil, doc: "The current user"
attr :club_name, :string, required: true, doc: "The name of the club"
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
@@ -72,56 +70,33 @@ defmodule MvWeb.Layouts.Sidebar do
defp sidebar_menu(assigns) do
~H"""
"""
end
- defp admin_menu_visible?(user) do
- Enum.any?(PagePaths.admin_menu_paths(), &can_access_page?(user, &1))
- end
-
attr :href, :string, required: true, doc: "Navigation path"
attr :icon, :string, required: true, doc: "Heroicon name"
attr :label, :string, required: true, doc: "Menu item label"
@@ -144,13 +119,12 @@ defmodule MvWeb.Layouts.Sidebar do
attr :icon, :string, required: true, doc: "Heroicon name for the menu group"
attr :label, :string, required: true, doc: "Menu group label"
- attr :testid, :string, default: nil, doc: "data-testid for stable test selectors"
slot :inner_block, required: true, doc: "Submenu items"
defp menu_group(assigns) do
~H"""
-
+
-
<%!-- Tab Navigation --%>
diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex
index cb945e2..9314f1e 100644
--- a/lib/mv_web/live/user_live/index.html.heex
+++ b/lib/mv_web/live/user_live/index.html.heex
@@ -2,20 +2,13 @@
<.header>
{gettext("Listing Users")}
<:actions>
- <%= if can?(@current_user, :create, Mv.Accounts.User) do %>
- <.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
- <.icon name="hero-plus" /> {gettext("New User")}
-
- <% end %>
+ <.button variant="primary" navigate={~p"/users/new"}>
+ <.icon name="hero-plus" /> {gettext("New User")}
+
- <.table
- id="users"
- rows={@users}
- row_id={fn user -> "row-#{user.id}" end}
- row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
- >
+ <.table id="users" rows={@users} row_click={fn user -> JS.navigate(~p"/users/#{user}") end}>
<:col
:let={user}
label={
@@ -69,23 +62,16 @@
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}
- <%= if can?(@current_user, :update, user) do %>
- <.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
- {gettext("Edit")}
-
- <% end %>
+ <.link navigate={~p"/users/#{user}/edit"}>{gettext("Edit")}
<:action :let={user}>
- <%= if can?(@current_user, :destroy, user) do %>
- <.link
- phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
- data-confirm={gettext("Are you sure?")}
- data-testid="user-delete"
- >
- {gettext("Delete")}
-
- <% end %>
+ <.link
+ phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
+ data-confirm={gettext("Are you sure?")}
+ >
+ {gettext("Delete")}
+
diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex
index 5114b74..e961d84 100644
--- a/lib/mv_web/live/user_live/show.ex
+++ b/lib/mv_web/live/user_live/show.ex
@@ -41,15 +41,9 @@ defmodule MvWeb.UserLive.Show do
<.icon name="hero-arrow-left" />
{gettext("Back to users list")}
- <%= if can?(@current_user, :update, @user) do %>
- <.button
- variant="primary"
- navigate={~p"/users/#{@user}/edit?return_to=show"}
- data-testid="user-edit"
- >
- <.icon name="hero-pencil-square" /> {gettext("Edit User")}
-
- <% end %>
+ <.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}>
+ <.icon name="hero-pencil-square" /> {gettext("Edit User")}
+
diff --git a/lib/mv_web/page_paths.ex b/lib/mv_web/page_paths.ex
deleted file mode 100644
index 5606c76..0000000
--- a/lib/mv_web/page_paths.ex
+++ /dev/null
@@ -1,42 +0,0 @@
-defmodule MvWeb.PagePaths do
- @moduledoc """
- Central path strings for UI authorization and sidebar menu.
-
- Keep in sync with `MvWeb.Router`. Used by Sidebar and `can_access_page?/2`
- so route changes (prefix, rename) are updated in one place.
- """
-
- # Sidebar top-level menu paths
- @members "/members"
- @membership_fee_types "/membership_fee_types"
-
- # Administration submenu paths (all must match router)
- @users "/users"
- @groups "/groups"
- @admin_roles "/admin/roles"
- @membership_fee_settings "/membership_fee_settings"
- @settings "/settings"
-
- @admin_page_paths [
- @users,
- @groups,
- @admin_roles,
- @membership_fee_settings,
- @settings
- ]
-
- @doc "Path for Members index (sidebar and page permission check)."
- def members, do: @members
-
- @doc "Path for Membership Fee Types index (sidebar and page permission check)."
- def membership_fee_types, do: @membership_fee_types
-
- @doc "Paths for Administration menu; show group if user can access any of these."
- def admin_menu_paths, do: @admin_page_paths
-
- def users, do: @users
- def groups, do: @groups
- def admin_roles, do: @admin_roles
- def membership_fee_settings, do: @membership_fee_settings
- def settings, do: @settings
-end
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index c4fd57d..041507b 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -2298,7 +2298,17 @@ msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV Datei. Da
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
msgstr "Unbekannte Spalte '%{header}' wird ignoriert. Falls dies ein Datenfeld ist, erstellen Sie es in Mila vor dem Import."
-#: lib/mv/membership/member/validations/email_change_permission.ex
-#, 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/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Custom Fields in CSV Import"
+#~ msgstr "Benutzerdefinierte Felder"
+
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
+#~ msgstr "Individuelle Datenfelder müssen in Mila erstellt werden, bevor sie importiert werden können. Verwenden Sie den Namen des Datenfeldes als CSV-Spaltenüberschrift. Unbekannte Spaltenüberschriften werden mit einer Warnung ignoriert."
+
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Manage Custom Fields"
+#~ msgstr "Benutzerdefinierte Felder verwalten"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 0908fd8..2861f2d 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -2298,8 +2298,3 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
msgstr ""
-
-#: lib/mv/membership/member/validations/email_change_permission.ex
-#, elixir-autogen, elixir-format
-msgid "Only administrators or the linked user can change the email for members linked to users"
-msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 6faa102..3fe9ce3 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -2299,7 +2299,17 @@ msgstr ""
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
msgstr "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
-#: lib/mv/membership/member/validations/email_change_permission.ex
-#, 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/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Custom Fields in CSV Import"
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format, fuzzy
+#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
+#~ msgstr ""
+
+#~ #: lib/mv_web/live/global_settings_live.ex
+#~ #, elixir-autogen, elixir-format
+#~ msgid "Manage Custom Fields"
+#~ msgstr ""
diff --git a/test/mv/membership/member_email_validation_test.exs b/test/mv/membership/member_email_validation_test.exs
deleted file mode 100644
index d1b5a10..0000000
--- a/test/mv/membership/member_email_validation_test.exs
+++ /dev/null
@@ -1,236 +0,0 @@
-defmodule Mv.Membership.MemberEmailValidationTest do
- @moduledoc """
- Tests for Member email-change permission validation.
-
- When a member is linked to a user, only admins or the linked user may change
- that member's email. Unlinked members and non-email updates are unaffected.
- """
- use Mv.DataCase, async: false
-
- alias Mv.Accounts
- alias Mv.Authorization
- alias Mv.Helpers.SystemActor
- alias Mv.Membership
-
- setup do
- system_actor = SystemActor.get_system_actor()
- %{actor: system_actor}
- end
-
- defp create_role_with_permission_set(permission_set_name, actor) do
- role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
-
- case Authorization.create_role(
- %{
- name: role_name,
- description: "Test role for #{permission_set_name}",
- permission_set_name: permission_set_name
- },
- actor: actor
- ) do
- {:ok, role} -> role
- {:error, error} -> raise "Failed to create role: #{inspect(error)}"
- end
- end
-
- defp create_user_with_permission_set(permission_set_name, actor) do
- role = create_role_with_permission_set(permission_set_name, actor)
-
- {:ok, user} =
- Accounts.User
- |> Ash.Changeset.for_create(:register_with_password, %{
- email: "user#{System.unique_integer([:positive])}@example.com",
- password: "testpassword123"
- })
- |> Ash.create(actor: actor)
-
- {:ok, user} =
- user
- |> Ash.Changeset.for_update(:update, %{})
- |> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
- |> Ash.update(actor: actor)
-
- {:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
- user_with_role
- end
-
- defp create_admin_user(actor) do
- create_user_with_permission_set("admin", actor)
- end
-
- defp create_linked_member_for_user(user, actor) do
- admin = create_admin_user(actor)
-
- {:ok, member} =
- Membership.create_member(
- %{
- first_name: "Linked",
- last_name: "Member",
- email: "linked#{System.unique_integer([:positive])}@example.com"
- },
- actor: admin
- )
-
- user
- |> Ash.Changeset.for_update(:update, %{})
- |> Ash.Changeset.force_change_attribute(:member_id, member.id)
- |> Ash.update(actor: admin, domain: Mv.Accounts, return_notifications?: false)
-
- member
- end
-
- defp create_unlinked_member(actor) do
- admin = create_admin_user(actor)
-
- {:ok, member} =
- Membership.create_member(
- %{
- first_name: "Unlinked",
- last_name: "Member",
- email: "unlinked#{System.unique_integer([:positive])}@example.com"
- },
- actor: admin
- )
-
- member
- end
-
- describe "unlinked member" do
- test "normal_user can update email of unlinked member", %{actor: actor} do
- normal_user = create_user_with_permission_set("normal_user", actor)
- unlinked_member = create_unlinked_member(actor)
-
- new_email = "new#{System.unique_integer([:positive])}@example.com"
-
- assert {:ok, updated} =
- Membership.update_member(unlinked_member, %{email: new_email}, actor: normal_user)
-
- assert updated.email == new_email
- end
-
- test "validation does not block when member has no linked user", %{actor: actor} do
- normal_user = create_user_with_permission_set("normal_user", actor)
- unlinked_member = create_unlinked_member(actor)
-
- new_email = "other#{System.unique_integer([:positive])}@example.com"
-
- assert {:ok, _} =
- Membership.update_member(unlinked_member, %{email: new_email}, actor: normal_user)
- end
- end
-
- describe "linked member – another user's member" do
- test "normal_user cannot update email of another user's linked member", %{actor: actor} do
- user_a = create_user_with_permission_set("own_data", actor)
- linked_member = create_linked_member_for_user(user_a, actor)
-
- normal_user_b = create_user_with_permission_set("normal_user", actor)
- new_email = "other#{System.unique_integer([:positive])}@example.com"
-
- assert {:error, %Ash.Error.Invalid{} = error} =
- Membership.update_member(linked_member, %{email: new_email}, actor: normal_user_b)
-
- assert Enum.any?(error.errors, &(&1.field == :email)),
- "expected an error for field :email, got: #{inspect(error.errors)}"
- end
-
- test "admin can update email of linked member", %{actor: actor} do
- user_a = create_user_with_permission_set("own_data", actor)
- linked_member = create_linked_member_for_user(user_a, actor)
- admin = create_admin_user(actor)
-
- new_email = "admin_changed#{System.unique_integer([:positive])}@example.com"
-
- assert {:ok, updated} =
- Membership.update_member(linked_member, %{email: new_email}, actor: admin)
-
- assert updated.email == new_email
- end
- end
-
- describe "linked member – own member" do
- test "own_data user can update email of their own linked member", %{actor: actor} do
- own_data_user = create_user_with_permission_set("own_data", actor)
- linked_member = create_linked_member_for_user(own_data_user, actor)
-
- {:ok, own_data_user} =
- Ash.get(Accounts.User, own_data_user.id, domain: Mv.Accounts, load: [:role], actor: actor)
-
- {:ok, own_data_user} =
- Ash.load(own_data_user, :member, domain: Mv.Accounts, actor: actor)
-
- new_email = "own_updated#{System.unique_integer([:positive])}@example.com"
-
- assert {:ok, updated} =
- Membership.update_member(linked_member, %{email: new_email}, actor: own_data_user)
-
- assert updated.email == new_email
- end
-
- test "normal_user with linked member can update email of that same member", %{actor: actor} do
- normal_user = create_user_with_permission_set("normal_user", actor)
- linked_member = create_linked_member_for_user(normal_user, actor)
-
- {:ok, normal_user} =
- Ash.get(Accounts.User, normal_user.id, domain: Mv.Accounts, load: [:role], actor: actor)
-
- {:ok, normal_user} = Ash.load(normal_user, :member, domain: Mv.Accounts, actor: actor)
-
- new_email = "normal_own#{System.unique_integer([:positive])}@example.com"
-
- assert {:ok, updated} =
- Membership.update_member(linked_member, %{email: new_email}, actor: normal_user)
-
- assert updated.email == new_email
- end
- end
-
- describe "no-op / other fields" do
- test "updating only other attributes on linked member as normal_user does not trigger validation error",
- %{actor: actor} do
- user_a = create_user_with_permission_set("own_data", actor)
- linked_member = create_linked_member_for_user(user_a, actor)
- normal_user_b = create_user_with_permission_set("normal_user", actor)
-
- assert {:ok, updated} =
- Membership.update_member(linked_member, %{first_name: "UpdatedName"},
- actor: normal_user_b
- )
-
- assert updated.first_name == "UpdatedName"
- assert updated.email == linked_member.email
- end
-
- test "updating email of linked member as admin succeeds", %{actor: actor} do
- user_a = create_user_with_permission_set("own_data", actor)
- linked_member = create_linked_member_for_user(user_a, actor)
- admin = create_admin_user(actor)
-
- new_email = "admin_ok#{System.unique_integer([:positive])}@example.com"
-
- assert {:ok, updated} =
- Membership.update_member(linked_member, %{email: new_email}, actor: admin)
-
- assert updated.email == new_email
- end
- end
-
- describe "read_only" do
- test "read_only cannot update any member (policy rejects before validation)", %{actor: actor} do
- read_only_user = create_user_with_permission_set("read_only", actor)
- linked_member = create_linked_member_for_user(read_only_user, actor)
-
- {:ok, read_only_user} =
- Ash.get(Accounts.User, read_only_user.id,
- domain: Mv.Accounts,
- load: [:role],
- actor: actor
- )
-
- assert {:error, %Ash.Error.Forbidden{}} =
- Membership.update_member(linked_member, %{email: "changed@example.com"},
- actor: read_only_user
- )
- end
- end
-end
diff --git a/test/mv_web/components/layouts/sidebar_test.exs b/test/mv_web/components/layouts/sidebar_test.exs
index ff81f24..75727e3 100644
--- a/test/mv_web/components/layouts/sidebar_test.exs
+++ b/test/mv_web/components/layouts/sidebar_test.exs
@@ -22,14 +22,9 @@ defmodule MvWeb.Layouts.SidebarTest do
# =============================================================================
# Returns assigns for an authenticated user with all required attributes.
- # User has admin role so can_access_page? returns true for all sidebar links.
defp authenticated_assigns(mobile \\ false) do
%{
- current_user: %{
- id: "user-123",
- email: "test@example.com",
- role: %{permission_set_name: "admin"}
- },
+ current_user: %{id: "user-123", email: "test@example.com"},
club_name: "Test Club",
mobile: mobile
}
@@ -149,9 +144,7 @@ defmodule MvWeb.Layouts.SidebarTest do
assert menu_item_count > 0, "Should have at least one top-level menu item"
# Check that nested menu groups exist
- assert html =~
- ~s()
-
+ assert html =~ ~s()
assert html =~ ~s(role="group")
assert has_class?(html, "expanded-menu-group")
@@ -200,9 +193,7 @@ defmodule MvWeb.Layouts.SidebarTest do
html = render_sidebar(authenticated_assigns())
# Check for nested menu structure
- assert html =~
- ~s()
-
+ assert html =~ ~s()
assert html =~ ~s(role="group")
assert html =~ ~s(aria-label="Administration")
assert has_class?(html, "expanded-menu-group")
@@ -530,9 +521,7 @@ defmodule MvWeb.Layouts.SidebarTest do
assert html =~ ~s(role="menuitem")
# Check that nested menus exist
- assert html =~
- ~s()
-
+ assert html =~ ~s()
assert html =~ ~s(role="group")
# Footer section
@@ -640,9 +629,7 @@ defmodule MvWeb.Layouts.SidebarTest do
html = render_sidebar(authenticated_assigns())
# expanded-menu-group structure present
- assert html =~
- ~s()
-
+ assert html =~ ~s()
assert html =~ ~s(role="group")
assert html =~ ~s(aria-label="Administration")
assert has_class?(html, "expanded-menu-group")
@@ -856,9 +843,7 @@ defmodule MvWeb.Layouts.SidebarTest do
# Expanded menu group should have correct structure
# (CSS handles hover effects, but we verify structure)
- assert html =~
- ~s()
-
+ assert html =~ ~s()
assert html =~ ~s(role="group")
end
diff --git a/test/mv_web/components/sidebar_authorization_test.exs b/test/mv_web/components/sidebar_authorization_test.exs
deleted file mode 100644
index 079572f..0000000
--- a/test/mv_web/components/sidebar_authorization_test.exs
+++ /dev/null
@@ -1,120 +0,0 @@
-defmodule MvWeb.SidebarAuthorizationTest do
- @moduledoc """
- Tests for sidebar menu visibility based on user permissions (can_access_page?).
- """
- use MvWeb.ConnCase, async: false
-
- import Phoenix.LiveViewTest
- import MvWeb.Layouts.Sidebar
-
- alias Mv.Fixtures
-
- defp render_sidebar(assigns) do
- render_component(&sidebar/1, assigns)
- end
-
- defp sidebar_assigns(current_user, opts \\ []) do
- mobile = Keyword.get(opts, :mobile, false)
- club_name = Keyword.get(opts, :club_name, "Test Club")
-
- %{
- current_user: current_user,
- club_name: club_name,
- mobile: mobile
- }
- end
-
- describe "sidebar menu with admin user" do
- test "shows Members, Fee Types and Administration with all subitems" do
- user = Fixtures.user_with_role_fixture("admin")
- html = render_sidebar(sidebar_assigns(user))
-
- assert html =~ ~s(href="/members")
- assert html =~ ~s(href="/membership_fee_types")
- assert html =~ ~s(data-testid="sidebar-administration")
- assert html =~ ~s(href="/users")
- assert html =~ ~s(href="/groups")
- assert html =~ ~s(href="/admin/roles")
- assert html =~ ~s(href="/membership_fee_settings")
- assert html =~ ~s(href="/settings")
- end
- end
-
- describe "sidebar menu with read_only user (Vorstand/Buchhaltung)" do
- test "shows Members and Groups (from Administration)" do
- user = Fixtures.user_with_role_fixture("read_only")
- html = render_sidebar(sidebar_assigns(user))
-
- assert html =~ ~s(href="/members")
- assert html =~ ~s(href="/groups")
- end
-
- test "does not show Fee Types, Users, Roles or Settings" do
- user = Fixtures.user_with_role_fixture("read_only")
- html = render_sidebar(sidebar_assigns(user))
-
- refute html =~ ~s(href="/membership_fee_types")
- refute html =~ ~s(href="/users")
- refute html =~ ~s(href="/admin/roles")
- refute html =~ ~s(href="/settings")
- end
- end
-
- describe "sidebar menu with normal_user (Kassenwart)" do
- test "shows Members and Groups" do
- user = Fixtures.user_with_role_fixture("normal_user")
- html = render_sidebar(sidebar_assigns(user))
-
- assert html =~ ~s(href="/members")
- assert html =~ ~s(href="/groups")
- end
-
- test "does not show Fee Types, Users, Roles or Settings" do
- user = Fixtures.user_with_role_fixture("normal_user")
- html = render_sidebar(sidebar_assigns(user))
-
- refute html =~ ~s(href="/membership_fee_types")
- refute html =~ ~s(href="/users")
- refute html =~ ~s(href="/admin/roles")
- refute html =~ ~s(href="/settings")
- end
- end
-
- describe "sidebar menu with own_data user (Mitglied)" do
- test "does not show Members link (no /members page access)" do
- user = Fixtures.user_with_role_fixture("own_data")
- html = render_sidebar(sidebar_assigns(user))
-
- refute html =~ ~s(href="/members")
- end
-
- test "does not show Fee Types or Administration" do
- user = Fixtures.user_with_role_fixture("own_data")
- html = render_sidebar(sidebar_assigns(user))
-
- refute html =~ ~s(href="/membership_fee_types")
- refute html =~ ~s(href="/users")
- refute html =~ ~s(data-testid="sidebar-administration")
- end
- end
-
- describe "sidebar with nil current_user" do
- test "does not render menu items (only header and footer when present)" do
- html = render_sidebar(sidebar_assigns(nil))
-
- refute html =~ ~s(role="menubar")
- refute html =~ ~s(href="/members")
- end
- end
-
- describe "sidebar with user without role" do
- test "does not show any navigation links" do
- user = %{id: "user-no-role", email: "noreply@test.com", role: nil}
- html = render_sidebar(sidebar_assigns(user))
-
- refute html =~ ~s(href="/members")
- refute html =~ ~s(href="/membership_fee_types")
- refute html =~ ~s(href="/users")
- end
- end
-end
diff --git a/test/mv_web/live/member_live_authorization_test.exs b/test/mv_web/live/member_live_authorization_test.exs
deleted file mode 100644
index 9a23019..0000000
--- a/test/mv_web/live/member_live_authorization_test.exs
+++ /dev/null
@@ -1,102 +0,0 @@
-defmodule MvWeb.MemberLiveAuthorizationTest do
- @moduledoc """
- Tests for UI authorization on Member LiveViews (Index and Show).
- """
- use MvWeb.ConnCase, async: false
-
- import Phoenix.LiveViewTest
-
- alias Mv.Fixtures
-
- describe "Member Index - Vorstand (read_only)" do
- @tag role: :read_only
- test "sees member list but not New Member button", %{conn: conn} do
- _member = Fixtures.member_fixture()
-
- {:ok, view, _html} = live(conn, "/members")
-
- refute has_element?(view, "[data-testid=member-new]")
- end
-
- @tag role: :read_only
- test "does not see Edit or Delete buttons in table", %{conn: conn} do
- member = Fixtures.member_fixture()
-
- {:ok, view, _html} = live(conn, "/members")
-
- refute has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
- refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
- end
- end
-
- describe "Member Index - Kassenwart (normal_user)" do
- @tag role: :normal_user
- test "sees New Member and Edit buttons", %{conn: conn} do
- member = Fixtures.member_fixture()
-
- {:ok, view, _html} = live(conn, "/members")
-
- assert has_element?(view, "[data-testid=member-new]")
- assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
- end
-
- @tag role: :normal_user
- test "does not see Delete button", %{conn: conn} do
- member = Fixtures.member_fixture()
-
- {:ok, view, _html} = live(conn, "/members")
-
- refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
- end
- end
-
- describe "Member Index - Admin" do
- @tag role: :admin
- test "sees New Member, Edit and Delete buttons", %{conn: conn} do
- member = Fixtures.member_fixture()
-
- {:ok, view, _html} = live(conn, "/members")
-
- assert has_element?(view, "[data-testid=member-new]")
- assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
- assert has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
- end
- end
-
- describe "Member Index - Mitglied (own_data)" do
- @tag role: :member
- test "is redirected when accessing /members", %{conn: conn, current_user: user} do
- assert {:error, {:redirect, %{to: to}}} = live(conn, "/members")
- assert to == "/users/#{user.id}"
- end
- end
-
- describe "Member Show - Edit button visibility" do
- @tag role: :admin
- test "admin sees Edit button", %{conn: conn} do
- member = Fixtures.member_fixture()
-
- {:ok, view, _html} = live(conn, "/members/#{member.id}")
-
- assert has_element?(view, "[data-testid=member-edit]")
- end
-
- @tag role: :read_only
- test "read_only does not see Edit button", %{conn: conn} do
- member = Fixtures.member_fixture()
-
- {:ok, view, _html} = live(conn, "/members/#{member.id}")
-
- refute has_element?(view, "[data-testid=member-edit]")
- end
-
- @tag role: :normal_user
- test "normal_user sees Edit button", %{conn: conn} do
- member = Fixtures.member_fixture()
-
- {:ok, view, _html} = live(conn, "/members/#{member.id}")
-
- assert has_element?(view, "[data-testid=member-edit]")
- end
- end
-end
diff --git a/test/mv_web/live/user_live_authorization_test.exs b/test/mv_web/live/user_live_authorization_test.exs
deleted file mode 100644
index f4b4746..0000000
--- a/test/mv_web/live/user_live_authorization_test.exs
+++ /dev/null
@@ -1,81 +0,0 @@
-defmodule MvWeb.UserLiveAuthorizationTest do
- @moduledoc """
- Tests for UI authorization on User LiveViews (Index and Show).
- """
- use MvWeb.ConnCase, async: false
-
- import Phoenix.LiveViewTest
-
- alias Mv.Fixtures
-
- describe "User Index - Admin" do
- @tag role: :admin
- test "sees New User, Edit and Delete buttons", %{conn: conn} do
- user = Fixtures.user_with_role_fixture("admin")
-
- {:ok, view, _html} = live(conn, "/users")
-
- assert has_element?(view, "[data-testid=user-new]")
- assert has_element?(view, "#row-#{user.id} [data-testid=user-edit]")
- assert has_element?(view, "#row-#{user.id} [data-testid=user-delete]")
- end
- end
-
- describe "User Index - Non-Admin is redirected" do
- @tag role: :read_only
- test "read_only is redirected when accessing /users", %{conn: conn, current_user: user} do
- assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
- assert to == "/users/#{user.id}"
- end
-
- @tag role: :member
- test "member is redirected when accessing /users", %{conn: conn, current_user: user} do
- assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
- assert to == "/users/#{user.id}"
- end
-
- @tag role: :normal_user
- test "normal_user is redirected when accessing /users", %{conn: conn, current_user: user} do
- assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
- assert to == "/users/#{user.id}"
- end
- end
-
- describe "User Show - own profile" do
- @tag role: :member
- test "member sees Edit button on own profile", %{conn: conn, current_user: user} do
- {:ok, view, _html} = live(conn, "/users/#{user.id}")
-
- assert has_element?(view, "[data-testid=user-edit]")
- end
-
- @tag role: :read_only
- test "read_only sees Edit button on own profile", %{conn: conn, current_user: user} do
- {:ok, view, _html} = live(conn, "/users/#{user.id}")
-
- assert has_element?(view, "[data-testid=user-edit]")
- end
-
- @tag role: :admin
- test "admin sees Edit button on user show", %{conn: conn} do
- user = Fixtures.user_with_role_fixture("read_only")
-
- {:ok, view, _html} = live(conn, "/users/#{user.id}")
-
- assert has_element?(view, "[data-testid=user-edit]")
- end
- end
-
- describe "User Show - other user (non-admin redirected)" do
- @tag role: :member
- test "member is redirected when accessing other user's profile", %{
- conn: conn,
- current_user: current_user
- } do
- other_user = Fixtures.user_with_role_fixture("admin")
-
- assert {:error, {:redirect, %{to: to}}} = live(conn, "/users/#{other_user.id}")
- assert to == "/users/#{current_user.id}"
- end
- end
-end