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 %>