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"""
+
+ 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} />
+
+
+
| + Account Name: + | ++ <%= @account.name %> + | +
| + Account Slug: + | ++ <%= @account.slug %> + | +
+ 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,