Apply UI Authorization to Existing LiveViews closes #400 #403
12 changed files with 488 additions and 59 deletions
|
|
@ -97,12 +97,18 @@ defmodule MvWeb.Authorization do
|
||||||
@doc """
|
@doc """
|
||||||
Checks if user can access a specific page.
|
Checks if user can access a specific page.
|
||||||
|
|
||||||
|
Nil-safe: returns false when user is nil (e.g. unauthenticated or layout
|
||||||
|
assigns regression), so callers do not need to guard.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
iex> admin = %{role: %{permission_set_name: "admin"}}
|
iex> admin = %{role: %{permission_set_name: "admin"}}
|
||||||
iex> can_access_page?(admin, "/admin/roles")
|
iex> can_access_page?(admin, "/admin/roles")
|
||||||
true
|
true
|
||||||
|
|
||||||
|
iex> can_access_page?(nil, "/members")
|
||||||
|
false
|
||||||
|
|
||||||
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
|
iex> mitglied = %{role: %{permission_set_name: "own_data"}}
|
||||||
iex> can_access_page?(mitglied, "/members")
|
iex> can_access_page?(mitglied, "/members")
|
||||||
false
|
false
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,13 @@ defmodule MvWeb.CoreComponents do
|
||||||
<.button navigate={~p"/"}>Home</.button>
|
<.button navigate={~p"/"}>Home</.button>
|
||||||
<.button disabled={true}>Disabled</.button>
|
<.button disabled={true}>Disabled</.button>
|
||||||
"""
|
"""
|
||||||
attr :rest, :global, include: ~w(href navigate patch method)
|
attr :rest, :global, include: ~w(href navigate patch method data-testid)
|
||||||
attr :variant, :string, values: ~w(primary)
|
attr :variant, :string, values: ~w(primary)
|
||||||
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
||||||
def button(%{rest: rest} = assigns) do
|
def button(assigns) do
|
||||||
|
rest = assigns.rest
|
||||||
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
||||||
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :html
|
use MvWeb, :html
|
||||||
|
|
||||||
|
alias MvWeb.PagePaths
|
||||||
|
|
||||||
attr :current_user, :map, default: nil, doc: "The current user"
|
attr :current_user, :map, default: nil, doc: "The current user"
|
||||||
attr :club_name, :string, required: true, doc: "The name of the club"
|
attr :club_name, :string, required: true, doc: "The name of the club"
|
||||||
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
|
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
|
||||||
|
|
@ -70,33 +72,56 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
defp sidebar_menu(assigns) do
|
defp sidebar_menu(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<ul class="menu flex-1 w-full p-2" role="menubar">
|
<ul class="menu flex-1 w-full p-2" role="menubar">
|
||||||
<.menu_item
|
<%= if can_access_page?(@current_user, PagePaths.members()) do %>
|
||||||
href={~p"/members"}
|
<.menu_item
|
||||||
icon="hero-users"
|
href={~p"/members"}
|
||||||
label={gettext("Members")}
|
icon="hero-users"
|
||||||
/>
|
label={gettext("Members")}
|
||||||
|
|
||||||
<.menu_item
|
|
||||||
href={~p"/membership_fee_types"}
|
|
||||||
icon="hero-currency-euro"
|
|
||||||
label={gettext("Fee Types")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Nested Admin Menu -->
|
|
||||||
<.menu_group icon="hero-cog-6-tooth" label={gettext("Administration")}>
|
|
||||||
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
|
|
||||||
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
|
|
||||||
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
|
|
||||||
<.menu_subitem
|
|
||||||
href={~p"/membership_fee_settings"}
|
|
||||||
label={gettext("Fee Settings")}
|
|
||||||
/>
|
/>
|
||||||
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
|
<% end %>
|
||||||
</.menu_group>
|
|
||||||
|
<%= if can_access_page?(@current_user, PagePaths.membership_fee_types()) do %>
|
||||||
|
<.menu_item
|
||||||
|
href={~p"/membership_fee_types"}
|
||||||
|
icon="hero-currency-euro"
|
||||||
|
label={gettext("Fee Types")}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if admin_menu_visible?(@current_user) do %>
|
||||||
|
<.menu_group
|
||||||
|
icon="hero-cog-6-tooth"
|
||||||
|
label={gettext("Administration")}
|
||||||
|
testid="sidebar-administration"
|
||||||
|
>
|
||||||
|
<%= if can_access_page?(@current_user, PagePaths.users()) do %>
|
||||||
|
<.menu_subitem href={~p"/users"} label={gettext("Users")} />
|
||||||
|
<% end %>
|
||||||
|
<%= if can_access_page?(@current_user, PagePaths.groups()) do %>
|
||||||
|
<.menu_subitem href={~p"/groups"} label={gettext("Groups")} />
|
||||||
|
<% end %>
|
||||||
|
<%= if can_access_page?(@current_user, PagePaths.admin_roles()) do %>
|
||||||
|
<.menu_subitem href={~p"/admin/roles"} label={gettext("Roles")} />
|
||||||
|
<% end %>
|
||||||
|
<%= if can_access_page?(@current_user, PagePaths.membership_fee_settings()) do %>
|
||||||
|
<.menu_subitem
|
||||||
|
href={~p"/membership_fee_settings"}
|
||||||
|
label={gettext("Fee Settings")}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
<%= if can_access_page?(@current_user, PagePaths.settings()) do %>
|
||||||
|
<.menu_subitem href={~p"/settings"} label={gettext("Settings")} />
|
||||||
|
<% end %>
|
||||||
|
</.menu_group>
|
||||||
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp admin_menu_visible?(user) do
|
||||||
|
Enum.any?(PagePaths.admin_menu_paths(), &can_access_page?(user, &1))
|
||||||
|
end
|
||||||
|
|
||||||
attr :href, :string, required: true, doc: "Navigation path"
|
attr :href, :string, required: true, doc: "Navigation path"
|
||||||
attr :icon, :string, required: true, doc: "Heroicon name"
|
attr :icon, :string, required: true, doc: "Heroicon name"
|
||||||
attr :label, :string, required: true, doc: "Menu item label"
|
attr :label, :string, required: true, doc: "Menu item label"
|
||||||
|
|
@ -119,12 +144,13 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
|
|
||||||
attr :icon, :string, required: true, doc: "Heroicon name for the menu group"
|
attr :icon, :string, required: true, doc: "Heroicon name for the menu group"
|
||||||
attr :label, :string, required: true, doc: "Menu group label"
|
attr :label, :string, required: true, doc: "Menu group label"
|
||||||
|
attr :testid, :string, default: nil, doc: "data-testid for stable test selectors"
|
||||||
slot :inner_block, required: true, doc: "Submenu items"
|
slot :inner_block, required: true, doc: "Submenu items"
|
||||||
|
|
||||||
defp menu_group(assigns) do
|
defp menu_group(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<!-- Expanded Mode: Always open div structure -->
|
<!-- Expanded Mode: Always open div structure -->
|
||||||
<li role="none" class="expanded-menu-group">
|
<li role="none" class="expanded-menu-group" data-testid={@testid}>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3"
|
class="flex items-center gap-3"
|
||||||
role="group"
|
role="group"
|
||||||
|
|
@ -138,7 +164,7 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<!-- Collapsed Mode: Dropdown -->
|
<!-- Collapsed Mode: Dropdown -->
|
||||||
<div class="collapsed-menu-group dropdown dropdown-right">
|
<div class="collapsed-menu-group dropdown dropdown-right" data-testid={@testid}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@
|
||||||
<.icon name="hero-envelope" />
|
<.icon name="hero-envelope" />
|
||||||
{gettext("Open in email program")}
|
{gettext("Open in email program")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button variant="primary" navigate={~p"/members/new"}>
|
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
<.button variant="primary" navigate={~p"/members/new"} data-testid="member-new">
|
||||||
</.button>
|
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
|
@ -84,6 +86,7 @@
|
||||||
<.table
|
<.table
|
||||||
id="members"
|
id="members"
|
||||||
rows={@members}
|
rows={@members}
|
||||||
|
row_id={fn member -> "row-#{member.id}" end}
|
||||||
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
||||||
dynamic_cols={@dynamic_cols}
|
dynamic_cols={@dynamic_cols}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
|
|
@ -297,16 +300,23 @@
|
||||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.link navigate={~p"/members/#{member}/edit"}>{gettext("Edit")}</.link>
|
<%= if can?(@current_user, :update, member) do %>
|
||||||
|
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
|
||||||
|
{gettext("Edit")}
|
||||||
|
</.link>
|
||||||
|
<% end %>
|
||||||
</:action>
|
</:action>
|
||||||
|
|
||||||
<:action :let={member}>
|
<:action :let={member}>
|
||||||
<.link
|
<%= if can?(@current_user, :destroy, member) do %>
|
||||||
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
|
<.link
|
||||||
data-confirm={gettext("Are you sure?")}
|
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
|
||||||
>
|
data-confirm={gettext("Are you sure?")}
|
||||||
{gettext("Delete")}
|
data-testid="member-delete"
|
||||||
</.link>
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.link>
|
||||||
|
<% end %>
|
||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,15 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
<%= if can?(@current_user, :update, @member) do %>
|
||||||
{gettext("Edit Member")}
|
<.button
|
||||||
</.button>
|
variant="primary"
|
||||||
|
navigate={~p"/members/#{@member}/edit?return_to=show"}
|
||||||
|
data-testid="member-edit"
|
||||||
|
>
|
||||||
|
{gettext("Edit Member")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Tab Navigation --%>
|
<%!-- Tab Navigation --%>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,20 @@
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Listing Users")}
|
{gettext("Listing Users")}
|
||||||
<:actions>
|
<:actions>
|
||||||
<.button variant="primary" navigate={~p"/users/new"}>
|
<%= if can?(@current_user, :create, Mv.Accounts.User) do %>
|
||||||
<.icon name="hero-plus" /> {gettext("New User")}
|
<.button variant="primary" navigate={~p"/users/new"} data-testid="user-new">
|
||||||
</.button>
|
<.icon name="hero-plus" /> {gettext("New User")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.table id="users" rows={@users} row_click={fn user -> JS.navigate(~p"/users/#{user}") end}>
|
<.table
|
||||||
|
id="users"
|
||||||
|
rows={@users}
|
||||||
|
row_id={fn user -> "row-#{user.id}" end}
|
||||||
|
row_click={fn user -> JS.navigate(~p"/users/#{user}") end}
|
||||||
|
>
|
||||||
<:col
|
<:col
|
||||||
:let={user}
|
:let={user}
|
||||||
label={
|
label={
|
||||||
|
|
@ -62,16 +69,23 @@
|
||||||
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
|
<.link navigate={~p"/users/#{user}"}>{gettext("Show")}</.link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.link navigate={~p"/users/#{user}/edit"}>{gettext("Edit")}</.link>
|
<%= if can?(@current_user, :update, user) do %>
|
||||||
|
<.link navigate={~p"/users/#{user}/edit"} data-testid="user-edit">
|
||||||
|
{gettext("Edit")}
|
||||||
|
</.link>
|
||||||
|
<% end %>
|
||||||
</:action>
|
</:action>
|
||||||
|
|
||||||
<:action :let={user}>
|
<:action :let={user}>
|
||||||
<.link
|
<%= if can?(@current_user, :destroy, user) do %>
|
||||||
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
|
<.link
|
||||||
data-confirm={gettext("Are you sure?")}
|
phx-click={JS.push("delete", value: %{id: user.id}) |> hide("#row-#{user.id}")}
|
||||||
>
|
data-confirm={gettext("Are you sure?")}
|
||||||
{gettext("Delete")}
|
data-testid="user-delete"
|
||||||
</.link>
|
>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.link>
|
||||||
|
<% end %>
|
||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,15 @@ defmodule MvWeb.UserLive.Show do
|
||||||
<.icon name="hero-arrow-left" />
|
<.icon name="hero-arrow-left" />
|
||||||
<span class="sr-only">{gettext("Back to users list")}</span>
|
<span class="sr-only">{gettext("Back to users list")}</span>
|
||||||
</.button>
|
</.button>
|
||||||
<.button variant="primary" navigate={~p"/users/#{@user}/edit?return_to=show"}>
|
<%= if can?(@current_user, :update, @user) do %>
|
||||||
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
|
<.button
|
||||||
</.button>
|
variant="primary"
|
||||||
|
navigate={~p"/users/#{@user}/edit?return_to=show"}
|
||||||
|
data-testid="user-edit"
|
||||||
|
>
|
||||||
|
<.icon name="hero-pencil-square" /> {gettext("Edit User")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
|
|
|
||||||
42
lib/mv_web/page_paths.ex
Normal file
42
lib/mv_web/page_paths.ex
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
defmodule MvWeb.PagePaths do
|
||||||
|
@moduledoc """
|
||||||
|
Central path strings for UI authorization and sidebar menu.
|
||||||
|
|
||||||
|
Keep in sync with `MvWeb.Router`. Used by Sidebar and `can_access_page?/2`
|
||||||
|
so route changes (prefix, rename) are updated in one place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Sidebar top-level menu paths
|
||||||
|
@members "/members"
|
||||||
|
@membership_fee_types "/membership_fee_types"
|
||||||
|
|
||||||
|
# Administration submenu paths (all must match router)
|
||||||
|
@users "/users"
|
||||||
|
@groups "/groups"
|
||||||
|
@admin_roles "/admin/roles"
|
||||||
|
@membership_fee_settings "/membership_fee_settings"
|
||||||
|
@settings "/settings"
|
||||||
|
|
||||||
|
@admin_page_paths [
|
||||||
|
@users,
|
||||||
|
@groups,
|
||||||
|
@admin_roles,
|
||||||
|
@membership_fee_settings,
|
||||||
|
@settings
|
||||||
|
]
|
||||||
|
|
||||||
|
@doc "Path for Members index (sidebar and page permission check)."
|
||||||
|
def members, do: @members
|
||||||
|
|
||||||
|
@doc "Path for Membership Fee Types index (sidebar and page permission check)."
|
||||||
|
def membership_fee_types, do: @membership_fee_types
|
||||||
|
|
||||||
|
@doc "Paths for Administration menu; show group if user can access any of these."
|
||||||
|
def admin_menu_paths, do: @admin_page_paths
|
||||||
|
|
||||||
|
def users, do: @users
|
||||||
|
def groups, do: @groups
|
||||||
|
def admin_roles, do: @admin_roles
|
||||||
|
def membership_fee_settings, do: @membership_fee_settings
|
||||||
|
def settings, do: @settings
|
||||||
|
end
|
||||||
|
|
@ -22,9 +22,14 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Returns assigns for an authenticated user with all required attributes.
|
# Returns assigns for an authenticated user with all required attributes.
|
||||||
|
# User has admin role so can_access_page? returns true for all sidebar links.
|
||||||
defp authenticated_assigns(mobile \\ false) do
|
defp authenticated_assigns(mobile \\ false) do
|
||||||
%{
|
%{
|
||||||
current_user: %{id: "user-123", email: "test@example.com"},
|
current_user: %{
|
||||||
|
id: "user-123",
|
||||||
|
email: "test@example.com",
|
||||||
|
role: %{permission_set_name: "admin"}
|
||||||
|
},
|
||||||
club_name: "Test Club",
|
club_name: "Test Club",
|
||||||
mobile: mobile
|
mobile: mobile
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +149,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
assert menu_item_count > 0, "Should have at least one top-level menu item"
|
assert menu_item_count > 0, "Should have at least one top-level menu item"
|
||||||
|
|
||||||
# Check that nested menu groups exist
|
# Check that nested menu groups exist
|
||||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
assert html =~
|
||||||
|
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||||
|
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
assert has_class?(html, "expanded-menu-group")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
|
|
||||||
|
|
@ -193,7 +200,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# Check for nested menu structure
|
# Check for nested menu structure
|
||||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
assert html =~
|
||||||
|
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||||
|
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
assert html =~ ~s(aria-label="Administration")
|
assert html =~ ~s(aria-label="Administration")
|
||||||
assert has_class?(html, "expanded-menu-group")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
|
|
@ -521,7 +530,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
assert html =~ ~s(role="menuitem")
|
assert html =~ ~s(role="menuitem")
|
||||||
|
|
||||||
# Check that nested menus exist
|
# Check that nested menus exist
|
||||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
assert html =~
|
||||||
|
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||||
|
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
|
|
||||||
# Footer section
|
# Footer section
|
||||||
|
|
@ -629,7 +640,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# expanded-menu-group structure present
|
# expanded-menu-group structure present
|
||||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
assert html =~
|
||||||
|
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||||
|
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
assert html =~ ~s(aria-label="Administration")
|
assert html =~ ~s(aria-label="Administration")
|
||||||
assert has_class?(html, "expanded-menu-group")
|
assert has_class?(html, "expanded-menu-group")
|
||||||
|
|
@ -843,7 +856,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
|
|
||||||
# Expanded menu group should have correct structure
|
# Expanded menu group should have correct structure
|
||||||
# (CSS handles hover effects, but we verify structure)
|
# (CSS handles hover effects, but we verify structure)
|
||||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
assert html =~
|
||||||
|
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||||
|
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
120
test/mv_web/components/sidebar_authorization_test.exs
Normal file
120
test/mv_web/components/sidebar_authorization_test.exs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
defmodule MvWeb.SidebarAuthorizationTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for sidebar menu visibility based on user permissions (can_access_page?).
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
import MvWeb.Layouts.Sidebar
|
||||||
|
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
|
defp render_sidebar(assigns) do
|
||||||
|
render_component(&sidebar/1, assigns)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sidebar_assigns(current_user, opts \\ []) do
|
||||||
|
mobile = Keyword.get(opts, :mobile, false)
|
||||||
|
club_name = Keyword.get(opts, :club_name, "Test Club")
|
||||||
|
|
||||||
|
%{
|
||||||
|
current_user: current_user,
|
||||||
|
club_name: club_name,
|
||||||
|
mobile: mobile
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "sidebar menu with admin user" do
|
||||||
|
test "shows Members, Fee Types and Administration with all subitems" do
|
||||||
|
user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
html = render_sidebar(sidebar_assigns(user))
|
||||||
|
|
||||||
|
assert html =~ ~s(href="/members")
|
||||||
|
assert html =~ ~s(href="/membership_fee_types")
|
||||||
|
assert html =~ ~s(data-testid="sidebar-administration")
|
||||||
|
assert html =~ ~s(href="/users")
|
||||||
|
assert html =~ ~s(href="/groups")
|
||||||
|
assert html =~ ~s(href="/admin/roles")
|
||||||
|
assert html =~ ~s(href="/membership_fee_settings")
|
||||||
|
assert html =~ ~s(href="/settings")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "sidebar menu with read_only user (Vorstand/Buchhaltung)" do
|
||||||
|
test "shows Members and Groups (from Administration)" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
html = render_sidebar(sidebar_assigns(user))
|
||||||
|
|
||||||
|
assert html =~ ~s(href="/members")
|
||||||
|
assert html =~ ~s(href="/groups")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not show Fee Types, Users, Roles or Settings" do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
html = render_sidebar(sidebar_assigns(user))
|
||||||
|
|
||||||
|
refute html =~ ~s(href="/membership_fee_types")
|
||||||
|
refute html =~ ~s(href="/users")
|
||||||
|
refute html =~ ~s(href="/admin/roles")
|
||||||
|
refute html =~ ~s(href="/settings")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "sidebar menu with normal_user (Kassenwart)" do
|
||||||
|
test "shows Members and Groups" do
|
||||||
|
user = Fixtures.user_with_role_fixture("normal_user")
|
||||||
|
html = render_sidebar(sidebar_assigns(user))
|
||||||
|
|
||||||
|
assert html =~ ~s(href="/members")
|
||||||
|
assert html =~ ~s(href="/groups")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not show Fee Types, Users, Roles or Settings" do
|
||||||
|
user = Fixtures.user_with_role_fixture("normal_user")
|
||||||
|
html = render_sidebar(sidebar_assigns(user))
|
||||||
|
|
||||||
|
refute html =~ ~s(href="/membership_fee_types")
|
||||||
|
refute html =~ ~s(href="/users")
|
||||||
|
refute html =~ ~s(href="/admin/roles")
|
||||||
|
refute html =~ ~s(href="/settings")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "sidebar menu with own_data user (Mitglied)" do
|
||||||
|
test "does not show Members link (no /members page access)" do
|
||||||
|
user = Fixtures.user_with_role_fixture("own_data")
|
||||||
|
html = render_sidebar(sidebar_assigns(user))
|
||||||
|
|
||||||
|
refute html =~ ~s(href="/members")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not show Fee Types or Administration" do
|
||||||
|
user = Fixtures.user_with_role_fixture("own_data")
|
||||||
|
html = render_sidebar(sidebar_assigns(user))
|
||||||
|
|
||||||
|
refute html =~ ~s(href="/membership_fee_types")
|
||||||
|
refute html =~ ~s(href="/users")
|
||||||
|
refute html =~ ~s(data-testid="sidebar-administration")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "sidebar with nil current_user" do
|
||||||
|
test "does not render menu items (only header and footer when present)" do
|
||||||
|
html = render_sidebar(sidebar_assigns(nil))
|
||||||
|
|
||||||
|
refute html =~ ~s(role="menubar")
|
||||||
|
refute html =~ ~s(href="/members")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "sidebar with user without role" do
|
||||||
|
test "does not show any navigation links" do
|
||||||
|
user = %{id: "user-no-role", email: "noreply@test.com", role: nil}
|
||||||
|
html = render_sidebar(sidebar_assigns(user))
|
||||||
|
|
||||||
|
refute html =~ ~s(href="/members")
|
||||||
|
refute html =~ ~s(href="/membership_fee_types")
|
||||||
|
refute html =~ ~s(href="/users")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
102
test/mv_web/live/member_live_authorization_test.exs
Normal file
102
test/mv_web/live/member_live_authorization_test.exs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
defmodule MvWeb.MemberLiveAuthorizationTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for UI authorization on Member LiveViews (Index and Show).
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
|
describe "Member Index - Vorstand (read_only)" do
|
||||||
|
@tag role: :read_only
|
||||||
|
test "sees member list but not New Member button", %{conn: conn} do
|
||||||
|
_member = Fixtures.member_fixture()
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
refute has_element?(view, "[data-testid=member-new]")
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "does not see Edit or Delete buttons in table", %{conn: conn} do
|
||||||
|
member = Fixtures.member_fixture()
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
refute has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
||||||
|
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Member Index - Kassenwart (normal_user)" do
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "sees New Member and Edit buttons", %{conn: conn} do
|
||||||
|
member = Fixtures.member_fixture()
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid=member-new]")
|
||||||
|
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "does not see Delete button", %{conn: conn} do
|
||||||
|
member = Fixtures.member_fixture()
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Member Index - Admin" do
|
||||||
|
@tag role: :admin
|
||||||
|
test "sees New Member, Edit and Delete buttons", %{conn: conn} do
|
||||||
|
member = Fixtures.member_fixture()
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid=member-new]")
|
||||||
|
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
||||||
|
assert has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Member Index - Mitglied (own_data)" do
|
||||||
|
@tag role: :member
|
||||||
|
test "is redirected when accessing /members", %{conn: conn, current_user: user} do
|
||||||
|
assert {:error, {:redirect, %{to: to}}} = live(conn, "/members")
|
||||||
|
assert to == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Member Show - Edit button visibility" do
|
||||||
|
@tag role: :admin
|
||||||
|
test "admin sees Edit button", %{conn: conn} do
|
||||||
|
member = Fixtures.member_fixture()
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid=member-edit]")
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "read_only does not see Edit button", %{conn: conn} do
|
||||||
|
member = Fixtures.member_fixture()
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||||
|
|
||||||
|
refute has_element?(view, "[data-testid=member-edit]")
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "normal_user sees Edit button", %{conn: conn} do
|
||||||
|
member = Fixtures.member_fixture()
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid=member-edit]")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
81
test/mv_web/live/user_live_authorization_test.exs
Normal file
81
test/mv_web/live/user_live_authorization_test.exs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
defmodule MvWeb.UserLiveAuthorizationTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for UI authorization on User LiveViews (Index and Show).
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
|
describe "User Index - Admin" do
|
||||||
|
@tag role: :admin
|
||||||
|
test "sees New User, Edit and Delete buttons", %{conn: conn} do
|
||||||
|
user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/users")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid=user-new]")
|
||||||
|
assert has_element?(view, "#row-#{user.id} [data-testid=user-edit]")
|
||||||
|
assert has_element?(view, "#row-#{user.id} [data-testid=user-delete]")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "User Index - Non-Admin is redirected" do
|
||||||
|
@tag role: :read_only
|
||||||
|
test "read_only is redirected when accessing /users", %{conn: conn, current_user: user} do
|
||||||
|
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
|
||||||
|
assert to == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :member
|
||||||
|
test "member is redirected when accessing /users", %{conn: conn, current_user: user} do
|
||||||
|
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
|
||||||
|
assert to == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :normal_user
|
||||||
|
test "normal_user is redirected when accessing /users", %{conn: conn, current_user: user} do
|
||||||
|
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
|
||||||
|
assert to == "/users/#{user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "User Show - own profile" do
|
||||||
|
@tag role: :member
|
||||||
|
test "member sees Edit button on own profile", %{conn: conn, current_user: user} do
|
||||||
|
{:ok, view, _html} = live(conn, "/users/#{user.id}")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid=user-edit]")
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :read_only
|
||||||
|
test "read_only sees Edit button on own profile", %{conn: conn, current_user: user} do
|
||||||
|
{:ok, view, _html} = live(conn, "/users/#{user.id}")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid=user-edit]")
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag role: :admin
|
||||||
|
test "admin sees Edit button on user show", %{conn: conn} do
|
||||||
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, "/users/#{user.id}")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid=user-edit]")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "User Show - other user (non-admin redirected)" do
|
||||||
|
@tag role: :member
|
||||||
|
test "member is redirected when accessing other user's profile", %{
|
||||||
|
conn: conn,
|
||||||
|
current_user: current_user
|
||||||
|
} do
|
||||||
|
other_user = Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
|
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users/#{other_user.id}")
|
||||||
|
assert to == "/users/#{current_user.id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue