Merge pull request 'Page Permission Router Plug closes #388' (#390) from feature/388_page_permissions into main
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #390
This commit is contained in:
commit
b9dd990f52
31 changed files with 1736 additions and 257 deletions
92
docs/page-permission-route-coverage.md
Normal file
92
docs/page-permission-route-coverage.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Page Permission – Route and Test Coverage
|
||||||
|
|
||||||
|
This document lists all protected routes, which permission set may access them, and how they are covered by tests.
|
||||||
|
|
||||||
|
## Protected Routes (Router scope with CheckPagePermission in :browser)
|
||||||
|
|
||||||
|
| Route | own_data | read_only | normal_user | admin |
|
||||||
|
|-------|----------|-----------|-------------|-------|
|
||||||
|
| `/` | ✗ | ✓ | ✓ | ✓ |
|
||||||
|
| `/members` | ✗ | ✓ | ✓ | ✓ |
|
||||||
|
| `/members/new` | ✗ | ✗ | ✓ | ✓ |
|
||||||
|
| `/members/:id` | ✓ (linked only) | ✓ | ✓ | ✓ |
|
||||||
|
| `/members/:id/edit` | ✓ (linked only) | ✗ | ✓ | ✓ |
|
||||||
|
| `/members/:id/show/edit` | ✓ (linked only) | ✗ | ✓ | ✓ |
|
||||||
|
| `/users` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/users/new` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/users/:id` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
|
||||||
|
| `/users/:id/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
|
||||||
|
| `/users/:id/show/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
|
||||||
|
| `/settings` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/membership_fee_settings` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/membership_fee_types` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/membership_fee_types/new` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/membership_fee_types/:id/edit` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/groups` | ✗ | ✓ | ✓ | ✓ |
|
||||||
|
| `/groups/new` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/groups/:slug` | ✗ | ✓ | ✓ | ✓ |
|
||||||
|
| `/groups/:slug/edit` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/admin/roles` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| `/admin/roles/:id/edit` | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
|
||||||
|
**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use.
|
||||||
|
|
||||||
|
## Public Paths (no permission check)
|
||||||
|
|
||||||
|
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
**File:** `test/mv_web/plugs/check_page_permission_test.exs`
|
||||||
|
|
||||||
|
### Unit tests (plug called directly with mock conn)
|
||||||
|
|
||||||
|
- Static: own_data denied `/members`; read_only allowed `/members`; flash on denial.
|
||||||
|
- Dynamic: read_only allowed `/members/123`; normal_user allowed `/members/456/edit`; read_only denied `/members/123/edit`.
|
||||||
|
- read_only / normal_user: denied `/admin/roles`; read_only denied `/members/new`.
|
||||||
|
- Wildcard: admin allowed `/admin/roles`, `/members/999/edit`.
|
||||||
|
- Unauthenticated: nil user denied, redirect `/sign-in`.
|
||||||
|
- Public: unauthenticated allowed `/auth/sign-in`, `/register`.
|
||||||
|
- Error: no role, invalid permission_set_name → denied.
|
||||||
|
|
||||||
|
### Integration tests (full router, Mitglied = own_data)
|
||||||
|
|
||||||
|
**Denied (Mitglied gets 302 → `/users/:id`):**
|
||||||
|
|
||||||
|
- `/members`, `/members/new`, `/users`, `/users/new`, `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/membership_fee_types/new`, `/groups`, `/groups/new`, `/admin/roles`, `/admin/roles/new`
|
||||||
|
- `/members/:id/edit`, `/members/:id/show/edit`, `/users/:id` (other user), `/users/:id/edit` (other), `/users/:id/show/edit` (other), `/membership_fee_types/:id/edit`, `/groups/:slug`, `/admin/roles/:id`, `/admin/roles/:id/edit`
|
||||||
|
|
||||||
|
**Allowed (Mitglied gets 200):**
|
||||||
|
|
||||||
|
- `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`
|
||||||
|
- `/members/:id`, `/members/:id/edit`, `/members/:id/show/edit` for linked member (plug unit tests; full-router tests for linked member skipped: session/LiveView constraints)
|
||||||
|
|
||||||
|
**Root:** `GET /` redirects Mitglied to profile (root not allowed for own_data).
|
||||||
|
|
||||||
|
All protected routes above are either covered by integration “denied” tests for Mitglied or by unit tests for the relevant permission set.
|
||||||
|
|
||||||
|
### Integration tests (full router, read_only = Vorstand/Buchhaltung)
|
||||||
|
|
||||||
|
**Allowed (200):** `/`, `/members`, `/members/:id`, `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`, `/groups`, `/groups/:slug`.
|
||||||
|
|
||||||
|
**Denied (302 → `/users/:id`):** `/members/new`, `/members/:id/edit`, `/members/:id/show/edit`, `/users`, `/users/new`, `/users/:id` (other user), `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/groups/new`, `/groups/:slug/edit`, `/admin/roles`, `/admin/roles/:id`.
|
||||||
|
|
||||||
|
### Integration tests (full router, normal_user = Kassenwart)
|
||||||
|
|
||||||
|
**Allowed (200):** `/`, `/members`, `/members/new`, `/members/:id`, `/members/:id/edit`, `/members/:id/show/edit`, `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`, `/groups`, `/groups/:slug`.
|
||||||
|
|
||||||
|
**Denied (302 → `/users/:id`):** `/users`, `/users/new`, `/users/:id` (other user), `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/groups/new`, `/groups/:slug/edit`, `/admin/roles`, `/admin/roles/:id`.
|
||||||
|
|
||||||
|
### Integration tests (full router, admin)
|
||||||
|
|
||||||
|
**Allowed (200):** All protected routes (sample covered: `/`, `/members`, `/users`, `/settings`, `/membership_fee_settings`, `/admin/roles`, `/members/:id`, `/admin/roles/:id`, `/groups/:slug`).
|
||||||
|
|
||||||
|
## Plug behaviour: reserved segments
|
||||||
|
|
||||||
|
The plug treats `"new"` as a reserved path segment so that patterns like `/members/:id` and `/groups/:slug` do not match `/members/new` or `/groups/new`. Thus `/groups/new` is only allowed when the permission set explicitly lists `/groups/new` (currently only admin).
|
||||||
|
|
||||||
|
## Role and member_id loading
|
||||||
|
|
||||||
|
The plug may reload the user's role (and optionally `member_id`) before checking page permission. Session/`load_from_session` can leave the role unloaded; the plug uses `Mv.Authorization.Actor.ensure_loaded/1` (and, when needed, loads `member_id`) so that permission checks always have the required data. No change to session loading is required; this is documented for clarity.
|
||||||
|
|
@ -2002,6 +2002,8 @@ Users and Members are separate entities that can be linked. Special rules:
|
||||||
- A user cannot link themselves to an existing member
|
- A user cannot link themselves to an existing member
|
||||||
- A user CAN create a new member and be directly linked to it (self-service)
|
- A user CAN create a new member and be directly linked to it (self-service)
|
||||||
|
|
||||||
|
**Enforcement:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit.
|
||||||
|
|
||||||
### Approach: Separate Ash Actions
|
### Approach: Separate Ash Actions
|
||||||
|
|
||||||
We use **different Ash actions** to enforce different policies:
|
We use **different Ash actions** to enforce different policies:
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ defmodule Mv.Accounts.User do
|
||||||
# the specialized :update_user action below.
|
# the specialized :update_user action below.
|
||||||
update :update do
|
update :update do
|
||||||
primary? true
|
primary? true
|
||||||
|
accept [:email]
|
||||||
|
|
||||||
# Required because custom validation functions (email validation, member relationship validation)
|
# Required because custom validation functions (email validation, member relationship validation)
|
||||||
# cannot be executed atomically. These validations need to query the database and perform
|
# cannot be executed atomically. These validations need to query the database and perform
|
||||||
|
|
@ -310,6 +311,14 @@ defmodule Mv.Accounts.User do
|
||||||
authorize_if expr(id == ^actor(:id))
|
authorize_if expr(id == ^actor(:id))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# update_user allows :member argument (link/unlink). Only admins may use it to prevent
|
||||||
|
# privilege escalation (own_data could otherwise link to any member and get :linked scope).
|
||||||
|
policy action(:update_user) do
|
||||||
|
description "Only admins can update user with member link/unlink"
|
||||||
|
forbid_unless Mv.Authorization.Checks.ActorIsAdmin
|
||||||
|
authorize_if Mv.Authorization.Checks.ActorIsAdmin
|
||||||
|
end
|
||||||
|
|
||||||
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
|
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
description "Check permissions from user's role and permission set"
|
description "Check permissions from user's role and permission set"
|
||||||
|
|
|
||||||
22
lib/mv/authorization/checks/actor_is_admin.ex
Normal file
22
lib/mv/authorization/checks/actor_is_admin.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
defmodule Mv.Authorization.Checks.ActorIsAdmin do
|
||||||
|
@moduledoc """
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
use Ash.Policy.SimpleCheck
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def describe(_opts), do: "actor has admin permission set"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
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
|
||||||
|
|
@ -118,12 +118,15 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
# Home page
|
# No "/" - Mitglied must not see member index at root (same content as /members).
|
||||||
"/",
|
# Own profile (sidebar links to /users/:id) and own user edit
|
||||||
# Own profile
|
"/users/:id",
|
||||||
"/profile",
|
"/users/:id/edit",
|
||||||
# Linked member detail (filtered by policy)
|
"/users/:id/show/edit",
|
||||||
"/members/:id"
|
# Linked member detail and edit (data access filtered by policy scope: :linked)
|
||||||
|
"/members/:id",
|
||||||
|
"/members/:id/edit",
|
||||||
|
"/members/:id/show/edit"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
@ -151,8 +154,10 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
"/",
|
"/",
|
||||||
# Own profile
|
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||||
"/profile",
|
"/users/:id",
|
||||||
|
"/users/:id/edit",
|
||||||
|
"/users/:id/show/edit",
|
||||||
# Member list
|
# Member list
|
||||||
"/members",
|
"/members",
|
||||||
# Member detail
|
# Member detail
|
||||||
|
|
@ -198,14 +203,17 @@ defmodule Mv.Authorization.PermissionSets do
|
||||||
],
|
],
|
||||||
pages: [
|
pages: [
|
||||||
"/",
|
"/",
|
||||||
# Own profile
|
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
|
||||||
"/profile",
|
"/users/:id",
|
||||||
|
"/users/:id/edit",
|
||||||
|
"/users/:id/show/edit",
|
||||||
"/members",
|
"/members",
|
||||||
# Create member
|
# Create member
|
||||||
"/members/new",
|
"/members/new",
|
||||||
"/members/:id",
|
"/members/:id",
|
||||||
# Edit member
|
# Edit member
|
||||||
"/members/:id/edit",
|
"/members/:id/edit",
|
||||||
|
"/members/:id/show/edit",
|
||||||
"/custom_field_values",
|
"/custom_field_values",
|
||||||
# Custom field value detail
|
# Custom field value detail
|
||||||
"/custom_field_values/:id",
|
"/custom_field_values/:id",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ defmodule MvWeb.Authorization do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Mv.Authorization.PermissionSets
|
alias Mv.Authorization.PermissionSets
|
||||||
|
alias MvWeb.Plugs.CheckPagePermission
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Checks if user has permission for an action on a resource.
|
Checks if user has permission for an action on a resource.
|
||||||
|
|
@ -111,16 +112,9 @@ defmodule MvWeb.Authorization do
|
||||||
def can_access_page?(nil, _page_path), do: false
|
def can_access_page?(nil, _page_path), do: false
|
||||||
|
|
||||||
def can_access_page?(user, page_path) do
|
def can_access_page?(user, page_path) do
|
||||||
# Convert verified route to string if needed
|
# Delegate to plug logic so UI uses same rules (reserved "new", own/linked path checks).
|
||||||
page_path_str = if is_binary(page_path), do: page_path, else: to_string(page_path)
|
page_path_str = if is_binary(page_path), do: page_path, else: to_string(page_path)
|
||||||
|
CheckPagePermission.user_can_access_page?(user, page_path_str, router: MvWeb.Router)
|
||||||
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
|
|
||||||
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
|
||||||
permissions <- PermissionSets.get_permissions(ps_atom) do
|
|
||||||
page_matches?(permissions.pages, page_path_str)
|
|
||||||
else
|
|
||||||
_ -> false
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if scope allows access to record
|
# Check if scope allows access to record
|
||||||
|
|
@ -172,33 +166,6 @@ defmodule MvWeb.Authorization do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if page path matches any allowed pattern
|
|
||||||
defp page_matches?(allowed_pages, requested_path) do
|
|
||||||
Enum.any?(allowed_pages, fn pattern ->
|
|
||||||
cond do
|
|
||||||
pattern == "*" -> true
|
|
||||||
pattern == requested_path -> true
|
|
||||||
String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path)
|
|
||||||
true -> false
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Match dynamic route pattern
|
|
||||||
defp match_pattern?(pattern, path) do
|
|
||||||
pattern_segments = String.split(pattern, "/", trim: true)
|
|
||||||
path_segments = String.split(path, "/", trim: true)
|
|
||||||
|
|
||||||
if length(pattern_segments) == length(path_segments) do
|
|
||||||
Enum.zip(pattern_segments, path_segments)
|
|
||||||
|> Enum.all?(fn {pattern_seg, path_seg} ->
|
|
||||||
String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
|
|
||||||
end)
|
|
||||||
else
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Extract resource name from module
|
# Extract resource name from module
|
||||||
defp get_resource_name(resource) when is_atom(resource) do
|
defp get_resource_name(resource) when is_atom(resource) do
|
||||||
resource |> Module.split() |> List.last()
|
resource |> Module.split() |> List.last()
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
||||||
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_last_completed_cycle(member)
|
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_last_completed_cycle(member)
|
||||||
# => %MembershipFeeCycle{cycle_start: ~D[2024-01-01], ...}
|
# => %MembershipFeeCycle{cycle_start: ~D[2024-01-01], ...}
|
||||||
"""
|
"""
|
||||||
@spec get_last_completed_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
@spec get_last_completed_cycle(Member.t() | nil, Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
||||||
def get_last_completed_cycle(member, today \\ nil)
|
def get_last_completed_cycle(member, today \\ nil)
|
||||||
|
|
||||||
|
def get_last_completed_cycle(nil, _today), do: nil
|
||||||
|
|
||||||
def get_last_completed_cycle(%Member{} = member, today) do
|
def get_last_completed_cycle(%Member{} = member, today) do
|
||||||
today = today || Date.utc_today()
|
today = today || Date.utc_today()
|
||||||
|
|
||||||
|
|
@ -174,9 +176,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
||||||
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_current_cycle(member)
|
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_current_cycle(member)
|
||||||
# => %MembershipFeeCycle{cycle_start: ~D[2024-04-01], ...}
|
# => %MembershipFeeCycle{cycle_start: ~D[2024-04-01], ...}
|
||||||
"""
|
"""
|
||||||
@spec get_current_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
@spec get_current_cycle(Member.t() | nil, Date.t() | nil) :: MembershipFeeCycle.t() | nil
|
||||||
def get_current_cycle(member, today \\ nil)
|
def get_current_cycle(member, today \\ nil)
|
||||||
|
|
||||||
|
def get_current_cycle(nil, _today), do: nil
|
||||||
|
|
||||||
def get_current_cycle(%Member{} = member, today) do
|
def get_current_cycle(%Member{} = member, today) do
|
||||||
today = today || Date.utc_today()
|
today = today || Date.utc_today()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
require Jason
|
require Jason
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
import MvWeb.Authorization, only: [can?: 3]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
|
|
@ -94,7 +95,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @user do %>
|
<%= if @user && @can_manage_member_linking do %>
|
||||||
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
||||||
<p class="text-sm text-orange-800">
|
<p class="text-sm text-orange-800">
|
||||||
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
||||||
|
|
@ -125,129 +126,133 @@ defmodule MvWeb.UserLive.Form do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Member Linking Section -->
|
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
|
||||||
<div class="mt-6">
|
<%= if @can_manage_member_linking do %>
|
||||||
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
<div class="mt-6">
|
||||||
|
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
||||||
|
|
||||||
<%= if @user && @user.member && !@unlink_member do %>
|
<%= if @user && @user.member && !@unlink_member do %>
|
||||||
<!-- Show linked member with unlink button -->
|
<!-- Show linked member with unlink button -->
|
||||||
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-green-900">
|
<p class="font-medium text-green-900">
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-green-700">{@user.member.email}</p>
|
<p class="text-sm text-green-700">{@user.member.email}</p>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
phx-click="unlink_member"
|
|
||||||
class="btn btn-sm btn-error"
|
|
||||||
>
|
|
||||||
{gettext("Unlink Member")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<%= if @unlink_member do %>
|
|
||||||
<!-- Show unlink pending message -->
|
|
||||||
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
|
||||||
<p class="text-sm text-yellow-800">
|
|
||||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
|
||||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<!-- Show member search/selection for unlinked users -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="member-search-input"
|
|
||||||
role="combobox"
|
|
||||||
phx-hook="ComboBox"
|
|
||||||
phx-focus="show_member_dropdown"
|
|
||||||
phx-change="search_members"
|
|
||||||
phx-debounce="300"
|
|
||||||
phx-window-keydown="member_dropdown_keydown"
|
|
||||||
value={@member_search_query}
|
|
||||||
placeholder={gettext("Search for a member to link...")}
|
|
||||||
class="w-full input"
|
|
||||||
name="member_search"
|
|
||||||
disabled={@unlink_member}
|
|
||||||
aria-label={gettext("Search for member to link")}
|
|
||||||
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-controls="member-dropdown"
|
|
||||||
aria-expanded={to_string(@show_member_dropdown)}
|
|
||||||
aria-activedescendant={
|
|
||||||
if @focused_member_index,
|
|
||||||
do: "member-option-#{@focused_member_index}",
|
|
||||||
else: nil
|
|
||||||
}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<%= if length(@available_members) > 0 do %>
|
|
||||||
<div
|
|
||||||
id="member-dropdown"
|
|
||||||
role="listbox"
|
|
||||||
aria-label={gettext("Available members")}
|
|
||||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
|
||||||
phx-click-away="hide_member_dropdown"
|
|
||||||
>
|
|
||||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
|
||||||
<div
|
|
||||||
id={"member-option-#{index}"}
|
|
||||||
role="option"
|
|
||||||
tabindex="0"
|
|
||||||
aria-selected={to_string(@focused_member_index == index)}
|
|
||||||
phx-click="select_member"
|
|
||||||
phx-value-id={member.id}
|
|
||||||
data-member-id={member.id}
|
|
||||||
class={[
|
|
||||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
|
||||||
if(@focused_member_index == index,
|
|
||||||
do: "bg-base-300",
|
|
||||||
else: "hover:bg-base-200"
|
|
||||||
)
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<p class="font-medium">{MvWeb.Helpers.MemberHelpers.display_name(member)}</p>
|
|
||||||
<p class="text-sm text-base-content/70">{member.email}</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="unlink_member"
|
||||||
|
class="btn btn-sm btn-error"
|
||||||
|
>
|
||||||
|
{gettext("Unlink Member")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<% else %>
|
||||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
<%= if @unlink_member do %>
|
||||||
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
<!-- Show unlink pending message -->
|
||||||
|
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
||||||
<p class="text-sm text-yellow-800">
|
<p class="text-sm text-yellow-800">
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||||
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<!-- Show member search/selection for unlinked users -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="member-search-input"
|
||||||
|
role="combobox"
|
||||||
|
phx-hook="ComboBox"
|
||||||
|
phx-focus="show_member_dropdown"
|
||||||
|
phx-change="search_members"
|
||||||
|
phx-debounce="300"
|
||||||
|
phx-window-keydown="member_dropdown_keydown"
|
||||||
|
value={@member_search_query}
|
||||||
|
placeholder={gettext("Search for a member to link...")}
|
||||||
|
class="w-full input"
|
||||||
|
name="member_search"
|
||||||
|
disabled={@unlink_member}
|
||||||
|
aria-label={gettext("Search for member to link")}
|
||||||
|
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="member-dropdown"
|
||||||
|
aria-expanded={to_string(@show_member_dropdown)}
|
||||||
|
aria-activedescendant={
|
||||||
|
if @focused_member_index,
|
||||||
|
do: "member-option-#{@focused_member_index}",
|
||||||
|
else: nil
|
||||||
|
}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
<%= if @selected_member_id && @selected_member_name do %>
|
<%= if length(@available_members) > 0 do %>
|
||||||
<div
|
<div
|
||||||
id="member-selected"
|
id="member-dropdown"
|
||||||
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
role="listbox"
|
||||||
>
|
aria-label={gettext("Available members")}
|
||||||
<p class="text-sm text-blue-800">
|
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
|
||||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
phx-click-away="hide_member_dropdown"
|
||||||
</p>
|
>
|
||||||
<p class="mt-1 text-xs text-blue-600">
|
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||||
{gettext("Save to confirm linking.")}
|
<div
|
||||||
</p>
|
id={"member-option-#{index}"}
|
||||||
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={to_string(@focused_member_index == index)}
|
||||||
|
phx-click="select_member"
|
||||||
|
phx-value-id={member.id}
|
||||||
|
data-member-id={member.id}
|
||||||
|
class={[
|
||||||
|
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||||
|
if(@focused_member_index == index,
|
||||||
|
do: "bg-base-300",
|
||||||
|
else: "hover:bg-base-200"
|
||||||
|
)
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p class="font-medium">
|
||||||
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/70">{member.email}</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
</div>
|
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
||||||
<% end %>
|
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
||||||
</div>
|
<p class="text-sm text-yellow-800">
|
||||||
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
|
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if @selected_member_id && @selected_member_name do %>
|
||||||
|
<div
|
||||||
|
id="member-selected"
|
||||||
|
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-blue-600">
|
||||||
|
{gettext("Save to confirm linking.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
|
|
@ -289,14 +294,19 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp mount_continue(user, params, socket) do
|
defp mount_continue(user, params, socket) do
|
||||||
|
actor = current_actor(socket)
|
||||||
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
|
||||||
page_title = action <> " " <> gettext("User")
|
page_title = action <> " " <> gettext("User")
|
||||||
|
|
||||||
|
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
|
||||||
|
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:return_to, return_to(params["return_to"]))
|
|> assign(:return_to, return_to(params["return_to"]))
|
||||||
|> assign(user: user)
|
|> assign(user: user)
|
||||||
|> assign(:page_title, page_title)
|
|> assign(:page_title, page_title)
|
||||||
|
|> assign(:can_manage_member_linking, can_manage_member_linking)
|
||||||
|> assign(:show_password_fields, false)
|
|> assign(:show_password_fields, false)
|
||||||
|> assign(:member_search_query, "")
|
|> assign(:member_search_query, "")
|
||||||
|> assign(:available_members, [])
|
|> assign(:available_members, [])
|
||||||
|
|
@ -329,9 +339,9 @@ defmodule MvWeb.UserLive.Form do
|
||||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||||
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
|
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
|
||||||
|
|
||||||
# Reload members if email changed (for email-match priority)
|
# Reload members if email changed (for email-match priority; only when member linking UI is shown)
|
||||||
socket =
|
socket =
|
||||||
if Map.has_key?(user_params, "email") do
|
if Map.has_key?(user_params, "email") and socket.assigns[:can_manage_member_linking] do
|
||||||
user_email = user_params["email"]
|
user_email = user_params["email"]
|
||||||
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
|
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
|
||||||
|
|
||||||
|
|
@ -480,20 +490,25 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp perform_member_link_action(socket, user, actor) do
|
defp perform_member_link_action(socket, user, actor) do
|
||||||
cond do
|
# Only admins may link/unlink (backend policy also restricts update_user; UI must not call it).
|
||||||
# Selected member ID takes precedence (new link)
|
if can?(actor, :destroy, Mv.Accounts.User) do
|
||||||
socket.assigns.selected_member_id ->
|
cond do
|
||||||
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
|
# Selected member ID takes precedence (new link)
|
||||||
actor: actor
|
socket.assigns.selected_member_id ->
|
||||||
)
|
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
# Unlink flag is set
|
# Unlink flag is set
|
||||||
socket.assigns[:unlink_member] ->
|
socket.assigns[:unlink_member] ->
|
||||||
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
|
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
|
||||||
|
|
||||||
# No changes to member relationship
|
# No changes to member relationship
|
||||||
true ->
|
true ->
|
||||||
{:ok, user}
|
{:ok, user}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:ok, user}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -552,13 +567,28 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
|
||||||
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
|
defp assign_form(
|
||||||
|
%{
|
||||||
|
assigns: %{
|
||||||
|
user: user,
|
||||||
|
show_password_fields: show_password_fields,
|
||||||
|
can_manage_member_linking: can_manage_member_linking
|
||||||
|
}
|
||||||
|
} = socket
|
||||||
|
) do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
form =
|
form =
|
||||||
if user do
|
if user do
|
||||||
# For existing users, use admin password action if password fields are shown
|
# For existing users: admin uses update_user (email + member); non-admin uses update (email only).
|
||||||
action = if show_password_fields, do: :admin_set_password, else: :update_user
|
# Password change uses admin_set_password for both.
|
||||||
|
action =
|
||||||
|
cond do
|
||||||
|
show_password_fields -> :admin_set_password
|
||||||
|
can_manage_member_linking -> :update_user
|
||||||
|
true -> :update
|
||||||
|
end
|
||||||
|
|
||||||
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
|
||||||
else
|
else
|
||||||
# For new users, use password registration if password fields are shown
|
# For new users, use password registration if password fields are shown
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,18 @@ defmodule MvWeb.LiveHelpers do
|
||||||
## on_mount Hooks
|
## on_mount Hooks
|
||||||
- `:default` - Sets the user's locale from session (defaults to "de")
|
- `:default` - Sets the user's locale from session (defaults to "de")
|
||||||
- `:ensure_user_role_loaded` - Ensures current_user has role relationship loaded
|
- `:ensure_user_role_loaded` - Ensures current_user has role relationship loaded
|
||||||
|
- `:check_page_permission_on_params` - Attaches handle_params hook to enforce page permission on client-side navigation (push_patch)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
Add to LiveView modules via:
|
Add to LiveView modules via:
|
||||||
```elixir
|
```elixir
|
||||||
on_mount {MvWeb.LiveHelpers, :default}
|
on_mount {MvWeb.LiveHelpers, :default}
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
on_mount {MvWeb.LiveHelpers, :check_page_permission_on_params}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
import Phoenix.Component
|
import Phoenix.Component
|
||||||
|
alias MvWeb.Plugs.CheckPagePermission
|
||||||
|
|
||||||
def on_mount(:default, _params, session, socket) do
|
def on_mount(:default, _params, session, socket) do
|
||||||
locale = session["locale"] || "de"
|
locale = session["locale"] || "de"
|
||||||
|
|
@ -26,6 +29,40 @@ defmodule MvWeb.LiveHelpers do
|
||||||
{:cont, socket}
|
{:cont, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def on_mount(:check_page_permission_on_params, _params, _session, socket) do
|
||||||
|
{:cont,
|
||||||
|
Phoenix.LiveView.attach_hook(
|
||||||
|
socket,
|
||||||
|
:check_page_permission,
|
||||||
|
:handle_params,
|
||||||
|
&check_page_permission_handle_params/3
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_page_permission_handle_params(_params, uri, socket) do
|
||||||
|
path = uri |> URI.parse() |> Map.get(:path, "/") || "/"
|
||||||
|
|
||||||
|
if CheckPagePermission.public_path?(path) do
|
||||||
|
{:cont, socket}
|
||||||
|
else
|
||||||
|
user = socket.assigns[:current_user]
|
||||||
|
host = uri |> URI.parse() |> Map.get(:host) || "localhost"
|
||||||
|
|
||||||
|
if CheckPagePermission.user_can_access_page?(user, path, router: MvWeb.Router, host: host) do
|
||||||
|
{:cont, socket}
|
||||||
|
else
|
||||||
|
redirect_to = CheckPagePermission.redirect_target_for_user(user)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> Phoenix.LiveView.put_flash(:error, "You don't have permission to access this page.")
|
||||||
|
|> Phoenix.LiveView.push_navigate(to: redirect_to)
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp ensure_user_role_loaded(socket) do
|
defp ensure_user_role_loaded(socket) do
|
||||||
user = socket.assigns[:current_user]
|
user = socket.assigns[:current_user]
|
||||||
|
|
||||||
|
|
|
||||||
315
lib/mv_web/plugs/check_page_permission.ex
Normal file
315
lib/mv_web/plugs/check_page_permission.ex
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
defmodule MvWeb.Plugs.CheckPagePermission do
|
||||||
|
@moduledoc """
|
||||||
|
Plug that checks if the current user has permission to access the requested page.
|
||||||
|
|
||||||
|
Runs in the router pipeline before LiveView mounts. Uses PermissionSets page list
|
||||||
|
and matches the current route template (or request path) against allowed patterns.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Public paths (e.g. /auth, /register) are exempt and pass through.
|
||||||
|
2. Extracts page path from conn via `Phoenix.Router.route_info/4` (route template
|
||||||
|
like "/members/:id") or falls back to `conn.request_path`.
|
||||||
|
3. Gets current user from `conn.assigns[:current_user]`.
|
||||||
|
4. Gets user's permission_set_name from role and calls `PermissionSets.get_permissions/1`.
|
||||||
|
5. Matches requested path against allowed patterns (exact, dynamic `:param`, wildcard "*").
|
||||||
|
6. If unauthorized: redirects to "/sign-in" (no user) or "/users/:id" (user profile) with flash error and halts.
|
||||||
|
|
||||||
|
## Pattern Matching
|
||||||
|
|
||||||
|
- Exact: "/members" == "/members"
|
||||||
|
- Dynamic: "/members/:id" matches "/members/123"
|
||||||
|
- Wildcard: "*" matches everything (admin)
|
||||||
|
- Reserved: the segment "new" is never matched by `:id` or `:slug` (e.g. `/members/new` and `/groups/new` require an explicit page permission).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
alias Mv.Authorization.PermissionSets
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, _opts) do
|
||||||
|
if public_path?(conn.request_path) do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
# Ensure role is loaded (load_from_session does not load it; required for permission check)
|
||||||
|
user =
|
||||||
|
conn.assigns[:current_user]
|
||||||
|
|> Mv.Authorization.Actor.ensure_loaded()
|
||||||
|
|
||||||
|
conn = Plug.Conn.assign(conn, :current_user, user)
|
||||||
|
page_path = get_page_path(conn)
|
||||||
|
request_path = conn.request_path
|
||||||
|
|
||||||
|
if has_page_permission?(user, page_path, request_path) do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
log_page_access_denied(user, page_path)
|
||||||
|
|
||||||
|
redirect_to = redirect_target(user)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> fetch_session()
|
||||||
|
|> fetch_flash()
|
||||||
|
|> put_flash(:error, "You don't have permission to access this page.")
|
||||||
|
|> redirect(to: redirect_to)
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the redirect URL for an unauthorized user (for LiveView push_redirect).
|
||||||
|
"""
|
||||||
|
def redirect_target_for_user(nil), do: "/sign-in"
|
||||||
|
|
||||||
|
def redirect_target_for_user(user) when is_map(user) or is_struct(user) do
|
||||||
|
id = Map.get(user, :id) || Map.get(user, "id")
|
||||||
|
if id, do: "/users/#{to_string(id)}", else: "/sign-in"
|
||||||
|
end
|
||||||
|
|
||||||
|
def redirect_target_for_user(_), do: "/sign-in"
|
||||||
|
|
||||||
|
defp redirect_target(user), do: redirect_target_for_user(user)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if the path is public (no auth/permission check).
|
||||||
|
Used by LiveView hook to skip redirect on sign-in etc.
|
||||||
|
"""
|
||||||
|
def public_path?(path) when is_binary(path) do
|
||||||
|
path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out"] or
|
||||||
|
String.starts_with?(path, "/auth") or
|
||||||
|
String.starts_with?(path, "/confirm") or
|
||||||
|
String.starts_with?(path, "/password-reset")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_page_path(conn) do
|
||||||
|
router = conn.private[:phoenix_router]
|
||||||
|
get_page_path_from_router(router, conn.method, conn.request_path, conn.host)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns whether the user is allowed to access the given request path.
|
||||||
|
Used by the plug and by LiveView on_mount/handle_params for client-side navigation.
|
||||||
|
|
||||||
|
Options: `:router` (default MvWeb.Router), `:host` (default "localhost").
|
||||||
|
"""
|
||||||
|
def user_can_access_page?(user, request_path, opts \\ []) do
|
||||||
|
router = Keyword.get(opts, :router, MvWeb.Router)
|
||||||
|
host = Keyword.get(opts, :host, "localhost")
|
||||||
|
page_path = get_page_path_from_router(router, "GET", request_path, host)
|
||||||
|
has_page_permission?(user, page_path, request_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_page_path_from_router(router, method, request_path, host) do
|
||||||
|
case Phoenix.Router.route_info(router, method, request_path, host) do
|
||||||
|
%{route: route} -> route
|
||||||
|
_ -> request_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp has_page_permission?(nil, _page_path, _request_path), do: false
|
||||||
|
|
||||||
|
defp has_page_permission?(user, page_path, request_path) do
|
||||||
|
with ps_name when is_binary(ps_name) <- permission_set_name_from_user(user),
|
||||||
|
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
|
||||||
|
permissions <- PermissionSets.get_permissions(ps_atom) do
|
||||||
|
page_matches?(permissions.pages, page_path, request_path, user)
|
||||||
|
else
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp permission_set_name_from_user(user) when is_map(user) or is_struct(user) do
|
||||||
|
get_in(user, [Access.key(:role), Access.key(:permission_set_name)]) ||
|
||||||
|
get_in(user, [Access.key("role"), Access.key("permission_set_name")])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp permission_set_name_from_user(_), do: nil
|
||||||
|
|
||||||
|
defp user_id_from_user(user) when is_map(user) or is_struct(user) do
|
||||||
|
id = Map.get(user, :id) || Map.get(user, "id")
|
||||||
|
if id, do: to_string(id), else: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defp user_id_from_user(_), do: nil
|
||||||
|
|
||||||
|
# Reserved path segments that must not match a single :id param (e.g. /members/new, /users/new).
|
||||||
|
@reserved_id_segments ["new"]
|
||||||
|
|
||||||
|
# For "/users/:id" with own_data we only allow when the id in the path equals the current user's id.
|
||||||
|
# For "/members/:id" we reject when the segment is reserved (e.g. "new") so /members/new is not allowed.
|
||||||
|
defp page_matches?(allowed_pages, requested_path, request_path, user) do
|
||||||
|
Enum.any?(allowed_pages, fn pattern ->
|
||||||
|
pattern_match?(pattern, requested_path, request_path, user)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?("*", _requested_path, _request_path, _user), do: true
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||||
|
when pattern == "/users/:id" do
|
||||||
|
match_dynamic_route?(pattern, request_path) and
|
||||||
|
path_param_equals(pattern, request_path, "id", user_id_from_user(user))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||||
|
when pattern in ["/users/:id/edit", "/users/:id/show/edit"] do
|
||||||
|
match_dynamic_route?(pattern, request_path) and
|
||||||
|
path_param_equals(pattern, request_path, "id", user_id_from_user(user))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||||
|
when pattern == "/members/:id" do
|
||||||
|
match_dynamic_route?(pattern, request_path) and
|
||||||
|
path_param_not_reserved(pattern, request_path, "id", @reserved_id_segments) and
|
||||||
|
members_show_allowed?(pattern, request_path, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, user)
|
||||||
|
when pattern in ["/members/:id/edit", "/members/:id/show/edit"] do
|
||||||
|
match_dynamic_route?(pattern, request_path) and
|
||||||
|
members_edit_allowed?(pattern, request_path, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, _user)
|
||||||
|
when pattern == "/groups/:slug" do
|
||||||
|
match_dynamic_route?(pattern, request_path) and
|
||||||
|
path_param_not_reserved(pattern, request_path, "slug", @reserved_id_segments)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, requested_path, _request_path, _user)
|
||||||
|
when pattern == requested_path do
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pattern_match?(pattern, _requested_path, request_path, _user) do
|
||||||
|
if String.contains?(pattern, ":") do
|
||||||
|
match_dynamic_route?(pattern, request_path)
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp path_param_not_reserved(pattern, request_path, param_name, reserved)
|
||||||
|
when is_list(reserved) do
|
||||||
|
segments = String.split(request_path, "/", trim: true)
|
||||||
|
idx = param_index(pattern, param_name)
|
||||||
|
|
||||||
|
if idx < 0 do
|
||||||
|
false
|
||||||
|
else
|
||||||
|
value = Enum.at(segments, idx)
|
||||||
|
value not in reserved
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp path_param_equals(pattern, request_path, param_name, expected_value)
|
||||||
|
when is_binary(expected_value) do
|
||||||
|
segments = String.split(request_path, "/", trim: true)
|
||||||
|
idx = param_index(pattern, param_name)
|
||||||
|
|
||||||
|
if idx < 0 do
|
||||||
|
false
|
||||||
|
else
|
||||||
|
value = Enum.at(segments, idx)
|
||||||
|
value == expected_value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp path_param_equals(_, _, _, _), do: false
|
||||||
|
|
||||||
|
# For own_data: only allow show/edit when :id is the user's linked member. For other permission sets: allow when not reserved.
|
||||||
|
defp members_show_allowed?(pattern, request_path, user) do
|
||||||
|
if permission_set_name_from_user(user) == "own_data" do
|
||||||
|
path_param_equals(pattern, request_path, "id", user_member_id(user))
|
||||||
|
else
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp members_edit_allowed?(pattern, request_path, user) do
|
||||||
|
if permission_set_name_from_user(user) == "own_data" do
|
||||||
|
path_param_equals(pattern, request_path, "id", user_member_id(user))
|
||||||
|
else
|
||||||
|
path_param_not_reserved(pattern, request_path, "id", @reserved_id_segments)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp user_member_id(user) when is_map(user) or is_struct(user) do
|
||||||
|
member_id = Map.get(user, :member_id) || Map.get(user, "member_id")
|
||||||
|
|
||||||
|
if is_nil(member_id) do
|
||||||
|
load_member_id_for_user(user)
|
||||||
|
else
|
||||||
|
to_string(member_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp user_member_id(_), do: nil
|
||||||
|
|
||||||
|
defp load_member_id_for_user(user) do
|
||||||
|
id = user_id_from_user(user)
|
||||||
|
|
||||||
|
if id do
|
||||||
|
case Ash.get(Mv.Accounts.User, id, load: [:member], domain: Mv.Accounts, authorize?: false) do
|
||||||
|
{:ok, loaded} when not is_nil(loaded.member_id) -> to_string(loaded.member_id)
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp param_index(pattern, param_name) do
|
||||||
|
pattern
|
||||||
|
|> String.split("/", trim: true)
|
||||||
|
|> Enum.find_index(fn seg ->
|
||||||
|
String.starts_with?(seg, ":") and String.trim_leading(seg, ":") == param_name
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
nil -> -1
|
||||||
|
i -> i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp match_dynamic_route?(pattern, path) do
|
||||||
|
pattern_segments = String.split(pattern, "/", trim: true)
|
||||||
|
path_segments = String.split(path, "/", trim: true)
|
||||||
|
|
||||||
|
if length(pattern_segments) == length(path_segments) do
|
||||||
|
Enum.zip(pattern_segments, path_segments)
|
||||||
|
|> Enum.all?(fn {pattern_seg, path_seg} ->
|
||||||
|
String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_page_access_denied(user, page_path) do
|
||||||
|
user_id =
|
||||||
|
if user do
|
||||||
|
Map.get(user, :id) || Map.get(user, "id") || "nil"
|
||||||
|
else
|
||||||
|
"nil"
|
||||||
|
end
|
||||||
|
|
||||||
|
role_name =
|
||||||
|
if user do
|
||||||
|
get_in(user, [Access.key(:role), Access.key(:name)]) ||
|
||||||
|
get_in(user, [Access.key("role"), Access.key("name")]) || "nil"
|
||||||
|
else
|
||||||
|
"nil"
|
||||||
|
end
|
||||||
|
|
||||||
|
Logger.info("""
|
||||||
|
Page access denied:
|
||||||
|
User: #{user_id}
|
||||||
|
Role: #{role_name}
|
||||||
|
Page: #{page_path}
|
||||||
|
""")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -14,6 +14,7 @@ defmodule MvWeb.Router do
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
plug :load_from_session
|
plug :load_from_session
|
||||||
plug :set_locale
|
plug :set_locale
|
||||||
|
plug MvWeb.Plugs.CheckPagePermission
|
||||||
end
|
end
|
||||||
|
|
||||||
pipeline :api do
|
pipeline :api do
|
||||||
|
|
@ -48,7 +49,8 @@ defmodule MvWeb.Router do
|
||||||
ash_authentication_live_session :authentication_required,
|
ash_authentication_live_session :authentication_required,
|
||||||
on_mount: [
|
on_mount: [
|
||||||
{MvWeb.LiveUserAuth, :live_user_required},
|
{MvWeb.LiveUserAuth, :live_user_required},
|
||||||
{MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
{MvWeb.LiveHelpers, :ensure_user_role_loaded},
|
||||||
|
{MvWeb.LiveHelpers, :check_page_permission_on_params}
|
||||||
] do
|
] do
|
||||||
live "/", MemberLive.Index, :index
|
live "/", MemberLive.Index, :index
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
alias Mv.Membership.{CustomField, CustomFieldValue}
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
|
||||||
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
||||||
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "can update own email", %{user: user} do
|
test "can update own email", %{user: user} do
|
||||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
||||||
|
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
||||||
{:ok, updated_user} =
|
{:ok, updated_user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||||
|> Ash.update(actor: user)
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
assert updated_user.email == Ash.CiString.new(new_email)
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
|
|
@ -118,7 +119,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
other_user
|
other_user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||||
|> Ash.update!(actor: user)
|
|> Ash.update!(actor: user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -163,9 +164,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "can update own email", %{user: user} do
|
test "can update own email", %{user: user} do
|
||||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
||||||
|
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
||||||
{:ok, updated_user} =
|
{:ok, updated_user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||||
|> Ash.update(actor: user)
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
assert updated_user.email == Ash.CiString.new(new_email)
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
|
|
@ -186,7 +188,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
other_user
|
other_user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||||
|> Ash.update!(actor: user)
|
|> Ash.update!(actor: user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -231,9 +233,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "can update own email", %{user: user} do
|
test "can update own email", %{user: user} do
|
||||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
||||||
|
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
||||||
{:ok, updated_user} =
|
{:ok, updated_user} =
|
||||||
user
|
user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: new_email})
|
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||||
|> Ash.update(actor: user)
|
|> Ash.update(actor: user)
|
||||||
|
|
||||||
assert updated_user.email == Ash.CiString.new(new_email)
|
assert updated_user.email == Ash.CiString.new(new_email)
|
||||||
|
|
@ -254,7 +257,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
||||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||||
assert_raise Ash.Error.Forbidden, fn ->
|
assert_raise Ash.Error.Forbidden, fn ->
|
||||||
other_user
|
other_user
|
||||||
|> Ash.Changeset.for_update(:update_user, %{email: "hacked@example.com"})
|
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||||
|> Ash.update!(actor: user)
|
|> Ash.update!(actor: user)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -127,8 +127,10 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
||||||
test "includes correct pages" do
|
test "includes correct pages" do
|
||||||
permissions = PermissionSets.get_permissions(:own_data)
|
permissions = PermissionSets.get_permissions(:own_data)
|
||||||
|
|
||||||
assert "/" in permissions.pages
|
# Root "/" is not allowed for own_data (Mitglied is redirected to profile)
|
||||||
assert "/profile" in permissions.pages
|
refute "/" in permissions.pages
|
||||||
|
# Profile is at /users/:id, not a separate /profile route
|
||||||
|
assert "/users/:id" in permissions.pages
|
||||||
assert "/members/:id" in permissions.pages
|
assert "/members/:id" in permissions.pages
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -229,7 +231,7 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
||||||
permissions = PermissionSets.get_permissions(:read_only)
|
permissions = PermissionSets.get_permissions(:read_only)
|
||||||
|
|
||||||
assert "/" in permissions.pages
|
assert "/" in permissions.pages
|
||||||
assert "/profile" in permissions.pages
|
assert "/users/:id" in permissions.pages
|
||||||
assert "/members" in permissions.pages
|
assert "/members" in permissions.pages
|
||||||
assert "/members/:id" in permissions.pages
|
assert "/members/:id" in permissions.pages
|
||||||
assert "/custom_field_values" in permissions.pages
|
assert "/custom_field_values" in permissions.pages
|
||||||
|
|
@ -333,7 +335,7 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
||||||
permissions = PermissionSets.get_permissions(:normal_user)
|
permissions = PermissionSets.get_permissions(:normal_user)
|
||||||
|
|
||||||
assert "/" in permissions.pages
|
assert "/" in permissions.pages
|
||||||
assert "/profile" in permissions.pages
|
assert "/users/:id" in permissions.pages
|
||||||
assert "/members" in permissions.pages
|
assert "/members" in permissions.pages
|
||||||
assert "/members/new" in permissions.pages
|
assert "/members/new" in permissions.pages
|
||||||
assert "/members/:id" in permissions.pages
|
assert "/members/:id" in permissions.pages
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
|
||||||
alias Mv.MembershipFees.CycleGenerator
|
alias Mv.MembershipFees.CycleGenerator
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
|
||||||
alias Mv.MembershipFees.CycleGenerator
|
alias Mv.MembershipFees.CycleGenerator
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,39 @@ defmodule MvWeb.AuthorizationTest do
|
||||||
assert Authorization.can_access_page?(read_only_user, "/members/123/edit") == false
|
assert Authorization.can_access_page?(read_only_user, "/members/123/edit") == false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "read_only can access own profile /users/:id only" do
|
||||||
|
read_only_user = %{
|
||||||
|
id: "read-only-123",
|
||||||
|
role: %{permission_set_name: "read_only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/users/read-only-123") == true
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/users/read-only-123/edit") == true
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/users/other-id") == false
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/users/other-id/edit") == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normal_user can access own profile /users/:id only" do
|
||||||
|
normal_user = %{
|
||||||
|
id: "normal-456",
|
||||||
|
role: %{permission_set_name: "normal_user"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can_access_page?(normal_user, "/users/normal-456") == true
|
||||||
|
assert Authorization.can_access_page?(normal_user, "/users/normal-456/edit") == true
|
||||||
|
assert Authorization.can_access_page?(normal_user, "/users/other-id") == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reserved segment 'new' is not matched by :id" do
|
||||||
|
read_only_user = %{
|
||||||
|
id: "read-only-123",
|
||||||
|
role: %{permission_set_name: "read_only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/members/new") == false
|
||||||
|
assert Authorization.can_access_page?(read_only_user, "/groups/new") == false
|
||||||
|
end
|
||||||
|
|
||||||
test "returns false for nil user" do
|
test "returns false for nil user" do
|
||||||
assert Authorization.can_access_page?(nil, "/members") == false
|
assert Authorization.can_access_page?(nil, "/members") == false
|
||||||
assert Authorization.can_access_page?(nil, "/admin/roles") == false
|
assert Authorization.can_access_page?(nil, "/admin/roles") == false
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_last_completed_cycle/2" do
|
describe "get_last_completed_cycle/2" do
|
||||||
|
test "returns nil when member is nil" do
|
||||||
|
assert MembershipFeeHelpers.get_last_completed_cycle(nil) == nil
|
||||||
|
assert MembershipFeeHelpers.get_last_completed_cycle(nil, Date.utc_today()) == nil
|
||||||
|
end
|
||||||
|
|
||||||
test "returns last completed cycle for member", %{actor: actor} do
|
test "returns last completed cycle for member", %{actor: actor} do
|
||||||
# Create test data
|
# Create test data
|
||||||
fee_type =
|
fee_type =
|
||||||
|
|
@ -184,6 +189,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_current_cycle/2" do
|
describe "get_current_cycle/2" do
|
||||||
|
test "returns nil when member is nil" do
|
||||||
|
assert MembershipFeeHelpers.get_current_cycle(nil) == nil
|
||||||
|
assert MembershipFeeHelpers.get_current_cycle(nil, Date.utc_today()) == nil
|
||||||
|
end
|
||||||
|
|
||||||
test "returns current cycle for member", %{actor: actor} do
|
test "returns current cycle for member", %{actor: actor} do
|
||||||
fee_type =
|
fee_type =
|
||||||
Mv.MembershipFees.MembershipFeeType
|
Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
|
||||||
|
|
@ -158,15 +158,12 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "non-admin user does not see import section", %{conn: conn} do
|
test "non-admin user does not see import section", %{conn: conn} do
|
||||||
# Create non-admin user (member role)
|
# Member (own_data) is redirected when accessing /settings (no page permission)
|
||||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
|
||||||
|
assert to == "/users/#{member_user.id}"
|
||||||
# Import section should not be visible
|
|
||||||
refute html =~ "Import Members" or html =~ "CSV Import" or
|
|
||||||
(html =~ "Import" and html =~ "CSV")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -236,15 +233,12 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "non-admin cannot start import", %{conn: conn} do
|
test "non-admin cannot start import", %{conn: conn} do
|
||||||
# Create non-admin user
|
# Member (own_data) is redirected when accessing /settings (no page permission)
|
||||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
|
||||||
|
assert to == "/users/#{member_user.id}"
|
||||||
# Since non-admin shouldn't see the section, we check that import section is not visible
|
|
||||||
html = render(view)
|
|
||||||
refute html =~ "Import Members" or html =~ "CSV Import" or html =~ "start_import"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "invalid CSV shows user-friendly error", %{conn: conn} do
|
test "invalid CSV shows user-friendly error", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
# User must have admin role (or normal_user) to access /membership_fee_types pages
|
||||||
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
# Create admin user
|
|
||||||
{:ok, user} =
|
|
||||||
Mv.Accounts.User
|
|
||||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
|
||||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
|
||||||
password: "testpassword123"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: system_actor)
|
|
||||||
|
|
||||||
authenticated_conn = conn_with_password_user(conn, user)
|
authenticated_conn = conn_with_password_user(conn, user)
|
||||||
%{conn: authenticated_conn, user: user}
|
%{conn: authenticated_conn, user: user}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
|> Ash.create!(actor: admin_user)
|
|> Ash.create!(actor: admin_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member
|
# Helper to create a member. Requires actor (e.g. admin_user from setup); no fallback so
|
||||||
# Uses admin actor from global setup to ensure authorization; falls back to system_actor when nil
|
# missing-actor bugs are not masked in tests.
|
||||||
defp create_member(attrs, actor) do
|
defp create_member(attrs, actor) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
|
|
@ -39,8 +39,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
attrs = Map.merge(default_attrs, attrs)
|
attrs = Map.merge(default_attrs, attrs)
|
||||||
effective_actor = actor || Mv.Helpers.SystemActor.get_system_actor()
|
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: effective_actor)
|
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
|
|
||||||
describe "profile navigation" do
|
describe "profile navigation" do
|
||||||
test "clicking profile button redirects to current user profile", %{conn: conn} do
|
test "clicking profile button redirects to current user profile", %{conn: conn} do
|
||||||
# Setup: Create and login a user
|
# User needs a role with page permission for "/" (e.g. admin)
|
||||||
user = create_test_user(%{email: "test@example.com"})
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
conn = conn_with_password_user(conn, user)
|
conn = conn_with_password_user(conn, user)
|
||||||
{:ok, view, _html} = live(conn, "/")
|
{:ok, view, _html} = live(conn, "/")
|
||||||
|
|
||||||
|
|
@ -21,9 +21,18 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
assert_redirected(view, "/users/#{user.id}")
|
assert_redirected(view, "/users/#{user.id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "profile navigation shows correct user data", %{conn: conn} do
|
test "profile navigation shows correct user data", %{conn: conn, actor: actor} do
|
||||||
# Setup: Create and login a user
|
# User with password (from create_test_user) and admin role so they can access "/"
|
||||||
user = create_test_user(%{email: "test@example.com"})
|
user = create_test_user(%{email: "test@example.com"})
|
||||||
|
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
|
||||||
|
{:ok, user} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
user = Ash.load!(user, :role, domain: Mv.Accounts, actor: actor)
|
||||||
conn = conn_with_password_user(conn, user)
|
conn = conn_with_password_user(conn, user)
|
||||||
|
|
||||||
# Navigate to profile
|
# Navigate to profile
|
||||||
|
|
@ -40,8 +49,8 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
|
|
||||||
describe "sidebar" do
|
describe "sidebar" do
|
||||||
test "renders profile button with correct attributes", %{conn: conn} do
|
test "renders profile button with correct attributes", %{conn: conn} do
|
||||||
# Setup: Create and login a user
|
# User needs a role with page permission for "/"
|
||||||
user = create_test_user(%{email: "test@example.com"})
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
conn = conn_with_password_user(conn, user)
|
conn = conn_with_password_user(conn, user)
|
||||||
{:ok, _view, html} = live(conn, "/")
|
{:ok, _view, html} = live(conn, "/")
|
||||||
|
|
||||||
|
|
@ -85,16 +94,27 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
})
|
})
|
||||||
|> Ash.create!(domain: Mv.Accounts, actor: actor)
|
|> Ash.create!(domain: Mv.Accounts, actor: actor)
|
||||||
|
|
||||||
|
# Assign role so user can access "/" (page permission)
|
||||||
|
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
|
||||||
|
{:ok, user_with_role} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
user_with_role = Ash.load!(user_with_role, :role, domain: Mv.Accounts, actor: actor)
|
||||||
|
|
||||||
# Login user via OIDC
|
# Login user via OIDC
|
||||||
conn = sign_in_user_via_oidc(conn, user)
|
conn = sign_in_user_via_oidc(conn, user_with_role)
|
||||||
|
|
||||||
# Navigate to home and click profile
|
# Navigate to home and click profile
|
||||||
{:ok, view, _html} = live(conn, "/")
|
{:ok, view, _html} = live(conn, "/")
|
||||||
view |> element("a", "Profil") |> render_click()
|
view |> element("a", "Profil") |> render_click()
|
||||||
|
|
||||||
# Verify we're on the correct profile page with OIDC specific information
|
# Verify we're on the correct profile page with OIDC specific information
|
||||||
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
|
{:ok, _profile_view, html} = live(conn, "/users/#{user_with_role.id}")
|
||||||
assert html =~ to_string(user.email)
|
assert html =~ to_string(user_with_role.email)
|
||||||
# Password auth should be disabled for OIDC users
|
# Password auth should be disabled for OIDC users
|
||||||
assert html =~ "Not enabled"
|
assert html =~ "Not enabled"
|
||||||
end
|
end
|
||||||
|
|
@ -103,14 +123,10 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
conn: conn,
|
conn: conn,
|
||||||
actor: actor
|
actor: actor
|
||||||
} do
|
} do
|
||||||
# Create password user
|
# Users need a role with page permission for "/"
|
||||||
password_user =
|
password_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
create_test_user(%{
|
|
||||||
email: "password2@example.com",
|
|
||||||
password: "test_password123"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Create OIDC user
|
# Create OIDC user and assign admin role
|
||||||
user_info = %{
|
user_info = %{
|
||||||
"sub" => "oidc_789",
|
"sub" => "oidc_789",
|
||||||
"preferred_username" => "oidc@example.com"
|
"preferred_username" => "oidc@example.com"
|
||||||
|
|
@ -129,6 +145,17 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
})
|
})
|
||||||
|> Ash.create!(domain: Mv.Accounts, actor: actor)
|
|> Ash.create!(domain: Mv.Accounts, actor: actor)
|
||||||
|
|
||||||
|
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
|
||||||
|
{:ok, oidc_user_with_role} =
|
||||||
|
oidc_user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
oidc_user_with_role =
|
||||||
|
Ash.load!(oidc_user_with_role, :role, domain: Mv.Accounts, actor: actor)
|
||||||
|
|
||||||
# Test with password user
|
# Test with password user
|
||||||
conn_password = conn_with_password_user(conn, password_user)
|
conn_password = conn_with_password_user(conn, password_user)
|
||||||
{:ok, view_password, _html} = live(conn_password, "/")
|
{:ok, view_password, _html} = live(conn_password, "/")
|
||||||
|
|
@ -136,16 +163,17 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
assert_redirected(view_password, "/users/#{password_user.id}")
|
assert_redirected(view_password, "/users/#{password_user.id}")
|
||||||
|
|
||||||
# Test with OIDC user
|
# Test with OIDC user
|
||||||
conn_oidc = sign_in_user_via_oidc(conn, oidc_user)
|
conn_oidc = sign_in_user_via_oidc(conn, oidc_user_with_role)
|
||||||
{:ok, view_oidc, _html} = live(conn_oidc, "/")
|
{:ok, view_oidc, _html} = live(conn_oidc, "/")
|
||||||
view_oidc |> element("a", "Profil") |> render_click()
|
view_oidc |> element("a", "Profil") |> render_click()
|
||||||
assert_redirected(view_oidc, "/users/#{oidc_user.id}")
|
assert_redirected(view_oidc, "/users/#{oidc_user_with_role.id}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "authenticated views" do
|
describe "authenticated views" do
|
||||||
|
# User must have a role with page permission to access /members, /users, etc.
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
user = create_test_user(%{email: "test@example.com"})
|
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
conn = conn_with_password_user(conn, user)
|
conn = conn_with_password_user(conn, user)
|
||||||
{:ok, conn: conn, user: user}
|
{:ok, conn: conn, user: user}
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -441,18 +441,11 @@ defmodule MvWeb.RoleLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "only admin can access /admin/roles", %{conn: conn, actor: actor} do
|
test "only admin can access /admin/roles", %{conn: conn, actor: actor} do
|
||||||
{conn, _user} = create_non_admin_user(conn, actor)
|
{conn, user} = create_non_admin_user(conn, actor)
|
||||||
|
|
||||||
# Non-admin should be redirected or see error
|
# Non-admin (no role or non-admin role) is redirected by CheckPagePermission plug
|
||||||
# Note: Authorization is checked via can_access_page? which returns false
|
assert {:error, {:redirect, %{to: to}}} = live(conn, "/admin/roles")
|
||||||
# The page might still mount but show no content or redirect
|
assert to == "/users/#{user.id}"
|
||||||
# For now, we just verify the page doesn't work as expected for non-admin
|
|
||||||
{:ok, _view, html} = live(conn, "/admin/roles")
|
|
||||||
|
|
||||||
# Non-admin should not see "New Role" button (can? returns false)
|
|
||||||
# But the button might still be in HTML, just hidden or disabled
|
|
||||||
# We verify that the page loads but admin features are restricted
|
|
||||||
assert html =~ "Listing Roles" || html =~ "Roles"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "admin can access /admin/roles", %{conn: conn, actor: actor} do
|
test "admin can access /admin/roles", %{conn: conn, actor: actor} do
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ defmodule MvWeb.UserLive.ShowTest do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
# Create test user
|
# Create test user
|
||||||
user = create_test_user(%{email: "test@example.com", oidc_id: "test123"})
|
user = create_test_user(%{email: "test@example.com", oidc_id: "test123"})
|
||||||
|
|
|
||||||
934
test/mv_web/plugs/check_page_permission_test.exs
Normal file
934
test/mv_web/plugs/check_page_permission_test.exs
Normal file
|
|
@ -0,0 +1,934 @@
|
||||||
|
defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the CheckPagePermission plug.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
alias MvWeb.Plugs.CheckPagePermission
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
|
defp conn_with_user(path, user) do
|
||||||
|
build_conn(:get, path)
|
||||||
|
|> Phoenix.ConnTest.init_test_session(%{})
|
||||||
|
|> Plug.Conn.put_private(:phoenix_router, MvWeb.Router)
|
||||||
|
|> Plug.Conn.assign(:current_user, user)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp conn_without_user(path) do
|
||||||
|
build_conn(:get, path)
|
||||||
|
|> Phoenix.ConnTest.init_test_session(%{})
|
||||||
|
|> Plug.Conn.put_private(:phoenix_router, MvWeb.Router)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "static routes" do
|
||||||
|
test "user with permission for \"/members\" can access (conn not halted)" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
conn = conn_with_user("/members", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user without permission for \"/members\" is denied (conn halted, redirected to user profile)" do
|
||||||
|
user = Fixtures.user_with_role_fixture("own_data")
|
||||||
|
conn = conn_with_user("/members", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "flash error message present after denial" do
|
||||||
|
user = Fixtures.user_with_role_fixture("own_data")
|
||||||
|
conn = conn_with_user("/members", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
|
||||||
|
"You don't have permission to access this page."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "dynamic routes" do
|
||||||
|
test "user with \"/members/:id\" permission can access \"/members/123\"" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
conn = conn_with_user("/members/123", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user with \"/members/:id/edit\" permission can access \"/members/456/edit\"" do
|
||||||
|
user = Fixtures.user_with_role_fixture("normal_user")
|
||||||
|
conn = conn_with_user("/members/456/edit", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user with only \"/members/:id\" cannot access \"/members/123/edit\"" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
conn = conn_with_user("/members/123/edit", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "own_data user with linked member can access /members/:id/edit (plug direct call)" do
|
||||||
|
member = Fixtures.member_fixture()
|
||||||
|
user = Fixtures.user_with_role_fixture("own_data")
|
||||||
|
user_with_member = Mv.Authorization.Actor.ensure_loaded(user)
|
||||||
|
# Simulate user with linked member (struct may not have member_id after session load)
|
||||||
|
user_with_member = %{user_with_member | member_id: member.id}
|
||||||
|
|
||||||
|
assert CheckPagePermission.user_can_access_page?(
|
||||||
|
user_with_member,
|
||||||
|
"/members/#{member.id}/edit"
|
||||||
|
),
|
||||||
|
"plug must allow own_data user with linked member to access member edit"
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn_with_user("/members/#{member.id}/edit", user_with_member)
|
||||||
|
|> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "own_data user with linked member can access /members/:id/show/edit (plug direct call)" do
|
||||||
|
member = Fixtures.member_fixture()
|
||||||
|
user = Fixtures.user_with_role_fixture("own_data")
|
||||||
|
user_with_member = Mv.Authorization.Actor.ensure_loaded(user)
|
||||||
|
user_with_member = %{user_with_member | member_id: member.id}
|
||||||
|
|
||||||
|
assert CheckPagePermission.user_can_access_page?(
|
||||||
|
user_with_member,
|
||||||
|
"/members/#{member.id}/show/edit"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn_with_user("/members/#{member.id}/show/edit", user_with_member)
|
||||||
|
|> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "read_only and normal_user denied on admin routes" do
|
||||||
|
test "read_only cannot access /admin/roles" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normal_user cannot access /admin/roles" do
|
||||||
|
user = Fixtures.user_with_role_fixture("normal_user")
|
||||||
|
conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "read_only cannot access /members/new" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
conn = conn_with_user("/members/new", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "wildcard" do
|
||||||
|
test "admin with \"*\" permission can access any page" do
|
||||||
|
user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin can access \"/members/999/edit\"" do
|
||||||
|
user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = conn_with_user("/members/999/edit", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "unauthenticated user" do
|
||||||
|
test "nil current_user is denied and redirected to \"/sign-in\"" do
|
||||||
|
conn = conn_without_user("/members") |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/sign-in"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
|
||||||
|
"You don't have permission to access this page."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "public paths" do
|
||||||
|
test "unauthenticated user can access /auth/sign-in (no redirect)" do
|
||||||
|
conn = conn_without_user("/auth/sign-in") |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unauthenticated user can access /register" do
|
||||||
|
conn = conn_without_user("/register") |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
refute conn.halted
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "error handling" do
|
||||||
|
test "user with no role is denied" do
|
||||||
|
user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
user_without_role = %{user | role: nil}
|
||||||
|
conn = conn_with_user("/members", user_without_role) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "user with invalid permission_set_name is denied" do
|
||||||
|
user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
bad_role = %{user.role | permission_set_name: "invalid_set"}
|
||||||
|
user_bad_role = %{user | role: bad_role}
|
||||||
|
conn = conn_with_user("/members", user_bad_role) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
assert conn.halted
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Integration: dispatch through full router (endpoint) so pipeline and load_from_session run.
|
||||||
|
# These tests ensure a Mitglied (own_data) user is denied on every forbidden path.
|
||||||
|
describe "integration: Mitglied (own_data) denied on all forbidden paths via full router" do
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /members redirects to user profile with error flash", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/members")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) =~
|
||||||
|
"don't have permission"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /members/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
assert user.role.permission_set_name == "own_data",
|
||||||
|
"setup must provide Mitglied (own_data) user"
|
||||||
|
|
||||||
|
conn = get(conn, "/members/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /membership_fee_settings redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /membership_fee_types redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/membership_fee_types")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /membership_fee_types/new redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_types/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /groups redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/groups")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/groups/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/admin/roles")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /admin/roles/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/admin/roles/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Dynamic routes need a valid path segment; use a real UUID from fixtures.
|
||||||
|
describe "integration: Mitglied denied on dynamic forbidden paths via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
conn: conn,
|
||||||
|
current_user: current_user,
|
||||||
|
member_id: member.id,
|
||||||
|
role_id: role.id,
|
||||||
|
group_slug: group.slug}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /members/:id/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
member_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/members/#{id}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /members/:id/show/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
member_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/members/#{id}/show/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /members/:id (unlinked member show) redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
member_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/members/#{id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
other_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = get(conn, "/users/#{other_user.id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id/edit redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
other_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = get(conn, "/users/#{other_user.id}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id/show/edit redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
other_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = get(conn, "/users/#{other_user.id}/show/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /membership_fee_types/:id/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
type =
|
||||||
|
Mv.MembershipFees.MembershipFeeType
|
||||||
|
|> Ash.Query.limit(1)
|
||||||
|
|> Ash.read!(actor: Mv.Helpers.SystemActor.get_system_actor())
|
||||||
|
|> List.first()
|
||||||
|
|
||||||
|
if type do
|
||||||
|
conn = get(conn, "/membership_fee_types/#{type.id}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /groups/:slug redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user,
|
||||||
|
group_slug: slug
|
||||||
|
} do
|
||||||
|
assert redirected_to(get(conn, "/groups/#{slug}")) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /admin/roles/:id redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
role_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/admin/roles/#{id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /admin/roles/:id/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
role_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/admin/roles/#{id}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "integration: Mitglied (own_data) can access allowed paths via full router" do
|
||||||
|
@tag role: :member
|
||||||
|
test "GET / redirects to user profile (root not allowed for own_data)", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id (own profile) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id/edit (own profile edit) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "GET /users/:id/show/edit (own profile show edit) returns 200", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/show/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
# Full-router test: session may not preserve member_id; plug logic covered by unit test "own_data user with linked member can access /members/:id/edit (plug direct call)"
|
||||||
|
@tag role: :member
|
||||||
|
@tag :skip
|
||||||
|
test "GET /members/:id/edit (linked member edit) returns 200 when user has linked member", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, user_after_update} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.force_set_argument(:member, %{id: member.id})
|
||||||
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
|
user_with_member =
|
||||||
|
user_after_update
|
||||||
|
|> Ash.load!([:role], domain: Mv.Accounts)
|
||||||
|
|> Mv.Authorization.Actor.ensure_loaded()
|
||||||
|
|> Map.put(:member_id, member.id)
|
||||||
|
|
||||||
|
conn = conn_with_password_user(conn, user_with_member)
|
||||||
|
|
||||||
|
conn = get(conn, "/members/#{member.id}/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
@tag :skip
|
||||||
|
test "GET /members/:id/show/edit (linked member show edit) returns 200 when user has linked member",
|
||||||
|
%{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
{:ok, user_after_update} =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update, %{})
|
||||||
|
|> Ash.Changeset.force_set_argument(:member, %{id: member.id})
|
||||||
|
|> Ash.update(actor: system_actor)
|
||||||
|
|
||||||
|
user_with_member =
|
||||||
|
user_after_update
|
||||||
|
|> Ash.load!([:role], domain: Mv.Accounts)
|
||||||
|
|> Mv.Authorization.Actor.ensure_loaded()
|
||||||
|
|> Map.put(:member_id, member.id)
|
||||||
|
|
||||||
|
conn = conn_with_password_user(conn, user_with_member)
|
||||||
|
|
||||||
|
conn = get(conn, "/members/#{member.id}/show/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
# Skipped: MemberLive.Show requires membership fee cycle data; plug allows access (page loads then LiveView may error).
|
||||||
|
@tag role: :member
|
||||||
|
@tag :skip
|
||||||
|
test "GET /members/:id for linked member returns 200", %{conn: conn, current_user: user} do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
|
||||||
|
user =
|
||||||
|
user
|
||||||
|
|> Ash.Changeset.for_update(:update_user, %{})
|
||||||
|
|> Ash.Changeset.force_set_argument(:member, %{id: member.id})
|
||||||
|
|> Ash.update(actor: system_actor)
|
||||||
|
|> case do
|
||||||
|
{:ok, u} -> Ash.load!(u, :role, domain: Mv.Accounts, actor: system_actor)
|
||||||
|
{:error, _} -> user
|
||||||
|
end
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> MvWeb.ConnCase.conn_with_password_user(user)
|
||||||
|
|> get("/members/#{member.id}")
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# read_only (Vorstand/Buchhaltung): allowed /, /members, /members/:id, /groups, /groups/:slug
|
||||||
|
describe "integration: read_only (Vorstand/Buchhaltung) allowed paths via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
{:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET / returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /members returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/members")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /members/:id returns 200", %{conn: conn, member_id: id} do
|
||||||
|
conn = get(conn, "/members/#{id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /groups returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/groups")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do
|
||||||
|
conn = get(conn, "/groups/#{slug}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users/:id (own profile) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users/:id/edit (own profile edit) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users/:id/show/edit (own profile show edit) returns 200", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/show/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "integration: read_only denied paths via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
type =
|
||||||
|
Mv.MembershipFees.MembershipFeeType
|
||||||
|
|> Ash.Query.limit(1)
|
||||||
|
|> Ash.read!(actor: Mv.Helpers.SystemActor.get_system_actor())
|
||||||
|
|> List.first()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
conn: conn,
|
||||||
|
current_user: current_user,
|
||||||
|
member_id: member.id,
|
||||||
|
role_id: role.id,
|
||||||
|
group_slug: group.slug,
|
||||||
|
fee_type_id: type && type.id}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /members/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/members/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /members/:id/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
member_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/members/#{id}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /users/:id (other user) redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user,
|
||||||
|
role_id: _role_id
|
||||||
|
} do
|
||||||
|
other_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = get(conn, "/users/#{other_user.id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /membership_fee_settings redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /membership_fee_types redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_types")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/groups/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /groups/:slug/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user,
|
||||||
|
group_slug: slug
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/groups/#{slug}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/admin/roles")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "GET /admin/roles/:id redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
role_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/admin/roles/#{id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug
|
||||||
|
describe "integration: normal_user (Kassenwart) allowed paths via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
{:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET / returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /members returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/members")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /members/new returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/members/new")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /members/:id returns 200", %{conn: conn, member_id: id} do
|
||||||
|
conn = get(conn, "/members/#{id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /members/:id/edit returns 200", %{conn: conn, member_id: id} do
|
||||||
|
conn = get(conn, "/members/#{id}/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /groups returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/groups")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do
|
||||||
|
conn = get(conn, "/groups/#{slug}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /members/:id/show/edit returns 200", %{conn: conn, member_id: id} do
|
||||||
|
conn = get(conn, "/members/#{id}/show/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users/:id (own profile) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users/:id/edit (own profile edit) returns 200", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users/:id/show/edit (own profile show edit) returns 200", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/users/#{user.id}/show/edit")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "integration: normal_user denied paths via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
other_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
conn: conn,
|
||||||
|
current_user: current_user,
|
||||||
|
other_user_id: other_user.id,
|
||||||
|
role_id: role.id,
|
||||||
|
group_slug: group.slug}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/users/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /users/:id redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user,
|
||||||
|
other_user_id: id
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/users/#{id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /membership_fee_settings redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_settings")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /membership_fee_types redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/membership_fee_types")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/groups/new")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /groups/:slug/edit redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: user,
|
||||||
|
group_slug: slug
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/groups/#{slug}/edit")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
|
||||||
|
conn = get(conn, "/admin/roles")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "GET /admin/roles/:id redirects to user profile", %{
|
||||||
|
conn: conn,
|
||||||
|
role_id: id,
|
||||||
|
current_user: user
|
||||||
|
} do
|
||||||
|
conn = get(conn, "/admin/roles/#{id}")
|
||||||
|
assert redirected_to(conn) == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "integration: admin can access all protected routes via full router" do
|
||||||
|
setup %{conn: conn, current_user: current_user} do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
group = Mv.Fixtures.group_fixture()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
conn: conn,
|
||||||
|
current_user: current_user,
|
||||||
|
member_id: member.id,
|
||||||
|
role_id: role.id,
|
||||||
|
group_slug: group.slug}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET / returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /members returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/members")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /users returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/users")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /settings returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/settings")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /membership_fee_settings returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/membership_fee_settings")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /admin/roles returns 200", %{conn: conn} do
|
||||||
|
conn = get(conn, "/admin/roles")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /members/:id returns 200", %{conn: conn, member_id: id} do
|
||||||
|
conn = get(conn, "/members/#{id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /admin/roles/:id returns 200", %{conn: conn, role_id: id} do
|
||||||
|
conn = get(conn, "/admin/roles/#{id}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do
|
||||||
|
conn = get(conn, "/groups/#{slug}")
|
||||||
|
assert conn.status == 200
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -297,7 +297,7 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
test "navigation links point to correct pages", %{conn: conn} do
|
test "navigation links point to correct pages", %{conn: conn} do
|
||||||
user = create_test_user(%{email: "navigate@example.com"})
|
user = create_test_user(%{email: "navigate@example.com"})
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, html} = live(conn, "/users")
|
{:ok, _view, html} = live(conn, "/users")
|
||||||
|
|
||||||
# Check that user row contains link to show page
|
# Check that user row contains link to show page
|
||||||
assert html =~ ~s(/users/#{user.id})
|
assert html =~ ~s(/users/#{user.id})
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,18 @@ defmodule MvWeb.ConnCase do
|
||||||
authenticated_conn = conn_with_password_user(conn, member_user)
|
authenticated_conn = conn_with_password_user(conn, member_user)
|
||||||
{authenticated_conn, member_user}
|
{authenticated_conn, member_user}
|
||||||
|
|
||||||
|
:read_only ->
|
||||||
|
# Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings
|
||||||
|
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||||
|
authenticated_conn = conn_with_password_user(conn, read_only_user)
|
||||||
|
{authenticated_conn, read_only_user}
|
||||||
|
|
||||||
|
:normal_user ->
|
||||||
|
# Kassenwart: can read/update members, groups; cannot access users/settings/admin
|
||||||
|
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||||
|
authenticated_conn = conn_with_password_user(conn, normal_user)
|
||||||
|
{authenticated_conn, normal_user}
|
||||||
|
|
||||||
:unauthenticated ->
|
:unauthenticated ->
|
||||||
# No authentication for unauthenticated tests
|
# No authentication for unauthenticated tests
|
||||||
{conn, nil}
|
{conn, nil}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue