From edc80129c892e4e1706e457caca46d5264199406 Mon Sep 17 00:00:00 2001 From: Brian Manifold Date: Mon, 29 Jul 2024 18:06:59 -0400 Subject: [PATCH] feat(portal): Add REST API closed beta page (#6027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: * Before the REST API is release to all Firezone users a closed beta program will be run. Rather than blurring out the API Clients page for users that are not apart of the closed beta program, a 'beta' page will be shown that will allow users to request access to the closed beta. Once the REST API is released to all accounts, all of this can be removed. Closes: #5920 ### Screenshot Screenshot 2024-07-24 at 6 55 36 PM --- .../lib/web/components/layouts/app.html.heex | 5 +- .../lib/web/live/settings/api_clients/beta.ex | 73 ++++++++++++++ .../lib/web/live/settings/api_clients/edit.ex | 29 +++--- .../web/live/settings/api_clients/index.ex | 43 ++++---- .../lib/web/live/settings/api_clients/new.ex | 21 ++-- .../lib/web/live/settings/api_clients/show.ex | 33 ++++--- elixir/apps/web/lib/web/mailer.ex | 7 ++ elixir/apps/web/lib/web/mailer/beta_email.ex | 39 ++++++++ .../beta_email/rest_api_request.text.heex | 10 ++ elixir/apps/web/lib/web/router.ex | 1 + .../live/settings/api_clients/beta_test.exs | 98 +++++++++++++++++++ .../live/settings/api_clients/index_test.exs | 17 ++-- .../live/settings/api_clients/new_test.exs | 18 ++++ 13 files changed, 322 insertions(+), 72 deletions(-) create mode 100644 elixir/apps/web/lib/web/live/settings/api_clients/beta.ex create mode 100644 elixir/apps/web/lib/web/mailer/beta_email.ex create mode 100644 elixir/apps/web/lib/web/mailer/beta_email/rest_api_request.text.heex create mode 100644 elixir/apps/web/test/web/live/settings/api_clients/beta_test.exs diff --git a/elixir/apps/web/lib/web/components/layouts/app.html.heex b/elixir/apps/web/lib/web/components/layouts/app.html.heex index c37bb1650..5fd597446 100644 --- a/elixir/apps/web/lib/web/components/layouts/app.html.heex +++ b/elixir/apps/web/lib/web/components/layouts/app.html.heex @@ -72,10 +72,7 @@ Identity Providers <:item navigate={~p"/#{@account}/settings/dns"}>DNS - <:item - :if={Domain.Accounts.rest_api_enabled?(@account)} - navigate={~p"/#{@account}/settings/api_clients"} - > + <:item navigate={~p"/#{@account}/settings/api_clients"}> API Clients diff --git a/elixir/apps/web/lib/web/live/settings/api_clients/beta.ex b/elixir/apps/web/lib/web/live/settings/api_clients/beta.ex new file mode 100644 index 000000000..4380a7c18 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/api_clients/beta.ex @@ -0,0 +1,73 @@ +defmodule Web.Settings.ApiClients.Beta do + use Web, :live_view + + def mount(_params, _session, socket) do + if Domain.Accounts.rest_api_enabled?(socket.assigns.account) do + {:ok, push_navigate(socket, to: ~p"/#{socket.assigns.account}/settings/api_clients")} + else + socket = + socket + |> assign(:page_title, "API Clients") + |> assign(:requested, false) + + {:ok, socket} + end + end + + def render(assigns) do + ~H""" + <.breadcrumbs account={@account}> + <.breadcrumb path={~p"/#{@account}/settings/api_clients"}>API Clients + <.breadcrumb path={~p"/#{@account}/settings/api_clients/beta"}>Beta + + + <.section> + <:title><%= @page_title %> + <:help> + API Clients are used to manage Firezone configuration through a REST API. See our + interactive API docs + + <:content> + <.flash kind={:info}> +

+ REST API Beta +

+ The REST API is currently in closed beta. + +

+ + Click here + + to request access. +

+
+ +

+ Your request to join the closed beta has been made. +

+
+ + + + """ + end + + def handle_event("request_access", _params, socket) do + Web.Mailer.BetaEmail.rest_api_beta_email( + socket.assigns.account, + socket.assigns.subject + ) + |> Web.Mailer.deliver() + + socket = + socket + |> assign(:requested, true) + + {:noreply, socket} + end +end diff --git a/elixir/apps/web/lib/web/live/settings/api_clients/edit.ex b/elixir/apps/web/lib/web/live/settings/api_clients/edit.ex index 32f65a8ff..ff5d97579 100644 --- a/elixir/apps/web/lib/web/live/settings/api_clients/edit.ex +++ b/elixir/apps/web/lib/web/live/settings/api_clients/edit.ex @@ -4,23 +4,24 @@ defmodule Web.Settings.ApiClients.Edit do alias Domain.Actors def mount(%{"id" => id}, _session, socket) do - unless Domain.Config.global_feature_enabled?(:rest_api), - do: raise(Web.LiveErrors.NotFoundError) + if Domain.Accounts.rest_api_enabled?(socket.assigns.account) do + with {:ok, actor} <- Actors.fetch_actor_by_id(id, socket.assigns.subject, preload: []), + nil <- actor.deleted_at do + changeset = Actors.change_actor(actor) - with {:ok, actor} <- Actors.fetch_actor_by_id(id, socket.assigns.subject, preload: []), - nil <- actor.deleted_at do - changeset = Actors.change_actor(actor) + socket = + assign(socket, + actor: actor, + form: to_form(changeset), + page_title: "Edit #{actor.name}" + ) - socket = - assign(socket, - actor: actor, - form: to_form(changeset), - page_title: "Edit #{actor.name}" - ) - - {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} + else + _other -> raise Web.LiveErrors.NotFoundError + end else - _other -> raise Web.LiveErrors.NotFoundError + {:ok, push_navigate(socket, to: ~p"/#{socket.assigns.account}/settings/api_clients/beta")} end end diff --git a/elixir/apps/web/lib/web/live/settings/api_clients/index.ex b/elixir/apps/web/lib/web/live/settings/api_clients/index.ex index d964e71e3..c974f476e 100644 --- a/elixir/apps/web/lib/web/live/settings/api_clients/index.ex +++ b/elixir/apps/web/lib/web/live/settings/api_clients/index.ex @@ -3,28 +3,29 @@ defmodule Web.Settings.ApiClients.Index do alias Domain.Actors def mount(_params, _session, socket) do - unless Domain.Config.global_feature_enabled?(:rest_api), - do: raise(Web.LiveErrors.NotFoundError) + if Domain.Accounts.rest_api_enabled?(socket.assigns.account) do + socket = + socket + |> assign(page_title: "API Clients") + |> assign_live_table("actors", + query_module: Actors.Actor.Query, + sortable_fields: [ + {:actors, :name}, + {:actors, :status} + ], + enforce_filters: [ + {:type, "api_client"} + ], + hide_filters: [ + :provider_id + ], + callback: &handle_api_clients_update!/2 + ) - socket = - socket - |> assign(page_title: "API Clients") - |> assign_live_table("actors", - query_module: Actors.Actor.Query, - sortable_fields: [ - {:actors, :name}, - {:actors, :status} - ], - enforce_filters: [ - {:type, "api_client"} - ], - hide_filters: [ - :provider_id - ], - callback: &handle_api_clients_update!/2 - ) - - {:ok, socket} + {:ok, socket} + else + {:ok, push_navigate(socket, to: ~p"/#{socket.assigns.account}/settings/api_clients/beta")} + end end def handle_params(params, uri, socket) do diff --git a/elixir/apps/web/lib/web/live/settings/api_clients/new.ex b/elixir/apps/web/lib/web/live/settings/api_clients/new.ex index a96039569..2f2610526 100644 --- a/elixir/apps/web/lib/web/live/settings/api_clients/new.ex +++ b/elixir/apps/web/lib/web/live/settings/api_clients/new.ex @@ -4,18 +4,19 @@ defmodule Web.Settings.ApiClients.New do alias Domain.Actors def mount(_params, _session, socket) do - unless Domain.Config.global_feature_enabled?(:rest_api), - do: raise(Web.LiveErrors.NotFoundError) + if Domain.Accounts.rest_api_enabled?(socket.assigns.account) do + changeset = Actors.new_actor(%{type: :api_client}) - changeset = Actors.new_actor(%{type: :api_client}) + socket = + assign(socket, + form: to_form(changeset), + page_title: "New API Client" + ) - socket = - assign(socket, - form: to_form(changeset), - page_title: "New API Client" - ) - - {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} + {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} + else + {:ok, push_navigate(socket, to: ~p"/#{socket.assigns.account}/settings/api_clients/beta")} + end end def render(assigns) do diff --git a/elixir/apps/web/lib/web/live/settings/api_clients/show.ex b/elixir/apps/web/lib/web/live/settings/api_clients/show.ex index 9d1cfcb38..c981c6c51 100644 --- a/elixir/apps/web/lib/web/live/settings/api_clients/show.ex +++ b/elixir/apps/web/lib/web/live/settings/api_clients/show.ex @@ -3,23 +3,24 @@ defmodule Web.Settings.ApiClients.Show do alias Domain.{Actors, Tokens} def mount(%{"id" => id}, _session, socket) do - unless Domain.Config.global_feature_enabled?(:rest_api), - do: raise(Web.LiveErrors.NotFoundError) + if Domain.Accounts.rest_api_enabled?(socket.assigns.account) do + with {:ok, actor} <- Actors.fetch_actor_by_id(id, socket.assigns.subject, preload: []) do + socket = + socket + |> assign( + actor: actor, + page_title: "API Client #{actor.name}" + ) + |> assign_live_table("tokens", + query_module: Tokens.Token.Query, + sortable_fields: [], + callback: &handle_tokens_update!/2 + ) - with {:ok, actor} <- Actors.fetch_actor_by_id(id, socket.assigns.subject, preload: []) do - socket = - socket - |> assign( - actor: actor, - page_title: "API Client #{actor.name}" - ) - |> assign_live_table("tokens", - query_module: Tokens.Token.Query, - sortable_fields: [], - callback: &handle_tokens_update!/2 - ) - - {:ok, socket} + {:ok, socket} + end + else + {:ok, push_navigate(socket, to: ~p"/#{socket.assigns.account}/settings/api_clients/beta")} end end diff --git a/elixir/apps/web/lib/web/mailer.ex b/elixir/apps/web/lib/web/mailer.ex index b97373f8b..e6b708dac 100644 --- a/elixir/apps/web/lib/web/mailer.ex +++ b/elixir/apps/web/lib/web/mailer.ex @@ -72,6 +72,13 @@ defmodule Web.Mailer do |> Email.text_body(render_template(view, template, "text", assigns)) end + def render_text_body(%Swoosh.Email{} = email, view, template, assigns) do + assigns = assigns ++ [email: email] + + email + |> Email.text_body(render_template(view, template, "text", assigns)) + end + def active? do mailer_config = Domain.Config.fetch_env!(:web, Web.Mailer) mailer_config[:from_email] && mailer_config[:adapter] diff --git a/elixir/apps/web/lib/web/mailer/beta_email.ex b/elixir/apps/web/lib/web/mailer/beta_email.ex new file mode 100644 index 000000000..8cdd809dc --- /dev/null +++ b/elixir/apps/web/lib/web/mailer/beta_email.ex @@ -0,0 +1,39 @@ +defmodule Web.Mailer.BetaEmail do + use Web, :html + import Swoosh.Email + import Web.Mailer + + embed_templates "beta_email/*.text", suffix: "_text" + + def rest_api_beta_email( + %Domain.Accounts.Account{} = account, + %Domain.Auth.Subject{} = subject + ) do + default_email() + |> subject("REST API Beta Request - #{account.slug}") + |> to("support@firezone.dev") + |> render_text_body(__MODULE__, :rest_api_request, + account: account, + subject: subject + ) + end + + # def sign_up_link_email( + # %Domain.Accounts.Account{} = account, + # %Domain.Auth.Identity{} = identity, + # user_agent, + # remote_ip + # ) do + # sign_in_form_url = url(~p"/#{account}") + + # default_email() + # |> subject("Welcome to Firezone") + # |> to(identity.provider_identifier) + # |> render_body(__MODULE__, :sign_up_link, + # account: account, + # sign_in_form_url: sign_in_form_url, + # user_agent: user_agent, + # remote_ip: "#{:inet.ntoa(remote_ip)}" + # ) + # end +end diff --git a/elixir/apps/web/lib/web/mailer/beta_email/rest_api_request.text.heex b/elixir/apps/web/lib/web/mailer/beta_email/rest_api_request.text.heex new file mode 100644 index 000000000..33491b0ed --- /dev/null +++ b/elixir/apps/web/lib/web/mailer/beta_email/rest_api_request.text.heex @@ -0,0 +1,10 @@ +REST API Beta Request + +Request details: +---------------- +Account ID: <%= @account.id %> +Account Slug: <%= @account.slug %> +Account Name: <%= @account.name %> +Actor ID: <%= @subject.actor.id %> +Actor Name: <%= @subject.actor.name %> +Identifier: <%= @subject.identity.provider_identifier %> diff --git a/elixir/apps/web/lib/web/router.ex b/elixir/apps/web/lib/web/router.ex index c73effce1..a27e39ae1 100644 --- a/elixir/apps/web/lib/web/router.ex +++ b/elixir/apps/web/lib/web/router.ex @@ -209,6 +209,7 @@ defmodule Web.Router do live "/billing", Billing scope "/api_clients", ApiClients do + live "/beta", Beta live "/", Index live "/new", New live "/:id/new_token", NewToken diff --git a/elixir/apps/web/test/web/live/settings/api_clients/beta_test.exs b/elixir/apps/web/test/web/live/settings/api_clients/beta_test.exs new file mode 100644 index 000000000..8f46677a3 --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/api_clients/beta_test.exs @@ -0,0 +1,98 @@ +defmodule Web.Live.Settings.ApiClients.BetaTest do + use Web.ConnCase, async: true + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: [type: :account_admin_user]) + + %{ + account: account, + actor: actor, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{account: account, conn: conn} do + path = ~p"/#{account}/settings/api_clients/beta" + + assert live(conn, path) == + {:error, + {:redirect, + %{ + to: ~p"/#{account}?#{%{redirect_to: path}}", + flash: %{"error" => "You must sign in to access this page."} + }}} + end + + test "redirects to API client index when feature enabled", %{ + account: account, + identity: identity, + conn: conn + } do + assert {:error, {:live_redirect, %{to: path, flash: _}}} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/api_clients/beta") + + assert path == ~p"/#{account}/settings/api_clients" + end + + test "renders breadcrumbs item", %{ + account: account, + identity: identity, + conn: conn + } do + features = Map.from_struct(account.features) + attrs = %{features: %{features | rest_api: false}} + + {:ok, account} = Domain.Accounts.update_account(account, attrs) + + {:ok, _lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/api_clients/beta") + + assert item = Floki.find(html, "[aria-label='Breadcrumb']") + breadcrumbs = String.trim(Floki.text(item)) + assert breadcrumbs =~ "API Clients" + assert breadcrumbs =~ "Beta" + end + + test "sends beta request email", %{ + account: account, + identity: identity, + conn: conn + } do + attrs = %{ + features: %{ + rest_api: false, + traffic_filters: true, + flow_activities: true, + policy_conditions: true, + multi_site_resources: true, + idp_sync: true + } + } + + {:ok, account} = Domain.Accounts.update_account(account, attrs) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/api_clients/beta") + + assert lv + |> element("#beta-request") + |> render_click() + |> Floki.find(".flash-info") + |> element_to_text() =~ "request to join" + + assert_email_sent(fn email -> + assert email.subject == "REST API Beta Request - #{account.slug}" + assert email.text_body =~ "REST API Beta Request" + assert email.text_body =~ "#{account.id}" + assert email.text_body =~ "#{account.slug}" + end) + end +end diff --git a/elixir/apps/web/test/web/live/settings/api_clients/index_test.exs b/elixir/apps/web/test/web/live/settings/api_clients/index_test.exs index 16f90fe5e..1a274b19c 100644 --- a/elixir/apps/web/test/web/live/settings/api_clients/index_test.exs +++ b/elixir/apps/web/test/web/live/settings/api_clients/index_test.exs @@ -27,19 +27,22 @@ defmodule Web.Live.Settings.ApiClients.IndexTest do }}} end - test "does not display API clients link when feature disabled", %{ + test "redirects to beta page when feature not enabled for account", %{ account: account, identity: identity, conn: conn } do - Domain.Config.feature_flag_override(:rest_api, false) + features = Map.from_struct(account.features) + attrs = %{features: %{features | rest_api: false}} - {:ok, _lv, html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/sites") + {:ok, account} = Domain.Accounts.update_account(account, attrs) - assert Floki.find(html, "a[href=\"/#{account.slug}/settings/api_clients\"]") == [] + assert {:error, {:live_redirect, %{to: path, flash: _}}} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/api_clients") + + assert path == ~p"/#{account}/settings/api_clients/beta" end test "renders breadcrumbs item", %{ diff --git a/elixir/apps/web/test/web/live/settings/api_clients/new_test.exs b/elixir/apps/web/test/web/live/settings/api_clients/new_test.exs index af6930807..573530bd8 100644 --- a/elixir/apps/web/test/web/live/settings/api_clients/new_test.exs +++ b/elixir/apps/web/test/web/live/settings/api_clients/new_test.exs @@ -28,6 +28,24 @@ defmodule Web.Live.Settings.ApiClient.NewTest do }}} end + test "redirects to beta page when feature not enabled for account", %{ + account: account, + identity: identity, + conn: conn + } do + features = Map.from_struct(account.features) + attrs = %{features: %{features | rest_api: false}} + + {:ok, account} = Domain.Accounts.update_account(account, attrs) + + assert {:error, {:live_redirect, %{to: path, flash: _}}} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/api_clients/new") + + assert path == ~p"/#{account}/settings/api_clients/beta" + end + test "renders breadcrumbs item", %{ account: account, identity: identity,