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.
This commit is contained in:
Brian Manifold
2024-10-11 05:46:00 -07:00
committed by GitHub
parent cd2dea7846
commit 7fda4c52c4
37 changed files with 1332 additions and 104 deletions

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -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: &notifications_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

View File

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

View File

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

View File

@@ -34,6 +34,8 @@ defmodule Domain.Application do
Domain.Billing,
Domain.Mailer,
Domain.Mailer.RateLimiter,
Domain.Notifications,
Domain.ComponentVersions,
# Observability
Domain.Telemetry

View File

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

View File

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

View File

@@ -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),

View File

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

View File

@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
</style>
<![endif]-->
<title>Firezone Gateway Update Available</title>
<style>
.hover-important-text-decoration-underline:hover {
text-decoration: underline !important
}
@media (max-width: 600px) {
.sm-my-8 {
margin-top: 32px !important;
margin-bottom: 32px !important
}
.sm-px-4 {
padding-left: 16px !important;
padding-right: 16px !important
}
.sm-px-6 {
padding-left: 24px !important;
padding-right: 24px !important
}
.sm-leading-8 {
line-height: 32px !important
}
}
.rtl-text-right:where([dir="rtl"], [dir="rtl"] *) {
text-align: right !important
}
</style>
</head>
<body style="margin: 0; width: 100%; background-color: #f6f6f6; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
<div style="display: none">
Firezone Gateway Update Available
</div>
<div role="article" aria-roledescription="email" aria-label="Firezone Gateway Update Available" lang="en">
<div class="sm-px-4" style="background-color: #f6f6f6; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
<table align="center" cellpadding="0" cellspacing="0" role="none">
<tr>
<td style="width: 552px; max-width: 100%">
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
<a href="https://firezone.dev">
<div>
<img src="https://www.firezone.dev/images/logo-lockup.png" width="250" alt="Firezone logo" style="max-width: 100%; vertical-align: middle; line-height: 1">
</div>
</a>
</div>
<table style="width: 100%;" cellpadding="0" cellspacing="0" role="none">
<tr>
<td class="sm-px-6" style="border-radius: 4px; background-color: #ffffff; padding: 48px; font-size: 16px; color: #4f4f4f; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)">
<h1 class="sm-leading-8" style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #000000">
Gateway Update Available!
</h1>
<p style="margin: 0; line-height: 24px">
The latest Firezone Gateway release is: <%= @latest_version %>
</p>
<div role="separator" style="line-height: 16px">&zwj;</div>
<p style="margin: 0; line-height: 24px;">
The following list of Gateways in your Firezone Account can be updated.
</p>
<div role="separator" style="background-color: #d1d1d1; height: 1px; line-height: 1px; margin: 32px 0">&zwj;</div>
<p style="margin: 0; line-height: 24px;">
<span style="margin-bottom: 32px; font-weight: 500; text-decoration-line: underline; text-underline-offset: 2px">Outdated Gateways</span>
</p>
<table class="rtl-text-right" style="width: 100%; text-align: left; font-size: 14px; color: #6d6d6d" cellpadding="0" cellspacing="0" role="none">
<%= for gateway <- @gateways do %>
<tr style="border-bottom-width: 1px; background-color: #ffffff">
<th scope="row" style="white-space: nowrap; padding-left: 24px; padding-right: 24px; font-weight: 500; color: #3d3d3d"><%= gateway.name %></th>
<td style="padding: 4px 24px"><%= gateway.last_seen_version %></td>
</tr>
<% end %>
</table>
<p></p>
<div role="separator" style="background-color: #d1d1d1; height: 1px; line-height: 1px; margin: 32px 0;">&zwj;</div>
<p style="margin: 0; line-height: 24px;">
We recommend updating the Gateways at your earliest convenience. Help on how to update Gateways can be found
in our <a href="https://www.firezone.dev/kb/administer/upgrading">Firezone Gateway Upgrade Guide</a>
<br>
<br>
Thanks, <br>The Firezone Team
</p>
<div role="separator" style="line-height: 16px">&zwj;</div>
</td>
</tr>
<tr role="separator">
<td style="line-height: 48px">&zwj;</td>
</tr>
<tr>
<td style="padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #575757">
<p style="margin: 0; font-style: italic">
Blazing-fast alternative to legacy VPNs
</p>
<p style="cursor: default">
<a href="https://www.firezone.dev/kb" class="hover-important-text-decoration-underline" style="color: #37007f; text-decoration: none">Docs</a>
&bull;
<a href="https://github.com/firezone" class="hover-important-text-decoration-underline" style="color: #37007f; text-decoration: none;">Github</a>
&bull;
<a href="https://x.com/firezonehq" class="hover-important-text-decoration-underline" style="color: #37007f; text-decoration: none;">X</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</div>
</body>
</html>

View File

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

View File

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

View File

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

View File

@@ -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()

View File

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

View File

@@ -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()

View File

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

View File

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

View File

@@ -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)

View File

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

View File

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

View File

@@ -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
</:content>
</.section>
<.section>
<:title>
Notifications
</:title>
<:action>
<.edit_button
:if={@account_type != "Starter"}
navigate={~p"/#{@account}/settings/account/notifications/edit"}
>
Edit Notifications
</.edit_button>
<.upgrade_badge :if={@account_type == "Starter"} account={@account} />
</:action>
<:content>
<div class="relative overflow-x-auto">
<.notifications_table notifications={@account.config.notifications} />
</div>
</:content>
</.section>
<.section>
<:title>
Danger zone
@@ -66,4 +87,49 @@ defmodule Web.Settings.Account do
</.section>
"""
end
defp notifications_table(assigns) do
~H"""
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-neutral-500">
<thead class="text-xs text-neutral-700 uppercase bg-neutral-50">
<tr>
<th class="px-4 py-3 font-medium">Notification Type</th>
<th class="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
<tr class="border-b">
<td class="px-4 py-3">
Gateway Upgrade Available
</td>
<td class="px-4 py-3">
<.notification_badge notification={
Map.get(@notifications || %{}, :outdated_gateway, %{enabled: false})
} />
</td>
</tr>
</tbody>
</table>
</div>
"""
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
</.badge>
</.link>
"""
end
defp notification_badge(assigns) do
~H"""
<.badge type={if @notification.enabled, do: "success", else: "neutral"}>
<%= if @notification.enabled, do: "Enabled", else: "Disabled" %>
</.badge>
"""
end
end

View File

@@ -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>
<.breadcrumb path={~p"/#{@account}/settings/account/edit"}>
Edit Notifications
</.breadcrumb>
</.breadcrumbs>
<.section>
<:title>
Edit Notifications
</:title>
<:content>
<div class="max-w-2xl px-4 py-8 mx-auto lg:py-16">
<h2 class="mb-4 text-xl text-neutral-900">Edit account notifications</h2>
<.form for={@form} phx-change={:change} phx-submit={:submit}>
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
<.inputs_for :let={config} field={@form[:config]}>
<.inputs_for :let={notifications} field={config[:notifications]}>
<table class="w-full text-sm text-left text-neutral-500">
<thead class="text-xs text-neutral-700 uppercase bg-neutral-50">
<tr>
<th scope="col" class="px-6 py-3 font-medium">
Notification
</th>
<th scope="col" class="px-6 py-3 font-medium text-center">
Enable
</th>
<th scope="col" class="px-6 py-3 font-medium text-center">
Disable
</th>
</tr>
</thead>
<tbody>
<tr class="bg-white border-b">
<.inputs_for :let={outdated_gateway} field={notifications[:outdated_gateway]}>
<td scope="row" class="px-6 py-4 whitespace-nowrap">
Outdated Gateways
</td>
<td class="px-6 py-4 text-center">
<.input
class="mx-auto"
type="radio"
field={outdated_gateway[:enabled]}
value="true"
checked={outdated_gateway[:enabled].value == true}
/>
</td>
<td class="px-6 py-4 text-center">
<.input
class="mx-auto"
type="radio"
field={outdated_gateway[:enabled]}
value="false"
checked={outdated_gateway[:enabled].value != true}
/>
</td>
</.inputs_for>
</tr>
</tbody>
</table>
</.inputs_for>
</.inputs_for>
</div>
<.submit_button>
Save
</.submit_button>
</.form>
</div>
</:content>
</.section>
"""
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

View File

@@ -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
<div>
<.inputs_for :let={config} field={@form[:config]}>
<.inputs_for :let={dns} field={config[:clients_upstream_dns]}>
<input
type="hidden"
name={"#{config.name}[clients_upstream_dns_sort][]"}
value={dns.index}
/>
<div class="flex gap-4 items-start mb-2">
<div class="w-1/4">
<div class="w-3/12">
<.input
type="select"
label="Protocol"
@@ -69,21 +74,42 @@ defmodule Web.Settings.DNS do
value={dns[:protocol].value}
/>
</div>
<div class="w-3/4">
<div class="w-8/12">
<.input
label="Address"
field={dns[:address]}
placeholder="DNS Server Address"
/>
</div>
<div class="w-1/12 flex">
<div class="pt-7">
<button
type="button"
name={"#{config.name}[clients_upstream_dns_drop][]"}
value={dns.index}
phx-click={JS.dispatch("change")}
>
<.icon name="hero-trash" class="text-red-500 w-6 h-6 relative top-2" />
</button>
</div>
</div>
</div>
</.inputs_for>
<% errors =
translate_errors(
@form.source.changes.config.errors,
:clients_upstream_dns
) %>
<.error :for={error <- errors} data-validation-error-for="clients_upstream_dns">
<input type="hidden" name={"#{config.name}[clients_upstream_dns_drop][]"} />
<.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
</.button>
<.error
:for={error <- dns_config_errors(@form.source.changes)}
data-validation-error-for="clients_upstream_dns"
>
<%= error %>
</.error>
</.inputs_for>
@@ -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

View File

@@ -84,11 +84,14 @@ defmodule Web.Sites.Gateways.Index do
<%= gateway.name %>
</.link>
</:col>
<:col :let={gateway} label="remote iP">
<:col :let={gateway} label="remote ip">
<code>
<%= gateway.last_seen_remote_ip %>
</code>
</:col>
<:col :let={gateway} label="version">
<%= gateway.last_seen_version %>
</:col>
<:col :let={gateway} label="status">
<.connection_status schema={gateway} />
</:col>

View File

@@ -180,6 +180,10 @@ defmodule Web.Sites.Show do
<%= gateway.last_seen_remote_ip %>
</code>
</:col>
<:col :let={gateway} label="version">
<.version_status outdated={Gateways.gateway_outdated?(gateway)} />
<%= gateway.last_seen_version %>
</:col>
<:col :let={gateway} label="status">
<.connection_status schema={gateway} />
</:col>
@@ -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

View File

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

View File

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

View File

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

View File

@@ -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: %{

View File

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

View File

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

View File

@@ -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: []

View File

@@ -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"