From 7fda4c52c43521f04714870e417eaa889f270f0b Mon Sep 17 00:00:00 2001 From: Brian Manifold Date: Fri, 11 Oct 2024 05:46:00 -0700 Subject: [PATCH] feat(portal): Add outdated gateway notifications (#6841) Why: * Without some type of notification, users do not realize that new Gateway versions have been released and thus do not seem to be upgrading their deployed Gateways. --- elixir/apps/domain/lib/domain/accounts.ex | 21 +++ .../domain/lib/domain/accounts/account.ex | 3 +- .../lib/domain/accounts/account/query.ex | 30 +++- .../apps/domain/lib/domain/accounts/config.ex | 9 ++ .../lib/domain/accounts/config/changeset.ex | 14 +- .../accounts/config/notifications/email.ex | 10 ++ .../config/notifications/email/changeset.ex | 9 ++ elixir/apps/domain/lib/domain/application.ex | 2 + .../domain/lib/domain/component_versions.ex | 88 ++++++++++++ .../domain/component_versions/refresher.ex | 57 ++++++++ elixir/apps/domain/lib/domain/gateways.ex | 25 ++++ .../domain/lib/domain/mailer/notifications.ex | 19 +++ .../notifications/outdated_gateway.html.eex | 126 ++++++++++++++++ .../notifications/outdated_gateway.text.eex | 16 +++ .../apps/domain/lib/domain/notifications.ex | 17 +++ .../notifications/jobs/outdated_gateways.ex | 88 ++++++++++++ .../apps/domain/test/domain/accounts_test.exs | 104 ++++++++++++++ .../test/domain/component_versions_test.exs | 54 +++++++ .../apps/domain/test/domain/gateways_test.exs | 13 ++ .../test/domain/mailer/notifications_test.exs | 45 ++++++ .../jobs/outdated_gateways_test.exs | 81 +++++++++++ .../domain/test/support/fixtures/accounts.ex | 12 ++ .../test/support/mocks/firezone_website.ex | 12 ++ .../web/lib/web/components/core_components.ex | 6 +- .../apps/web/lib/web/live/settings/account.ex | 68 ++++++++- .../settings/account/notifications_edit.ex | 116 +++++++++++++++ elixir/apps/web/lib/web/live/settings/dns.ex | 136 +++++++----------- .../web/lib/web/live/sites/gateways/index.ex | 5 +- elixir/apps/web/lib/web/live/sites/show.ex | 23 +++ elixir/apps/web/lib/web/router.ex | 1 + .../account/notifications_edit_test.exs | 106 ++++++++++++++ .../test/web/live/settings/account_test.exs | 14 ++ .../web/test/web/live/settings/dns_test.exs | 64 +++++++-- .../web/live/sites/gateways/index_test.exs | 20 ++- .../web/test/web/live/sites/show_test.exs | 1 + elixir/config/config.exs | 11 ++ elixir/config/test.exs | 10 ++ 37 files changed, 1332 insertions(+), 104 deletions(-) create mode 100644 elixir/apps/domain/lib/domain/accounts/config/notifications/email.ex create mode 100644 elixir/apps/domain/lib/domain/accounts/config/notifications/email/changeset.ex create mode 100644 elixir/apps/domain/lib/domain/component_versions.ex create mode 100644 elixir/apps/domain/lib/domain/component_versions/refresher.ex create mode 100644 elixir/apps/domain/lib/domain/mailer/notifications.ex create mode 100644 elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.html.eex create mode 100644 elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.text.eex create mode 100644 elixir/apps/domain/lib/domain/notifications.ex create mode 100644 elixir/apps/domain/lib/domain/notifications/jobs/outdated_gateways.ex create mode 100644 elixir/apps/domain/test/domain/component_versions_test.exs create mode 100644 elixir/apps/domain/test/domain/mailer/notifications_test.exs create mode 100644 elixir/apps/domain/test/domain/notifications/jobs/outdated_gateways_test.exs create mode 100644 elixir/apps/domain/test/support/mocks/firezone_website.ex create mode 100644 elixir/apps/web/lib/web/live/settings/account/notifications_edit.ex create mode 100644 elixir/apps/web/test/web/live/settings/account/notifications_edit_test.exs diff --git a/elixir/apps/domain/lib/domain/accounts.ex b/elixir/apps/domain/lib/domain/accounts.ex index 8e53b92e0..2e2933915 100644 --- a/elixir/apps/domain/lib/domain/accounts.ex +++ b/elixir/apps/domain/lib/domain/accounts.ex @@ -1,4 +1,5 @@ defmodule Domain.Accounts do + alias Web.Settings.Account alias Domain.{Repo, Config, PubSub} alias Domain.{Auth, Billing} alias Domain.Accounts.{Account, Features, Authorizer} @@ -18,6 +19,18 @@ defmodule Domain.Accounts do end end + def all_active_paid_accounts_pending_notification! do + ["Team", "Enterprise"] + |> Enum.flat_map(&all_active_accounts_by_subscription_name_pending_notification!/1) + end + + def all_active_accounts_by_subscription_name_pending_notification!(subscription_name) do + Account.Query.not_disabled() + |> Account.Query.by_stripe_product_name(subscription_name) + |> Account.Query.by_notification_last_notified("outdated_gateway", 24) + |> Repo.all() + end + def fetch_account_by_id(id, %Auth.Subject{} = subject, opts \\ []) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_own_account_permission()), true <- Repo.valid_uuid?(id) do @@ -138,6 +151,14 @@ defmodule Domain.Accounts do end end + def type(%Account{metadata: %{stripe: %{product_name: type}}}) do + type || "Starter" + end + + def type(%Account{}) do + "Starter" + end + ### PubSub defp account_topic(%Account{} = account), do: account_topic(account.id) diff --git a/elixir/apps/domain/lib/domain/accounts/account.ex b/elixir/apps/domain/lib/domain/accounts/account.ex index 2c1e30d84..d966850de 100644 --- a/elixir/apps/domain/lib/domain/accounts/account.ex +++ b/elixir/apps/domain/lib/domain/accounts/account.ex @@ -10,8 +10,7 @@ defmodule Domain.Accounts.Account do # Updated by the billing subscription metadata fields embeds_one :features, Domain.Accounts.Features, on_replace: :delete embeds_one :limits, Domain.Accounts.Limits, on_replace: :delete - - embeds_one :config, Domain.Accounts.Config, on_replace: :delete + embeds_one :config, Domain.Accounts.Config, on_replace: :update embeds_one :metadata, Metadata, primary_key: false, on_replace: :update do embeds_one :stripe, Stripe, primary_key: false, on_replace: :update do diff --git a/elixir/apps/domain/lib/domain/accounts/account/query.ex b/elixir/apps/domain/lib/domain/accounts/account/query.ex index b69abab83..af5c0058c 100644 --- a/elixir/apps/domain/lib/domain/accounts/account/query.ex +++ b/elixir/apps/domain/lib/domain/accounts/account/query.ex @@ -35,6 +35,14 @@ defmodule Domain.Accounts.Account.Query do ) end + def by_stripe_product_name(queryable, account_type) do + where( + queryable, + [accounts: accounts], + fragment("?->'stripe'->>'product_name' = ?", accounts.metadata, ^account_type) + ) + end + def by_slug(queryable, slug) do where(queryable, [accounts: accounts], accounts.slug == ^slug) end @@ -47,6 +55,26 @@ defmodule Domain.Accounts.Account.Query do end end + def by_notification_last_notified(queryable, notification, hours) do + interval = Duration.new!(hour: hours) + + where( + queryable, + [accounts: accounts], + fragment( + "(?->'notifications'->?->>'last_notified')::timestamp < NOW() - ?::interval", + accounts.config, + ^notification, + ^interval + ) or + fragment( + "(?->'notifications'->?->>'last_notified') IS NULL", + accounts.config, + ^notification + ) + ) + end + # Pagination @impl Domain.Repo.Query @@ -98,7 +126,7 @@ defmodule Domain.Accounts.Account.Query do end def filter_by_status(queryable, "enabled") do - {queryable, dynamic([accounts: accounts], not is_nil(accounts.disabled_at))} + {queryable, dynamic([accounts: accounts], is_nil(accounts.disabled_at))} end def filter_by_status(queryable, "disabled") do diff --git a/elixir/apps/domain/lib/domain/accounts/config.ex b/elixir/apps/domain/lib/domain/accounts/config.ex index dabf5e9ba..3fc038d27 100644 --- a/elixir/apps/domain/lib/domain/accounts/config.ex +++ b/elixir/apps/domain/lib/domain/accounts/config.ex @@ -7,6 +7,15 @@ defmodule Domain.Accounts.Config do field :protocol, Ecto.Enum, values: [:ip_port, :dns_over_tls, :dns_over_http] field :address, :string end + + embeds_one :notifications, Notifications, + primary_key: false, + on_replace: :update do + embeds_one :outdated_gateway, Domain.Accounts.Config.Notifications.Email, + on_replace: :update + + embeds_one :idp_sync_error, Domain.Accounts.Config.Notifications.Email, on_replace: :update + end end def supported_dns_protocols, do: ~w[ip_port]a diff --git a/elixir/apps/domain/lib/domain/accounts/config/changeset.ex b/elixir/apps/domain/lib/domain/accounts/config/changeset.ex index 483e99796..82daae5f1 100644 --- a/elixir/apps/domain/lib/domain/accounts/config/changeset.ex +++ b/elixir/apps/domain/lib/domain/accounts/config/changeset.ex @@ -8,7 +8,12 @@ defmodule Domain.Accounts.Config.Changeset do def changeset(config \\ %Config{}, attrs) do config |> cast(attrs, []) - |> cast_embed(:clients_upstream_dns, with: &client_upstream_dns_changeset/2) + |> cast_embed(:clients_upstream_dns, + with: &client_upstream_dns_changeset/2, + sort_param: :clients_upstream_dns_sort, + drop_param: :clients_upstream_dns_drop + ) + |> cast_embed(:notifications, with: ¬ifications_changeset/2) |> validate_unique_clients_upstream_dns() end @@ -76,4 +81,11 @@ defmodule Domain.Accounts.Config.Changeset do end end) end + + def notifications_changeset(notifications, attrs) do + notifications + |> cast(attrs, []) + |> cast_embed(:outdated_gateway, with: &Config.Notifications.Email.Changeset.changeset/2) + |> cast_embed(:idp_sync_error, with: &Config.Notifications.Email.Changeset.changeset/2) + end end diff --git a/elixir/apps/domain/lib/domain/accounts/config/notifications/email.ex b/elixir/apps/domain/lib/domain/accounts/config/notifications/email.ex new file mode 100644 index 000000000..09c8537d3 --- /dev/null +++ b/elixir/apps/domain/lib/domain/accounts/config/notifications/email.ex @@ -0,0 +1,10 @@ +defmodule Domain.Accounts.Config.Notifications.Email do + use Domain, :schema + + @primary_key false + + embedded_schema do + field :enabled, :boolean + field :last_notified, :utc_datetime + end +end diff --git a/elixir/apps/domain/lib/domain/accounts/config/notifications/email/changeset.ex b/elixir/apps/domain/lib/domain/accounts/config/notifications/email/changeset.ex new file mode 100644 index 000000000..88373d704 --- /dev/null +++ b/elixir/apps/domain/lib/domain/accounts/config/notifications/email/changeset.ex @@ -0,0 +1,9 @@ +defmodule Domain.Accounts.Config.Notifications.Email.Changeset do + use Domain, :changeset + alias Domain.Accounts.Config.Notifications.Email + + def changeset(config \\ %Email{}, attrs) do + config + |> cast(attrs, [:enabled, :last_notified]) + end +end diff --git a/elixir/apps/domain/lib/domain/application.ex b/elixir/apps/domain/lib/domain/application.ex index 4cf0ca4b8..32c3dbe5c 100644 --- a/elixir/apps/domain/lib/domain/application.ex +++ b/elixir/apps/domain/lib/domain/application.ex @@ -34,6 +34,8 @@ defmodule Domain.Application do Domain.Billing, Domain.Mailer, Domain.Mailer.RateLimiter, + Domain.Notifications, + Domain.ComponentVersions, # Observability Domain.Telemetry diff --git a/elixir/apps/domain/lib/domain/component_versions.ex b/elixir/apps/domain/lib/domain/component_versions.ex new file mode 100644 index 000000000..ca1fa426e --- /dev/null +++ b/elixir/apps/domain/lib/domain/component_versions.ex @@ -0,0 +1,88 @@ +defmodule Domain.ComponentVersions do + alias Domain.ComponentVersions + use Supervisor + require Logger + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__.Supervisor) + end + + @impl true + def init(_opts) do + pool_opts = Domain.Config.fetch_env!(:domain, :http_client_ssl_opts) + + fetch_from_url? = + Domain.Config.fetch_env!(:domain, ComponentVersions) + |> Keyword.get(:fetch_from_url) + + children = + [ + {Finch, name: __MODULE__.Finch, pools: %{default: pool_opts}} + ] + + children = + if fetch_from_url? do + children ++ [ComponentVersions.Refresher] + else + children + end + + Supervisor.init(children, strategy: :rest_for_one) + end + + def gateway_version do + ComponentVersions.component_version(:gateway) + end + + def component_version(component) do + Domain.Config.get_env(:domain, ComponentVersions, []) + |> Keyword.get(:versions, []) + |> Keyword.get(component, "0.0.0") + end + + def fetch_versions do + config = fetch_config!() + releases_url = Keyword.fetch!(config, :firezone_releases_url) + fetch_from_url? = Keyword.fetch!(config, :fetch_from_url) + + if fetch_from_url? do + fetch_versions_from_url(releases_url) + else + {:ok, Keyword.fetch!(config, :versions)} + end + end + + defp fetch_config! do + Domain.Config.fetch_env!(:domain, __MODULE__) + end + + defp fetch_versions_from_url(releases_url) do + request = + Finch.build(:get, releases_url, []) + + case Finch.request(request, __MODULE__.Finch) do + {:ok, %Finch.Response{status: 200, body: response}} -> + versions = decode_versions_response(response) + {:ok, Enum.into(versions, [])} + + {:ok, response} -> + Logger.error("Can't fetch Firezone versions", reason: inspect(response)) + {:error, {response.status, response.body}} + + {:error, reason} -> + Logger.error("Can't fetch Firezone versions", reason: inspect(reason)) + {:error, reason} + end + end + + defp decode_versions_response(response) do + case Jason.decode(response, keys: :atoms) do + {:ok, %{apple: _, android: _, gateway: _, gui: _, headless: _} = decoded_json} -> + decoded_json + + _ -> + fetch_config!() + |> Keyword.fetch!(:versions) + end + end +end diff --git a/elixir/apps/domain/lib/domain/component_versions/refresher.ex b/elixir/apps/domain/lib/domain/component_versions/refresher.ex new file mode 100644 index 000000000..53a6c7c8a --- /dev/null +++ b/elixir/apps/domain/lib/domain/component_versions/refresher.ex @@ -0,0 +1,57 @@ +defmodule Domain.ComponentVersions.Refresher do + use GenServer + require Logger + alias Domain.ComponentVersions + + @default_refresh_interval :timer.minutes(1) + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(opts) do + refresh_interval = Keyword.get(opts, :refresh_interval, @default_refresh_interval) + + {:ok, %{refresh_interval: refresh_interval}, {:continue, :load}} + end + + @impl true + def handle_continue(:load, state) do + refresh_versions() + :ok = schedule_refresh(state.refresh_interval) + {:noreply, state} + end + + @impl true + def handle_info(:refresh_versions, state) do + refresh_versions() + :ok = schedule_refresh(state.refresh_interval) + {:noreply, state} + end + + @impl true + def handle_info(:force_refesh, state) do + refresh_versions() + {:noreply, state} + end + + defp refresh_versions do + case ComponentVersions.fetch_versions() do + {:ok, versions} -> + new_config = + Domain.Config.get_env(:domain, ComponentVersions) + |> Keyword.merge(versions: versions) + + Application.put_env(:domain, ComponentVersions, new_config) + + {:error, reason} -> + Logger.debug("Error fetching component versions: #{inspect(reason)}") + end + end + + defp schedule_refresh(refresh_interval) do + Process.send_after(self(), :refresh_versions, refresh_interval) + :ok + end +end diff --git a/elixir/apps/domain/lib/domain/gateways.ex b/elixir/apps/domain/lib/domain/gateways.ex index 4a3e855eb..42ed582cb 100644 --- a/elixir/apps/domain/lib/domain/gateways.ex +++ b/elixir/apps/domain/lib/domain/gateways.ex @@ -1,5 +1,6 @@ defmodule Domain.Gateways do use Supervisor + alias Domain.Accounts.Account alias Domain.{Repo, Auth, Geo, PubSub} alias Domain.{Accounts, Resources, Tokens, Billing} alias Domain.Gateways.{Authorizer, Gateway, Group, Presence} @@ -49,6 +50,12 @@ defmodule Domain.Gateways do |> Repo.all() end + def all_groups_for_account!(%Accounts.Account{} = account) do + Group.Query.not_deleted() + |> Group.Query.by_account_id(account.id) + |> Repo.all() + end + def new_group(attrs \\ %{}) do change_group(%Group{}, attrs) end @@ -189,6 +196,15 @@ defmodule Domain.Gateways do end end + def all_gateways_for_account!(%Account{} = account, opts \\ []) do + {preload, _opts} = Keyword.pop(opts, :preload, []) + + Gateway.Query.not_deleted() + |> Gateway.Query.by_account_id(account.id) + |> Repo.all() + |> Repo.preload(preload) + end + @doc false def preload_gateways_presence([gateway]) do gateway.account_id @@ -405,6 +421,15 @@ defmodule Domain.Gateways do end end + def gateway_outdated?(gateway) do + latest_release = Domain.ComponentVersions.gateway_version() + + case Version.compare(gateway.last_seen_version, latest_release) do + :lt -> true + _ -> false + end + end + ### Presence def account_gateways_presence_topic(account_or_id), diff --git a/elixir/apps/domain/lib/domain/mailer/notifications.ex b/elixir/apps/domain/lib/domain/mailer/notifications.ex new file mode 100644 index 000000000..bd7773bca --- /dev/null +++ b/elixir/apps/domain/lib/domain/mailer/notifications.ex @@ -0,0 +1,19 @@ +defmodule Domain.Mailer.Notifications do + import Swoosh.Email + import Domain.Mailer + import Phoenix.Template, only: [embed_templates: 2] + + embed_templates "notifications/*.html", suffix: "_html" + embed_templates "notifications/*.text", suffix: "_text" + + def outdated_gateway_email(account, gateways, email) do + default_email() + |> subject("Firezone Gateway Upgrade Available") + |> to(email) + |> render_body(__MODULE__, :outdated_gateway, + account: account, + gateways: gateways, + latest_version: Domain.ComponentVersions.gateway_version() + ) + end +end diff --git a/elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.html.eex b/elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.html.eex new file mode 100644 index 000000000..ed6c23ff5 --- /dev/null +++ b/elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.html.eex @@ -0,0 +1,126 @@ + + + + + + + + + + + Firezone Gateway Update Available + + + +
+ Firezone Gateway Update Available +
+
+
+ + + + +
+ + + + + + + + + + + +
+

+ Gateway Update Available! +

+

+ The latest Firezone Gateway release is: <%= @latest_version %> +

+
+

+ The following list of Gateways in your Firezone Account can be updated. +

+
+

+ Outdated Gateways +

+ + <%= for gateway <- @gateways do %> + + + + + <% end %> +
<%= gateway.name %><%= gateway.last_seen_version %>
+

+
+

+ We recommend updating the Gateways at your earliest convenience. Help on how to update Gateways can be found + in our Firezone Gateway Upgrade Guide +
+
+ Thanks,
The Firezone Team +

+
+
+

+ Blazing-fast alternative to legacy VPNs +

+

+ Docs + • + Github + • + X +

+
+
+
+
+ + diff --git a/elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.text.eex b/elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.text.eex new file mode 100644 index 000000000..d6a287dd9 --- /dev/null +++ b/elixir/apps/domain/lib/domain/mailer/notifications/outdated_gateway.text.eex @@ -0,0 +1,16 @@ +Gateway Update Available! + +The latest Firezone Gateway release is: <%= @latest_version %> + +The following list of Gateways in your Firezone Account can be updated: +<%= for gateway <- @gateways do %> +<%= "- #{gateway.name} -> #{gateway.last_seen_version}" %> +<% end %> + +We recommend updating the Gateways at your earliest convenience. + +Help on how to update Gateways can be found in our Firezone Documentation: +https://www.firezone.dev/kb/administer/upgrading + +Thanks, +The Firezone Team diff --git a/elixir/apps/domain/lib/domain/notifications.ex b/elixir/apps/domain/lib/domain/notifications.ex new file mode 100644 index 000000000..8ea5eee6e --- /dev/null +++ b/elixir/apps/domain/lib/domain/notifications.ex @@ -0,0 +1,17 @@ +defmodule Domain.Notifications do + use Supervisor + require Logger + + def start_link(_init_arg) do + Supervisor.start_link(__MODULE__, nil, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + children = [ + Domain.Notifications.Jobs.OutdatedGateways + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/elixir/apps/domain/lib/domain/notifications/jobs/outdated_gateways.ex b/elixir/apps/domain/lib/domain/notifications/jobs/outdated_gateways.ex new file mode 100644 index 000000000..ecfc15a2b --- /dev/null +++ b/elixir/apps/domain/lib/domain/notifications/jobs/outdated_gateways.ex @@ -0,0 +1,88 @@ +defmodule Domain.Notifications.Jobs.OutdatedGateways do + use Domain.Jobs.Job, + otp_app: :domain, + every: :timer.minutes(5), + executor: Domain.Jobs.Executors.GloballyUnique + + require Logger + require OpenTelemetry.Tracer + + alias Domain.Actors + alias Domain.{Accounts, Gateways, Mailer} + + @impl true + if Mix.env() == :prod do + def execute(_config) do + # Should only run on Sundays + day_of_week = Date.utc_today() |> Date.day_of_week() + if day_of_week == 7, do: run_check() + end + else + def execute(_config) do + run_check() + end + end + + defp run_check do + Accounts.all_active_paid_accounts_pending_notification!() + |> Enum.each(fn account -> + all_online_gateways_for_account(account) + |> Enum.filter(&Gateways.gateway_outdated?/1) + |> send_notifications(account) + end) + end + + defp all_online_gateways_for_account(account) do + gateways_by_id = + Gateways.all_gateways_for_account!(account) + |> Enum.group_by(& &1.id) + + Gateways.all_groups_for_account!(account) + |> Enum.flat_map(&Gateways.all_online_gateway_ids_by_group_id!(&1.id)) + |> Enum.flat_map(&Map.get(gateways_by_id, &1)) + end + + defp send_notifications([], _account) do + Logger.debug("No outdated gateways for account") + end + + defp send_notifications(gateways, account) do + Domain.Actors.all_admins_for_account!(account, preload: :identities) + |> Enum.flat_map(&list_emails_for_actor/1) + |> Enum.each(&send_email(account, gateways, &1)) + + Domain.Accounts.update_account(account, %{ + config: %{ + notifications: %{ + outdated_gateway: %{ + last_notified: DateTime.utc_now() + } + } + } + }) + end + + defp list_emails_for_actor(%Actors.Actor{} = actor) do + actor.identities + |> Enum.map(&Domain.Auth.get_identity_email/1) + |> Enum.uniq() + end + + defp send_email(account, gateways, email) do + Mailer.Notifications.outdated_gateway_email(account, gateways, email) + |> Mailer.deliver_with_rate_limit() + end + + def notified_in_last_24h?(%Accounts.Account{} = account) do + last_notification = last_notified(account.config.notifications) + + if is_nil(last_notification) do + false + else + DateTime.diff(DateTime.utc_now(), last_notification, :hour) < 24 + end + end + + defp last_notified(%{outdated_gateway: %{last_notified: datetime}}), do: datetime + defp last_notified(_), do: nil +end diff --git a/elixir/apps/domain/test/domain/accounts_test.exs b/elixir/apps/domain/test/domain/accounts_test.exs index a5864e372..e694b91b6 100644 --- a/elixir/apps/domain/test/domain/accounts_test.exs +++ b/elixir/apps/domain/test/domain/accounts_test.exs @@ -41,6 +41,110 @@ defmodule Domain.AccountsTest do end end + describe "all_active_paid_accounts_pending_notification!/0" do + test "returns paid and active accounts" do + Fixtures.Accounts.create_account() + Fixtures.Accounts.create_account() |> Fixtures.Accounts.change_to_enterprise() + Fixtures.Accounts.create_account() |> Fixtures.Accounts.change_to_team() + + Fixtures.Accounts.create_account() + |> Fixtures.Accounts.change_to_team() + |> Fixtures.Accounts.disable_account() + + accounts = all_active_paid_accounts_pending_notification!() + assert length(accounts) == 2 + end + + test "returns empty list when no paid accounts exist" do + Fixtures.Accounts.create_account() + Fixtures.Accounts.create_account() |> Fixtures.Accounts.change_to_starter() + + accounts = all_active_paid_accounts_pending_notification!() + assert length(accounts) == 0 + end + + test "does not return accounts that have been notified within 24 hours" do + attrs = %{ + config: %{ + notifications: %{ + outdated_gateway: %{ + enabled: true, + last_notified: DateTime.utc_now() |> DateTime.add(-(60 * 60 * 12)) + } + } + } + } + + Fixtures.Accounts.create_account() + + Fixtures.Accounts.create_account(attrs) + |> Fixtures.Accounts.change_to_enterprise() + + Fixtures.Accounts.create_account(attrs) + |> Fixtures.Accounts.change_to_team() + + Fixtures.Accounts.create_account(attrs) + |> Fixtures.Accounts.change_to_team() + |> Fixtures.Accounts.disable_account() + + accounts = all_active_paid_accounts_pending_notification!() + assert length(accounts) == 0 + end + + test "returns accounts that have been notified more than 24 hours ago" do + attrs = %{ + config: %{ + notifications: %{ + outdated_gateway: %{ + enabled: true, + last_notified: DateTime.utc_now() |> DateTime.add(-(60 * 60 * 36)) + } + } + } + } + + Fixtures.Accounts.create_account() + + Fixtures.Accounts.create_account(attrs) + |> Fixtures.Accounts.change_to_enterprise() + + Fixtures.Accounts.create_account(attrs) + |> Fixtures.Accounts.change_to_team() + + Fixtures.Accounts.create_account(attrs) + |> Fixtures.Accounts.change_to_team() + |> Fixtures.Accounts.disable_account() + + accounts = all_active_paid_accounts_pending_notification!() + assert length(accounts) == 2 + end + end + + describe "all_active_accounts_by_subscription_name_pending_notification!/1" do + test "returns all active accounts for given subscription name" do + Fixtures.Accounts.create_account() + Fixtures.Accounts.create_account() |> Fixtures.Accounts.change_to_team() + + Fixtures.Accounts.create_account() + |> Fixtures.Accounts.change_to_team() + |> Fixtures.Accounts.disable_account() + + accounts = all_active_accounts_by_subscription_name_pending_notification!("Team") + assert length(accounts) == 1 + end + + test "returns an empty list when no active accounts with type" do + Fixtures.Accounts.create_account() + + Fixtures.Accounts.create_account() + |> Fixtures.Accounts.change_to_team() + |> Fixtures.Accounts.disable_account() + + accounts = all_active_accounts_by_subscription_name_pending_notification!("Team") + assert length(accounts) == 0 + end + end + describe "fetch_account_by_id/3" do setup do account = Fixtures.Accounts.create_account() diff --git a/elixir/apps/domain/test/domain/component_versions_test.exs b/elixir/apps/domain/test/domain/component_versions_test.exs new file mode 100644 index 000000000..0e204b31a --- /dev/null +++ b/elixir/apps/domain/test/domain/component_versions_test.exs @@ -0,0 +1,54 @@ +defmodule Domain.ComponentVersionsTest do + use ExUnit.Case, async: true + import Domain.ComponentVersions + alias Domain.ComponentVersions + alias Domain.Mocks.FirezoneWebsite + + setup do + bypass = Bypass.open() + %{bypass: bypass} + end + + describe "fetch_versions/0" do + test "fetches versions from url", %{bypass: bypass} do + versions = %{ + apple: "1.1.1", + android: "1.1.1", + gateway: "1.2.3", + gui: "1.1.1", + headless: "1.1.1" + } + + FirezoneWebsite.mock_versions_endpoint(bypass, versions) + + new_config = + Domain.Config.get_env(:domain, ComponentVersions) + |> Keyword.merge( + fetch_from_url: true, + firezone_releases_url: "http://localhost:#{bypass.port}/api/releases" + ) + + Domain.Config.put_env_override(ComponentVersions, new_config) + + assert fetch_versions() == {:ok, Enum.into(versions, [])} + end + + test "fetches versions from config" do + versions = %{ + apple: "2.1.1", + android: "2.1.1", + gateway: "2.2.3", + gui: "2.1.1", + headless: "2.1.1" + } + + new_config = + Domain.Config.get_env(:domain, ComponentVersions) + |> Keyword.merge(versions: Enum.into(versions, [])) + + Domain.Config.put_env_override(ComponentVersions, new_config) + + assert fetch_versions() == {:ok, Enum.into(versions, [])} + end + end +end diff --git a/elixir/apps/domain/test/domain/gateways_test.exs b/elixir/apps/domain/test/domain/gateways_test.exs index 81d3c1eb9..8d3f6cb8d 100644 --- a/elixir/apps/domain/test/domain/gateways_test.exs +++ b/elixir/apps/domain/test/domain/gateways_test.exs @@ -142,6 +142,19 @@ defmodule Domain.GatewaysTest do end end + describe "all_groups_for_account!/1" do + test "returns groups for given account", %{account: account} do + Fixtures.Gateways.create_group(account: account) + Fixtures.Gateways.create_group(account: account) + external_group = Fixtures.Gateways.create_group(account: Fixtures.Accounts.create_account()) + + groups = all_groups_for_account!(account) + + assert length(groups) == 2 + refute Enum.any?(groups, &(&1.id == external_group.id)) + end + end + describe "new_group/0" do test "returns group changeset" do assert %Ecto.Changeset{data: %Gateways.Group{}, changes: changes} = new_group() diff --git a/elixir/apps/domain/test/domain/mailer/notifications_test.exs b/elixir/apps/domain/test/domain/mailer/notifications_test.exs new file mode 100644 index 000000000..297edb7bc --- /dev/null +++ b/elixir/apps/domain/test/domain/mailer/notifications_test.exs @@ -0,0 +1,45 @@ +defmodule Domain.Mailer.NotificationsTest do + alias Domain.ComponentVersions + use Domain.DataCase, async: true + import Domain.Mailer.Notifications + + setup do + account = Fixtures.Accounts.create_account() + + %{ + account: account + } + end + + describe "outdated_gateway_email/3" do + test "should contain current gateway version and list of outdated gateways", %{ + account: account + } do + admin_email = "admin@foo.local" + + gateway_1 = Fixtures.Gateways.create_gateway(account: account) + gateway_2 = Fixtures.Gateways.create_gateway(account: account) + + current_version = "3.2.1" + set_current_version(current_version) + + email_body = outdated_gateway_email(account, [gateway_1, gateway_2], admin_email) + + assert email_body.text_body =~ "The latest Firezone Gateway release is: #{current_version}" + assert email_body.text_body =~ gateway_1.name + assert email_body.text_body =~ gateway_2.name + end + end + + defp set_current_version(version) do + config = Domain.Config.get_env(:domain, ComponentVersions) + + new_versions = + config + |> Keyword.get(:versions) + |> Keyword.merge(gateway: version) + + new_config = Keyword.merge(config, versions: new_versions) + Domain.Config.put_env_override(:domain, ComponentVersions, new_config) + end +end diff --git a/elixir/apps/domain/test/domain/notifications/jobs/outdated_gateways_test.exs b/elixir/apps/domain/test/domain/notifications/jobs/outdated_gateways_test.exs new file mode 100644 index 000000000..5ff247146 --- /dev/null +++ b/elixir/apps/domain/test/domain/notifications/jobs/outdated_gateways_test.exs @@ -0,0 +1,81 @@ +defmodule Domain.Notifications.Jobs.OutdatedGatewaysTest do + alias Domain.ComponentVersions + use Domain.DataCase, async: true + import Domain.Notifications.Jobs.OutdatedGateways + + describe "execute/1" do + setup do + account = + Fixtures.Accounts.create_account() + |> Fixtures.Accounts.change_to_enterprise() + + gateway_group = Fixtures.Gateways.create_group(account: account) + + %{ + account: account, + gateway_group: gateway_group + } + end + + test "sends notification for outdated gateways", %{ + account: account, + gateway_group: gateway_group + } do + # Create Gateway + gateway = Fixtures.Gateways.create_gateway(account: account, group: gateway_group) + version = gateway.last_seen_version + + # Set ComponentVersions + new_version = bump_version(version) + new_config = update_component_versions_config(gateway: new_version) + Domain.Config.put_env_override(ComponentVersions, new_config) + + :ok = Domain.Gateways.subscribe_to_gateways_presence_in_group(gateway_group) + :ok = Domain.Gateways.connect_gateway(gateway) + assert_receive %Phoenix.Socket.Broadcast{topic: "presences:group_gateways:" <> _} + + assert execute(%{}) == :ok + + assert_email_sent(fn email -> + assert email.subject == "Firezone Gateway Upgrade Available" + assert email.text_body =~ "The latest Firezone Gateway release is: #{new_version}" + end) + end + + test "does not send notification if gateway up to date", %{ + account: account, + gateway_group: gateway_group + } do + # Create Gateway + gateway = Fixtures.Gateways.create_gateway(account: account, group: gateway_group) + version = gateway.last_seen_version + + # Set ComponentVersions + new_config = update_component_versions_config(gateway: version) + Domain.Config.put_env_override(ComponentVersions, new_config) + + :ok = Domain.Gateways.subscribe_to_gateways_presence_in_group(gateway_group) + :ok = Domain.Gateways.connect_gateway(gateway) + assert_receive %Phoenix.Socket.Broadcast{topic: "presences:group_gateways:" <> _} + + assert execute(%{}) == :ok + refute_email_sent() + end + end + + defp bump_version(version) do + {:ok, current_version} = Version.parse(version) + new_minor = current_version.minor + 1 + "#{current_version.major}.#{new_minor}.0" + end + + defp update_component_versions_config(versions) do + config = Domain.Config.get_env(:domain, Domain.ComponentVersions) + + new_versions = + Keyword.get(config, :versions) + |> Keyword.merge(versions) + + Keyword.merge(config, versions: new_versions) + end +end diff --git a/elixir/apps/domain/test/support/fixtures/accounts.ex b/elixir/apps/domain/test/support/fixtures/accounts.ex index f63430ff7..e18aaea4c 100644 --- a/elixir/apps/domain/test/support/fixtures/accounts.ex +++ b/elixir/apps/domain/test/support/fixtures/accounts.ex @@ -49,6 +49,18 @@ defmodule Domain.Fixtures.Accounts do update_account(account, disabled_at: DateTime.utc_now()) end + def change_to_enterprise(%Accounts.Account{} = account) do + update_account(account, %{metadata: %{stripe: %{product_name: "Enterprise"}}}) + end + + def change_to_team(%Accounts.Account{} = account) do + update_account(account, %{metadata: %{stripe: %{product_name: "Team"}}}) + end + + def change_to_starter(%Accounts.Account{} = account) do + update_account(account, %{metadata: %{stripe: %{product_name: "Starter"}}}) + end + def update_account(account, attrs \\ %{}) do account |> Ecto.Changeset.change(attrs) diff --git a/elixir/apps/domain/test/support/mocks/firezone_website.ex b/elixir/apps/domain/test/support/mocks/firezone_website.ex new file mode 100644 index 000000000..e9818eac2 --- /dev/null +++ b/elixir/apps/domain/test/support/mocks/firezone_website.ex @@ -0,0 +1,12 @@ +defmodule Domain.Mocks.FirezoneWebsite do + def mock_versions_endpoint(bypass, versions \\ %{}) do + versions_path = "/api/releases" + test_pid = self() + + Bypass.stub(bypass, "GET", versions_path, fn conn -> + conn = Plug.Conn.fetch_query_params(conn) + send(test_pid, {:bypass_request, conn}) + Plug.Conn.send_resp(conn, 200, Jason.encode!(versions)) + end) + end +end diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index c160e1d4a..58e4a0967 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -1206,10 +1206,14 @@ defmodule Web.CoreComponents do @doc """ Translates the errors for a field from a keyword list of errors. """ - def translate_errors(errors, field) when is_list(errors) do + def translate_errors(errors, field) when is_list(errors) or is_map(errors) do for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) end + def translate_errors(errors, _field) when is_nil(errors) do + [] + end + @doc """ This component is meant to be used for step by step instructions diff --git a/elixir/apps/web/lib/web/live/settings/account.ex b/elixir/apps/web/lib/web/live/settings/account.ex index a6e631a97..0e2b42d2b 100644 --- a/elixir/apps/web/lib/web/live/settings/account.ex +++ b/elixir/apps/web/lib/web/live/settings/account.ex @@ -5,7 +5,8 @@ defmodule Web.Settings.Account do def mount(_params, _session, socket) do socket = assign(socket, - page_title: "Account" + page_title: "Account", + account_type: Accounts.type(socket.assigns.account) ) {:ok, socket} @@ -46,6 +47,26 @@ defmodule Web.Settings.Account do + <.section> + <:title> + Notifications + + <:action> + <.edit_button + :if={@account_type != "Starter"} + navigate={~p"/#{@account}/settings/account/notifications/edit"} + > + Edit Notifications + + <.upgrade_badge :if={@account_type == "Starter"} account={@account} /> + + <:content> +
+ <.notifications_table notifications={@account.config.notifications} /> +
+ + + <.section> <:title> Danger zone @@ -66,4 +87,49 @@ defmodule Web.Settings.Account do """ end + + defp notifications_table(assigns) do + ~H""" +
+ + + + + + + + + + + + + +
Notification TypeStatus
+ Gateway Upgrade Available + + <.notification_badge notification={ + Map.get(@notifications || %{}, :outdated_gateway, %{enabled: false}) + } /> +
+
+ """ + end + + defp upgrade_badge(assigns) do + ~H""" + <.link navigate={~p"/#{@account}/settings/billing"} class="text-sm text-primary-500"> + <.badge type="primary" title="Feature available on a higher pricing plan"> + <.icon name="hero-lock-closed" class="w-3.5 h-3.5 mr-1" /> UPGRADE TO UNLOCK + + + """ + end + + defp notification_badge(assigns) do + ~H""" + <.badge type={if @notification.enabled, do: "success", else: "neutral"}> + <%= if @notification.enabled, do: "Enabled", else: "Disabled" %> + + """ + end end diff --git a/elixir/apps/web/lib/web/live/settings/account/notifications_edit.ex b/elixir/apps/web/lib/web/live/settings/account/notifications_edit.ex new file mode 100644 index 000000000..06e7081c9 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/account/notifications_edit.ex @@ -0,0 +1,116 @@ +defmodule Web.Settings.Account.Notifications.Edit do + use Web, :live_view + alias Domain.Accounts + + def mount(_params, _session, socket) do + socket = + assign(socket, + page_title: "Edit Notifications", + form: to_form(Accounts.change_account(socket.assigns.account)) + ) + + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} + end + + def render(assigns) do + ~H""" + <.breadcrumbs account={@account}> + <.breadcrumb path={~p"/#{@account}/settings/account"}> + Account Settings + + <.breadcrumb path={~p"/#{@account}/settings/account/edit"}> + Edit Notifications + + + <.section> + <:title> + Edit Notifications + + <:content> +
+

Edit account notifications

+ <.form for={@form} phx-change={:change} phx-submit={:submit}> +
+ <.inputs_for :let={config} field={@form[:config]}> + <.inputs_for :let={notifications} field={config[:notifications]}> + + + + + + + + + + + <.inputs_for :let={outdated_gateway} field={notifications[:outdated_gateway]}> + + + + + + +
+ Notification + + Enable + + Disable +
+ Outdated Gateways + + <.input + class="mx-auto" + type="radio" + field={outdated_gateway[:enabled]} + value="true" + checked={outdated_gateway[:enabled].value == true} + /> + + <.input + class="mx-auto" + type="radio" + field={outdated_gateway[:enabled]} + value="false" + checked={outdated_gateway[:enabled].value != true} + /> +
+ + +
+ <.submit_button> + Save + + +
+ + + """ + end + + def handle_event("change", %{"account" => attrs}, socket) do + account = socket.assigns.account + + changeset = + Accounts.change_account(account, attrs) + |> Map.put(:action, :validate) + + socket = assign(socket, form: to_form(changeset)) + + {:noreply, socket} + end + + def handle_event("submit", %{"account" => attrs}, socket) do + with {:ok, account} <- + Accounts.update_account(socket.assigns.account, attrs, socket.assigns.subject) do + {:noreply, + push_navigate(socket, + to: ~p"/#{account}/settings/account" + )} + else + {:error, changeset} -> + changeset = changeset |> Map.put(:action, :validate) + {:noreply, assign(socket, form: to_form(changeset))} + end + end +end diff --git a/elixir/apps/web/lib/web/live/settings/dns.ex b/elixir/apps/web/lib/web/live/settings/dns.ex index 2509caebf..9b28bcb3d 100644 --- a/elixir/apps/web/lib/web/live/settings/dns.ex +++ b/elixir/apps/web/lib/web/live/settings/dns.ex @@ -7,7 +7,6 @@ defmodule Web.Settings.DNS do Accounts.fetch_account_by_id(socket.assigns.account.id, socket.assigns.subject) do form = Accounts.change_account(account, %{}) - |> maybe_append_empty_embed() |> to_form() socket = @@ -58,8 +57,14 @@ defmodule Web.Settings.DNS do
<.inputs_for :let={config} field={@form[:config]}> <.inputs_for :let={dns} field={config[:clients_upstream_dns]}> + +
-
+
<.input type="select" label="Protocol" @@ -69,21 +74,42 @@ defmodule Web.Settings.DNS do value={dns[:protocol].value} />
-
+
<.input label="Address" field={dns[:address]} placeholder="DNS Server Address" />
+
+
+ +
+
- <% errors = - translate_errors( - @form.source.changes.config.errors, - :clients_upstream_dns - ) %> - <.error :for={error <- errors} data-validation-error-for="clients_upstream_dns"> + + <.button + class="mt-6 w-full" + type="button" + style="info" + name={"#{config.name}[clients_upstream_dns_sort][]"} + value="new" + phx-click={JS.dispatch("change")} + > + New DNS Server + + <.error + :for={error <- dns_config_errors(@form.source.changes)} + data-validation-error-for="clients_upstream_dns" + > <%= error %> @@ -106,103 +132,35 @@ defmodule Web.Settings.DNS do end def handle_event("change", %{"account" => attrs}, socket) do - changeset = + form = Accounts.change_account(socket.assigns.account, attrs) - |> maybe_append_empty_embed() - |> filter_errors() |> Map.put(:action, :validate) + |> to_form() - {:noreply, assign(socket, form: to_form(changeset))} + {:noreply, assign(socket, form: form)} end def handle_event("submit", %{"account" => attrs}, socket) do - attrs = remove_empty_servers(attrs) - with {:ok, account} <- Accounts.update_account(socket.assigns.account, attrs, socket.assigns.subject) do form = Accounts.change_account(account, %{}) - |> maybe_append_empty_embed() |> to_form() + socket = put_flash(socket, :success, "Save successful!") + {:noreply, assign(socket, account: account, form: form)} else {:error, changeset} -> - changeset = + form = changeset - |> maybe_append_empty_embed() - |> filter_errors() |> Map.put(:action, :validate) + |> to_form() - {:noreply, assign(socket, form: to_form(changeset))} + {:noreply, assign(socket, form: form)} end end - defp filter_errors(changeset) do - update_clients_upstream_dns(changeset, fn - clients_upstream_dns_changesets -> - remove_errors(clients_upstream_dns_changesets, :address, "can't be blank") - end) - end - - defp remove_errors(changesets, field, message) do - Enum.map(changesets, fn changeset -> - errors = - Enum.filter(changeset.errors, fn - {^field, {^message, _}} -> false - {_, _} -> true - end) - - %{changeset | errors: errors} - end) - end - - defp maybe_append_empty_embed(changeset) do - update_clients_upstream_dns(changeset, fn - clients_upstream_dns_changesets -> - last_client_upstream_dns_changeset = List.last(clients_upstream_dns_changesets) - - with true <- last_client_upstream_dns_changeset != nil, - {_data_or_changes, last_address} <- - Ecto.Changeset.fetch_field(last_client_upstream_dns_changeset, :address), - true <- last_address in [nil, ""] do - clients_upstream_dns_changesets - else - _other -> clients_upstream_dns_changesets ++ [%Accounts.Config.ClientsUpstreamDNS{}] - end - end) - end - - defp update_clients_upstream_dns(changeset, cb) do - config_changeset = Ecto.Changeset.get_embed(changeset, :config) - - clients_upstream_dns_changeset = - Ecto.Changeset.get_embed(config_changeset, :clients_upstream_dns) - - config_changeset = - Ecto.Changeset.put_embed( - config_changeset, - :clients_upstream_dns, - cb.(clients_upstream_dns_changeset) - ) - - Ecto.Changeset.put_embed(changeset, :config, config_changeset) - end - - defp remove_empty_servers(attrs) do - update_in(attrs, [Access.key("config", %{}), "clients_upstream_dns"], fn - nil -> - nil - - servers -> - Map.filter(servers, fn - {_index, %{"address" => ""}} -> false - {_index, %{"address" => nil}} -> false - _ -> true - end) - end) - end - defp dns_options do supported_dns_protocols = Enum.map(Accounts.Config.supported_dns_protocols(), &to_string/1) @@ -218,4 +176,12 @@ defmodule Web.Settings.DNS do end end) end + + defp dns_config_errors(changes) when changes == %{} do + [] + end + + defp dns_config_errors(changes) do + translate_errors(changes.config.errors, :clients_upstream_dns) + end end diff --git a/elixir/apps/web/lib/web/live/sites/gateways/index.ex b/elixir/apps/web/lib/web/live/sites/gateways/index.ex index d01c22c6c..9cb66c609 100644 --- a/elixir/apps/web/lib/web/live/sites/gateways/index.ex +++ b/elixir/apps/web/lib/web/live/sites/gateways/index.ex @@ -84,11 +84,14 @@ defmodule Web.Sites.Gateways.Index do <%= gateway.name %> - <:col :let={gateway} label="remote iP"> + <:col :let={gateway} label="remote ip"> <%= gateway.last_seen_remote_ip %> + <:col :let={gateway} label="version"> + <%= gateway.last_seen_version %> + <:col :let={gateway} label="status"> <.connection_status schema={gateway} /> diff --git a/elixir/apps/web/lib/web/live/sites/show.ex b/elixir/apps/web/lib/web/live/sites/show.ex index 1b965059b..15274db60 100644 --- a/elixir/apps/web/lib/web/live/sites/show.ex +++ b/elixir/apps/web/lib/web/live/sites/show.ex @@ -180,6 +180,10 @@ defmodule Web.Sites.Show do <%= gateway.last_seen_remote_ip %> + <:col :let={gateway} label="version"> + <.version_status outdated={Gateways.gateway_outdated?(gateway)} /> + <%= gateway.last_seen_version %> + <:col :let={gateway} label="status"> <.connection_status schema={gateway} /> @@ -324,4 +328,23 @@ defmodule Web.Sites.Show do {:ok, _group} = Gateways.delete_group(socket.assigns.group, socket.assigns.subject) {:noreply, push_navigate(socket, to: ~p"/#{socket.assigns.account}/sites")} end + + attr :outdated, :boolean + + defp version_status(assigns) do + ~H""" + <.icon + :if={!@outdated} + name="hero-check-circle" + class="w-4 h-4 text-green-500" + title="Up to date" + /> + <.icon + :if={@outdated} + name="hero-arrow-up-circle" + class="w-4 h-4 text-primary-500" + title="New version available" + /> + """ + end end diff --git a/elixir/apps/web/lib/web/router.ex b/elixir/apps/web/lib/web/router.ex index 40d3cdee4..fcbb5ff33 100644 --- a/elixir/apps/web/lib/web/router.ex +++ b/elixir/apps/web/lib/web/router.ex @@ -211,6 +211,7 @@ defmodule Web.Router do scope "/account" do live "/", Account live "/edit", Account.Edit + live "/notifications/edit", Account.Notifications.Edit end live "/billing", Billing diff --git a/elixir/apps/web/test/web/live/settings/account/notifications_edit_test.exs b/elixir/apps/web/test/web/live/settings/account/notifications_edit_test.exs new file mode 100644 index 000000000..94364ac80 --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/account/notifications_edit_test.exs @@ -0,0 +1,106 @@ +defmodule Web.Live.Settings.Account.NotificationsEditTest do + use Web.ConnCase, async: true + + setup do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + + account = + Fixtures.Accounts.create_account( + metadata: %{ + stripe: %{ + customer_id: "cus_NffrFeUfNV2Hib", + subscription_id: "sub_NffrFeUfNV2Hib", + product_name: "Enterprise" + } + }, + limits: %{ + monthly_active_users_count: 100 + } + ) + + identity = Fixtures.Auth.create_identity(account: account, actor: [type: :account_admin_user]) + + %{ + account: account, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{account: account, conn: conn} do + path = ~p"/#{account}/settings/account/notifications/edit" + + assert live(conn, path) == + {:error, + {:redirect, + %{ + to: ~p"/#{account}?#{%{redirect_to: path}}", + flash: %{"error" => "You must sign in to access this page."} + }}} + end + + test "renders breadcrumbs item", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, _lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/account/notifications/edit") + + assert item = Floki.find(html, "[aria-label='Breadcrumb']") + breadcrumbs = String.trim(Floki.text(item)) + assert breadcrumbs =~ "Account Settings" + assert breadcrumbs =~ "Edit Notifications" + end + + test "renders enable/disable form", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/account/notifications/edit") + + form = form(lv, "form") + + assert find_inputs(form) == [ + "account[config][_persistent_id]", + "account[config][notifications][_persistent_id]", + "account[config][notifications][outdated_gateway][_persistent_id]", + "account[config][notifications][outdated_gateway][enabled]" + ] + end + + test "updates notifications status on valid attrs", %{ + account: account, + identity: identity, + conn: conn + } do + attrs = %{ + "config" => %{ + "_persistent_id" => "0", + "notifications" => %{ + "_persistent_id" => "0", + "outdated_gateway" => %{"_persistent_id" => "0", "enabled" => "true"} + } + } + } + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/account/notifications/edit") + + lv + |> form("form", account: attrs) + |> render_submit() + + assert_redirected(lv, ~p"/#{account}/settings/account") + + assert account = Repo.get_by(Domain.Accounts.Account, id: account.id) + assert account.config.notifications.outdated_gateway.enabled == true + end +end diff --git a/elixir/apps/web/test/web/live/settings/account_test.exs b/elixir/apps/web/test/web/live/settings/account_test.exs index fcfc2628d..d1de50d0d 100644 --- a/elixir/apps/web/test/web/live/settings/account_test.exs +++ b/elixir/apps/web/test/web/live/settings/account_test.exs @@ -121,4 +121,18 @@ defmodule Web.Live.Settings.AccountTest do assert html =~ "This account has been disabled." assert html =~ "contact support" end + + test "renders notification settings for account", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/account") + + html = lv |> render() + assert html =~ "Gateway Upgrade Available" + end end diff --git a/elixir/apps/web/test/web/live/settings/dns_test.exs b/elixir/apps/web/test/web/live/settings/dns_test.exs index 7dc909585..d0a4f21a4 100644 --- a/elixir/apps/web/test/web/live/settings/dns_test.exs +++ b/elixir/apps/web/test/web/live/settings/dns_test.exs @@ -40,7 +40,7 @@ defmodule Web.Live.Settings.DNSTest do assert breadcrumbs =~ "DNS Settings" end - test "renders form", %{ + test "renders form with no input fields", %{ account: account, identity: identity, conn: conn @@ -54,11 +54,47 @@ defmodule Web.Live.Settings.DNSTest do form = lv |> form("form") + assert find_inputs(form) == [ + "account[config][_persistent_id]", + "account[config][clients_upstream_dns_drop][]" + ] + end + + test "renders input field on button click", %{ + account: account, + identity: identity, + conn: conn + } do + Fixtures.Accounts.update_account(account, %{config: %{clients_upstream_dns: []}}) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/dns") + + attrs = %{ + "_target" => ["account", "config", "clients_upstream_dns_sort"], + "account" => %{ + "config" => %{ + "_persistent_id" => "0", + "clients_upstream_dns_drop" => [""], + "clients_upstream_dns_sort" => ["new"] + } + } + } + + lv + |> render_click(:change, attrs) + + form = lv |> form("form") + assert find_inputs(form) == [ "account[config][_persistent_id]", "account[config][clients_upstream_dns][0][_persistent_id]", "account[config][clients_upstream_dns][0][address]", - "account[config][clients_upstream_dns][0][protocol]" + "account[config][clients_upstream_dns][0][protocol]", + "account[config][clients_upstream_dns_drop][]", + "account[config][clients_upstream_dns_sort][]" ] end @@ -82,6 +118,17 @@ defmodule Web.Live.Settings.DNSTest do |> authorize_conn(identity) |> live(~p"/#{account}/settings/dns") + lv + |> element("form") + |> render_change(%{ + "account" => %{ + "config" => %{ + "clients_upstream_dns_drop" => [""], + "clients_upstream_dns_sort" => ["new"] + } + } + }) + lv |> form("form", attrs) |> render_submit() @@ -93,9 +140,8 @@ defmodule Web.Live.Settings.DNSTest do "account[config][clients_upstream_dns][0][_persistent_id]", "account[config][clients_upstream_dns][0][address]", "account[config][clients_upstream_dns][0][protocol]", - "account[config][clients_upstream_dns][1][_persistent_id]", - "account[config][clients_upstream_dns][1][address]", - "account[config][clients_upstream_dns][1][protocol]" + "account[config][clients_upstream_dns_drop][]", + "account[config][clients_upstream_dns_sort][]" ] end @@ -135,7 +181,9 @@ defmodule Web.Live.Settings.DNSTest do "account[config][clients_upstream_dns][1][protocol]", "account[config][clients_upstream_dns][2][_persistent_id]", "account[config][clients_upstream_dns][2][address]", - "account[config][clients_upstream_dns][2][protocol]" + "account[config][clients_upstream_dns][2][protocol]", + "account[config][clients_upstream_dns_drop][]", + "account[config][clients_upstream_dns_sort][]" ] end @@ -190,7 +238,7 @@ defmodule Web.Live.Settings.DNSTest do |> render_change() =~ "all addresses must be unique" end - test "does not display 'cannot be empty' error message", %{ + test "displays 'cannot be empty' error message", %{ account: account, identity: identity, conn: conn @@ -212,7 +260,7 @@ defmodule Web.Live.Settings.DNSTest do |> form("form", attrs) |> render_submit() - refute lv + assert lv |> form("form", %{ account: %{ config: %{ diff --git a/elixir/apps/web/test/web/live/sites/gateways/index_test.exs b/elixir/apps/web/test/web/live/sites/gateways/index_test.exs index 540e92628..b8157ba85 100644 --- a/elixir/apps/web/test/web/live/sites/gateways/index_test.exs +++ b/elixir/apps/web/test/web/live/sites/gateways/index_test.exs @@ -51,7 +51,12 @@ defmodule Web.Live.Sites.Gateways.IndexTest do group: group, conn: conn } do - gateway = Fixtures.Gateways.create_gateway(account: account, group: group) + gateway = + Fixtures.Gateways.create_gateway( + account: account, + group: group, + context: %{user_agent: "iOS/12.5 (iPhone) connlib/1.3.2"} + ) {:ok, lv, _html} = conn @@ -67,7 +72,8 @@ defmodule Web.Live.Sites.Gateways.IndexTest do assert row == %{ "instance" => gateway.name, "remote ip" => to_string(gateway.last_seen_remote_ip), - "status" => "Offline" + "status" => "Offline", + "version" => "1.3.2" } end @@ -77,7 +83,12 @@ defmodule Web.Live.Sites.Gateways.IndexTest do group: group, conn: conn } do - gateway = Fixtures.Gateways.create_gateway(account: account, group: group) + gateway = + Fixtures.Gateways.create_gateway( + account: account, + group: group, + context: %{user_agent: "iOS/12.5 (iPhone) connlib/1.3.2"} + ) {:ok, lv, _html} = conn @@ -98,7 +109,8 @@ defmodule Web.Live.Sites.Gateways.IndexTest do assert row == %{ "instance" => gateway.name, "remote ip" => to_string(gateway.last_seen_remote_ip), - "status" => "Online" + "status" => "Online", + "version" => "1.3.2" } end) end diff --git a/elixir/apps/web/test/web/live/sites/show_test.exs b/elixir/apps/web/test/web/live/sites/show_test.exs index a77fff865..8c2feeb55 100644 --- a/elixir/apps/web/test/web/live/sites/show_test.exs +++ b/elixir/apps/web/test/web/live/sites/show_test.exs @@ -161,6 +161,7 @@ defmodule Web.Live.Sites.ShowTest do |> with_table_row("instance", gateway.name, fn row -> assert gateway.last_seen_remote_ip assert row["remote ip"] =~ to_string(gateway.last_seen_remote_ip) + assert row["version"] =~ gateway.last_seen_version assert row["status"] =~ "Online" end) end diff --git a/elixir/config/config.exs b/elixir/config/config.exs index c7bb12943..9e6e44143 100644 --- a/elixir/config/config.exs +++ b/elixir/config/config.exs @@ -75,6 +75,17 @@ config :domain, Domain.GoogleCloudPlatform, sign_endpoint_url: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/", cloud_storage_url: "https://storage.googleapis.com" +config :domain, Domain.ComponentVersions, + firezone_releases_url: "https://www.firezone.dev/api/releases", + fetch_from_url: true, + versions: [ + apple: "1.3.6", + android: "1.3.5", + gateway: "1.3.2", + gui: "1.3.8", + headless: "1.3.4" + ] + config :domain, Domain.Cluster, adapter: nil, adapter_config: [] diff --git a/elixir/config/test.exs b/elixir/config/test.exs index 2ec4005c9..62cf87b76 100644 --- a/elixir/config/test.exs +++ b/elixir/config/test.exs @@ -26,6 +26,16 @@ config :domain, platform_adapter: Domain.GoogleCloudPlatform config :domain, Domain.GoogleCloudPlatform, service_account_email: "foo@iam.example.com" +config :domain, Domain.ComponentVersions, + fetch_from_url: false, + versions: [ + apple: "1.0.0", + android: "1.0.0", + gateway: "1.0.0", + gui: "1.0.0", + headless: "1.0.0" + ] + config :domain, Domain.Telemetry.GoogleCloudMetricsReporter, project_id: "fz-test" config :domain, web_external_url: "http://localhost:13100"