From 37ef2cb591d29b708de617bad1f2fa1bf3ef89a7 Mon Sep 17 00:00:00 2001 From: Andrew Dryga Date: Wed, 16 Oct 2024 13:29:24 -0600 Subject: [PATCH] feat(portal): Allow filtering clients by presence and deleting them (#7078) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #7073 Screenshot 2024-10-16 at 12 40 50 PM Screenshot 2024-10-16 at 12 47 37 PM --- elixir/apps/domain/lib/domain/clients.ex | 15 +++-- .../domain/lib/domain/clients/client/query.ex | 44 ++++++++++++++ elixir/apps/domain/priv/repo/seeds.exs | 57 +++++++++++++++++-- .../web/lib/web/components/core_components.ex | 29 ++++++++-- elixir/apps/web/lib/web/live/actors/show.ex | 2 +- elixir/apps/web/lib/web/live/clients/index.ex | 7 ++- elixir/apps/web/lib/web/live/clients/show.ex | 49 +++++++++++++++- elixir/apps/web/lib/web/live/gateways/show.ex | 29 ++++------ elixir/apps/web/lib/web/live_table.ex | 31 +++++++++- elixir/apps/web/test/support/conn_case.ex | 1 - .../web/test/web/live/clients/show_test.exs | 20 +++++++ 11 files changed, 248 insertions(+), 36 deletions(-) diff --git a/elixir/apps/domain/lib/domain/clients.ex b/elixir/apps/domain/lib/domain/clients.ex index 576f598ed..cb9c03dc0 100644 --- a/elixir/apps/domain/lib/domain/clients.ex +++ b/elixir/apps/domain/lib/domain/clients.ex @@ -123,16 +123,23 @@ defmodule Domain.Clients do |> Enum.map(& &1.account_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - |> Enum.reduce(%{}, fn account_id, acc -> - connected_clients = account_id |> account_clients_presence_topic() |> Presence.list() - Map.merge(acc, connected_clients) + |> Enum.reduce([], fn account_id, acc -> + connected_client_ids = online_client_ids(account_id) + connected_client_ids ++ acc end) Enum.map(clients, fn client -> - %{client | online?: Map.has_key?(connected_clients, client.id)} + %{client | online?: client.id in connected_clients} end) end + def online_client_ids(account_id) do + account_id + |> account_clients_presence_topic() + |> Presence.list() + |> Map.keys() + end + def change_client(%Client{} = client, attrs \\ %{}) do Client.Changeset.update(client, attrs) end diff --git a/elixir/apps/domain/lib/domain/clients/client/query.ex b/elixir/apps/domain/lib/domain/clients/client/query.ex index 2ba60b51f..23c42d32e 100644 --- a/elixir/apps/domain/lib/domain/clients/client/query.ex +++ b/elixir/apps/domain/lib/domain/clients/client/query.ex @@ -123,6 +123,16 @@ defmodule Domain.Clients.Client.Query do ], fun: &filter_by_verification/2 }, + %Domain.Repo.Filter{ + name: :presence, + title: "Presence", + type: :string, + values: [ + {"Online", "online"}, + {"Offline", "offline"} + ], + fun: &filter_by_presence/2 + }, %Domain.Repo.Filter{ name: :client_or_actor_name, title: "Client Name or Actor Name", @@ -143,6 +153,40 @@ defmodule Domain.Clients.Client.Query do {queryable, dynamic([clients: clients], is_nil(clients.verified_at))} end + def filter_by_presence(queryable, "online") do + ids = + queryable + |> fetch_queried_account_id() + |> Domain.Clients.online_client_ids() + + {queryable, dynamic([clients: clients], clients.id in ^ids)} + end + + def filter_by_presence(queryable, "offline") do + ids = + queryable + |> fetch_queried_account_id() + |> Domain.Clients.online_client_ids() + + {queryable, dynamic([clients: clients], clients.id not in ^ids)} + end + + # there is no easy way to pass additional data to our filters right now so we + # extract the account_id from the queryable instead + defp fetch_queried_account_id(queryable) do + Enum.find_value(queryable.wheres, fn + %Ecto.Query.BooleanExpr{ + op: :and, + expr: {:==, _, [{{_, _, [_, :account_id]}, _, _}, {:^, _, [b_idx]}]}, + params: [{account_id, {b_idx, :account_id}}] + } -> + account_id + + _ -> + nil + end) + end + def filter_by_client_or_actor_name(queryable, name) do queryable = with_named_binding(queryable, :actor, fn queryable, binding -> diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index 11c46a8bd..ba8dbc4c8 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -348,10 +348,57 @@ IO.puts("") name: "FZ User iPhone", external_id: Ecto.UUID.generate(), public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(), - last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412", identifier_for_vendor: "APPL-#{Ecto.UUID.generate()}" }, - unprivileged_subject + %{ + unprivileged_subject + | context: %{unprivileged_subject.context | user_agent: "iOS/12.7 (iPhone) connlib/0.7.412"} + } + ) + +{:ok, _user_android_phone} = + Domain.Clients.upsert_client( + %{ + name: "FZ User Android", + external_id: Ecto.UUID.generate(), + public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(), + identifier_for_vendor: "GOOG-#{Ecto.UUID.generate()}" + }, + %{ + unprivileged_subject + | context: %{unprivileged_subject.context | user_agent: "Android/14 connlib/0.7.412"} + } + ) + +{:ok, _user_windows_laptop} = + Domain.Clients.upsert_client( + %{ + name: "FZ User Surface", + external_id: Ecto.UUID.generate(), + public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(), + device_uuid: "WIN-#{Ecto.UUID.generate()}" + }, + %{ + unprivileged_subject + | context: %{ + unprivileged_subject.context + | user_agent: "Windows/10.0.22631 connlib/0.7.412" + } + } + ) + +{:ok, _user_linux_laptop} = + Domain.Clients.upsert_client( + %{ + name: "FZ User Rendering Station", + external_id: Ecto.UUID.generate(), + public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(), + device_uuid: "UB-#{Ecto.UUID.generate()}" + }, + %{ + unprivileged_subject + | context: %{unprivileged_subject.context | user_agent: "Ubuntu/22.4.0 connlib/0.7.412"} + } ) {:ok, _admin_iphone} = @@ -360,11 +407,13 @@ IO.puts("") name: "FZ Admin Laptop", external_id: Ecto.UUID.generate(), public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(), - last_seen_user_agent: "Mac OS/14.5 connlib/0.7.412", device_serial: "FVFHF246Q72Z", device_uuid: "#{Ecto.UUID.generate()}" }, - admin_subject + %{ + admin_subject + | context: %{admin_subject.context | user_agent: "Mac OS/14.5 connlib/0.7.412"} + } ) IO.puts("Clients created") diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index 2b5b0f17f..12a91cc30 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -606,14 +606,33 @@ defmodule Web.CoreComponents do - - - - + + + """ end diff --git a/elixir/apps/web/lib/web/live/actors/show.ex b/elixir/apps/web/lib/web/live/actors/show.ex index ecb11b0d1..4f781f5a6 100644 --- a/elixir/apps/web/lib/web/live/actors/show.ex +++ b/elixir/apps/web/lib/web/live/actors/show.ex @@ -31,7 +31,7 @@ defmodule Web.Actors.Show do |> assign_live_table("clients", query_module: Clients.Client.Query, sortable_fields: [], - hide_filters: [:client_or_actor_name], + hide_filters: [:client_or_actor_name, :presence, :verification], callback: &handle_clients_update!/2 ) |> assign_live_table("flows", diff --git a/elixir/apps/web/lib/web/live/clients/index.ex b/elixir/apps/web/lib/web/live/clients/index.ex index 5f86a2ee5..de59d882d 100644 --- a/elixir/apps/web/lib/web/live/clients/index.ex +++ b/elixir/apps/web/lib/web/live/clients/index.ex @@ -16,7 +16,9 @@ defmodule Web.Clients.Index do query_module: Clients.Client.Query, sortable_fields: [ {:clients, :name}, - {:clients, :inserted_at} + {:clients, :last_seen_at}, + {:clients, :inserted_at}, + {:clients, :last_seen_user_agent} ], hide_filters: [ :name @@ -94,6 +96,9 @@ defmodule Web.Clients.Index do <:col :let={client} label="status"> <.connection_status schema={client} /> + <:col :let={client} field={{:clients, :last_seen_at}} label="last started at"> + <.relative_datetime datetime={client.last_seen_at} /> + <:col :let={client} field={{:clients, :inserted_at}} label="created at"> <.relative_datetime datetime={client.inserted_at} /> diff --git a/elixir/apps/web/lib/web/live/clients/show.ex b/elixir/apps/web/lib/web/live/clients/show.ex index f4fe29942..ef1adda4a 100644 --- a/elixir/apps/web/lib/web/live/clients/show.ex +++ b/elixir/apps/web/lib/web/live/clients/show.ex @@ -334,6 +334,40 @@ defmodule Web.Clients.Show do + + <.danger_zone :if={is_nil(@client.deleted_at)}> + <:action> + <.button_with_confirmation + id="delete_client" + style="danger" + icon="hero-trash-solid" + on_confirm="delete" + > + <:dialog_title>Confirm deletion of client + <:dialog_content> +

+ Deleting the client doesn't remove it from the device; it will be re-created with the same + hardware attributes upon the next sign-in, but the verification status won't carry over. +

+ +

+ To prevent the client owner from logging in again, + <.link navigate={~p"/#{@account}/actors/#{@client.actor_id}"} class={link_style()}> + disable the owning actor + + instead. +

+ + <:dialog_confirm_button> + Delete Client + + <:dialog_cancel_button> + Cancel + + Delete Client + + + """ end @@ -411,6 +445,9 @@ defmodule Web.Clients.Show do {:noreply, socket} end + def handle_event(event, params, socket) when event in ["paginate", "order_by", "filter"], + do: handle_live_table_event(event, params, socket) + def handle_event("verify_client", _params, socket) do {:ok, client} = Clients.verify_client(socket.assigns.client, socket.assigns.subject) @@ -438,6 +475,14 @@ defmodule Web.Clients.Show do {:noreply, assign(socket, :client, client)} end - def handle_event(event, params, socket) when event in ["paginate", "order_by", "filter"], - do: handle_live_table_event(event, params, socket) + def handle_event("delete", _params, socket) do + {:ok, _client} = Clients.delete_client(socket.assigns.client, socket.assigns.subject) + + socket = + socket + |> put_flash(:info, "Client was deleted.") + |> push_navigate(to: ~p"/#{socket.assigns.account}/clients") + + {:noreply, socket} + end end diff --git a/elixir/apps/web/lib/web/live/gateways/show.ex b/elixir/apps/web/lib/web/live/gateways/show.ex index 65e0b43d0..a952e6aeb 100644 --- a/elixir/apps/web/lib/web/live/gateways/show.ex +++ b/elixir/apps/web/lib/web/live/gateways/show.ex @@ -116,20 +116,15 @@ defmodule Web.Gateways.Show do > <:dialog_title>Confirm deletion of Gateway <:dialog_content> -

- Are you sure you want to delete this Gateway? -

-

- Deleting the gateway does not remove it's access token so it can be re-created again, - revoke the token on the - <.link - navigate={~p"/#{@account}/sites/#{@gateway.group}"} - class={["font-medium", link_style()]} - > - site - - page if you want to prevent the gateway from connecting to the portal. -

+ Deleting the gateway does not remove it's access token so it can be re-created again, + revoke the token on the + <.link + navigate={~p"/#{@account}/sites/#{@gateway.group}"} + class={["font-medium", link_style()]} + > + site + + page if you want to prevent the gateway from connecting to the portal. <:dialog_confirm_button> Delete Gateway @@ -175,9 +170,9 @@ defmodule Web.Gateways.Show do {:ok, _gateway} = Gateways.delete_gateway(socket.assigns.gateway, socket.assigns.subject) socket = - push_navigate(socket, - to: ~p"/#{socket.assigns.account}/sites/#{socket.assigns.gateway.group}" - ) + socket + |> put_flash(:info, "Gateway was deleted.") + |> push_navigate(to: ~p"/#{socket.assigns.account}/sites/#{socket.assigns.gateway.group}") {:noreply, socket} end diff --git a/elixir/apps/web/lib/web/live_table.ex b/elixir/apps/web/lib/web/live_table.ex index e04fbea0a..97651c694 100644 --- a/elixir/apps/web/lib/web/live_table.ex +++ b/elixir/apps/web/lib/web/live_table.ex @@ -468,6 +468,13 @@ defmodule Web.LiveTable do assign(socket, live_table_ids: [id] ++ (socket.assigns[:live_table_ids] || []), + query_module_by_table_id: + put_table_state( + socket, + id, + :query_module_by_table_id, + query_module + ), callback_by_table_id: put_table_state( socket, @@ -496,6 +503,13 @@ defmodule Web.LiveTable do :enforced_filters_by_table_id, enforce_filters ), + order_by_table_id: + put_table_state( + socket, + id, + :order_by_table_id, + maybe_use_default_order_by(query_module) + ), limit_by_table_id: put_table_state(socket, id, :limit_by_table_id, limit) ) end @@ -559,6 +573,7 @@ defmodule Web.LiveTable do end defp handle_live_table_params(socket, params, id) do + query_module = Map.fetch!(socket.assigns.query_module_by_table_id, id) enforced_filters = Map.fetch!(socket.assigns.enforced_filters_by_table_id, id) sortable_fields = Map.fetch!(socket.assigns.sortable_fields_by_table_id, id) limit = Map.fetch!(socket.assigns.limit_by_table_id, id) @@ -589,7 +604,7 @@ defmodule Web.LiveTable do socket, id, :order_by_table_id, - order_by + maybe_use_default_order_by(query_module, order_by) ), list_opts_by_table_id: put_table_state( @@ -626,6 +641,20 @@ defmodule Web.LiveTable do end end + defp maybe_use_default_order_by(query_module, order_by \\ nil) + + defp maybe_use_default_order_by(query_module, nil) do + if function_exported?(query_module, :cursor_fields, 0) do + query_module.cursor_fields() |> List.first() + else + [] + end + end + + defp maybe_use_default_order_by(_query_module, order_by) do + order_by + end + defp reset_live_table_params(socket, id, message) do {:noreply, socket} = socket diff --git a/elixir/apps/web/test/support/conn_case.ex b/elixir/apps/web/test/support/conn_case.ex index 4b61330a7..53794866a 100644 --- a/elixir/apps/web/test/support/conn_case.ex +++ b/elixir/apps/web/test/support/conn_case.ex @@ -271,7 +271,6 @@ defmodule Web.ConnCase do end defp reject_tooltips(other) do - dbg(other) other end diff --git a/elixir/apps/web/test/web/live/clients/show_test.exs b/elixir/apps/web/test/web/live/clients/show_test.exs index 5c570ebdd..5429e96e9 100644 --- a/elixir/apps/web/test/web/live/clients/show_test.exs +++ b/elixir/apps/web/test/web/live/clients/show_test.exs @@ -330,4 +330,24 @@ defmodule Web.Live.Clients.ShowTest do assert table["verification"] =~ "Verified" assert table["verification"] =~ "by" end + + test "allows deleting clients", %{ + account: account, + client: client, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/clients/#{client}") + + lv + |> element("button[type=submit]", "Delete Client") + |> render_click() + + assert_redirected(lv, ~p"/#{account}/clients") + + assert Repo.get(Domain.Clients.Client, client.id).deleted_at + end end