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 <jamilbk@users.noreply.github.com>
This commit is contained in:
bmanifold
2023-08-28 10:43:01 -04:00
committed by GitHub
parent f4ff3eb571
commit 58e0fb2032
14 changed files with 406 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"""
<section class="bg-gray-50 dark:bg-gray-900">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto lg:py-0">
<.logo />
<div class="w-full col-span-6 mx-auto bg-white rounded-lg shadow dark:bg-gray-800 md:mt-0 sm:max-w-lg xl:p-0">
<div class="p-6 space-y-4 lg:space-y-6 sm:p-8">
<h1 class="text-center text-xl font-bold leading-tight tracking-tight text-gray-900 sm:text-2xl dark:text-white">
Welcome to Firezone
</h1>
<.flash flash={@flash} kind={:error} />
<.flash flash={@flash} kind={:info} />
<.intersperse_blocks>
<:separator>
<.separator />
</:separator>
<:item>
<.sign_up_form :if={@account == nil} flash={@flash} form={@form} />
<.welcome :if={@account} account={@account} />
</:item>
</.intersperse_blocks>
</div>
</div>
</div>
</section>
"""
end
def separator(assigns) do
~H"""
<div class="flex items-center">
<div class="w-full h-0.5 bg-gray-200 dark:bg-gray-700"></div>
<div class="px-5 text-center text-gray-500 dark:text-gray-400">or</div>
<div class="w-full h-0.5 bg-gray-200 dark:bg-gray-700"></div>
</div>
"""
end
def welcome(assigns) do
~H"""
<div class="space-y-6">
<div class="text-center text-gray-900 dark:text-white">
Your account has been created!
</div>
<div class="text-center">
<div class="px-12">
<table class="border-collapse table-fixed w-full text-sm">
<tbody>
<tr>
<td class={~w[border-b border-slate-100 dark:border-slate-700
p-4 pl-8 text-gray-900 dark:text-white]}>
Account Name:
</td>
<td class={~w[border-b border-slate-100 dark:border-slate-700
p-4 pl-8 text-gray-900 dark:text-white]}>
<%= @account.name %>
</td>
</tr>
<tr>
<td class={~w[border-b border-slate-100 dark:border-slate-700
p-4 pl-8 text-gray-900 dark:text-white]}>
Account Slug:
</td>
<td class={~w[border-b border-slate-100 dark:border-slate-700
p-4 pl-8 text-gray-900 dark:text-white]}>
<%= @account.slug %>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="text-base leading-7 text-center text-gray-900 dark:text-white">
<div>
Sign In URL
</div>
<div>
<.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" %>
</.link>
</div>
</div>
</div>
"""
end
def sign_up_form(assigns) do
~H"""
<h3 class="text-center text-m font-bold leading-tight tracking-tight text-gray-900 sm:text-xl dark:text-white">
Sign Up Now
</h3>
<.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>
<.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" />
</.inputs_for>
<.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
</.button>
</:actions>
<p class="text-xs text-center">
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</.link>.
</p>
</.simple_form>
"""
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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", %{

View File

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