Sep 9, 2021

Restricting registrations when using Phx.Gen.Auth - Part 1

Phx.Gen.Auth is a great authentication solution when building out Phoenix applications. I’ve previously gone over some options for customizing the functionality and workflow around Phx.Gen.Auth. Today we’ll look at how we can restrict user registrations via a registration token.

We’ll create a fairly flexible solution:

  • Registration will require a registration token, which will be included in the URL for the registration page.
  • The token itself will come in a few different flavors:
    • The token can be generic and anyone who has the token / URL can register with it.
    • The token can be scoped to an email address, in which case it will only work when registering with the scoped email address.
    • An optional generated_by field is available which associates a token to an existing user. This could be useful if we want to allow existing users to generate tokens and invite other users to the application. Having the generated_by field provides a way to trace back thru the invites to see who has invited a particular user or group of users.

Note: I’m not a fan of “flexible” solutions unless there is a use case to support the flexibility. Adding extra scenarios because “you might need them” is a great way to add extra code, effort and bloat. If your requirements are more locked down I’d suggest a targeted approach. For instance if I only needed email scoped tokens, I would not implement non-email scoped tokens.

Anyway, enough preamble, let’s get down to some code!

Getting started

First off let’s create a new Phoenix project. I’m using the latest and greatest version of Phoenix, Phoenix 1.6 (which of this writing is in release candidate mode but seems super solid, see this post on the Elixir forum for further information on 1.6 and the release candidate. A simple mix archive.install hex phx_new 1.6.0-rc.0 will install what you need. If you are on an older version of Phoenix, you’ll need to install Phx.Gen.Auth as a dependency via your mix file as it was only merged into Phoenix as of version 1.6. There will also be some other minor differences you’ll encounter especially around templates as 1.6 introduced heex templates.

Terminal
mix phx.new reg_tokens

Note: add a --live flag to the above if using a Phoenix version older than 1.6.

Select yes when asked to install dependencies.

Now we can change into the directory where the application was created and build our database.

Terminal
cd reg_tokens
Terminal
mix do deps.get, ecto.create, ecto.migrate

Next we’ll generate the authentication code.

Terminal
mix phx.gen.auth Accounts User users

We’ll follow the instructions from the mix output:

Terminal
mix do deps.get, ecto.migrate

Finally let’s ensure all our tests are running out of the gate.

Terminal
mix test

Great! Everything is looking good, we’re ready to make our changes to the registration workflow. We’ll start off with the backend changes.

Updating the backend to restrict registrations

Starting with the data layer

Since we’ll be requiring a token in order to register, we need somewhere to store registration tokens. There is already a user_tokens table that gets created as part of phx.gen.auth.

We could look to re-use and augment this table to also work with registration tokens. I think it’s going to make more sense and be easier to create a specific table for the registration tokens however… so we’ll need a migration for that!

Terminal
mix ecto.gen.migration create_registration_tokens

The contents of the migration will be as follows.

/priv/repo/migrations/time_stamp_create_registration_tokens.exs
defmodule RegTokens.Repo.Migrations.CreateRegistrationTokens do
  use Ecto.Migration

  def change do
    create table(:registration_tokens) do
      add :token, :uuid, null: false
      add :used_by_user_id, references(:users, on_delete: :delete_all)
      add :scoped_to_email, :string
      add :generated_by_user_id, references(:users, on_delete: :delete_all)

      timestamps()
    end

    create unique_index(:registration_tokens, [:used_by_user_id])
  end
end

Pretty straight forward. We’re using a UUID for the actual token value / column. And we’ve created a used_by_user_id column and a generated_by_user_id column which have references back to the user table. These will contain references to the user who generated the token (if applicable) and the user who registered with the token. Then we have a scoped_to_email column for the scenario where we want to restrict the token to a particular email address. Everything is nullable other than the token column.

Finally we have a unique index on the used_by_user_id column as a user’s registration should only ever be associated with a single token.

Let’s run the migration.

Terminal
mix ecto.migrate

And now we can create the schema file.

Terminal
touch lib/reg_tokens/accounts/registration_token.ex
/lib/reg_tokens/accounts/registration_token.ex
defmodule RegTokens.Accounts.RegistrationToken do
  use Ecto.Schema

  alias RegTokens.Accounts.User

  schema "registration_tokens" do
    field :token, :binary_id
    field :scoped_to_email, :string
    belongs_to :used_by_user, User, foreign_key: :used_by_user_id
    belongs_to :generated_by_user, User, foreign_key: :generated_by_user_id

    timestamps()
  end
end

Simple, we’re just representing the database table as an Ecto Schema.

That’s it for the data layer… let’s start building out the context functions.

The context functions

We need two things out of our context; the ability to generate registration tokens and the ability to register a user with a token. Since the second of these two options requires a token, let’s start with figuring out how to generate that token.

Token generation

This is pretty easy. We’ll add a new alias and function to the existing accounts.ex context module.

/lib/reg_tokens/accounts.ex
alias RegTokens.Accounts.{RegistrationToken, User, UserToken, UserNotifier}

We’ve added the RegistrationToken schema to our list of aliases… and now for the generation function:

/lib/reg_tokens/accounts.ex
def generate_registration_token(opts \\ []) do
  %{
    generated_by_user_id: Keyword.get(opts, :generated_by),
    scoped_to_email: Keyword.get(opts, :scoped_to_email)
  }
  |> RegistrationToken.create_changeset()
  |> Repo.insert()
end

Simple! We’re passing in our optional values (the generated_by and scoped_to_email fields) as a keyword list. This works great as Keyword.get returns nil when the key is missing from the keyword list… which is exactly what we want to insert when these fields are not supplied.

We create a map from the opts parameters:

%{
  generated_by_user_id: Keyword.get(opts, :generated_by),
  scoped_to_email: Keyword.get(opts, :scoped_to_email)
}

… which we pipe into a changeset function (RegistrationToken.create_changeset), the result of which gets piped into Repo.insert.

|> RegistrationToken.create_changeset()
|> Repo.insert()

All standard stuff, we need to add the changeset function referred to above to registration_token.ex.

/lib/reg_tokens/accounts/registration_token.ex
defmodule RegTokens.Accounts.RegistrationToken do
  use Ecto.Schema
  import Ecto.Changeset

  alias RegTokens.Accounts.User

  schema "registration_tokens" do
    field :token, :binary_id
    field :scoped_to_email, :string
    belongs_to :used_by_user, User, foreign_key: :used_by_user_id
    belongs_to :generated_by_user, User, foreign_key: :generated_by_user_id

    timestamps()
  end

  def create_changeset(attrs) do
    %__MODULE__{}
    |> cast(attrs, [:scoped_to_email, :generated_by_user_id])
    |> put_change(:token, Ecto.UUID.generate())
    |> foreign_key_constraint(:generated_by_user_id)
  end
end

All we’ve done is add a new import for Ecto.Changeset and then a simple changeset function:

def create_changeset(attrs) do
  %__MODULE__{}
  |> cast(attrs, [:scoped_to_email, :generated_by_user_id])
  |> put_change(:token, Ecto.UUID.generate())
  |> foreign_key_constraint(:generated_by_user_id)
end

We cast the two fields we expect in the attrs map, and then explicitly generate the token UUID value via put_change and UUID.generate. We’ve also added a foreign key check for the generated_by_user_id as we want to return an error if the user id for the generating user does not exist.

Let’s give it a spin, we’ll fire up iex.

Terminal
iex -S mix phx.server

… and generate some tokens.

Terminal
RegTokens.Accounts.generate_registration_token()

Terminal
RegTokens.Accounts.generate_registration_token(scoped_to_email: "sally@example.com")

If we want to create a token with a generated_by value, we’ll first need to create a user.

Terminal
RegTokens.Accounts.register_user(%{email: "bob@example.com", password: "foobarfoobar", password_confirmation: "foobarfoobar"})

Then we can create a token with a generated_by value set to the id of the above user.

Terminal
RegTokens.Accounts.generate_registration_token(generated_by: 1, scoped_to_email: "jill@example.com")

Great, everything seems to be working and if we look at our data with something like TablePlus we’ll see the 3 tokens we’ve created.

iex is a super convenient way to test drive functionality while building things out but we’ll definitely want to write some tests… let’s tackle that next.

Token generation tests

Just like the implementation, the tests will be pretty straight forward.

First thing, we’ll need a new alias in accounts_test.exs.

/test/reg_tokens/accounts_test.exs
alias RegTokens.Accounts.{RegistrationToken, User, UserToken}

Now we’ll create a new describe block for the token generation tests.

/test/reg_tokens/accounts_test.exs
describe "generate_registration_token/1" do
  test "generates a valid non email scoped registration token" do
    assert {:ok, %RegistrationToken{} = token} = Accounts.generate_registration_token()
    refute token.scoped_to_email
    refute token.used_by_user_id
    refute token.generated_by_user_id
    assert token.token
  end

  test "generates a valid email scoped registration token" do
    assert {:ok, %RegistrationToken{} = token} =
             Accounts.generate_registration_token(scoped_to_email: "sally@example.com")

    assert token.scoped_to_email == "sally@example.com"
    refute token.used_by_user_id
    refute token.generated_by_user_id
    assert token.token
  end

  test "can assign a valid user to the generated_by field of a token" do
    %{id: id} = user_fixture()

    assert {:ok, %RegistrationToken{} = token} =
             Accounts.generate_registration_token(generated_by: id)

    refute token.scoped_to_email
    refute token.used_by_user_id
    assert token.generated_by_user_id == id
    assert token.token
  end

  test "validates generated_by field when supplied" do
    assert {:error, changeset} = Accounts.generate_registration_token(generated_by: 123_456)

    assert %{generated_by_user_id: ["does not exist"]} == errors_on(changeset)
  end
end

Pretty self-explanatory, we’re testing the various token options and ensuring the token is created with the expected values… and we have a test to make sure the generated_by foreign key constraint is acting as expected.

Let’s make sure everything is passing.

Terminal
mix test

Nice!

Onto the registration function.

User registration

We have the token generation part of our registration workflow done and dusted, now we need to register a user with a token. We’ll create a register_user_with_token/2 function in accounts.ex. We’ll build this function up bit by bit, starting with the top level function and filling in the supporting pieces as we proceed thru the explanation.

/lib/reg_tokens/accounts.ex
def register_user_with_token(token, attrs) do
  token
  |> Ecto.UUID.dump()
  |> case do
    {:ok, _} ->
      get_registration_token(token)
      |> maybe_register_user_with_token(attrs)

    # bad UUID
    :error ->
      invalid_token_response(attrs)
  end
end

The function above maps out the basic workflow of the registration functionality. The first thing we check is that the token is a valid UUID by calling into Ecto.UUID.dump. If the token isn’t valid we can stop processing right away and return an error changeset (which we build in the invalid_token_response function). If the token is a valid UUID, the next step is to retrieve the token, we then pipe the result of the retrieval into maybe_register_user_with_token. This is what performs the actual registration.

Let’s continue by filling in the code for the scenario where we have an invalid UUID. We need to implement the invalid_token_response function, it looks like:

/lib/reg_tokens/accounts.ex
defp invalid_token_response(attrs) do
  %User{}
  |> User.registration_changeset(attrs)
  |> Ecto.Changeset.add_error(:registration_token, "Invalid registration token!")
  |> Ecto.Changeset.apply_action(:update)
end

We create a changeset and manually add an error to indicate the token is invalid.

I’ve chosen to return a generic invalid token message as part of the changeset, but depending on your front end requirements you might want to return specific messages around specific failures or even return specific error tuples. For instance instead of returning a changeset, you could return something like {:error, :invalid_uuid}. This would be useful if you want to provide more detailed information around what went wrong to the caller of the registration function.

So with the above in place we’ve covered off the scenario of an invalid UUID. If we get past the UUID check, the next step is to retrieve the token from the database via get_registration_token/1. Let’s implement that next.

/lib/reg_tokens/accounts.ex
defp get_registration_token(token) do
  RegistrationToken.get_registration_token_query(token)
  |> Repo.one()
end

Ok, nothing exciting to see here, we’re just making use of a query function we’ll add to registration_token.ex that gets piped into a Repo.one call. The query function looks like:

/lib/reg_tokens/accounts/registration_token.ex
@token_validity_in_days 30

def get_registration_token_query(token) do
  from(rt in __MODULE__,
    where: rt.token == ^token,
    where: rt.inserted_at > ago(@token_validity_in_days, "day"),
    where: is_nil(rt.used_by_user_id)
  )
end

This is pretty simple, we are querying for a token with a UUID that matches the UUID passed to the function. Then we check the token has not expired and hasn’t been used. I’ve settled on an expiry of 30 days but depending on your use case and especially if you are going with non-email scoped tokens you might want to tighten up the expiry period.

We also need to add a Ecto.Query import at the top of the file.

/lib/reg_tokens/accounts/registration_token.ex
import Ecto.Query

So that takes care of token retrieval, the only thing left is maybe_register_user_with_token/2. We’ll use some pattern matching to handle the various scenarios that can arise when piping the retrieved token into maybe_register_user_with_token. Our function signatures will look like:

# pattern match on a nil token value
# i.e. a valid token has not been retrieved
maybe_register_user_with_token(nil, attrs)

# pattern match on a token with a nil scoped_to_email value
# i.e. we retrieved a non email scoped token
maybe_register_user_with_token(%RegistrationToken{scoped_to_email: nil} = token, attrs)

# the fall thru case, if neither of the above match we know we retrieved an email scoped token
maybe_register_user_with_token(token, attrs)

Let’s get to the implementation, we’ll tackle the above cases one by one.

/lib/reg_tokens/accounts.ex
# token was not found
defp maybe_register_user_with_token(nil, attrs) do
  invalid_token_response(attrs)
end

The first case is simple. If we don’t retrieve a token from get_registration_token we know either the token doesn’t exist, is expired, or has already been used. We pattern match on nil as the token’s value and send back an error changeset via invalid_token_response.

The next case is a non-email scoped token.

/lib/reg_tokens/accounts.ex
# non-email scoped token
defp maybe_register_user_with_token(%RegistrationToken{scoped_to_email: nil} = token, attrs) do
  Repo.transaction(fn ->
    with {:ok, user} <- register_user(attrs),
         {:ok, _registration_token} <- consume_registration_token(token, user) do
      user
    else
      {:error, error} ->
        Repo.rollback(error)
    end
  end)
end

Again we use pattern matching to determine that we have retrieved a token… but it is a non-email scoped token (i.e. via %RegistrationToken{scoped_to_email: nil}). We then use a transaction to register the user and consume the token. If for some reason either of these operations fail we perform a rollback. register_user/1 already exists, we’re just re-using the existing registration function from Phx.Gen.Auth, we need to write consume_registration_token however.

/lib/reg_tokens/accounts.ex
defp consume_registration_token(token, user) do
  RegistrationToken.consume_token_changeset(token, user.id)
  |> Repo.update()
end

Simple! Just a repo update with some help from a new changeset function we’ll create in registration_token.ex.

/lib/reg_tokens/accounts/registration_token.ex
def consume_token_changeset(token, user_id) do
  token
  |> cast(%{used_by_user_id: user_id}, [:used_by_user_id])
  |> unique_constraint(:used_by_user_id)
end

Nothing complicated, we set the used_by_user_id value to the id of the created user and perform a unique constraint check.

That’s it for non email scoped tokens, the final case to handle is tokens scoped to an email.

/lib/reg_tokens/accounts.ex
# email scoped token
defp maybe_register_user_with_token(token, attrs) do
  if token.scoped_to_email == (attrs["email"] || attrs[:email]) do
    Repo.transaction(fn ->
      with {:ok, user} <- register_user(attrs),
           {:ok, _registration_token} <- consume_registration_token(token, user) do
        user
      else
        {:error, error} ->
          Repo.rollback(error)
      end
    end)

  # wrong email scope
  else
    invalid_token_response(attrs)
  end
end

Very similar to the non-email scoped version of the function. The only difference being we have a surronding if statement that ensures the registering user’s email is the same as the email in the token. The Repo.transaction blocks are the same in both places, I thought about creating another sub function to remove the duplication but felt it read better having the transaction blocks inline.

Trying it out

Let’s give our code a spin in iex.

Terminal
iex -S mix phx.server

First we’ll try an invalid UUID.

Terminal
RegTokens.Accounts.register_user_with_token(1234, %{email: "bob7456@example.com", password: "foobarfoobar", password_confirmation: "foobarfoobar"})

The output looks as expected.

Next let’s try a valid token. First we’ll create a token.

Terminal
RegTokens.Accounts.generate_registration_token(scoped_to_email: "bob7456@example.com")

And then register.

Terminal
RegTokens.Accounts.register_user_with_token("69103994-db32-42e8-b175-e5454cbf5c34", %{email: "bob7456@example.com", password: "foobarfoobar", password_confirmation: "foobarfoobar"})

Sweet! Looks like things are working, feel free to run thru some other scenarios with iex such as mis-matching email addresses… all of which we’ll be tackling in our tests.

Adding some tests

Test time! The first thing we’re going to do is update the existing register_user/1 tests to run thru register_user_with_token/2. When we get around to the front-end changes we’ll make register_user a private function. In anticipation of that and to ensure we’re covering off the existing functionality around registration, we want to update the existing tests to run thru our new registration function.

To accomplish this, we need to:

  • Add a setup block.
  • Update the test signatures.
  • Replace the register_user function calls with register_user_with_token.
  • Update the title of the describe block, replacing describe register_user/1 with describe register_user_with_token/2.

Finally we won’t bother testing the happy path scenario as we’ll do so in subsequent tests.

To achieve the above, replace the current register_user/1 describe block with the following:

/test/reg_tokens/accounts_test.exs
describe "register_user_with_token/2" do
  setup do
    [
      token: registration_token_fixture().token
    ]
  end

  test "requires email and password to be set", %{token: token} do
    {:error, changeset} = Accounts.register_user_with_token(token, %{})

    assert %{
             password: ["can't be blank"],
             email: ["can't be blank"]
           } = errors_on(changeset)
  end

  test "validates email and password when given", %{token: token} do
    {:error, changeset} = Accounts.register_user_with_token(token, %{email: "not valid", password: "not valid"})

    assert %{
             email: ["must have the @ sign and no spaces"],
             password: ["should be at least 12 character(s)"]
           } = errors_on(changeset)
  end

  test "validates maximum values for email and password for security", %{token: token} do
    too_long = String.duplicate("db", 100)
    {:error, changeset} = Accounts.register_user_with_token(token, %{email: too_long, password: too_long})
    assert "should be at most 160 character(s)" in errors_on(changeset).email
    assert "should be at most 80 character(s)" in errors_on(changeset).password
  end

  test "validates email uniqueness", %{token: token} do
    %{email: email} = user_fixture()
    {:error, changeset} = Accounts.register_user_with_token(token, %{email: email})
    assert "has already been taken" in errors_on(changeset).email

    # Now try with the upper cased email too, to check that email case is ignored.
    {:error, changeset} = Accounts.register_user_with_token(token, %{email: String.upcase(email)})
    assert "has already been taken" in errors_on(changeset).email
  end

  # we'll test the happy path scenario in subsequent tests so we can remove this
  # test "registers users with a hashed password" do
  #   email = unique_user_email()
  #   {:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
  #   assert user.email == email
  #   assert is_binary(user.hashed_password)
  #   assert is_nil(user.confirmed_at)
  #   assert is_nil(user.password)
  # end
end

We’re making use of a new fixture function during the test setup (registration_token_fixture), so we need to add this to accounts_fixtures.ex.

/test/support/fixtures/accounts_fixtures.ex
def registration_token_fixture(email \\ nil) do
  {:ok, token} = RegTokens.Accounts.generate_registration_token(scoped_to_email: email)
  token
end

Let’s make sure the account tests are still passing.

Terminal
mix test test/reg_tokens/accounts_test.exs

Nice! Next we’ll add some tests for non-email scoped tokens.

/test/reg_tokens/accounts_test.exs
describe "register_user_with_token/2 - non email scoped token" do
  setup do
    [
      token: registration_token_fixture()
    ]
  end

  test "when valid attributes and token, registers user with hashed password and consumes token",
       %{token: token} do
    email = unique_user_email()

    {:ok, user} =
      Accounts.register_user_with_token(token.token, valid_user_attributes(email: email))

    assert user.email == email
    assert is_binary(user.hashed_password)
    assert is_nil(user.confirmed_at)
    assert is_nil(user.password)

    updated_token = Repo.get!(RegistrationToken, token.id)
    assert updated_token.used_by_user_id == user.id
  end

  test "with badly formatted token returns error", %{token: token} do
    email = unique_user_email()

    {:error, changeset} =
      Accounts.register_user_with_token(
        "not_a_valid_token",
        valid_user_attributes(email: email)
      )

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    refute updated_token.used_by_user_id
  end

  test "when token has expired returns error", %{token: token} do
    {1, nil} = Repo.update_all(RegistrationToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
    email = unique_user_email()

    {:error, changeset} =
      Accounts.register_user_with_token(token.token, valid_user_attributes(email: email))

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    refute updated_token.used_by_user_id
  end

  test "when token has already been used returns error", %{token: token} do
    %{id: id} = user_fixture()
    {1, nil} = Repo.update_all(RegistrationToken, set: [used_by_user_id: id])

    email = unique_user_email()

    {:error, changeset} =
      Accounts.register_user_with_token(token.token, valid_user_attributes(email: email))

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    assert updated_token.used_by_user_id == id
  end
end

I think these are all pretty self-explanatory. The first test validates the happy path, the remaining tests check the failure scenarios: a token which is not a UIID; a token which has expired; and a token which has already been used.

Let’s try ‘em out.

Terminal
mix test test/reg_tokens/accounts_test.exs

Fantastic! Now let’s create a similar describe block for email scoped tokens.

/test/reg_tokens/accounts_test.exs
describe "register_user_with_token/2 - email scoped token" do
  setup do
    email = unique_user_email()

    [
      token: registration_token_fixture(email),
      email: email
    ]
  end

  test "when valid attributes and token registers user with hashed password and consumes token",
       %{token: token, email: email} do
    {:ok, user} =
      Accounts.register_user_with_token(token.token, valid_user_attributes(email: email))

    assert user.email == email
    assert is_binary(user.hashed_password)
    assert is_nil(user.confirmed_at)
    assert is_nil(user.password)

    updated_token = Repo.get!(RegistrationToken, token.id)
    assert updated_token.used_by_user_id == user.id
  end

  test "with badly formatted token returns error", %{token: token, email: email} do
    {:error, changeset} =
      Accounts.register_user_with_token(
        "not_a_valid_token",
        valid_user_attributes(email: email)
      )

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    refute updated_token.used_by_user_id
  end

  test "when token has expired returns error", %{token: token, email: email} do
    {1, nil} = Repo.update_all(RegistrationToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])

    {:error, changeset} =
      Accounts.register_user_with_token(token.token, valid_user_attributes(email: email))

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    refute updated_token.used_by_user_id
  end

  test "when token has already been used returns error", %{token: token, email: email} do
    %{id: id} = user_fixture()
    {1, nil} = Repo.update_all(RegistrationToken, set: [used_by_user_id: id])

    {:error, changeset} =
      Accounts.register_user_with_token(token.token, valid_user_attributes(email: email))

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    assert updated_token.used_by_user_id == id
  end

  test "when given an incorrect email returns error", %{token: token, email: email} do
    {:error, changeset} =
      Accounts.register_user_with_token(token.token, valid_user_attributes(email: "#{email}a"))

    assert %{
             registration_token: ["Invalid registration token!"]
           } = errors_on(changeset)

    updated_token = Repo.get!(RegistrationToken, token.id)
    refute updated_token.used_by_user_id
  end
end

This is largely the same as the previous describe block, this time we’ve just added a test for the scenario where the registering email address does not match the email address the token is scoped to.

Let’s run thru the full test suite.

Terminal
mix test

Awesome, all good! This closes out the backend changes required for the new registration workflow.

Summary

I was hoping to get everything done in a single go but this post is starting to get long… so we’ll handle the front-end changes in an upcoming post.

Todays code is available on GitHub, you can grab it via:

Terminal
mkdir <some new directory>
cd <the new directory>
Terminal
git init
git remote add origin git@github.com:riebeekn/phx_gen_auth_with_registration_tokens.git
git fetch origin 1c8ac756ada2c41758d621291ff22ca2854cd87c
git reset --hard FETCH_HEAD
git branch -m master main

You should now see the following git history.

Terminal
git log --pretty=oneline

Thanks for reading and I hope you enjoyed today’s post!



Comment on this post!