From 58e0fb2032600c65524ab395ffd8bc026355286b Mon Sep 17 00:00:00 2001 From: bmanifold Date: Mon, 28 Aug 2023 10:43:01 -0400 Subject: [PATCH] Add Sign Up page (#1939) The Sign Up page will allow users to create new organization accounts. During sign-up, a randomly generated slug will be created for the account and "magic link" will be set as the first identity provider to allow the user to login to the newly created account. --------- Co-authored-by: Jamil --- elixir/apps/domain/lib/domain/accounts.ex | 10 + .../lib/domain/accounts/account/changeset.ex | 39 ++- .../lib/domain/actors/actor/changeset.ex | 10 +- .../domain/lib/domain/config/definitions.ex | 47 ++-- .../apps/domain/lib/domain/name_generator.ex | 5 + elixir/apps/domain/priv/repo/seeds.exs | 2 +- .../apps/domain/test/domain/accounts_test.exs | 54 ++++ elixir/apps/web/lib/web/live/sign_up.ex | 253 ++++++++++++++++++ elixir/apps/web/lib/web/router.ex | 6 + .../test/web/acceptance/auth/email_test.exs | 2 +- .../acceptance/auth/openid_connect_test.exs | 2 +- .../web/acceptance/auth/userpass_test.exs | 2 +- elixir/apps/web/test/web/auth_test.exs | 5 +- .../web/controllers/auth_controller_test.exs | 6 +- 14 files changed, 406 insertions(+), 37 deletions(-) create mode 100644 elixir/apps/web/lib/web/live/sign_up.ex diff --git a/elixir/apps/domain/lib/domain/accounts.ex b/elixir/apps/domain/lib/domain/accounts.ex index e6fe30e39..c7c84be61 100644 --- a/elixir/apps/domain/lib/domain/accounts.ex +++ b/elixir/apps/domain/lib/domain/accounts.ex @@ -61,4 +61,14 @@ defmodule Domain.Accounts do {:error, :unauthorized} end end + + def generate_unique_slug do + slug_candidate = Domain.NameGenerator.generate_slug() + + if Account.Query.by_slug(slug_candidate) |> Repo.exists?() do + generate_unique_slug() + else + slug_candidate + end + end end diff --git a/elixir/apps/domain/lib/domain/accounts/account/changeset.ex b/elixir/apps/domain/lib/domain/accounts/account/changeset.ex index 68aacad53..a2687d7bb 100644 --- a/elixir/apps/domain/lib/domain/accounts/account/changeset.ex +++ b/elixir/apps/domain/lib/domain/accounts/account/changeset.ex @@ -2,22 +2,49 @@ defmodule Domain.Accounts.Account.Changeset do use Domain, :changeset alias Domain.Accounts.Account - def create_changeset(attrs) do - %Account{} + def changeset(account, attrs) do + account |> cast(attrs, [:name, :slug]) |> validate_required([:name]) - |> validate_length(:name, min: 1, max: 255) + |> validate_name() + |> trim_change(:name) + |> prepare_changes(fn changeset -> put_slug_default(changeset) end) + |> downcase_slug() |> validate_slug() - |> validate_length(:slug, min: 3, max: 100) + |> unique_constraint(:slug, name: :accounts_slug_index) + end + + def create_changeset(attrs) do + %Account{} + |> changeset(attrs) + end + + defp validate_name(changeset) do + changeset + |> validate_length(:name, min: 3, max: 64) + end + + defp put_slug_default(changeset) do + changeset + |> put_default_value(:slug, &Domain.Accounts.generate_unique_slug/0) end defp validate_slug(changeset) do - validate_change(changeset, :slug, fn field, slug -> + changeset + |> validate_length(:slug, min: 3, max: 100) + |> validate_format(:slug, ~r/^[a-zA-Z0-9_]+$/, + message: "can only contain letters, numbers, and underscores" + ) + |> validate_change(:slug, fn field, slug -> if valid_uuid?(slug) do - [{field, "must can not be a valid UUID"}] + [{field, "cannot be a valid UUID"}] else [] end end) end + + defp downcase_slug(changeset) do + update_change(changeset, :slug, &String.downcase/1) + end end diff --git a/elixir/apps/domain/lib/domain/actors/actor/changeset.ex b/elixir/apps/domain/lib/domain/actors/actor/changeset.ex index ece84ff2c..5c8908eaa 100644 --- a/elixir/apps/domain/lib/domain/actors/actor/changeset.ex +++ b/elixir/apps/domain/lib/domain/actors/actor/changeset.ex @@ -2,10 +2,16 @@ defmodule Domain.Actors.Actor.Changeset do use Domain, :changeset alias Domain.Actors - def create_changeset(account_id, attrs) do - %Actors.Actor{} + def changeset(actor, attrs) do + actor |> cast(attrs, ~w[type name]a) |> validate_required(~w[type name]a) + |> validate_length(:name, min: 1, max: 255) + end + + def create_changeset(account_id, attrs) do + %Actors.Actor{} + |> changeset(attrs) |> put_change(:account_id, account_id) end diff --git a/elixir/apps/domain/lib/domain/config/definitions.ex b/elixir/apps/domain/lib/domain/config/definitions.ex index c19e56f6a..aabd135c5 100644 --- a/elixir/apps/domain/lib/domain/config/definitions.ex +++ b/elixir/apps/domain/lib/domain/config/definitions.ex @@ -32,6 +32,12 @@ defmodule Domain.Config.Definitions do alias Domain.Types alias Domain.Config.Logo + if Mix.env() in [:test, :dev] do + @local_development_adapters [Swoosh.Adapters.Local] + else + @local_development_adapters [] + end + def doc_sections do [ {"WebServer", @@ -506,26 +512,27 @@ defmodule Domain.Config.Definitions do :outbound_email_adapter, {:parameterized, Ecto.Enum, Ecto.Enum.init( - values: [ - Swoosh.Adapters.AmazonSES, - Swoosh.Adapters.CustomerIO, - Swoosh.Adapters.Dyn, - Swoosh.Adapters.ExAwsAmazonSES, - Swoosh.Adapters.Gmail, - Swoosh.Adapters.MailPace, - Swoosh.Adapters.Mailgun, - Swoosh.Adapters.Mailjet, - Swoosh.Adapters.Mandrill, - Swoosh.Adapters.Postmark, - Swoosh.Adapters.ProtonBridge, - Swoosh.Adapters.SMTP, - Swoosh.Adapters.SMTP2GO, - Swoosh.Adapters.Sendgrid, - Swoosh.Adapters.Sendinblue, - Swoosh.Adapters.Sendmail, - Swoosh.Adapters.SocketLabs, - Swoosh.Adapters.SparkPost - ] + values: + [ + Swoosh.Adapters.AmazonSES, + Swoosh.Adapters.CustomerIO, + Swoosh.Adapters.Dyn, + Swoosh.Adapters.ExAwsAmazonSES, + Swoosh.Adapters.Gmail, + Swoosh.Adapters.MailPace, + Swoosh.Adapters.Mailgun, + Swoosh.Adapters.Mailjet, + Swoosh.Adapters.Mandrill, + Swoosh.Adapters.Postmark, + Swoosh.Adapters.ProtonBridge, + Swoosh.Adapters.SMTP, + Swoosh.Adapters.SMTP2GO, + Swoosh.Adapters.Sendgrid, + Swoosh.Adapters.Sendinblue, + Swoosh.Adapters.Sendmail, + Swoosh.Adapters.SocketLabs, + Swoosh.Adapters.SparkPost + ] ++ @local_development_adapters )}, default: nil ) diff --git a/elixir/apps/domain/lib/domain/name_generator.ex b/elixir/apps/domain/lib/domain/name_generator.ex index 41041f4cd..4d64bcbd0 100644 --- a/elixir/apps/domain/lib/domain/name_generator.ex +++ b/elixir/apps/domain/lib/domain/name_generator.ex @@ -247,4 +247,9 @@ defmodule Domain.NameGenerator do def generate do "#{Enum.random(@adjectives)}-#{Enum.random(@nouns)}" end + + def generate_slug do + generate() + |> String.replace(~r/-/, "_") + end end diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index dfb44d898..bc9b95519 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -26,7 +26,7 @@ account = maybe_repo_update.(account, id: "c89bcc8c-9392-4dae-a40d-888aef6d28e0" {:ok, other_account} = Accounts.create_account(%{ name: "Other Corp Account", - slug: "not-firezone" + slug: "not_firezone" }) other_account = maybe_repo_update.(other_account, id: "9b9290bf-e1bc-4dd3-b401-511908262690") diff --git a/elixir/apps/domain/test/domain/accounts_test.exs b/elixir/apps/domain/test/domain/accounts_test.exs index 1505caf43..d0b987270 100644 --- a/elixir/apps/domain/test/domain/accounts_test.exs +++ b/elixir/apps/domain/test/domain/accounts_test.exs @@ -127,4 +127,58 @@ defmodule Domain.AccountsTest do assert ensure_has_access_to(subject, account) == {:error, :unauthorized} end end + + describe "create_account/1" do + test "creates account given a valid name" do + assert {:ok, account} = create_account(%{name: "foo"}) + assert account.name == "foo" + end + + test "creates account given a valid name and valid slug" do + assert {:ok, account1} = create_account(%{name: "foobar", slug: "foobar"}) + assert account1.slug == "foobar" + + assert {:ok, account2} = create_account(%{name: "foo1", slug: "foo1"}) + assert account2.slug == "foo1" + + assert {:ok, account3} = create_account(%{name: "foo_bar", slug: "foo_bar"}) + assert account3.slug == "foo_bar" + end + + test "returns error when account name is blank" do + assert {:error, changeset} = create_account(%{name: ""}) + assert errors_on(changeset) == %{name: ["can't be blank"]} + end + + test "returns error when account name is too long" do + max_name_length = 64 + assert {:ok, _account} = create_account(%{name: String.duplicate("a", max_name_length)}) + + assert {:error, changeset} = + create_account(%{name: String.duplicate("b", max_name_length + 1)}) + + assert errors_on(changeset) == %{name: ["should be at most 64 character(s)"]} + end + + test "returns error when account name is too short" do + assert {:error, changeset} = create_account(%{name: "a"}) + assert errors_on(changeset) == %{name: ["should be at least 3 character(s)"]} + end + + test "returns error when slug contains invalid characters" do + assert {:error, changeset} = create_account(%{name: "foo-bar", slug: "foo-bar"}) + + assert errors_on(changeset) == %{ + slug: ["can only contain letters, numbers, and underscores"] + } + end + + test "returns error when slug already exists" do + assert {:ok, _account} = create_account(%{name: "foo", slug: "foo"}) + + assert {:error, changeset} = create_account(%{name: "bar", slug: "foo"}) + + assert errors_on(changeset) == %{slug: ["has already been taken"]} + end + end end diff --git a/elixir/apps/web/lib/web/live/sign_up.ex b/elixir/apps/web/lib/web/live/sign_up.ex new file mode 100644 index 000000000..4dc9572fc --- /dev/null +++ b/elixir/apps/web/lib/web/live/sign_up.ex @@ -0,0 +1,253 @@ +defmodule Web.SignUp do + use Web, {:live_view, layout: {Web.Layouts, :public}} + + alias Domain.{Auth, Accounts, Actors} + alias Web.Registration + + defmodule Registration do + use Domain, :schema + + alias Domain.{Accounts, Actors} + + @primary_key false + + embedded_schema do + field(:email, :string) + embeds_one(:account, Accounts.Account) + embeds_one(:actor, Actors.Actor) + end + + def changeset(%Registration{} = registration, attrs) do + registration + |> Ecto.Changeset.cast(attrs, [:email]) + |> Ecto.Changeset.validate_format(:email, ~r/.+@.+/) + |> Ecto.Changeset.cast_embed(:account, with: &Accounts.Account.Changeset.changeset/2) + |> Ecto.Changeset.cast_embed(:actor, with: &Actors.Actor.Changeset.changeset/2) + end + end + + def mount(_params, _session, socket) do + changeset = + Registration.changeset(%Registration{}, %{ + account: %{slug: "placeholder"}, + actor: %{type: :account_admin_user} + }) + + {:ok, assign(socket, form: to_form(changeset), account: nil)} + end + + def render(assigns) do + ~H""" +
+
+ <.logo /> + +
+
+

+ Welcome to Firezone +

+ + <.flash flash={@flash} kind={:error} /> + <.flash flash={@flash} kind={:info} /> + + <.intersperse_blocks> + <:separator> + <.separator /> + + + <:item> + <.sign_up_form :if={@account == nil} flash={@flash} form={@form} /> + <.welcome :if={@account} account={@account} /> + + +
+
+
+
+ """ + end + + def separator(assigns) do + ~H""" +
+
+
or
+
+
+ """ + end + + def welcome(assigns) do + ~H""" +
+
+ Your account has been created! +
+
+
+ + + + + + + + + + + +
+ Account Name: + + <%= @account.name %> +
+ Account Slug: + + <%= @account.slug %> +
+
+
+
+
+ Sign In URL +
+
+ <.link + class="font-medium text-blue-600 dark:text-blue-500 hover:underline" + navigate={~p"/#{@account.slug}/sign_in"} + > + <%= "#{Web.Endpoint.url()}/#{@account.slug}/sign_in" %> + +
+
+
+ """ + end + + def sign_up_form(assigns) do + ~H""" +

+ Sign Up Now +

+ <.simple_form for={@form} class="space-y-4 lg:space-y-6" phx-submit="submit" phx-change="validate"> + <.inputs_for :let={account} field={@form[:account]}> + <.input + field={account[:name]} + type="text" + label="Account Name" + placeholder="Enter an Account Name here" + required + phx-debounce="300" + /> + <.input field={account[:slug]} type="hidden" /> + + + <.inputs_for :let={actor} field={@form[:actor]}> + <.input + field={actor[:name]} + type="text" + label="Your Name" + placeholder="Enter your name here" + required + phx-debounce="300" + /> + <.input field={actor[:type]} type="hidden" /> + + + <.input + field={@form[:email]} + type="text" + label="Email" + placeholder="Enter your email here" + required + phx-debounce="300" + /> + + <:actions> + <.button phx-disable-with="Creating Account..." class="w-full"> + Create Account + + +

+ By signing up you agree to our <.link + href="https://www.firezone.dev/terms" + class="text-blue-600 dark:text-blue-500 hover:underline" + >Terms of Use. +

+ + """ + end + + def handle_event("validate", %{"registration" => attrs}, socket) do + changeset = + %Registration{} + |> Registration.changeset(attrs) + |> Map.put(:action, :validate) + + socket = assign(socket, form: to_form(changeset)) + + {:noreply, socket} + end + + def handle_event("submit", %{"registration" => orig_attrs}, socket) do + attrs = put_in(orig_attrs, ["actor", "type"], :account_admin_user) + + changeset = + %Registration{} + |> Registration.changeset(attrs) + |> Map.put(:action, :insert) + + if changeset.valid? do + registration = Ecto.Changeset.apply_changes(changeset) + + multi = + Ecto.Multi.new() + |> Ecto.Multi.run( + :account, + fn _repo, _changes -> + Accounts.create_account(%{ + name: registration.account.name + }) + end + ) + |> Ecto.Multi.run( + :provider, + fn _repo, %{account: account} -> + Auth.create_provider(account, %{ + name: "Magic Link", + adapter: :email, + adapter_config: %{} + }) + end + ) + |> Ecto.Multi.run( + :actor, + fn _repo, %{provider: provider} -> + Actors.create_actor(provider, registration.email, %{ + type: :account_admin_user, + name: registration.actor.name + }) + end + ) + + case Domain.Repo.transaction(multi) do + {:ok, result} -> + socket = assign(socket, account: result.account) + {:noreply, socket} + + {:error, :account, err_changeset, _effects_so_far} -> + new_changeset = Ecto.Changeset.put_change(changeset, :account, err_changeset) + form = to_form(new_changeset) + + {:noreply, assign(socket, form: form)} + end + else + {:noreply, assign(socket, form: to_form(changeset))} + end + end +end diff --git a/elixir/apps/web/lib/web/router.ex b/elixir/apps/web/lib/web/router.ex index 06423406e..50153d829 100644 --- a/elixir/apps/web/lib/web/router.ex +++ b/elixir/apps/web/lib/web/router.ex @@ -46,6 +46,12 @@ defmodule Web.Router do end end + scope "/sign_up", Web do + pipe_through :browser + + live "/", SignUp + end + scope "/:account_id_or_slug/sign_in", Web do pipe_through [:browser, :redirect_if_user_is_authenticated] diff --git a/elixir/apps/web/test/web/acceptance/auth/email_test.exs b/elixir/apps/web/test/web/acceptance/auth/email_test.exs index 6ec053205..4fded075c 100644 --- a/elixir/apps/web/test/web/acceptance/auth/email_test.exs +++ b/elixir/apps/web/test/web/acceptance/auth/email_test.exs @@ -34,7 +34,7 @@ defmodule Web.Acceptance.Auth.EmailTest do session |> email_login_flow(account, identity.provider_identifier) |> assert_el(Query.css("#user-menu-button")) - |> assert_path(~p"/#{account}/dashboard") + |> assert_path(~p"/#{account.slug}/dashboard") |> Auth.assert_authenticated(identity) end diff --git a/elixir/apps/web/test/web/acceptance/auth/openid_connect_test.exs b/elixir/apps/web/test/web/acceptance/auth/openid_connect_test.exs index c05e36fd6..c944192d7 100644 --- a/elixir/apps/web/test/web/acceptance/auth/openid_connect_test.exs +++ b/elixir/apps/web/test/web/acceptance/auth/openid_connect_test.exs @@ -47,6 +47,6 @@ defmodule Web.Acceptance.Auth.OpenIDConnectTest do |> Vault.userpass_flow(oidc_login, oidc_password) |> assert_el(Query.css("#user-menu-button")) |> Auth.assert_authenticated(identity) - |> assert_path(~p"/#{account}/dashboard") + |> assert_path(~p"/#{account.slug}/dashboard") end end diff --git a/elixir/apps/web/test/web/acceptance/auth/userpass_test.exs b/elixir/apps/web/test/web/acceptance/auth/userpass_test.exs index c9b2661f0..c29027780 100644 --- a/elixir/apps/web/test/web/acceptance/auth/userpass_test.exs +++ b/elixir/apps/web/test/web/acceptance/auth/userpass_test.exs @@ -113,7 +113,7 @@ defmodule Web.Acceptance.Auth.UserPassTest do session |> password_login_flow(account, identity.provider_identifier, password) |> assert_el(Query.css("#user-menu-button")) - |> assert_path(~p"/#{account}/dashboard") + |> assert_path(~p"/#{account.slug}/dashboard") |> Auth.assert_authenticated(identity) end diff --git a/elixir/apps/web/test/web/auth_test.exs b/elixir/apps/web/test/web/auth_test.exs index f818358f1..26d479527 100644 --- a/elixir/apps/web/test/web/auth_test.exs +++ b/elixir/apps/web/test/web/auth_test.exs @@ -28,7 +28,7 @@ defmodule Web.AuthTest do describe "signed_in_path/1" do test "redirects to dashboard after sign in as account admin", %{admin_subject: subject} do - assert signed_in_path(subject) == ~p"/#{subject.account}/dashboard" + assert signed_in_path(subject) == ~p"/#{subject.account.slug}/dashboard" end end @@ -405,7 +405,8 @@ defmodule Web.AuthTest do assert {:halt, updated_socket} = on_mount(:redirect_if_user_is_authenticated, params, session, socket) - assert updated_socket.redirected == {:redirect, %{to: ~p"/#{subject.account}/dashboard"}} + assert updated_socket.redirected == + {:redirect, %{to: ~p"/#{subject.account.slug}/dashboard"}} end test "doesn't redirect if there is no authenticated user", %{ diff --git a/elixir/apps/web/test/web/controllers/auth_controller_test.exs b/elixir/apps/web/test/web/controllers/auth_controller_test.exs index 313fb0aa5..dfcbd1346 100644 --- a/elixir/apps/web/test/web/controllers/auth_controller_test.exs +++ b/elixir/apps/web/test/web/controllers/auth_controller_test.exs @@ -163,7 +163,7 @@ defmodule Web.AuthControllerTest do } ) - assert redirected_to(conn) == "/#{account.id}/dashboard" + assert redirected_to(conn) == "/#{account.slug}/dashboard" end test "renews the session when credentials are valid", %{conn: conn} do @@ -507,7 +507,7 @@ defmodule Web.AuthControllerTest do "secret" => identity.provider_virtual_state.sign_in_token }) - assert redirected_to(conn) == "/#{account.id}/dashboard" + assert redirected_to(conn) == "/#{account.slug}/dashboard" end test "redirects to the platform link when credentials are valid for account users", %{ @@ -799,7 +799,7 @@ defmodule Web.AuthControllerTest do "code" => "MyFakeCode" }) - assert redirected_to(conn) == "/#{account.id}/dashboard" + assert redirected_to(conn) == "/#{account.slug}/dashboard" assert %{ "live_socket_id" => "actors_sessions:" <> socket_id,