mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-28 02:18:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
253
elixir/apps/web/lib/web/live/sign_up.ex
Normal file
253
elixir/apps/web/lib/web/live/sign_up.ex
Normal 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
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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", %{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user