From 9a06a9bb14a87b3c5f97750f3debee462d80c649 Mon Sep 17 00:00:00 2001 From: bmanifold Date: Tue, 18 Jul 2023 17:15:59 -0400 Subject: [PATCH] Refactor Gateway Liveviews to use real data (#1760) Why: * The previous Gateway Liveviews had used static views and data as a starting point for fleshing out the web UI. This commit builds on that and replaces (most) of the static data with data from the database, as well as updating the static Liveview templates to use components where possible. Note: These changes are only meant to involve the Gateway views (index/show/edit). More changes to other resources will follow(i.e. Resource, Users, Devices, etc...) --------- Signed-off-by: bmanifold Co-authored-by: Andrew Dryga --- elixir/apps/domain/lib/domain/gateways.ex | 21 +- elixir/apps/domain/lib/domain/resources.ex | 39 ++ .../lib/domain/resources/resource/query.ex | 19 + .../apps/domain/lib/domain/types/protocols.ex | 12 +- elixir/apps/domain/priv/repo/seeds.exs | 26 +- .../apps/domain/test/domain/gateways_test.exs | 21 + .../domain/test/domain/resources_test.exs | 173 +++++- elixir/apps/web/assets/tailwind.config.js | 74 ++- elixir/apps/web/lib/web.ex | 2 + .../web/lib/web/components/core_components.ex | 550 ++++-------------- .../web/lib/web/components/form_components.ex | 333 +++++++++++ .../lib/web/components/table_components.ex | 200 +++++++ .../web/lib/web/live/gateways_live/edit.ex | 16 +- .../web/lib/web/live/gateways_live/index.ex | 450 ++++---------- .../web/lib/web/live/gateways_live/show.ex | 136 ++--- .../identity_providers/new/components.ex | 1 + 16 files changed, 1180 insertions(+), 893 deletions(-) create mode 100644 elixir/apps/web/lib/web/components/table_components.ex diff --git a/elixir/apps/domain/lib/domain/gateways.ex b/elixir/apps/domain/lib/domain/gateways.ex index 4a01aca51..3ea1ebe41 100644 --- a/elixir/apps/domain/lib/domain/gateways.ex +++ b/elixir/apps/domain/lib/domain/gateways.ex @@ -101,12 +101,18 @@ defmodule Domain.Gateways do end end - def fetch_gateway_by_id(id, %Auth.Subject{} = subject) do + def fetch_gateway_by_id(id, %Auth.Subject{} = subject, opts \\ []) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()), true <- Validator.valid_uuid?(id) do Gateway.Query.by_id(id) |> Authorizer.for_subject(subject) |> Repo.fetch() + |> case do + {:ok, gateway} -> {:ok, Repo.preload(gateway, preload)} + {:error, reason} -> {:error, reason} + end else false -> {:error, :not_found} other -> other @@ -121,11 +127,16 @@ defmodule Domain.Gateways do |> Repo.preload(preload) end - def list_gateways(%Auth.Subject{} = subject) do + def list_gateways(%Auth.Subject{} = subject, opts \\ []) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do - Gateway.Query.all() - |> Authorizer.for_subject(subject) - |> Repo.list() + {:ok, gateways} = + Gateway.Query.all() + |> Authorizer.for_subject(subject) + |> Repo.list() + + {:ok, Repo.preload(gateways, preload)} end end diff --git a/elixir/apps/domain/lib/domain/resources.ex b/elixir/apps/domain/lib/domain/resources.ex index 144fa9782..65345b57a 100644 --- a/elixir/apps/domain/lib/domain/resources.ex +++ b/elixir/apps/domain/lib/domain/resources.ex @@ -1,5 +1,6 @@ defmodule Domain.Resources do alias Domain.{Repo, Validator, Auth} + alias Domain.Gateways alias Domain.Resources.{Authorizer, Resource} def fetch_resource_by_id(id, %Auth.Subject{} = subject) do @@ -46,6 +47,44 @@ defmodule Domain.Resources do end end + def count_resources_for_gateway(%Gateways.Gateway{} = gateway, %Auth.Subject{} = subject) do + required_permissions = + {:one_of, + [ + Authorizer.manage_resources_permission(), + Authorizer.view_available_resources_permission() + ]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do + count = + Resource.Query.all() + |> Authorizer.for_subject(subject) + |> Resource.Query.by_gateway_group_id(gateway.group_id) + |> Repo.aggregate(:count) + + {:ok, count} + end + end + + def list_resources_for_gateway(%Gateways.Gateway{} = gateway, %Auth.Subject{} = subject) do + required_permissions = + {:one_of, + [ + Authorizer.manage_resources_permission(), + Authorizer.view_available_resources_permission() + ]} + + with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do + resources = + Resource.Query.all() + |> Resource.Query.by_account_id(subject.account.id) + |> Resource.Query.by_gateway_group_id(gateway.group_id) + |> Repo.all() + + {:ok, resources} + end + end + def create_resource(attrs, %Auth.Subject{} = subject) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do changeset = Resource.Changeset.create_changeset(subject.account, attrs) diff --git a/elixir/apps/domain/lib/domain/resources/resource/query.ex b/elixir/apps/domain/lib/domain/resources/resource/query.ex index a6f74db8e..e1578fbf8 100644 --- a/elixir/apps/domain/lib/domain/resources/resource/query.ex +++ b/elixir/apps/domain/lib/domain/resources/resource/query.ex @@ -13,4 +13,23 @@ defmodule Domain.Resources.Resource.Query do def by_account_id(queryable \\ all(), account_id) do where(queryable, [resources: resources], resources.account_id == ^account_id) end + + def by_gateway_group_id(queryable \\ all(), gateway_group_id) do + queryable + |> with_joined_connections() + |> where([connections: connections], connections.gateway_group_id == ^gateway_group_id) + end + + def with_joined_connections(queryable \\ all()) do + with_named_binding(queryable, :connections, fn queryable, binding -> + queryable + |> join( + :inner, + [resources: resources], + connections in ^Domain.Resources.Connection.Query.all(), + on: connections.resource_id == resources.id, + as: ^binding + ) + end) + end end diff --git a/elixir/apps/domain/lib/domain/types/protocols.ex b/elixir/apps/domain/lib/domain/types/protocols.ex index 1805c10b2..df6c39112 100644 --- a/elixir/apps/domain/lib/domain/types/protocols.ex +++ b/elixir/apps/domain/lib/domain/types/protocols.ex @@ -2,9 +2,9 @@ defimpl String.Chars, for: Postgrex.INET do def to_string(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet) end -# defimpl Phoenix.HTML.Safe, for: Postgrex.INET do -# def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet) -# end +defimpl Phoenix.HTML.Safe, for: Postgrex.INET do + def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet) +end defimpl Jason.Encoder, for: Postgrex.INET do def encode(%Postgrex.INET{} = struct, opts) do @@ -16,6 +16,6 @@ defimpl String.Chars, for: Domain.Types.IPPort do def to_string(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port) end -# defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do -# def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port) -# end +defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do + def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port) +end diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index 5c1b4e0b4..f8183acf7 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -171,7 +171,7 @@ IO.puts("Created gateway groups:") IO.puts(" #{gateway_group.name_prefix} token: #{Gateways.encode_token!(gateway_group_token)}") IO.puts("") -{:ok, gateway} = +{:ok, gateway1} = Gateways.upsert_gateway(gateway_group_token, %{ external_id: Ecto.UUID.generate(), name_suffix: "gw-#{Domain.Crypto.rand_string(5)}", @@ -180,12 +180,28 @@ IO.puts("") last_seen_remote_ip: %Postgrex.INET{address: {189, 172, 73, 153}} }) +{:ok, gateway2} = + Gateways.upsert_gateway(gateway_group_token, %{ + external_id: Ecto.UUID.generate(), + name_suffix: "gw-#{Domain.Crypto.rand_string(5)}", + public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(), + last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412", + last_seen_remote_ip: %Postgrex.INET{address: {164, 112, 78, 62}} + }) + IO.puts("Created gateways:") -gateway_name = "#{gateway_group.name_prefix}-#{gateway.name_suffix}" +gateway_name = "#{gateway_group.name_prefix}-#{gateway1.name_suffix}" IO.puts(" #{gateway_name}:") -IO.puts(" External UUID: #{gateway.external_id}") -IO.puts(" Public Key: #{gateway.public_key}") -IO.puts(" IPv4: #{gateway.ipv4} IPv6: #{gateway.ipv6}") +IO.puts(" External UUID: #{gateway1.external_id}") +IO.puts(" Public Key: #{gateway1.public_key}") +IO.puts(" IPv4: #{gateway1.ipv4} IPv6: #{gateway1.ipv6}") +IO.puts("") + +gateway_name = "#{gateway_group.name_prefix}-#{gateway2.name_suffix}" +IO.puts(" #{gateway_name}:") +IO.puts(" External UUID: #{gateway1.external_id}") +IO.puts(" Public Key: #{gateway2.public_key}") +IO.puts(" IPv4: #{gateway2.ipv4} IPv6: #{gateway2.ipv6}") IO.puts("") {:ok, dns_resource} = diff --git a/elixir/apps/domain/test/domain/gateways_test.exs b/elixir/apps/domain/test/domain/gateways_test.exs index 02588ab13..da2294713 100644 --- a/elixir/apps/domain/test/domain/gateways_test.exs +++ b/elixir/apps/domain/test/domain/gateways_test.exs @@ -400,6 +400,15 @@ defmodule Domain.GatewaysTest do {:unauthorized, [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} end + + # TODO: add a test that soft-deleted assocs are not preloaded + test "associations are preloaded when opts given", %{account: account, subject: subject} do + gateway = GatewaysFixtures.create_gateway(account: account) + {:ok, gateway} = fetch_gateway_by_id(gateway.id, subject, preload: [:group, :account]) + + assert Ecto.assoc_loaded?(gateway.group) == true + assert Ecto.assoc_loaded?(gateway.account) == true + end end describe "list_gateways/1" do @@ -438,6 +447,18 @@ defmodule Domain.GatewaysTest do {:unauthorized, [missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}} end + + # TODO: add a test that soft-deleted assocs are not preloaded + test "associations are preloaded when opts given", %{account: account, subject: subject} do + GatewaysFixtures.create_gateway(account: account) + GatewaysFixtures.create_gateway(account: account) + + {:ok, gateways} = list_gateways(subject, preload: [:group, :account]) + assert length(gateways) == 2 + + assert Enum.all?(gateways, fn g -> Ecto.assoc_loaded?(g.group) end) == true + assert Enum.all?(gateways, fn g -> Ecto.assoc_loaded?(g.account) end) == true + end end describe "list_connected_gateways_for_resource/1" do diff --git a/elixir/apps/domain/test/domain/resources_test.exs b/elixir/apps/domain/test/domain/resources_test.exs index b115944be..c1d8b4538 100644 --- a/elixir/apps/domain/test/domain/resources_test.exs +++ b/elixir/apps/domain/test/domain/resources_test.exs @@ -29,18 +29,15 @@ defmodule Domain.ResourcesTest do end test "returns resource when resource exists", %{account: account, subject: subject} do - gateway = GatewaysFixtures.create_gateway(account: account) - resource = ResourcesFixtures.create_resource(account: account, gateway: gateway) + resource = ResourcesFixtures.create_resource(account: account) assert {:ok, fetched_resource} = fetch_resource_by_id(resource.id, subject) assert fetched_resource.id == resource.id end test "does not return deleted resources", %{account: account, subject: subject} do - gateway = GatewaysFixtures.create_gateway(account: account) - {:ok, resource} = - ResourcesFixtures.create_resource(account: account, gateway: gateway) + ResourcesFixtures.create_resource(account: account) |> delete_resource(subject) assert fetch_resource_by_id(resource.id, subject) == {:error, :not_found} @@ -138,6 +135,172 @@ defmodule Domain.ResourcesTest do end end + describe "list_resources_for_gateway/2" do + test "returns empty list when there are no resources associated to gateway", %{ + account: account, + subject: subject + } do + gateway = GatewaysFixtures.create_gateway(account: account) + + assert list_resources_for_gateway(gateway, subject) == {:ok, []} + end + + test "does not list resources that are not associated to the gateway", %{ + account: account, + subject: subject + } do + gateway = GatewaysFixtures.create_gateway(account: account) + ResourcesFixtures.create_resource() + + assert list_resources_for_gateway(gateway, subject) == {:ok, []} + end + + test "does not list deleted resources associated to gateway", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + |> delete_resource(subject) + + assert list_resources_for_gateway(gateway, subject) == {:ok, []} + end + + test "returns all resources for a given gateway and account user subject", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + ResourcesFixtures.create_resource(account: account) + + assert {:ok, resources} = list_resources_for_gateway(gateway, subject) + assert length(resources) == 2 + end + + test "returns error when subject has no permission to manage resources", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + subject = AuthFixtures.remove_permissions(subject) + + assert list_resources_for_gateway(gateway, subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, + [ + Resources.Authorizer.manage_resources_permission(), + Resources.Authorizer.view_available_resources_permission() + ]} + ] + ]}} + end + end + + describe "count_resources_for_gateway/2" do + test "returns zero when there are no resources associated to gateway", %{ + account: account, + subject: subject + } do + gateway = GatewaysFixtures.create_gateway(account: account) + + assert count_resources_for_gateway(gateway, subject) == {:ok, 0} + end + + test "does not count resources that are not associated to the gateway", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + ResourcesFixtures.create_resource(account: account) + + assert count_resources_for_gateway(gateway, subject) == {:ok, 1} + end + + test "does not count deleted resources associated to gateway", %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + |> delete_resource(subject) + + assert count_resources_for_gateway(gateway, subject) == {:ok, 1} + end + + test "returns error when subject has no permission to manage resources", + %{ + account: account, + subject: subject + } do + group = GatewaysFixtures.create_group(account: account, subject: subject) + gateway = GatewaysFixtures.create_gateway(account: account, group: group) + + ResourcesFixtures.create_resource( + account: account, + gateway_groups: [%{gateway_group_id: group.id}] + ) + + subject = AuthFixtures.remove_permissions(subject) + + assert count_resources_for_gateway(gateway, subject) == + {:error, + {:unauthorized, + [ + missing_permissions: [ + {:one_of, + [ + Resources.Authorizer.manage_resources_permission(), + Resources.Authorizer.view_available_resources_permission() + ]} + ] + ]}} + end + end + describe "create_resource/2" do test "returns changeset error on empty attrs", %{subject: subject} do assert {:error, changeset} = create_resource(%{}, subject) diff --git a/elixir/apps/web/assets/tailwind.config.js b/elixir/apps/web/assets/tailwind.config.js index 040715e44..58bb37e23 100644 --- a/elixir/apps/web/assets/tailwind.config.js +++ b/elixir/apps/web/assets/tailwind.config.js @@ -6,6 +6,54 @@ const fs = require("fs") const path = require("path") const defaultTheme = require("tailwindcss/defaultTheme") + +const firezoneColors = { + // See our brand palette in Figma. + // These have been reversed to match Tailwind's default order. + + // primary: orange + "heat-wave": { + 50: "#fff9f5", + 100: "#fff1e5", + 200: "#ffddc2", + 300: "#ffbc85", + 400: "#ff9a47", + 450: "#ff7300", + 500: "#ff7605", + 600: "#c25700", + 700: "#7f3900", + 800: "#5c2900", + 900: "#331700", + }, + // accent: violet + "electric-violet": { + 50: "#f8f5ff", + 100: "#ece5ff", + 200: "#d2c2ff", + 300: "#a585ff", + 400: "#7847ff", + 450: "#5e00d6", + 500: "#4805ff", + 600: "#3400c2", + 700: "#37007f", + 800: "#28005c", + 900: "#160033", + }, + // neutral: night-rider + "night-rider": { + 50: "#fcfcfc", + 100: "#f8f7f7", + 200: "#ebebea", + 300: "#dfdedd", + 400: "#c7c4c2", + 500: "#a7a3a0", + 600: "#90867f", + 700: "#766a60", + 800: "#4c3e33", + 900: "#1b140e", + }, +}; + module.exports = { // Use "media" to synchronize dark mode with the OS, "class" to require manual toggle darkMode: "class", @@ -22,17 +70,21 @@ module.exports = { extend: { colors: { brand: "#FD4F00", - primary: { - "50": "#eff6ff", - "100": "#dbeafe", - "200": "#bfdbfe", - "300": "#93c5fd", - "400": "#60a5fa", - "500": "#3b82f6", - "600": "#2563eb", - "700": "#1d4ed8", - "800": "#1e40af", "900": "#1e3a8a" - } + primary: firezoneColors["heat-wave"], + accent: firezoneColors["electric-violet"], + neutral: firezoneColors["night-rider"] + //primary: { + // "50": "#eff6ff", + // "100": "#dbeafe", + // "200": "#bfdbfe", + // "300": "#93c5fd", + // "400": "#60a5fa", + // "500": "#3b82f6", + // "600": "#2563eb", + // "700": "#1d4ed8", + // "800": "#1e40af", + // "900": "#1e3a8a" + //} } }, }, diff --git a/elixir/apps/web/lib/web.ex b/elixir/apps/web/lib/web.ex index ae2046523..06d89a9ed 100644 --- a/elixir/apps/web/lib/web.ex +++ b/elixir/apps/web/lib/web.ex @@ -99,6 +99,8 @@ defmodule Web do import Phoenix.HTML # Core UI components and translation import Web.CoreComponents + import Web.FormComponents + import Web.TableComponents import Web.Gettext # Shortcut for generating JS commands diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index 4d5fe52d5..6299e005a 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -94,7 +94,10 @@ defmodule Web.CoreComponents do <%= for tab <- @tab do %>