Compare commits
1 commit
4e55ec9123
...
b530076e06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b530076e06 |
24 changed files with 119 additions and 885 deletions
|
|
@ -25,7 +25,7 @@ services:
|
||||||
|
|
||||||
rauthy:
|
rauthy:
|
||||||
container_name: rauthy-dev
|
container_name: rauthy-dev
|
||||||
image: ghcr.io/sebadob/rauthy:0.34.2
|
image: ghcr.io/sebadob/rauthy:0.33.4
|
||||||
environment:
|
environment:
|
||||||
- LOCAL_TEST=true
|
- LOCAL_TEST=true
|
||||||
- SMTP_URL=mailcrab
|
- SMTP_URL=mailcrab
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
|
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
|
||||||
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
|
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
|
||||||
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
|
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ defmodule Mv.Membership.Member do
|
||||||
- Postal code format: exactly 5 digits (German format)
|
- Postal code format: exactly 5 digits (German format)
|
||||||
- Date validations: join_date not in future, exit_date after join_date
|
- Date validations: join_date not in future, exit_date after join_date
|
||||||
- Email uniqueness: prevents conflicts with unlinked users
|
- 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
|
## Full-Text Search
|
||||||
Members have a `search_vector` attribute (tsvector) that is automatically
|
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
|
# Validates that member email is not already used by another (unlinked) user
|
||||||
validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser
|
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
|
# Prevent linking to a user that already has a member
|
||||||
# This validation prevents "stealing" users from other members by checking
|
# This validation prevents "stealing" users from other members by checking
|
||||||
# if the target user is already linked to a different member
|
# if the target user is already linked to a different member
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
defmodule Mv.Authorization.Actor do
|
defmodule Mv.Authorization.Actor do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Helper functions for ensuring User actors have required data loaded
|
Helper functions for ensuring User actors have required data loaded.
|
||||||
and for querying actor capabilities (e.g. admin, permission set).
|
|
||||||
|
|
||||||
## Actor Invariant
|
## Actor Invariant
|
||||||
|
|
||||||
|
|
@ -28,11 +27,8 @@ defmodule Mv.Authorization.Actor do
|
||||||
assign(socket, :current_user, user)
|
assign(socket, :current_user, user)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if actor is admin (policy checks, validations)
|
# In tests
|
||||||
if Actor.admin?(actor), do: ...
|
user = Actor.ensure_loaded(user)
|
||||||
|
|
||||||
# Get permission set name (string or nil)
|
|
||||||
ps_name = Actor.permission_set_name(actor)
|
|
||||||
|
|
||||||
## Security Note
|
## Security Note
|
||||||
|
|
||||||
|
|
@ -51,8 +47,6 @@ defmodule Mv.Authorization.Actor do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Ensures the actor (User) has their `:role` relationship loaded.
|
Ensures the actor (User) has their `:role` relationship loaded.
|
||||||
|
|
||||||
|
|
@ -102,45 +96,4 @@ defmodule Mv.Authorization.Actor do
|
||||||
actor
|
actor
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
defmodule Mv.Authorization.Checks.ActorIsAdmin do
|
defmodule Mv.Authorization.Checks.ActorIsAdmin do
|
||||||
@moduledoc """
|
@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.
|
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
|
use Ash.Policy.SimpleCheck
|
||||||
|
|
||||||
alias Mv.Authorization.Actor
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def describe(_opts), do: "actor has admin permission set"
|
def describe(_opts), do: "actor has admin permission set"
|
||||||
|
|
||||||
@impl true
|
@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
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,13 @@ defmodule Mv.EmailSync.Loader do
|
||||||
Helper functions for loading linked records in email synchronization.
|
Helper functions for loading linked records in email synchronization.
|
||||||
Centralizes the logic for retrieving related User/Member entities.
|
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
|
This module runs systemically and uses the system actor for all operations.
|
||||||
(linked vs not linked) is therefore determined **independently of the
|
This ensures that email synchronization always works, regardless of user permissions.
|
||||||
current request actor**. This is required so that validations (e.g.
|
|
||||||
`EmailChangePermission`, `EmailNotUsedByOtherUser`) can correctly decide
|
All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass
|
||||||
"member is linked" even when the current user would not have read permission
|
user permission checks, as email sync is a mandatory side effect.
|
||||||
on the related User. Using the request actor would otherwise allow
|
|
||||||
treating a linked member as unlinked and bypass the permission rule.
|
|
||||||
"""
|
"""
|
||||||
alias Mv.Helpers
|
alias Mv.Helpers
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -8,8 +8,6 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
This allows creating members with the same email as unlinked users.
|
This allows creating members with the same email as unlinked users.
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Validation
|
use Ash.Resource.Validation
|
||||||
|
|
||||||
alias Mv.EmailSync.Loader
|
|
||||||
alias Mv.Helpers
|
alias Mv.Helpers
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
@ -34,8 +32,7 @@ defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
|
||||||
def validate(changeset, _opts, _context) do
|
def validate(changeset, _opts, _context) do
|
||||||
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
|
||||||
|
|
||||||
linked_user = Loader.get_linked_user(changeset.data)
|
linked_user_id = get_linked_user_id(changeset.data)
|
||||||
linked_user_id = if linked_user, do: linked_user.id, else: nil
|
|
||||||
is_linked? = not is_nil(linked_user_id)
|
is_linked? = not is_nil(linked_user_id)
|
||||||
|
|
||||||
# Only validate if member is already linked AND email is changing
|
# 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, nil), do: query
|
||||||
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -97,18 +97,12 @@ defmodule MvWeb.Authorization do
|
||||||
@doc """
|
@doc """
|
||||||
Checks if user can access a specific page.
|
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
|
## Examples
|
||||||
|
|
||||||
iex> admin = %{role: %{permission_set_name: "admin"}}
|
iex> admin = %{role: %{permission_set_name: "admin"}}
|
||||||
iex> can_access_page?(admin, "/admin/roles")
|
iex> can_access_page?(admin, "/admin/roles")
|
||||||
true
|
true
|
||||||
|
|
||||||
iex> can_access_page?(nil, "/members")
|
|
||||||
false
|
|
||||||
|
|
||||||
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
|
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
|
||||||
iex> can_access_page?(mitglied, "/members")
|
iex> can_access_page?(mitglied, "/members")
|
||||||
false
|
false
|
||||||
|
|
|
||||||
|
|
@ -97,13 +97,12 @@ defmodule MvWeb.CoreComponents do
|
||||||
<.button navigate={~p"/"}>Home</.button>
|
<.button navigate={~p"/"}>Home</.button>
|
||||||
<.button disabled={true}>Disabled</.button>
|
<.button disabled={true}>Disabled</.button>
|
||||||
"""
|
"""
|
||||||
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 :variant, :string, values: ~w(primary)
|
||||||
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def button(assigns) do
|
def button(%{rest: rest} = assigns) do
|
||||||
rest = assigns.rest
|
|
||||||
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
||||||
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :html
|
use MvWeb, :html
|
||||||
|
|
||||||
alias MvWeb.PagePaths
|
|
||||||
|
|
||||||
attr :current_user, :map, default: nil, doc: "The current user"
|
attr :current_user, :map, default: nil, doc: "The current user"
|
||||||
attr :club_name, :string, required: true, doc: "The name of the club"
|
attr :club_name, :string, required: true, doc: "The name of the club"
|
||||||
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
|
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
|
defp sidebar_menu(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<ul class="menu flex-1 w-full p-2" role="menubar">
|
<ul class="menu flex-1 w-full p-2" role="menubar">
|
||||||
<%= if can_access_page?(@current_user, PagePaths.members()) do %>
|
<.menu_item
|
||||||
<.menu_item
|
href={~p"/members"}
|
||||||
href={~p"/members"}
|
icon="hero-users"
|
||||||
icon="hero-users"
|
label={gettext("Members")}
|
||||||
label={gettext("Members")}
|
/>
|
||||||
/>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= if can_access_page?(@current_user, PagePaths.membership_fee_types()) do %>
|
<.menu_item
|
||||||
<.menu_item
|
href={~p"/membership_fee_types"}
|
||||||
href={~p"/membership_fee_types"}
|
icon="hero-currency-euro"
|
||||||
icon="hero-currency-euro"
|
label={gettext("Fee Types")}
|
||||||
label={gettext("Fee Types")}
|
/>
|
||||||
/>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= if admin_menu_visible?(@current_user) do %>
|
<!-- Nested Admin Menu -->
|
||||||
<.menu_group
|
<.menu_group icon="hero-cog-6-tooth" label={gettext("Administration")}>
|
||||||
icon="hero-cog-6-tooth"
|
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
|
||||||
label={gettext("Administration")}
|
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
|
||||||
testid="sidebar-administration"
|
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
|
||||||
>
|
<.menu_subitem
|
||||||
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
|
href={~p"/membership_fee_settings"}
|
||||||
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
|
label={gettext("Fee Settings")}
|
||||||
<% end %>
|
/>
|
||||||
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
|
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
|
||||||
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
|
</.menu_group>
|
||||||
<% end %>
|
|
||||||
<%= if can_access_page?(@current_user, PagePaths.admin_roles()) do %>
|
|
||||||
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
|
|
||||||
<% end %>
|
|
||||||
<%= if can_access_page?(@current_user, PagePaths.membership_fee_settings()) do %>
|
|
||||||
<.menu_subitem
|
|
||||||
href={~p"/membership_fee_settings"}
|
|
||||||
label={gettext("Fee Settings")}
|
|
||||||
/>
|
|
||||||
<% end %>
|
|
||||||
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
|
|
||||||
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
|
|
||||||
<% end %>
|
|
||||||
</.menu_group>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
</ul>
|
||||||
"""
|
"""
|
||||||
end
|
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 :href, :string, required: true, doc: "Navigation path"
|
||||||
attr :icon, :string, required: true, doc: "Heroicon name"
|
attr :icon, :string, required: true, doc: "Heroicon name"
|
||||||
attr :label, :string, required: true, doc: "Menu item label"
|
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 :icon, :string, required: true, doc: "Heroicon name for the menu group"
|
||||||
attr :label, :string, required: true, doc: "Menu group label"
|
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"
|
slot :inner_block, required: true, doc: "Submenu items"
|
||||||
|
|
||||||
defp menu_group(assigns) do
|
defp menu_group(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<!-- Expanded Mode: Always open div structure -->
|
<!-- Expanded Mode: Always open div structure -->
|
||||||
<li role="none" class="expanded-menu-group" data-testid={@testid}>
|
<li role="none" class="expanded-menu-group">
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3"
|
class="flex items-center gap-3"
|
||||||
role="group"
|
role="group"
|
||||||
|
|
@ -164,7 +138,7 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<!-- Collapsed Mode: Dropdown -->
|
<!-- Collapsed Mode: Dropdown -->
|
||||||
<div class="collapsed-menu-group dropdown dropdown-right" data-testid={@testid}>
|
<div class="collapsed-menu-group dropdown dropdown-right">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,9 @@
|
||||||
<.icon name="hero-envelope" />
|
<.icon name="hero-envelope" />
|
||||||
{gettext("Open in email program")}
|
{gettext("Open in email program")}
|
||||||
</.button>
|
</.button>
|
||||||
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
|
<.button variant="primary" navigate={~p"/members/new"}>
|
||||||
<.button variant="primary" navigate={~p"/members/new"} data-testid="member-new">
|
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
</.button>
|
||||||
</.button>
|
|
||||||
<% end %>
|
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
|
@ -86,7 +84,6 @@
|
||||||
<.table
|
<.table
|
||||||
id="members"
|
id="members"
|
||||||
rows={@members}
|
rows={@members}
|
||||||
row_id={fn member -> "row-#{member.id}" end}
|
|
||||||
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
||||||
dynamic_cols={@dynamic_cols}
|
dynamic_cols={@dynamic_cols}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
|
|
@ -300,23 +297,16 @@
|
||||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if can?(@current_user, :update, member) do %>
|
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
|
||||||
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
|
|
||||||
{gettext("Edit")}
|
|
||||||
</.link>
|
|
||||||
<% end %>
|
|
||||||
</:action>
|
</:action>
|
||||||
|
|
||||||
<:action :let={member}>
|
<:action :let={member}>
|
||||||
<%= if can?(@current_user, :destroy, member) do %>
|
<.link
|
||||||
<.link
|
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
|
||||||
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
|
data-confirm={gettext("Are you sure?")}
|
||||||
data-confirm={gettext("Are you sure?")}
|
>
|
||||||
data-testid="member-delete"
|
{gettext("Delete")}
|
||||||
>
|
</.link>
|
||||||
{gettext("Delete")}
|
|
||||||
</.link>
|
|
||||||
<% end %>
|
|
||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -39,15 +39,9 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<%= if can?(@current_user, :update, @member) do %>
|
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
||||||
<.button
|
{gettext("Edit Member")}
|
||||||
variant="primary"
|
</.button>
|
||||||
navigate={~p"/members/#{@member}/edit?return_to=show"}
|
|
||||||
data-testid="member-edit"
|
|
||||||
>
|
|
||||||
{gettext("Edit Member")}
|
|
||||||
</.button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Tab Navigation --%>
|
<%!-- Tab Navigation --%>
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,13 @@
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Listing Users")}
|
{gettext("Listing Users")}
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
<.button variant="primary" navigate={~p"/users/new"}>
|
||||||
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
|
<.icon name="hero-plus" /> {gettext("New User")}
|
||||||
<.icon name="hero-plus" /> {gettext("New User")}
|
</.button>
|
||||||
</.button>
|
|
||||||
<% end %>
|
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.table
|
<.table id="users" rows={@users} row_click={fn user -> JS.navigate(~p"/users/#{user}") end}>
|
||||||
id="users"
|
|
||||||
rows={@users}
|
|
||||||
row_id={fn user -> "row-#{user.id}" end}
|
|
||||||
row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
|
|
||||||
>
|
|
||||||
<:col
|
<:col
|
||||||
:let={user}
|
:let={user}
|
||||||
label={
|
label={
|
||||||
|
|
@ -69,23 +62,16 @@
|
||||||
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
|
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if can?(@current_user, :update, user) do %>
|
<.link navigate={~p"/users/#{user}/edit"}>{gettext("Edit")}</.link>
|
||||||
<.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
|
|
||||||
{gettext("Edit")}
|
|
||||||
</.link>
|
|
||||||
<% end %>
|
|
||||||
</:action>
|
</:action>
|
||||||
|
|
||||||
<:action :let={user}>
|
<:action :let={user}>
|
||||||
<%= if can?(@current_user, :destroy, user) do %>
|
<.link
|
||||||
<.link
|
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
|
||||||
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
|
data-confirm={gettext("Are you sure?")}
|
||||||
data-confirm={gettext("Are you sure?")}
|
>
|
||||||
data-testid="user-delete"
|
{gettext("Delete")}
|
||||||
>
|
</.link>
|
||||||
{gettext("Delete")}
|
|
||||||
</.link>
|
|
||||||
<% end %>
|
|
||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,9 @@ defmodule MvWeb.UserLive.Show do
|
||||||
<.icon name="hero-arrow-left" />
|
<.icon name="hero-arrow-left" />
|
||||||
<span class="sr-only">{gettext("Back to users list")}</span>
|
<span class="sr-only">{gettext("Back to users list")}</span>
|
||||||
</.button>
|
</.button>
|
||||||
<%= if can?(@current_user, :update, @user) do %>
|
<.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}>
|
||||||
<.button
|
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
|
||||||
variant="primary"
|
</.button>
|
||||||
navigate={~p"/users/#{@user}/edit?return_to=show"}
|
|
||||||
data-testid="user-edit"
|
|
||||||
>
|
|
||||||
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
|
|
||||||
</.button>
|
|
||||||
<% end %>
|
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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."
|
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."
|
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
|
#~ #: lib/mv_web/live/global_settings_live.ex
|
||||||
#, 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 "Custom Fields in CSV Import"
|
||||||
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 "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"
|
||||||
|
|
|
||||||
|
|
@ -2298,8 +2298,3 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
|
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
|
||||||
msgstr ""
|
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 ""
|
|
||||||
|
|
|
||||||
|
|
@ -2299,7 +2299,17 @@ msgstr ""
|
||||||
msgid "Unknown column '%{header}' will be ignored. If this is a custom field, create it in Mila before importing."
|
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."
|
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
|
#~ #: lib/mv_web/live/global_settings_live.ex
|
||||||
#, 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 "Custom Fields in CSV Import"
|
||||||
msgstr "Only administrators or the linked user can change the email for members linked to users"
|
#~ 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 ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -22,14 +22,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Returns assigns for an authenticated user with all required attributes.
|
# 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
|
defp authenticated_assigns(mobile \\ false) do
|
||||||
%{
|
%{
|
||||||
current_user: %{
|
current_user: %{id: "user-123", email: "test@example.com"},
|
||||||
id: "user-123",
|
|
||||||
email: "test@example.com",
|
|
||||||
role: %{permission_set_name: "admin"}
|
|
||||||
},
|
|
||||||
club_name: "Test Club",
|
club_name: "Test Club",
|
||||||
mobile: mobile
|
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"
|
assert menu_item_count > 0, "Should have at least one top-level menu item"
|
||||||
|
|
||||||
# Check that nested menu groups exist
|
# Check that nested menu groups exist
|
||||||
assert html =~
|
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
|
||||||
|
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
assert has_class?(html, "expanded-menu-group")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
|
|
||||||
|
|
@ -200,9 +193,7 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# Check for nested menu structure
|
# Check for nested menu structure
|
||||||
assert html =~
|
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
|
||||||
|
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
assert html =~ ~s(aria-label="Administration")
|
assert html =~ ~s(aria-label="Administration")
|
||||||
assert has_class?(html, "expanded-menu-group")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
|
|
@ -530,9 +521,7 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
assert html =~ ~s(role="menuitem")
|
assert html =~ ~s(role="menuitem")
|
||||||
|
|
||||||
# Check that nested menus exist
|
# Check that nested menus exist
|
||||||
assert html =~
|
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
|
||||||
|
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
|
|
||||||
# Footer section
|
# Footer section
|
||||||
|
|
@ -640,9 +629,7 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# expanded-menu-group structure present
|
# expanded-menu-group structure present
|
||||||
assert html =~
|
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
|
||||||
|
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
assert html =~ ~s(aria-label="Administration")
|
assert html =~ ~s(aria-label="Administration")
|
||||||
assert has_class?(html, "expanded-menu-group")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
|
|
@ -856,9 +843,7 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
|
|
||||||
# Expanded menu group should have correct structure
|
# Expanded menu group should have correct structure
|
||||||
# (CSS handles hover effects, but we verify structure)
|
# (CSS handles hover effects, but we verify structure)
|
||||||
assert html =~
|
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
|
||||||
|
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue