Compare commits

..

5 commits

Author SHA1 Message Date
5998fb643d
perf: optimize load_user_counts with DB-side aggregation
All checks were successful
continuous-integration/drone/push Build is passing
Replace Elixir-side counting with Ecto GROUP BY COUNT query for
better performance. This avoids loading all users into memory and
performs the aggregation directly in the database.
2026-01-08 16:01:26 +01:00
08182300b9
refactor: add opts_with_actor helper and improve error formatting
Add opts_with_actor helper function to reduce duplication when building
Ash options with actor and domain. Improve format_error documentation
and ensure consistent error message formatting.
2026-01-08 16:01:15 +01:00
ddb4da266d
fix: use verified routes in navbar and improve can_access_page?
Use ~p verified routes instead of string paths in navbar template.
Update can_access_page? to handle both string and verified route paths
for better type safety.
2026-01-08 16:01:01 +01:00
ce395ec112
fix: add ensure_user_role_loaded to router live_session globally 2026-01-08 16:00:38 +01:00
583c2b1766
fix: correct German translations for role management
Fix incorrect translations:
- 'Listing Roles' -> 'Rollen auflisten' (was 'Benutzer*innen auflisten')
- 'Custom' -> 'Benutzerdefiniert' (was 'Benutzerdefinierte Felder')
2026-01-08 16:00:26 +01:00
8 changed files with 59 additions and 34 deletions

View file

@ -106,14 +106,18 @@ defmodule MvWeb.Authorization do
iex> can_access_page?(mitglied, "/members")
false
"""
@spec can_access_page?(map() | nil, String.t()) :: boolean()
@spec can_access_page?(map() | nil, String.t() | Phoenix.VerifiedRoutes.unverified_path()) ::
boolean()
def can_access_page?(nil, _page_path), do: false
def can_access_page?(user, page_path) do
# Convert verified route to string if needed
page_path_str = if is_binary(page_path), do: page_path, else: to_string(page_path)
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)
page_matches?(permissions.pages, page_path_str)
else
_ -> false
end

View file

@ -34,9 +34,9 @@ defmodule MvWeb.Layouts.Navbar do
<li>
<.link navigate="/settings">{gettext("Global Settings")}</.link>
</li>
<%= if can_access_page?(@current_user, "/admin/roles") do %>
<%= if can_access_page?(@current_user, ~p"/admin/roles") do %>
<li>
<.link navigate="/admin/roles">{gettext("Roles")}</.link>
<.link navigate={~p"/admin/roles"}>{gettext("Roles")}</.link>
</li>
<% end %>
</ul>

View file

@ -15,7 +15,7 @@ defmodule MvWeb.RoleLive.Form do
alias Mv.Authorization.PermissionSets
import MvWeb.RoleLive.Helpers
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}

View file

@ -6,6 +6,7 @@ defmodule MvWeb.RoleLive.Helpers do
@doc """
Formats an error for display to the user.
Extracts error messages from Ash.Error.Invalid and joins them.
"""
@spec format_error(Ash.Error.Invalid.t() | String.t() | any()) :: String.t()
def format_error(%Ash.Error.Invalid{} = error) do
@ -24,4 +25,20 @@ defmodule MvWeb.RoleLive.Helpers do
def permission_set_badge_class("normal_user"), do: "badge badge-success badge-sm"
def permission_set_badge_class("admin"), do: "badge badge-error badge-sm"
def permission_set_badge_class(_), do: "badge badge-ghost badge-sm"
@doc """
Builds Ash options with actor and domain, ensuring actor is never nil in real paths.
"""
@spec opts_with_actor(keyword(), map() | nil, atom()) :: keyword()
def opts_with_actor(base_opts \\ [], actor, domain) do
opts = Keyword.put(base_opts, :domain, domain)
if actor do
Keyword.put(opts, :actor, actor)
else
require Logger
Logger.warning("opts_with_actor called with nil actor - this may bypass policies")
opts
end
end
end

View file

@ -21,7 +21,8 @@ defmodule MvWeb.RoleLive.Index do
require Ash.Query
import MvWeb.RoleLive.Helpers
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@ -125,33 +126,28 @@ defmodule MvWeb.RoleLive.Index do
end
end
# Loads all user counts for roles in a single query to avoid N+1 queries
# Loads all user counts for roles using DB-side aggregation for better performance
@spec load_user_counts([Mv.Authorization.Role.t()], map() | nil) :: %{
Ecto.UUID.t() => non_neg_integer()
}
defp load_user_counts(roles, actor) do
defp load_user_counts(roles, _actor) do
role_ids = Enum.map(roles, & &1.id)
# Load all users with role_id in a single query
opts = [domain: Mv.Accounts]
opts = if actor, do: Keyword.put(opts, :actor, actor), else: opts
# Use Ecto directly for efficient GROUP BY COUNT query
# This is much more performant than loading all users and counting in Elixir
# Note: We bypass Ash here for performance, but this is a simple read-only query
import Ecto.Query
users =
case Ash.read(
Accounts.User
|> Ash.Query.filter(role_id in ^role_ids)
|> Ash.Query.select([:role_id]),
opts
) do
{:ok, users_list} -> users_list
{:error, _} -> []
end
query =
from u in Accounts.User,
where: u.role_id in ^role_ids,
group_by: u.role_id,
select: {u.role_id, count(u.id)}
# Group by role_id and count
users
|> Enum.group_by(& &1.role_id)
|> Enum.map(fn {role_id, users_list} -> {role_id, length(users_list)} end)
|> Map.new()
results = Mv.Repo.all(query)
results
|> Enum.into(%{}, fn {role_id, count} -> {role_id, count} end)
end
# Gets user count from preloaded assigns map
@ -164,8 +160,7 @@ defmodule MvWeb.RoleLive.Index do
# Recalculates user count for a specific role (used before deletion)
@spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
defp recalculate_user_count(role, actor) do
opts = [domain: Mv.Accounts]
opts = if actor, do: Keyword.put(opts, :actor, actor), else: opts
opts = opts_with_actor([], actor, Mv.Accounts)
case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do
{:ok, count} -> count

View file

@ -16,7 +16,8 @@ defmodule MvWeb.RoleLive.Show do
require Ash.Query
import MvWeb.RoleLive.Helpers
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_class: 1, opts_with_actor: 3]
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@ -143,8 +144,7 @@ defmodule MvWeb.RoleLive.Show do
# Recalculates user count for a specific role (used before deletion)
@spec recalculate_user_count(Mv.Authorization.Role.t(), map() | nil) :: non_neg_integer()
defp recalculate_user_count(role, actor) do
opts = [domain: Mv.Accounts]
opts = if actor, do: Keyword.put(opts, :actor, actor), else: opts
opts = opts_with_actor([], actor, Mv.Accounts)
case Ash.count(Accounts.User |> Ash.Query.filter(role_id == ^role.id), opts) do
{:ok, count} -> count

View file

@ -49,8 +49,14 @@ defmodule MvWeb.LiveHelpers do
opts = [domain: Mv.Accounts, actor: user]
case Ash.load(user, :role, opts) do
{:ok, loaded_user} -> loaded_user
{:error, _} -> user
{:ok, loaded_user} ->
loaded_user
{:error, error} ->
# Log warning if role loading fails - this can cause authorization issues
require Logger
Logger.warning("Failed to load role for user #{user.id}: #{inspect(error)}")
user
end
end
end

View file

@ -46,7 +46,10 @@ defmodule MvWeb.Router do
AshAuthentication-specific: We define that all routes can only be accessed when the user is signed in.
"""
ash_authentication_live_session :authentication_required,
on_mount: {MvWeb.LiveUserAuth, :live_user_required} do
on_mount: [
{MvWeb.LiveUserAuth, :live_user_required},
{MvWeb.LiveHelpers, :ensure_user_role_loaded}
] do
live "/", MemberLive.Index, :index
live "/members", MemberLive.Index, :index