mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat(portal): Show clients peek on actors index (#7100)
We will show up to 5 recently started client icons and a status for them as a green dot badge (no dot when it's offline to keep things simple). Additional details are available on hover. <img width="1415" alt="1" src="https://github.com/user-attachments/assets/1d48d08b-f024-4016-837a-3a2ac9a34718"> <img width="1413" alt="2" src="https://github.com/user-attachments/assets/101ff122-26e2-4282-ae1d-073b4eba9c56"> I also extended the `Clients` table on "Actor" view page to match the "Clients" index view. Also closes #7096
This commit is contained in:
@@ -121,6 +121,33 @@ defmodule Domain.Actors do
|
||||
end
|
||||
end
|
||||
|
||||
def peek_actor_clients(actors, limit, %Auth.Subject{} = subject) do
|
||||
with :ok <-
|
||||
Auth.ensure_has_permissions(subject, Clients.Authorizer.manage_clients_permission()) do
|
||||
ids = actors |> Enum.map(& &1.id) |> Enum.uniq()
|
||||
|
||||
{:ok, peek} =
|
||||
Actor.Query.not_deleted()
|
||||
|> Actor.Query.by_id({:in, ids})
|
||||
|> Actor.Query.preload_few_clients_for_each_actor(limit)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.peek(actors)
|
||||
|
||||
group_by_ids =
|
||||
Enum.flat_map(peek, fn {_id, %{items: items}} -> items end)
|
||||
|> Clients.preload_clients_presence()
|
||||
|> Enum.map(&{&1.id, &1})
|
||||
|> Enum.into(%{})
|
||||
|
||||
peek =
|
||||
for {id, %{items: items} = map} <- peek, into: %{} do
|
||||
{id, %{map | items: Enum.map(items, &Map.fetch!(group_by_ids, &1.id))}}
|
||||
end
|
||||
|
||||
{:ok, peek}
|
||||
end
|
||||
end
|
||||
|
||||
def sync_provider_groups(%Auth.Provider{} = provider, attrs_list) do
|
||||
Group.Sync.sync_provider_groups(provider, attrs_list)
|
||||
end
|
||||
|
||||
@@ -66,6 +66,37 @@ defmodule Domain.Actors.Actor.Query do
|
||||
|
||||
# Preloads
|
||||
|
||||
def preload_few_clients_for_each_actor(queryable, limit) do
|
||||
queryable
|
||||
|> with_joined_clients(limit)
|
||||
|> with_joined_client_counts()
|
||||
|> select([actors: actors, clients: clients, client_counts: client_counts], %{
|
||||
id: actors.id,
|
||||
count: client_counts.count,
|
||||
item: clients
|
||||
})
|
||||
end
|
||||
|
||||
def with_joined_clients(queryable, limit) do
|
||||
subquery =
|
||||
Domain.Clients.Client.Query.not_deleted()
|
||||
|> where([clients: clients], clients.actor_id == parent_as(:actors).id)
|
||||
|> order_by([clients: clients], desc: clients.last_seen_at)
|
||||
|> limit(^limit)
|
||||
|
||||
join(queryable, :cross_lateral, [actors: actors], clients in subquery(subquery), as: :clients)
|
||||
end
|
||||
|
||||
def with_joined_client_counts(queryable) do
|
||||
subquery =
|
||||
Domain.Clients.Client.Query.count_clients_by_actor_id()
|
||||
|> where([clients: clients], clients.actor_id == parent_as(:actors).id)
|
||||
|
||||
join(queryable, :cross_lateral, [actors: actors], client_counts in subquery(subquery),
|
||||
as: :client_counts
|
||||
)
|
||||
end
|
||||
|
||||
def preload_few_groups_for_each_actor(queryable, limit) do
|
||||
queryable
|
||||
|> with_joined_memberships(limit)
|
||||
@@ -170,7 +201,8 @@ defmodule Domain.Actors.Actor.Query do
|
||||
@impl Domain.Repo.Query
|
||||
def preloads,
|
||||
do: [
|
||||
last_seen_at: &Domain.Actors.preload_last_seen_at/1
|
||||
last_seen_at: &Domain.Actors.preload_last_seen_at/1,
|
||||
clients: {nil, Domain.Clients.Client.Query.preloads()}
|
||||
]
|
||||
|
||||
@impl Domain.Repo.Query
|
||||
|
||||
@@ -48,6 +48,15 @@ defmodule Domain.Clients.Client.Query do
|
||||
|> distinct(true)
|
||||
end
|
||||
|
||||
def count_clients_by_actor_id(queryable \\ not_deleted()) do
|
||||
queryable
|
||||
|> group_by([clients: clients], clients.actor_id)
|
||||
|> select([clients: clients], %{
|
||||
actor_id: clients.actor_id,
|
||||
count: count(clients.id)
|
||||
})
|
||||
end
|
||||
|
||||
def returning_not_deleted(queryable) do
|
||||
select(queryable, [clients: clients], clients)
|
||||
end
|
||||
|
||||
@@ -66,8 +66,8 @@ defmodule Domain.Repo.Paginator do
|
||||
|
||||
def query(queryable, paginator_opts) do
|
||||
queryable
|
||||
|> order_by_cursor_fields(paginator_opts)
|
||||
|> maybe_query_page(paginator_opts)
|
||||
|> order_by_cursor_fields(paginator_opts)
|
||||
|> limit_page_size(paginator_opts)
|
||||
end
|
||||
|
||||
|
||||
@@ -221,14 +221,58 @@ for actor <- other_actors do
|
||||
provider_identifier_confirmation: email
|
||||
})
|
||||
|
||||
identity
|
||||
|> Ecto.Changeset.change(
|
||||
created_by: :provider,
|
||||
provider_id: oidc_provider.id,
|
||||
provider_identifier: email,
|
||||
provider_state: %{"claims" => %{"email" => email, "group" => "users"}}
|
||||
)
|
||||
|> Repo.update!()
|
||||
identity =
|
||||
identity
|
||||
|> Ecto.Changeset.change(
|
||||
created_by: :provider,
|
||||
provider_id: oidc_provider.id,
|
||||
provider_identifier: email,
|
||||
provider_state: %{"claims" => %{"email" => email, "group" => "users"}}
|
||||
)
|
||||
|> Repo.update!()
|
||||
|
||||
context = %Auth.Context{
|
||||
type: :browser,
|
||||
user_agent: "Windows/10.0.22631 seeds/1",
|
||||
remote_ip: {172, 28, 0, 100},
|
||||
remote_ip_location_region: "UA",
|
||||
remote_ip_location_city: "Kyiv",
|
||||
remote_ip_location_lat: 50.4333,
|
||||
remote_ip_location_lon: 30.5167
|
||||
}
|
||||
|
||||
{:ok, token} =
|
||||
Auth.create_token(identity, context, "n", nil)
|
||||
|
||||
{:ok, subject} = Auth.build_subject(token, context)
|
||||
|
||||
count = Enum.random([1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 240])
|
||||
|
||||
for i <- 0..count do
|
||||
user_agent =
|
||||
Enum.random([
|
||||
"iOS/12.7 (iPhone) connlib/1.5.0",
|
||||
"Android/14 connlib/1.4.1",
|
||||
"Windows/10.0.22631 connlib/1.3.412",
|
||||
"Ubuntu/22.4.0 connlib/1.2.2"
|
||||
])
|
||||
|
||||
client_name = String.split(user_agent, "/") |> List.first()
|
||||
|
||||
{:ok, _client} =
|
||||
Domain.Clients.upsert_client(
|
||||
%{
|
||||
name: "My #{client_name} #{i}",
|
||||
external_id: Ecto.UUID.generate(),
|
||||
public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(),
|
||||
identifier_for_vendor: Ecto.UUID.generate()
|
||||
},
|
||||
%{
|
||||
subject
|
||||
| context: %{subject.context | user_agent: user_agent}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Other Account Users
|
||||
@@ -432,7 +476,7 @@ end
|
||||
{:ok, finance_group} = Actors.create_group(%{name: "Finance", type: :static}, admin_subject)
|
||||
|
||||
{:ok, synced_group} =
|
||||
Actors.create_group(%{name: "Synced Group with long name", type: :static}, admin_subject)
|
||||
Actors.create_group(%{name: "Group:Synced Group with long name", type: :static}, admin_subject)
|
||||
|
||||
for group <- [eng_group, finance_group, synced_group] do
|
||||
IO.puts(" Name: #{group.name} ID: #{group.id}")
|
||||
@@ -476,6 +520,25 @@ oidc_provider
|
||||
|> Ecto.Changeset.change(last_synced_at: DateTime.utc_now())
|
||||
|> Repo.update!()
|
||||
|
||||
for name <- [
|
||||
"Group:gcp-logging-viewers",
|
||||
"Group:gcp-security-admins",
|
||||
"Group:gcp-organization-admins",
|
||||
"OU:Admins",
|
||||
"OU:Product",
|
||||
"Group:Engineering",
|
||||
"Group:gcp-developers"
|
||||
] do
|
||||
{:ok, group} = Actors.create_group(%{name: name, type: :static}, admin_subject)
|
||||
|
||||
group
|
||||
|> Repo.preload(:memberships)
|
||||
|> Actors.update_group(
|
||||
%{memberships: [%{actor_id: admin_subject.actor.id}]},
|
||||
admin_subject
|
||||
)
|
||||
end
|
||||
|
||||
IO.puts("")
|
||||
|
||||
{:ok, global_relay_group} =
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule Domain.ActorsTest do
|
||||
use Domain.DataCase, async: true
|
||||
import Domain.Actors
|
||||
alias Domain.Auth
|
||||
alias Domain.Clients
|
||||
alias Domain.Actors
|
||||
|
||||
describe "fetch_groups_count_grouped_by_provider_id/1" do
|
||||
@@ -585,6 +586,142 @@ defmodule Domain.ActorsTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "peek_actor_clients/3" do
|
||||
setup do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
|
||||
identity = Fixtures.Auth.create_identity(account: account, actor: actor)
|
||||
subject = Fixtures.Auth.create_subject(identity: identity)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
actor: actor,
|
||||
identity: identity,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
test "returns count of clients per actor and first 3 clients", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
actor1 = Fixtures.Actors.create_actor(account: account)
|
||||
Fixtures.Clients.create_client(account: account, actor: actor1)
|
||||
Fixtures.Clients.create_client(account: account, actor: actor1)
|
||||
Fixtures.Clients.create_client(account: account, actor: actor1)
|
||||
Fixtures.Clients.create_client(account: account, actor: actor1)
|
||||
|
||||
actor2 = Fixtures.Actors.create_actor(account: account)
|
||||
|
||||
assert {:ok, peek} = peek_actor_clients([actor1, actor2], 3, subject)
|
||||
|
||||
assert length(Map.keys(peek)) == 2
|
||||
|
||||
assert peek[actor1.id].count == 4
|
||||
assert length(peek[actor1.id].items) == 3
|
||||
assert [%Clients.Client{} | _] = peek[actor1.id].items
|
||||
|
||||
assert peek[actor2.id].count == 0
|
||||
assert Enum.empty?(peek[actor2.id].items)
|
||||
end
|
||||
|
||||
test "preloads client presence", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
actor = Fixtures.Actors.create_actor(account: account)
|
||||
client = Fixtures.Clients.create_client(account: account, actor: actor)
|
||||
Domain.Clients.connect_client(client)
|
||||
|
||||
assert {:ok, peek} = peek_actor_clients([actor], 3, subject)
|
||||
assert [%Clients.Client{} = client] = peek[actor.id].items
|
||||
assert client.online?
|
||||
end
|
||||
|
||||
test "returns count of clients per actor and first LIMIT clients", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
actor = Fixtures.Actors.create_actor(account: account)
|
||||
Fixtures.Clients.create_client(account: account, actor: actor)
|
||||
Fixtures.Clients.create_client(account: account, actor: actor)
|
||||
|
||||
other_actor = Fixtures.Actors.create_actor(account: account)
|
||||
Fixtures.Clients.create_client(account: account, actor: other_actor)
|
||||
|
||||
assert {:ok, peek} = peek_actor_clients([actor], 1, subject)
|
||||
assert length(peek[actor.id].items) == 1
|
||||
assert Enum.count(peek) == 1
|
||||
end
|
||||
|
||||
test "ignores deleted clients", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
actor = Fixtures.Actors.create_actor(account: account)
|
||||
|
||||
Fixtures.Clients.create_client(account: account, actor: actor)
|
||||
|> Fixtures.Clients.delete_client()
|
||||
|
||||
assert {:ok, peek} = peek_actor_clients([actor], 3, subject)
|
||||
assert peek[actor.id].count == 0
|
||||
assert Enum.empty?(peek[actor.id].items)
|
||||
end
|
||||
|
||||
test "ignores other clients", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
Fixtures.Clients.create_client(account: account)
|
||||
Fixtures.Clients.create_client(account: account)
|
||||
|
||||
actor = Fixtures.Actors.create_actor(account: account)
|
||||
|
||||
assert {:ok, peek} = peek_actor_clients([actor], 1, subject)
|
||||
assert peek[actor.id].count == 0
|
||||
assert Enum.empty?(peek[actor.id].items)
|
||||
end
|
||||
|
||||
test "returns empty map on empty actors", %{subject: subject} do
|
||||
assert peek_actor_clients([], 1, subject) == {:ok, %{}}
|
||||
end
|
||||
|
||||
test "returns empty map on empty clients", %{account: account, subject: subject} do
|
||||
actor = Fixtures.Actors.create_actor(account: account)
|
||||
|
||||
assert {:ok, peek} = peek_actor_clients([actor], 3, subject)
|
||||
|
||||
assert length(Map.keys(peek)) == 1
|
||||
assert peek[actor.id].count == 0
|
||||
assert Enum.empty?(peek[actor.id].items)
|
||||
end
|
||||
|
||||
test "does not allow peeking into other accounts", %{
|
||||
subject: subject
|
||||
} do
|
||||
other_account = Fixtures.Accounts.create_account()
|
||||
actor = Fixtures.Actors.create_actor(account: other_account)
|
||||
Fixtures.Clients.create_client(account: other_account, actor: actor)
|
||||
|
||||
assert {:ok, peek} = peek_actor_clients([actor], 3, subject)
|
||||
assert Map.has_key?(peek, actor.id)
|
||||
assert peek[actor.id].count == 0
|
||||
assert Enum.empty?(peek[actor.id].items)
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = Fixtures.Auth.remove_permissions(subject)
|
||||
|
||||
assert peek_actor_clients([], 3, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
reason: :missing_permissions,
|
||||
missing_permissions: [Domain.Clients.Authorizer.manage_clients_permission()]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "sync_provider_groups/2" do
|
||||
setup do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
|
||||
@@ -967,13 +967,14 @@ defmodule Web.CoreComponents do
|
||||
attr :datetime, DateTime, default: nil
|
||||
attr :relative_to, DateTime, required: false
|
||||
attr :negative_class, :string, default: ""
|
||||
attr :popover, :boolean, default: true
|
||||
|
||||
def relative_datetime(assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :relative_to, fn -> DateTime.utc_now() end)
|
||||
|
||||
~H"""
|
||||
<.popover :if={not is_nil(@datetime)}>
|
||||
<.popover :if={not is_nil(@datetime) and @popover}>
|
||||
<:target>
|
||||
<span class={[
|
||||
"underline underline-offset-2 decoration-1 decoration-dotted",
|
||||
@@ -987,6 +988,10 @@ defmodule Web.CoreComponents do
|
||||
<%= @datetime %>
|
||||
</:content>
|
||||
</.popover>
|
||||
<span :if={not @popover}>
|
||||
<%= Cldr.DateTime.Relative.to_string!(@datetime, Web.CLDR, relative_to: @relative_to)
|
||||
|> String.capitalize() %>
|
||||
</span>
|
||||
<span :if={is_nil(@datetime)}>
|
||||
Never
|
||||
</span>
|
||||
@@ -1056,6 +1061,17 @@ defmodule Web.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders online or offline status using an `online?` field of the schema.
|
||||
"""
|
||||
attr :schema, :any, required: true
|
||||
|
||||
def online_icon(assigns) do
|
||||
~H"""
|
||||
<span :if={@schema.online?} class="inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :navigate, :string, required: true
|
||||
attr :connected?, :boolean, required: true
|
||||
attr :type, :string, required: true
|
||||
@@ -1459,10 +1475,11 @@ defmodule Web.CoreComponents do
|
||||
"""
|
||||
|
||||
attr :color, :string, default: "info"
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def ping_icon(assigns) do
|
||||
~H"""
|
||||
<span class="relative flex h-2.5 w-2.5">
|
||||
<span class={["relative flex h-2.5 w-2.5", @class]}>
|
||||
<span class={~w[
|
||||
animate-ping absolute inline-flex
|
||||
h-full w-full rounded-full opacity-50
|
||||
|
||||
@@ -102,14 +102,18 @@ defmodule Web.NavigationComponents do
|
||||
|
||||
def sidebar(assigns) do
|
||||
~H"""
|
||||
<aside class={~w[
|
||||
fixed top-0 left-0 z-40
|
||||
w-64 h-screen
|
||||
pt-14 pb-8
|
||||
transition-transform -translate-x-full
|
||||
bg-white border-r border-neutral-200
|
||||
lg:translate-x-0
|
||||
]} aria-label="Sidenav" id="drawer-navigation">
|
||||
<aside
|
||||
class={[
|
||||
"fixed top-0 left-0 z-40 lg:z-10",
|
||||
"w-64 h-screen",
|
||||
"pt-14 pb-8",
|
||||
"transition-transform -translate-x-full",
|
||||
"bg-white border-r border-neutral-200",
|
||||
"lg:translate-x-0"
|
||||
]}
|
||||
aria-label="Sidenav"
|
||||
id="drawer-navigation"
|
||||
>
|
||||
<div class="overflow-y-auto py-1 px-2 h-full bg-white">
|
||||
<ul>
|
||||
<%= render_slot(@inner_block) %>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
defmodule Web.Actors.Index do
|
||||
use Web, :live_view
|
||||
import Web.Actors.Components
|
||||
import Web.Clients.Components
|
||||
alias Domain.Actors
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
@@ -9,7 +10,6 @@ defmodule Web.Actors.Index do
|
||||
|> assign(page_title: "Actors")
|
||||
|> assign_live_table("actors",
|
||||
query_module: Actors.Actor.Query,
|
||||
# TODO[bmanifold]: Enable this filter once we start collapsing them
|
||||
hide_filters: [:type],
|
||||
sortable_fields: [
|
||||
{:actors, :name}
|
||||
@@ -26,15 +26,21 @@ defmodule Web.Actors.Index do
|
||||
end
|
||||
|
||||
def handle_actors_update!(socket, list_opts) do
|
||||
list_opts = Keyword.put(list_opts, :preload, [:last_seen_at, identities: :provider])
|
||||
list_opts =
|
||||
Keyword.put(list_opts, :preload, [
|
||||
:last_seen_at,
|
||||
identities: :provider
|
||||
])
|
||||
|
||||
with {:ok, actors, metadata} <- Actors.list_actors(socket.assigns.subject, list_opts),
|
||||
{:ok, actor_groups} <- Actors.peek_actor_groups(actors, 3, socket.assigns.subject) do
|
||||
{:ok, actor_groups} <- Actors.peek_actor_groups(actors, 3, socket.assigns.subject),
|
||||
{:ok, actor_clients} <- Actors.peek_actor_clients(actors, 5, socket.assigns.subject) do
|
||||
{:ok,
|
||||
assign(socket,
|
||||
actors: actors,
|
||||
actors_metadata: metadata,
|
||||
actor_groups: actor_groups
|
||||
actor_groups: actor_groups,
|
||||
actor_clients: actor_clients
|
||||
)}
|
||||
end
|
||||
end
|
||||
@@ -73,7 +79,7 @@ defmodule Web.Actors.Index do
|
||||
ordered_by={@order_by_table_id["actors"]}
|
||||
metadata={@actors_metadata}
|
||||
>
|
||||
<:col :let={actor} field={{:actors, :name}} label="name">
|
||||
<:col :let={actor} field={{:actors, :name}} label="name" class="w-2/12">
|
||||
<span class="block truncate" title={actor.name}>
|
||||
<.actor_name_and_role account={@account} actor={actor} />
|
||||
</span>
|
||||
@@ -89,20 +95,60 @@ defmodule Web.Actors.Index do
|
||||
</div>
|
||||
</:col>
|
||||
|
||||
<:col :let={actor} label="groups" class="w-4/12">
|
||||
<.peek peek={@actor_groups[actor.id]}>
|
||||
<:col :let={actor} label="groups" class="w-1/12">
|
||||
<.popover placement="right">
|
||||
<:target>
|
||||
<.link
|
||||
navigate={~p"/#{@account}/actors/#{actor}?#groups"}
|
||||
class={[
|
||||
"hover:underline hover:decoration-line",
|
||||
"underline underline-offset-2 decoration-1 decoration-dotted"
|
||||
]}
|
||||
>
|
||||
<%= @actor_groups[actor.id].count %>
|
||||
</.link>
|
||||
</:target>
|
||||
<:content>
|
||||
<.peek peek={@actor_groups[actor.id]}>
|
||||
<:empty>
|
||||
None
|
||||
</:empty>
|
||||
|
||||
<:item :let={group}>
|
||||
<div class="flex flex-wrap gap-y-2 mr-2">
|
||||
<.group account={@account} group={group} />
|
||||
</div>
|
||||
</:item>
|
||||
|
||||
<:tail :let={count}>
|
||||
<span class="inline-block whitespace-nowrap">
|
||||
and <%= count %> more.
|
||||
</span>
|
||||
</:tail>
|
||||
</.peek>
|
||||
</:content>
|
||||
</.popover>
|
||||
</:col>
|
||||
|
||||
<:col :let={actor} label="clients" class="w-2/12">
|
||||
<.peek peek={@actor_clients[actor.id]}>
|
||||
<:empty>
|
||||
None
|
||||
</:empty>
|
||||
|
||||
<:item :let={group}>
|
||||
<div class="flex flex-wrap gap-y-2 mr-2">
|
||||
<.group account={@account} group={group} />
|
||||
<:item :let={client}>
|
||||
<div class="mr-2">
|
||||
<.client_as_icon client={client} />
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-y-2.5 -right-1">
|
||||
<.online_icon schema={client} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</:item>
|
||||
|
||||
<:tail :let={count}>
|
||||
<span class="inline-block whitespace-nowrap">
|
||||
<span class={["inline-block whitespace-nowrap"]}>
|
||||
and <%= count %> more.
|
||||
</span>
|
||||
</:tail>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
defmodule Web.Actors.Show do
|
||||
use Web, :live_view
|
||||
import Web.Actors.Components
|
||||
import Web.Clients.Components
|
||||
alias Domain.{Accounts, Auth, Tokens, Flows, Clients}
|
||||
alias Domain.Actors
|
||||
|
||||
@@ -467,16 +468,42 @@ defmodule Web.Actors.Show do
|
||||
ordered_by={@order_by_table_id["clients"]}
|
||||
metadata={@clients_metadata}
|
||||
>
|
||||
<:col :let={client} label="name">
|
||||
<.link navigate={~p"/#{@account}/clients/#{client.id}"} class={[link_style()]}>
|
||||
<%= client.name %>
|
||||
</.link>
|
||||
<:col :let={client} class="w-8">
|
||||
<.popover placement="right">
|
||||
<:target>
|
||||
<.client_os_icon client={client} />
|
||||
</:target>
|
||||
<:content>
|
||||
<.client_os_name_and_version client={client} />
|
||||
</:content>
|
||||
</.popover>
|
||||
</:col>
|
||||
<:col :let={client} field={{:clients, :name}} label="name">
|
||||
<div class="flex items-center space-x-1">
|
||||
<.link navigate={~p"/#{@account}/clients/#{client.id}"} class={[link_style()]}>
|
||||
<%= client.name %>
|
||||
</.link>
|
||||
<.icon
|
||||
:if={not is_nil(client.verified_at)}
|
||||
name="hero-shield-check"
|
||||
class="w-4 h-4"
|
||||
title="Device attributes of this client are manually verified"
|
||||
/>
|
||||
</div>
|
||||
</:col>
|
||||
<:col :let={client} label="status">
|
||||
<.connection_status schema={client} />
|
||||
</:col>
|
||||
<:col :let={client} field={{:clients, :last_seen_at}} label="last started">
|
||||
<.relative_datetime datetime={client.last_seen_at} />
|
||||
</:col>
|
||||
<:col :let={client} field={{:clients, :inserted_at}} label="created">
|
||||
<.relative_datetime datetime={client.inserted_at} />
|
||||
</:col>
|
||||
<:empty>
|
||||
<div class="text-center text-neutral-500 p-4">No clients to display.</div>
|
||||
<div class="text-center text-neutral-500 p-4">
|
||||
Actor has not signed in from any Client.
|
||||
</div>
|
||||
</:empty>
|
||||
</.live_table>
|
||||
</:content>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
defmodule Web.Clients.Components do
|
||||
use Web, :component_library
|
||||
import Web.CoreComponents
|
||||
|
||||
def client_os(assigns) do
|
||||
~H"""
|
||||
@@ -28,6 +29,31 @@ defmodule Web.Clients.Components do
|
||||
"""
|
||||
end
|
||||
|
||||
def client_as_icon(assigns) do
|
||||
~H"""
|
||||
<.popover placement="right">
|
||||
<:target>
|
||||
<.client_os_icon client={@client} />
|
||||
</:target>
|
||||
<:content>
|
||||
<div>
|
||||
<%= @client.name %>
|
||||
</div>
|
||||
<div>
|
||||
<.client_os_name_and_version client={@client} />
|
||||
</div>
|
||||
<div>
|
||||
<span>Last started:</span>
|
||||
<.relative_datetime datetime={@client.last_seen_at} popover={false} />
|
||||
</div>
|
||||
<div>
|
||||
<.connection_status schema={@client} />
|
||||
</div>
|
||||
</:content>
|
||||
</.popover>
|
||||
"""
|
||||
end
|
||||
|
||||
def client_os_icon_name("Windows/" <> _), do: "os-windows"
|
||||
def client_os_icon_name("Mac OS/" <> _), do: "os-macos"
|
||||
def client_os_icon_name("iOS/" <> _), do: "os-ios"
|
||||
|
||||
@@ -111,7 +111,7 @@ defmodule Web.Clients.Index do
|
||||
</:col>
|
||||
<:empty>
|
||||
<div class="text-center text-neutral-500 p-4">
|
||||
No clients to display. Clients are created automatically when a user connects to a resource.
|
||||
No Actors have signed in from any Client.
|
||||
</div>
|
||||
</:empty>
|
||||
</.live_table>
|
||||
|
||||
@@ -59,6 +59,8 @@ defmodule Web.Live.Actors.IndexTest do
|
||||
} do
|
||||
admin_actor = Fixtures.Actors.create_actor(account: account, type: :account_admin_user)
|
||||
Fixtures.Actors.create_membership(account: account, actor: admin_actor)
|
||||
client = Fixtures.Clients.create_client(account: account, actor: admin_actor)
|
||||
Domain.Clients.connect_client(client)
|
||||
admin_actor = Repo.preload(admin_actor, identities: [:provider], groups: [])
|
||||
|
||||
user_actor = Fixtures.Actors.create_actor(account: account, type: :account_user)
|
||||
@@ -84,14 +86,13 @@ defmodule Web.Live.Actors.IndexTest do
|
||||
|> render()
|
||||
|> table_to_map()
|
||||
|
||||
for {actor, name} <- [
|
||||
{admin_actor, "#{admin_actor.name} (admin)"},
|
||||
{user_actor, user_actor.name},
|
||||
{service_account_actor, "#{service_account_actor.name} (service account)"}
|
||||
for {actor, name, clients} <- [
|
||||
{admin_actor, "#{admin_actor.name} (admin)", [client]},
|
||||
{user_actor, user_actor.name, []},
|
||||
{service_account_actor, "#{service_account_actor.name} (service account)", []}
|
||||
] do
|
||||
with_table_row(rows, "name", name, fn row ->
|
||||
for identity <- actor.identities do
|
||||
assert row["identifiers"] =~ identity.provider.name
|
||||
assert row["identifiers"] =~ identity.provider_identifier
|
||||
end
|
||||
|
||||
@@ -99,7 +100,14 @@ defmodule Web.Live.Actors.IndexTest do
|
||||
assert row["groups"] =~ group.name
|
||||
end
|
||||
|
||||
assert row["last signed in"] == "Never"
|
||||
for client <- clients do
|
||||
assert row["clients"] =~ client.name
|
||||
assert row["clients"] =~ "Online"
|
||||
assert row["clients"] =~ "Apple"
|
||||
assert row["clients"] =~ "iOS 12.5"
|
||||
end
|
||||
|
||||
assert row["last signed in"]
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -70,8 +70,11 @@ defmodule Web.Live.Actors.ShowTest do
|
||||
|> render()
|
||||
|> table_to_map()
|
||||
|
||||
assert row[""] =~ "Apple iOS"
|
||||
assert row["name"] == client.name
|
||||
assert row["status"] == "Offline"
|
||||
assert row["last started"]
|
||||
assert row["created"]
|
||||
end
|
||||
|
||||
test "updates clients table using presence events", %{
|
||||
|
||||
@@ -48,7 +48,7 @@ defmodule Web.Live.Clients.IndexTest do
|
||||
|> authorize_conn(identity)
|
||||
|> live(~p"/#{account}/clients")
|
||||
|
||||
assert html =~ "No clients to display"
|
||||
assert html =~ "No Actors have signed in from any Client"
|
||||
end
|
||||
|
||||
test "renders clients table", %{
|
||||
|
||||
Reference in New Issue
Block a user