Sign Up

A sign-up form that checks itself as you type: whether the name is free, whether the email is made up, and each password rule ticking off as it is met. The rules are the account's own validations, written once in Ruby.

Make an account

  • At least 15 characters (not yet)
  • Not a common or predictable password (not yet)
  • Does not contain your username (not yet)
  • Typed the same twice (not yet)

The button waits for every check.

# frozen_string_literal: true

# Making a demo account. The form holds the fields as signals and asks the
# account's validations what is wrong with them. Whether the name is free
# is the one question that reads the database: it has a derived of its own,
# which reads only the name and the accounts' version, so it is asked again
# when the name changes or when anyone makes an account.
#
# The account itself is made by a plain POST (Recipes::Auth::
# RegistrationsController): signing in sets a cookie, and only an HTTP
# response can do that.
class Recipes::Signup::SignupChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Account = Recipes::Auth::Account
  SignupForm = Recipes::Signup::SignupForm
  VIEWS = "app/recipes/signup"

  # A live "is this name free" check tells anyone which names have
  # accounts, so it is limited: so many names a minute per tab, and per
  # address across every tab.
  TAB_CHECKS = 20
  ADDRESS_CHECKS = 60

  def build_graph
    @form = SignupForm.from(Account.new(username: params[:username].to_s.first(20), email: params[:email].to_s.first(60)))
    @checked = Hibiki::State.new(nil)
    @accounts = Hibiki::State.new(0)
    @rolled = Hibiki::State.new(nil)
    @checked_at = []
    check(@form.username)

    free = Hibiki::Derived.new do
      @accounts.value
      Account.free?(@checked.value) if @checked.value.present?
    end
    name = Hibiki::Derived.new { @form.name_status(checked: @checked.value, free: free.value) }

    Hibiki::Effect.new do
      broadcast_morph target: "signup_name", partial: "#{VIEWS}/name", locals: { status: name.value }
    end

    transmit_value(:signup_email_error) { @form.email_message }

    Hibiki::Effect.new do
      broadcast_morph target: "signup_checks", partial: "#{VIEWS}/checks", locals: { checks: @form.checks }
    end

    ready = Hibiki::Derived.new { @form.ready?(name.value) }
    Hibiki::Effect.new do
      broadcast_morph target: "signup_actions", partial: "#{VIEWS}/actions", locals: { ready: ready.value }
    end

    # The password fields are the visitor's to type in, so they are put
    # back only when a password is rolled.
    Hibiki::Effect.new do
      next unless (rolled = @rolled.value)

      Hibiki.untrack do
        broadcast_replace target: "signup_password", partial: "#{VIEWS}/password", locals: { rolled: }
      end
    end
  end

  def set_field(data)
    name = data["field"].to_s.to_sym
    return unless SignupForm.hibiki_attributes.include?(name)

    @form.public_send(:"#{name}=", data[name.to_s].to_s.first(100))
    check(@form.username) if name == :username
  end

  # Demo only: four words for the password. On a real site the browser's
  # password manager offers one, because the field says `new-password`.
  def roll
    password = Account.roll_password
    @form.password = @form.password_confirmation = password
    @rolled.value = password
  end

  private

  def subscribed
    super
    return if subscription_rejected?

    # An account made in any tab. The block runs on a cable thread, so it
    # hops onto the graph thread before touching a signal.
    stream_from Account::CHANGED do
      graph_actor&.post { hear }
    end
  end

  def hear = @accounts.value += 1

  # Asks about a name that passes the other rules, if there are checks left.
  def check(name)
    return if name.blank? || name == @checked.value || @form.username_error

    @checked.value = name if checks_left?
  end

  def checks_left?
    now = Time.current
    @checked_at.reject! { it < now - 1.minute }
    return false if @checked_at.size >= TAB_CHECKS

    minute = "recipes:signup:checks:#{connection.remote_ip}:#{now.to_i / 60}"
    return false if Rails.cache.increment(minute, 1, expires_in: 2.minutes).to_i > ADDRESS_CHECKS

    @checked_at << now
    true
  end
end

Try typing the username gnome: you will see the name is taken. Other validations work in real time, too. Each check comes from the account's own validations, asked on the server as you type. The form creates a real account on this server, backed by Rails authentication. Accounts are cleared every night.

How it fits together

The form lives in a channel: the server side of an Action Cable connection, an object that stays alive while the page is open and answers messages from it. Inside it, SignupForm is a ReactiveForm, a form object whose fields are states, values that remember who read them. Here there is one state each for the username, the email and the two passwords, and the channel's set_field action writes whichever field was typed in.

A derived is a value worked out from other values, and worked out again when one of them changes. The form's deriveds are its rules. Here draft builds an account from the name and the email, one that is never saved, and draft_errors asks it to validate, so username_error and email_error are the account's own messages. checks is the password checklist. Every rule is written once, on the account, and the form asks it as you type (with a debounce).

An effect is a block of code that runs once, and runs again whenever a value it read has changed. The channel has one for each part of the card: the line under the name, the email's message, the checklist and the button. Three of them render a partial and send it with broadcast_morph, which updates that part in place; the email's message is short enough to go as plain text. This is what happens when you type a letter of the password:

  1. The field sends set_field with the password so far (with a 250ms debounce).
  2. set_field writes the password state.
  3. rules reads it, so it is worked out again, and checks after it.
  4. The checklist effect read checks. If a tick changed, it renders the list again and sends it; a letter that changes no tick sends nothing. The name effect read none of this, so it stays quiet.

The part of a page that talks to a channel is an island: one ordinary HTML element with the channel's controls and output inside it. Here _signup.html.erb is the island, and each input carries on(:set_field, event: :input), which sends what is typed. An input event gets a 250 ms debounce unless you pass debounce: yourself. The inputs sit outside every fragment the channel sends, so a reply never replaces the field you are typing in.

The one check that reads the database

Whether a name is free is a question for the database, and there is no point asking it when the email changes. So the draft is validated in a context of its own, :draft, which leaves out the rule marked on: :create, and the channel asks about the name in a derived of its own. That derived reads the name last checked and a count that every new account bumps, in any tab, so the question is asked again only when the name changes or when someone takes it.

# app/models/recipes/auth/account.rb
validate :username_free, on: :create   # the one rule that reads the database

# app/forms/recipes/signup/signup_form.rb
derived(:draft)        { Account.new(username:, email:) }   # never the password
derived(:draft_errors) { draft.tap { it.validate(:draft) }.errors.to_hash.transform_values(&:first) }
derived(:username_error) { draft_errors[:username] }        # :draft leaves out on: :create

# app/channels/recipes/signup/signup_channel.rb
free = Hibiki::Derived.new do
  @accounts.value                                           # bumped by any new account
  Account.free?(@checked.value) if @checked.value.present?
end

A live check like this tells anyone which names have accounts, so the channel limits how many checks each tab and each address can perform per minute.

Why the email is not checked against other accounts

"This email is already registered" tells a stranger that someone has an account here. This form is for demo purposes, so we never really check email and don't want you to enter a real email address. For your real-world app that signs in by email, treat it like the username shown above, and rate-limit the live check per minute, or don't show the check at all.

# Not part of this demo. For an app that signs in by email, so the address
# has to be unique: the page says the same thing whether or not the address
# has an account, and a letter tells the rest to the one person who can
# read it.
class RegistrationsController < ApplicationController
  rate_limit to: 10, within: 1.hour, only: :create

  def create
    user = User.new(user_params)

    if (owner = User.find_by(email_address: user.email_address))
      # "Someone tried to sign up with your address. If it was you, sign in
      # or reset your password."
      AccountMailer.already_registered(owner).deliver_later
    elsif user.save
      # A link that sets confirmed_at. Until then the address is unproven.
      AccountMailer.confirm(user).deliver_later
    else
      return render :new, status: :unprocessable_entity  # rules that say nothing about others
    end

    redirect_to check_your_email_path  # the same page either way
  end
end

Making the account is a page load

The checks in the page help you along; the account is made by a plain POST to RegistrationsController, which asks the same validations once more and signs you in. It has to be a request, because signing in sets a cookie, and only an HTTP response can do that. The socket needs the reload too, since Action Cable decides who a connection is when it opens. So the form has data-turbo="false": the POST reloads the page, the page opens a new socket, and the new socket reads the new cookie.

# app/channels/application_cable/connection.rb
identified_by :demo_session

def connect
  self.demo_session = Recipes::Auth::Session.from(cookies)  # nil for a guest
end

# app/views/app/recipes/signup/_signup.html.erb
form_with url: recipes_auth_registration_path, data: { turbo: false }

# app/models/recipes/auth/session.rb
after_destroy_commit { ActionCable.server.remote_connections.where(demo_session: self).disconnect }

The sign-up form keeps every rule on the account and asks it as you type, with the account itself made by Rails authentication. The Sign In recipe signs in and out with the accounts made here.