diff --git a/lib/towerops/accounts/user.ex b/lib/towerops/accounts/user.ex index 48ebb04e..7cb71011 100644 --- a/lib/towerops/accounts/user.ex +++ b/lib/towerops/accounts/user.ex @@ -33,6 +33,7 @@ defmodule Towerops.Accounts.User do field :privacy_policy_consent, :boolean, virtual: true field :terms_of_service_consent, :boolean, virtual: true field :password_breach_count, :integer, virtual: true + field :device_count, :integer, virtual: true field :last_sudo_at, :utc_datetime has_many :memberships, Membership diff --git a/lib/towerops/admin.ex b/lib/towerops/admin.ex index 42b6d5aa..e1e61703 100644 --- a/lib/towerops/admin.ex +++ b/lib/towerops/admin.ex @@ -14,7 +14,7 @@ defmodule Towerops.Admin do ## User Management @doc """ - Lists all users in the system with their organizations. + Lists all users in the system with their organizations and device counts. ## Options @@ -34,7 +34,17 @@ defmodule Towerops.Admin do limit = Keyword.get(opts, :limit, 100) offset = Keyword.get(opts, :offset, 0) + # Subquery to count devices across all user's organizations + device_count_query = + from d in "devices", + join: m in "organization_memberships", + on: d.organization_id == m.organization_id, + where: m.user_id == parent_as(:user).id, + select: count(d.id) + User + |> from(as: :user) + |> select_merge([u], %{device_count: subquery(device_count_query)}) |> order_by([u], desc: u.inserted_at) |> limit(^limit) |> offset(^offset) diff --git a/lib/towerops_web/live/admin/user_live/index.html.heex b/lib/towerops_web/live/admin/user_live/index.html.heex index 79d290f8..0235d1f4 100644 --- a/lib/towerops_web/live/admin/user_live/index.html.heex +++ b/lib/towerops_web/live/admin/user_live/index.html.heex @@ -10,6 +10,7 @@ <:col :let={user} label="Email">{user.email} <:col :let={user} label="Superuser">{if user.is_superuser, do: "Yes", else: "No"} <:col :let={user} label="Organizations">{length(user.organizations)} + <:col :let={user} label="Devices">{user.device_count || 0} <:col :let={user} label="Joined"> {ToweropsWeb.TimeHelpers.format_date(user.inserted_at, @timezone)} diff --git a/test/towerops_web/live/admin/user_live_test.exs b/test/towerops_web/live/admin/user_live_test.exs new file mode 100644 index 00000000..a57aa010 --- /dev/null +++ b/test/towerops_web/live/admin/user_live_test.exs @@ -0,0 +1,101 @@ +defmodule ToweropsWeb.Admin.UserLiveTest do + use ToweropsWeb.ConnCase + + import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + describe "Index" do + setup do + superuser = + user_fixture() + |> Ecto.Changeset.change(%{is_superuser: true}) + |> Towerops.Repo.update!() + + %{superuser: superuser} + end + + test "displays device count for users", %{conn: conn, superuser: superuser} do + # Create a regular user + user = user_fixture() + + # Create organizations with user as owner + org1 = organization_fixture(user.id) + org2 = organization_fixture(user.id) + + # Create devices in different organizations (device_fixture auto-creates sites) + device_fixture(%{organization_id: org1.id}) + device_fixture(%{organization_id: org1.id}) + device_fixture(%{organization_id: org2.id}) + + # Log in as superuser + conn = log_in_user(conn, superuser) + + # Visit the admin users page + {:ok, view, html} = live(conn, ~p"/admin/users") + + # Check that the device count column header is displayed + assert html =~ "Devices" + + # Verify device count is in the assigns + users = :sys.get_state(view.pid).socket.assigns.users + test_user = Enum.find(users, &(&1.id == user.id)) + assert test_user.device_count == 3 + end + + test "displays 0 devices for users with no organizations", %{conn: conn, superuser: superuser} do + # Create a user with no organizations + user = user_fixture() + + # Log in as superuser + conn = log_in_user(conn, superuser) + + # Visit the admin users page + {:ok, view, html} = live(conn, ~p"/admin/users") + + # Check that the device count column header is displayed + assert html =~ "Devices" + + # Verify device count is 0 in the assigns + users = :sys.get_state(view.pid).socket.assigns.users + test_user = Enum.find(users, &(&1.id == user.id)) + assert test_user.device_count == 0 + end + + test "device count only includes devices from user's organizations", %{ + conn: conn, + superuser: superuser + } do + # Create two users + user1 = user_fixture() + user2 = user_fixture() + + # Create organizations (user1 belongs to org1, user2 to org2) + org1 = organization_fixture(user1.id) + org2 = organization_fixture(user2.id) + + # Create devices (user1 should only see devices from org1) + device_fixture(%{organization_id: org1.id}) + device_fixture(%{organization_id: org1.id}) + device_fixture(%{organization_id: org2.id}) + + # Log in as superuser + conn = log_in_user(conn, superuser) + + # Visit the admin users page + {:ok, view, _html} = live(conn, ~p"/admin/users") + + # Get all users from the assigns + users = :sys.get_state(view.pid).socket.assigns.users + + # Find user1 and verify device count + user1_with_count = Enum.find(users, &(&1.id == user1.id)) + assert user1_with_count.device_count == 2 + + # Find user2 and verify device count + user2_with_count = Enum.find(users, &(&1.id == user2.id)) + assert user2_with_count.device_count == 1 + end + end +end