mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat(portal): Allow filtering clients by presence and deleting them (#7078)
Closes #7073 <img width="1434" alt="Screenshot 2024-10-16 at 12 40 50 PM" src="https://github.com/user-attachments/assets/2c03f38c-c67e-49db-9453-e23651c8d61c"> <img width="1154" alt="Screenshot 2024-10-16 at 12 47 37 PM" src="https://github.com/user-attachments/assets/da519458-1447-4dfe-9cef-536bf7760ce2">
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -606,14 +606,33 @@ defmodule Web.CoreComponents do
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 30 30"
|
||||
viewBox="0 0 24 24"
|
||||
class={["inline-block", @class]}
|
||||
{@rest}
|
||||
>
|
||||
<path d="M1,15H12a1,1,0,0,0,1-1V4.17a1,1,0,0,0-.35-.77,1,1,0,0,0-.81-.22L.84,5A1,1,0,0,0,0,6v8A1,1,0,0,0,1,15ZM2,6.85l9-1.5V13H2Z" />
|
||||
<path d="M30.84,0l-15,2.5a1,1,0,0,0-.84,1V14a1,1,0,0,0,1,1H31a1,1,0,0,0,1-1V1a1,1,0,0,0-.35-.76A1,1,0,0,0,30.84,0ZM30,13H17V4.35L30,2.18Z" />
|
||||
<path d="M.84,27l11,1.83H12a1,1,0,0,0,1-1V18a1,1,0,0,0-1-1H1a1,1,0,0,0-1,1v8A1,1,0,0,0,.84,27ZM2,19h9v7.65l-9-1.5Z" />
|
||||
<path d="M31,17H16a1,1,0,0,0-1,1V28.5a1,1,0,0,0,.84,1l15,2.5H31a1,1,0,0,0,.65-.24A1,1,0,0,0,32,31V18A1,1,0,0,0,31,17ZM30,29.82,17,27.65V19H30Z" />
|
||||
<g xmlns="http://www.w3.org/2000/svg" data-name="<Group>" id="_Group_">
|
||||
<polygon
|
||||
data-name="<Path>"
|
||||
id="_Path_"
|
||||
points="12.5 10.5 22.5 10.5 22.5 1.5 12.5 2.69 12.5 10.5"
|
||||
style="fill:none;stroke:#303c42;stroke-linecap:round;stroke-linejoin:round"
|
||||
/><polygon
|
||||
data-name="<Path>"
|
||||
id="_Path_2"
|
||||
points="9.5 10.5 9.5 3.05 1.5 4 1.5 10.5 9.5 10.5"
|
||||
style="fill:none;stroke:#303c42;stroke-linecap:round;stroke-linejoin:round"
|
||||
/><polygon
|
||||
data-name="<Path>"
|
||||
id="_Path_3"
|
||||
points="9.5 13.5 1.5 13.5 1.5 20 9.5 20.95 9.5 13.5"
|
||||
style="fill:none;stroke:#303c42;stroke-linecap:round;stroke-linejoin:round"
|
||||
/><polygon
|
||||
data-name="<Path>"
|
||||
id="_Path_4"
|
||||
points="12.5 13.5 12.5 21.31 22.5 22.5 22.5 13.5 12.5 13.5"
|
||||
style="fill:none;stroke:#303c42;stroke-linecap:round;stroke-linejoin:round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
<:col :let={client} field={{:clients, :last_seen_at}} label="last started at">
|
||||
<.relative_datetime datetime={client.last_seen_at} />
|
||||
</:col>
|
||||
<:col :let={client} field={{:clients, :inserted_at}} label="created at">
|
||||
<.relative_datetime datetime={client.inserted_at} />
|
||||
</:col>
|
||||
|
||||
@@ -334,6 +334,40 @@ defmodule Web.Clients.Show do
|
||||
</.live_table>
|
||||
</:content>
|
||||
</.section>
|
||||
|
||||
<.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_title>
|
||||
<:dialog_content>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p class="mt-2">
|
||||
To prevent the client owner from logging in again,
|
||||
<.link navigate={~p"/#{@account}/actors/#{@client.actor_id}"} class={link_style()}>
|
||||
disable the owning actor
|
||||
</.link>
|
||||
instead.
|
||||
</p>
|
||||
</:dialog_content>
|
||||
<:dialog_confirm_button>
|
||||
Delete Client
|
||||
</:dialog_confirm_button>
|
||||
<:dialog_cancel_button>
|
||||
Cancel
|
||||
</:dialog_cancel_button>
|
||||
Delete Client
|
||||
</.button_with_confirmation>
|
||||
</:action>
|
||||
</.danger_zone>
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -116,20 +116,15 @@ defmodule Web.Gateways.Show do
|
||||
>
|
||||
<:dialog_title>Confirm deletion of Gateway</:dialog_title>
|
||||
<:dialog_content>
|
||||
<p>
|
||||
Are you sure you want to delete this Gateway?
|
||||
</p>
|
||||
<p class="mt-4 text-sm">
|
||||
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
|
||||
</.link>
|
||||
page if you want to prevent the gateway from connecting to the portal.
|
||||
</p>
|
||||
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
|
||||
</.link>
|
||||
page if you want to prevent the gateway from connecting to the portal.
|
||||
</:dialog_content>
|
||||
<: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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -271,7 +271,6 @@ defmodule Web.ConnCase do
|
||||
end
|
||||
|
||||
defp reject_tooltips(other) do
|
||||
dbg(other)
|
||||
other
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user