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,