Compare commits

..

27 commits

Author SHA1 Message Date
Renovate Bot
74f398e58e Update Mix dependencies
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-04 00:11:52 +00:00
36b7031dca Merge pull request 'chore(deps): update renovate/renovate docker tag to v42.95' (#393) from renovate/renovate-renovate-42.x into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #393
2026-02-03 19:52:08 +01:00
Renovate Bot
fa5afba6ba chore(deps): update renovate/renovate docker tag to v42.95
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-03 19:51:42 +01:00
0c313824fb Merge pull request 'chore(deps): update ghcr.io/sebadob/rauthy docker tag to v0.34.2' (#391) from renovate/ghcr.io-sebadob-rauthy-0.x into main
Some checks reported errors
continuous-integration/drone/push Build was killed
Reviewed-on: #391
2026-02-03 19:51:09 +01:00
Renovate Bot
f45ae66f18 chore(deps): update ghcr.io/sebadob/rauthy docker tag to v0.34.2
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-03 19:49:48 +01:00
c2bafe4acf Merge pull request 'Apply UI Authorization to Existing LiveViews closes #400' (#403) from feature/400_ui_authorization into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #403
2026-02-03 17:30:15 +01:00
cbc9376b7b Tests: data-testid selectors, scoped delete, sidebar testid
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
Member/User auth tests use data-testid and #row-id selectors.
Sidebar auth tests assert on data-testid=sidebar-administration.
Sidebar test expects data-testid in expanded-menu-group markup.
2026-02-03 17:16:15 +01:00
ee6bfbacbb User LiveViews: row_id and data-testid for actions
Table row_id for scoped selectors; data-testid on New/Edit/Delete.
2026-02-03 17:16:13 +01:00
a4b13cef49 Member LiveViews: row_id and data-testid for actions
Table row_id for scoped selectors; data-testid on New/Edit/Delete.
2026-02-03 17:16:11 +01:00
286972964d CoreComponents: allow data-testid on button
Include data-testid in button rest for test selectors.
2026-02-03 17:16:10 +01:00
c36812bf3f Authorization: document can_access_page? nil-safety
Doc and example for nil user returning false.
2026-02-03 17:16:09 +01:00
2ddd22078d Sidebar: use PagePaths, add testid for Administration
Gate menu items via PagePaths; add data-testid=sidebar-administration
for stable tests. menu_group accepts optional testid attr.
2026-02-03 17:16:08 +01:00
9e8910344e Add MvWeb.PagePaths for central sidebar/page paths
Single source for path strings used by Sidebar and can_access_page?.
Keep in sync with router when routes change.
2026-02-03 17:16:07 +01:00
1426ef1d38
Add sidebar authorization tests
All checks were successful
continuous-integration/drone/push Build is passing
Assert menu visibility per role: admin, read_only, normal_user,
own_data, nil user, user without role.
2026-02-03 16:56:52 +01:00
f779fd61e0
Gate sidebar menu items by can_access_page?
Members, Fee Types and Administration subitems only shown when user
has page permission. Add admin_menu_visible? helper. Sidebar test
uses admin user so menu items render.
2026-02-03 16:56:52 +01:00
cc9e530d80
Add User LiveView authorization tests
Covers admin, read_only, member, normal_user for Index and Show.
Asserts New User / Edit / Delete visibility and redirect for non-admin.
2026-02-03 16:56:51 +01:00
2f67c7099d
Apply UI authorization to User LiveViews (Index and Show)
Gate New User button, Edit and Delete links with can?/3.
Edit button on User Show visible only when user can update the user.
2026-02-03 16:56:51 +01:00
5e361ba400
Add Member LiveView authorization tests
Covers read_only, normal_user, admin, own_data for Index and Show.
Asserts New Member / Edit / Delete visibility and redirect for Mitglied.
2026-02-03 16:56:51 +01:00
505e31653a
Apply UI authorization to Member LiveViews (Index and Show)
Gate New Member button, Edit and Delete links with can?/3.
Edit button on Member Show visible only when user can update the member.
2026-02-03 16:56:51 +01:00
d3ad7c5013 Merge pull request 'Member Email Validation for Linked Members closes #397' (#399) from feature/397_emailsync_permission into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #399
2026-02-03 16:35:40 +01:00
131904f172
Test: assert on error field :email instead of message string
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/promote/production Build is passing
2026-02-03 16:07:47 +01:00
47b6a16177
Doc: Actor maybe_load_role comment; ActorIsAdmin system user = admin 2026-02-03 16:07:39 +01:00
60a4181255
Validation: error message admin or linked user; resolve_actor fallback 2026-02-03 16:07:26 +01:00
4e6b7305b6
Doc: Loader auth-independent for link checks; email-sync rule rationale 2026-02-03 16:07:13 +01:00
4ea31f0f37 Add email-change permission validation for linked members
All checks were successful
continuous-integration/drone/push Build is passing
Only admins or the linked user may change a linked member's email.
- New validation EmailChangePermission (uses Actor.admin?, Loader.get_linked_user).
- Register on Member update_member; docs and gettext.
2026-02-03 14:35:32 +01:00
ad02f8914f Use EmailSync.Loader.get_linked_user in EmailNotUsedByOtherUser
Remove duplicate get_linked_user_id; reuse Loader for linked user lookup.
2026-02-03 14:35:08 +01:00
3d46ba655f Add Actor.permission_set_name/1 and admin?/1 for consistent capability checks
- Actor.permission_set_name(actor) returns role's permission set (supports nil role load).
- Actor.admin?(actor) returns true for system user or admin permission set.
- ActorIsAdmin policy check delegates to Actor.admin?/1.
2026-02-03 14:34:24 +01:00
26 changed files with 890 additions and 124 deletions

View file

@ -273,7 +273,7 @@ environment:
steps: steps:
- name: renovate - name: renovate
image: renovate/renovate:42.81 image: renovate/renovate:42.95
environment: environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN: RENOVATE_TOKEN:

View file

@ -25,7 +25,7 @@ services:
rauthy: rauthy:
container_name: rauthy-dev container_name: rauthy-dev
image: ghcr.io/sebadob/rauthy:0.33.4 image: ghcr.io/sebadob/rauthy:0.34.2
environment: environment:
- LOCAL_TEST=true - LOCAL_TEST=true
- SMTP_URL=mailcrab - SMTP_URL=mailcrab

View file

@ -4,6 +4,7 @@
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.
--- ---

View file

@ -25,6 +25,7 @@ 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
@ -381,6 +382,9 @@ 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

View file

@ -1,6 +1,7 @@
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
@ -27,8 +28,11 @@ defmodule Mv.Authorization.Actor do
assign(socket, :current_user, user) assign(socket, :current_user, user)
end end
# In tests # Check if actor is admin (policy checks, validations)
user = Actor.ensure_loaded(user) if Actor.admin?(actor), do: ...
# Get permission set name (string or nil)
ps_name = Actor.permission_set_name(actor)
## Security Note ## Security Note
@ -47,6 +51,8 @@ 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.
@ -96,4 +102,45 @@ 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

View file

@ -1,22 +1,18 @@
defmodule Mv.Authorization.Checks.ActorIsAdmin do defmodule Mv.Authorization.Checks.ActorIsAdmin do
@moduledoc """ @moduledoc """
Policy check: true when the actor's role has permission_set_name "admin". Policy check: true when the actor is the system user or 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?(nil, _context, _opts), do: false def match?(actor, _context, _opts), do: Actor.admin?(actor)
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

View file

@ -3,13 +3,15 @@ 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 ## Authorization-independent link checks
This module runs systemically and uses the system actor for all operations. All functions use the **system actor** for the load. Link existence
This ensures that email synchronization always works, regardless of user permissions. (linked vs not linked) is therefore determined **independently of the
current request actor**. This is required so that validations (e.g.
All functions use `Mv.Helpers.SystemActor.get_system_actor/0` to bypass `EmailChangePermission`, `EmailNotUsedByOtherUser`) can correctly decide
user permission checks, as email sync is a mandatory side effect. "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.
""" """
alias Mv.Helpers alias Mv.Helpers
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor

View file

@ -0,0 +1,75 @@
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

View file

@ -8,6 +8,8 @@ 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
@ -32,7 +34,8 @@ 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_id = get_linked_user_id(changeset.data) linked_user = Loader.get_linked_user(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
@ -76,16 +79,4 @@ 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

View file

@ -97,12 +97,18 @@ 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

View file

@ -97,12 +97,13 @@ 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) attr :rest, :global, include: ~w(href navigate patch method data-testid)
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(%{rest: rest} = assigns) do def button(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]))

View file

@ -4,6 +4,8 @@ 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"
@ -70,33 +72,56 @@ 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">
<.menu_item <%= if can_access_page?(@current_user, PagePaths.members()) do %>
href={~p"/members"} <.menu_item
icon="hero-users" href={~p"/members"}
label={gettext("Members")} icon="hero-users"
/> label={gettext("Members")}
<.menu_item
href={~p"/membership_fee_types"}
icon="hero-currency-euro"
label={gettext("Fee Types")}
/>
<!-- Nested Admin Menu -->
<.menu_group icon="hero-cog-6-tooth" label={gettext("Administration")}>
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
<.menu_subitem
href={~p"/membership_fee_settings"}
label={gettext("Fee Settings")}
/> />
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} /> <% end %>
</.menu_group>
<%= if can_access_page?(@current_user, PagePaths.membership_fee_types()) do %>
<.menu_item
href={~p"/membership_fee_types"}
icon="hero-currency-euro"
label={gettext("Fee Types")}
/>
<% end %>
<%= if admin_menu_visible?(@current_user) do %>
<.menu_group
icon="hero-cog-6-tooth"
label={gettext("Administration")}
testid="sidebar-administration"
>
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
<% end %>
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
<% 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"
@ -119,12 +144,13 @@ 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"> <li role="none" class="expanded-menu-group" data-testid={@testid}>
<div <div
class="flex items-center gap-3" class="flex items-center gap-3"
role="group" role="group"
@ -138,7 +164,7 @@ defmodule MvWeb.Layouts.Sidebar do
</ul> </ul>
</li> </li>
<!-- Collapsed Mode: Dropdown --> <!-- Collapsed Mode: Dropdown -->
<div class="collapsed-menu-group dropdown dropdown-right"> <div class="collapsed-menu-group dropdown dropdown-right" data-testid={@testid}>
<button <button
type="button" type="button"
tabindex="0" tabindex="0"

View file

@ -23,9 +23,11 @@
<.icon name="hero-envelope" /> <.icon name="hero-envelope" />
{gettext("Open in email program")} {gettext("Open in email program")}
</.button> </.button>
<.button variant="primary" navigate={~p"/members/new"}> <%= if can?(@current_user, :create, Mv.Membership.Member) do %>
<.icon name="hero-plus" /> {gettext("New Member")} <.button variant="primary" navigate={~p"/members/new"} data-testid="member-new">
</.button> <.icon name="hero-plus" /> {gettext("New Member")}
</.button>
<% end %>
</:actions> </:actions>
</.header> </.header>
@ -84,6 +86,7 @@
<.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}
@ -297,16 +300,23 @@
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link> <.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
</div> </div>
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link> <%= if can?(@current_user, :update, member) do %>
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
{gettext("Edit")}
</.link>
<% end %>
</:action> </:action>
<:action :let={member}> <:action :let={member}>
<.link <%= if can?(@current_user, :destroy, member) do %>
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")} <.link
data-confirm={gettext("Are you sure?")} phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
> data-confirm={gettext("Are you sure?")}
{gettext("Delete")} data-testid="member-delete"
</.link> >
{gettext("Delete")}
</.link>
<% end %>
</:action> </:action>
</.table> </.table>
</Layouts.app> </Layouts.app>

View file

@ -39,9 +39,15 @@ defmodule MvWeb.MemberLive.Show do
{MvWeb.Helpers.MemberHelpers.display_name(@member)} {MvWeb.Helpers.MemberHelpers.display_name(@member)}
</h1> </h1>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}> <%= if can?(@current_user, :update, @member) do %>
{gettext("Edit Member")} <.button
</.button> variant="primary"
navigate={~p"/members/#{@member}/edit?return_to=show"}
data-testid="member-edit"
>
{gettext("Edit Member")}
</.button>
<% end %>
</div> </div>
<%!-- Tab Navigation --%> <%!-- Tab Navigation --%>

View file

@ -2,13 +2,20 @@
<.header> <.header>
{gettext("Listing Users")} {gettext("Listing Users")}
<:actions> <:actions>
<.button variant="primary" navigate={~p"/users/new"}> <%= if can?(@current_user, :create, Mv.Accounts.User) do %>
<.icon name="hero-plus" /> {gettext("New User")} <.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
</.button> <.icon name="hero-plus" /> {gettext("New User")}
</.button>
<% end %>
</:actions> </:actions>
</.header> </.header>
<.table id="users" rows={@users} row_click={fn user -> JS.navigate(~p"/users/#{user}") end}> <.table
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={
@ -62,16 +69,23 @@
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link> <.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
</div> </div>
<.link navigate={~p"/users/#{user}/edit"}>{gettext("Edit")}</.link> <%= if can?(@current_user, :update, user) do %>
<.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
{gettext("Edit")}
</.link>
<% end %>
</:action> </:action>
<:action :let={user}> <:action :let={user}>
<.link <%= if can?(@current_user, :destroy, user) do %>
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")} <.link
data-confirm={gettext("Are you sure?")} phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
> data-confirm={gettext("Are you sure?")}
{gettext("Delete")} data-testid="user-delete"
</.link> >
{gettext("Delete")}
</.link>
<% end %>
</:action> </:action>
</.table> </.table>
</Layouts.app> </Layouts.app>

View file

@ -41,9 +41,15 @@ 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>
<.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}> <%= if can?(@current_user, :update, @user) do %>
<.icon name="hero-pencil-square" /> {gettext("Edit User")} <.button
</.button> variant="primary"
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>

42
lib/mv_web/page_paths.ex Normal file
View file

@ -0,0 +1,42 @@
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

View file

@ -1,11 +1,11 @@
%{ %{
"ash": {:hex, :ash, "3.14.0", "528264e9185f4bfe56f3fb32b4f3dabff0709b0baa5c3c30180e4a95750e9cc0", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8a0a2fd1ded0262685b36c2b6f892b90d464e6afc16b65a2b87578eb2a05bb90"}, "ash": {:hex, :ash, "3.14.1", "22e0ac5dfd4c7d502bd103f0b4380defd66d7c6c83b3a4f54af7045f13da00d7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "776a5963790d5af79855ddca1718a037d06b49063a6b97fae9110050b3d5127d"},
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"}, "ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"}, "ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.19", "244b24256a7d730e5223f36f371a95971542a547a12f0fb73406f67977e86c97", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "754a7d869a3961a927abb7ff700af9895d2e69dd3b8f9471b0aa8e859cc4b135"}, "ash_phoenix": {:hex, :ash_phoenix, "2.3.19", "244b24256a7d730e5223f36f371a95971542a547a12f0fb73406f67977e86c97", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "754a7d869a3961a927abb7ff700af9895d2e69dd3b8f9471b0aa8e859cc4b135"},
"ash_postgres": {:hex, :ash_postgres, "2.6.28", "52cd2a5a701f15ffb76cf1db6ff3854bea616b963b869bac562420af73024ecf", [:mix], [{:ash, ">= 3.11.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.16 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "da354f3380d8a2c049904e8860fd2eea53e034a9be353bdd8a09e2a1f3315747"}, "ash_postgres": {:hex, :ash_postgres, "2.6.29", "93c7d39890930548acc704613b7f83e65c0880940be1b2048ee86dfb44918529", [:mix], [{:ash, "~> 3.14", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0aed7ac3d8407ff094218b1dc86b88ea7e39249fb9e94360c7dac1711e206d8b"},
"ash_sql": {:hex, :ash_sql, "0.4.2", "6ff701ee1f55d0310d1e6878ce57508f3c4aac70d7c751d3f9427dc0d5c36b50", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "eae73e0a84b10ceb508739eaef144d8d8e86b634aa177ed99d688758c29273cb"}, "ash_sql": {:hex, :ash_sql, "0.4.3", "2c74e0a19646e3d31a384a2712fc48a82d04ceea74467771ce496fd64dbb55db", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "b0ecc00502178407e607ae4bcfd2f264f36f6a884218024b5e4d5b3dcfa5e027"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"}, "bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},

View file

@ -2298,17 +2298,7 @@ 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_web/live/global_settings_live.ex #: lib/mv/membership/member/validations/email_change_permission.ex
#~ #, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Fields in CSV Import" msgid "Only administrators or the linked user can change the email for members linked to users"
#~ msgstr "Benutzerdefinierte Felder" 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 "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"

View file

@ -2298,3 +2298,8 @@ 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 ""

View file

@ -2299,17 +2299,7 @@ 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_web/live/global_settings_live.ex #: lib/mv/membership/member/validations/email_change_permission.ex
#~ #, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Custom Fields in CSV Import" msgid "Only administrators or the linked user can change the email for members linked to users"
#~ msgstr "" 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 "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 ""

View file

@ -0,0 +1,236 @@
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

View file

@ -22,9 +22,14 @@ 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: %{id: "user-123", email: "test@example.com"}, current_user: %{
id: "user-123",
email: "test@example.com",
role: %{permission_set_name: "admin"}
},
club_name: "Test Club", club_name: "Test Club",
mobile: mobile mobile: mobile
} }
@ -144,7 +149,9 @@ 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 =~ ~s(<li role="none" class="expanded-menu-group">) assert html =~
~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")
@ -193,7 +200,9 @@ 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 =~ ~s(<li role="none" class="expanded-menu-group">) assert html =~
~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")
@ -521,7 +530,9 @@ 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 =~ ~s(<li role="none" class="expanded-menu-group">) assert html =~
~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
@ -629,7 +640,9 @@ 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 =~ ~s(<li role="none" class="expanded-menu-group">) assert html =~
~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")
@ -843,7 +856,9 @@ 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 =~ ~s(<li role="none" class="expanded-menu-group">) assert html =~
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
assert html =~ ~s(role="group") assert html =~ ~s(role="group")
end end

View file

@ -0,0 +1,120 @@
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

View file

@ -0,0 +1,102 @@
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

View file

@ -0,0 +1,81 @@
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