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
# 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
# frozen_string_literal: true
# The sign-up form in the Sign Up recipe. Each field is a signal, and the
# rules are the account's own validations, asked in two parts.
#
# The username and email are put on an account that is never saved and
# validated in a context of their own, `:draft`. That leaves out the one
# rule that reads the database (`on: :create`), which the channel asks by
# itself, so typing an email costs no query. The password stays off the
# draft: an account given a password hashes it with bcrypt there and then,
# about a quarter of a second at production cost. Its rules are a plain
# value instead, the same one the account validates with.
class Recipes::Signup::SignupForm
include Hibiki::Rails::ReactiveForm
Account = Recipes::Auth::Account
PasswordRules = Recipes::Auth::PasswordRules
# What the line under the username says: a tone for its colour, and text.
Status = Data.define(:tone, :text)
reactive_attributes "Recipes::Auth::Account", :username, :email, :password, :password_confirmation
derived(:draft) { Account.new(username:, email:) }
derived(:draft_errors) { draft.tap { it.validate(:draft) }.errors.to_hash.transform_values(&:first) }
derived(:username_error) { draft_errors[:username] }
derived(:email_error) { draft_errors[:email] }
derived(:rules) { PasswordRules.new(password:, confirmation: password_confirmation, username:) }
derived(:checks) { rules.checks }
# An empty field says nothing yet.
def email_message = (email_error if email.present?)
# `checked` is the name last asked about, and `free` the answer. A name
# not yet asked about is waiting on the limit of checks.
def name_status(checked:, free:)
if username.blank? then nil
elsif username_error then Status.new(tone: :error, text: username_error)
elsif checked != username then Status.new(tone: :muted, text: "Too many names checked for now. Wait a moment, then type again.")
elsif free then Status.new(tone: :success, text: "That name is free.")
else Status.new(tone: :error, text: "That name is taken.")
end
end
def ready?(name)
name&.tone == :success && email.present? && email_error.nil? && rules.ok?
end
end
# frozen_string_literal: true
# A demo account for the Sign Up, Sign In and guild recipes. It is kept
# apart from any real `users` table, signs in by username, and lasts until
# the night: Recipes::Reset empties the table and seeds the starting
# accounts again, and `prune!` caps it in between.
#
# Every rule is here. The sign-up form asks these validations on each
# keystroke, except the one that reads the database, which it asks through
# `free?` in a derived of its own.
class Recipes::Auth::Account < ApplicationRecord
self.table_name = "recipes_auth_accounts"
PasswordRules = Recipes::Auth::PasswordRules
SEED_FILE = Rails.root.join("db/seeds/auth.yml")
NAME = /\A[a-z0-9_-]+\z/i
# Domains set aside for examples and tests (RFC 2606), so an address that
# passes is made up and reaches nobody.
MADE_UP = /\.(test|example|invalid|localhost)\z|(\A|\.)example\.(com|net|org)\z/
# How many accounts `prune!` leaves, besides the seeded ones.
KEEP = 300
# Every open sign-up form listens here, so a name taken in another tab
# shows as taken without a keystroke.
CHANGED = "recipes:auth:accounts"
has_secure_password validations: false
has_many :sessions, class_name: "Recipes::Auth::Session", inverse_of: :account, dependent: :delete_all
normalizes :username, with: -> { it.strip }
normalizes :email, with: -> { it.strip.downcase }
after_commit { self.class.announce }
validates :username, presence: { message: "Choose a name." }
validates :username, length: { in: 3..20, message: "Three to twenty characters." },
format: { with: NAME, message: "Letters, digits, - and _ only." },
allow_blank: true
validate :username_free, on: :create
validates :email, presence: { message: "An email address, made up." }
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP, message: "That does not look like an email address." },
allow_blank: true
validate :email_made_up
validate :password_follows_the_rules, if: -> { new_record? || !password.nil? }
def self.seeds = @seeds ||= YAML.load_file(SEED_FILE).freeze
# No account has this name, whatever its case, and it is not set aside.
# The sign-up form asks this live; `username_free` asks it on create.
def self.free?(name)
name = name.to_s.strip.downcase
!seeds.fetch("reserved").include?(name) && !where("lower(username) = ?", name).exists?
end
# The account whose password is printed on the Sign In page.
def self.shared_name = seeds.dig("shared", "username")
# Four words for the demo's "Roll a password". A real site leaves this to
# the browser's password manager.
def self.roll_password(random: Random) = seeds.fetch("words").sample(4, random:).join("-")
# A ping with nothing in it. Whoever hears it asks `free?` again.
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# Adds the shared account and the taken names, unless they are there. They
# skip the rules on purpose: the shared password is public, so the rules
# call it common. Safe to run again.
def self.seed!
shared = seeds.fetch("shared")
rows = [ shared ] + seeds.fetch("taken").map { { "username" => it, "email" => "#{it}@reverb.test", "password" => SecureRandom.base58(24) } }
there = pluck(Arel.sql("lower(username)"))
missing = rows.reject { it["username"].in?(there) }
return if missing.empty?
insert_all(missing.map { |row| { username: row["username"], email: row["email"], password_digest: new(password: row["password"]).password_digest } })
end
# The sessions go with the accounts (the foreign key cascades), and
# `delete_all` runs no callback, so the ping is sent by hand.
def self.reset!
transaction do
delete_all
seed!
end
announce
end
# Drops all but the newest accounts. The seeded ones stay.
def self.prune!
seeded = [ shared_name, *seeds.fetch("taken") ]
where.not(id: order(id: :desc).limit(KEEP).select(:id)).where.not("lower(username) IN (?)", seeded).delete_all
end
def shared? = username == self.class.shared_name
def password_rules = PasswordRules.new(password:, confirmation: password_confirmation, username:)
private
def username_free
errors.add(:username, "That name is taken.") if username.present? && !self.class.free?(username)
end
def email_made_up
return if email.blank? || errors.include?(:email)
errors.add(:email, "Use a made-up address, one ending in .test, .example or .invalid, or at example.com.") unless email.split("@").last.to_s.match?(MADE_UP)
end
def password_follows_the_rules
password_rules.errors.each { |field, message| errors.add(field, message) }
end
end
# frozen_string_literal: true
# What a demo account's password has to be, after NIST SP 800-63B: long
# enough, not one an attacker tries first, and not the username. There are
# no rules about digits or symbols, which make passwords harder to remember
# and no harder to guess.
#
# The account validates with it, and the sign-up form shows its checks as a
# list that ticks off while the visitor types, so each rule is written once.
# It is a plain value, so a derived holding one compares by what it says.
class Recipes::Auth::PasswordRules < Data.define(:password, :confirmation, :username)
Check = Data.define(:key, :label, :ok)
MIN = 15
# bcrypt reads no further than this, so a longer password would match
# anything that starts the same way.
MAX_BYTES = 72
# Runs of keys and digits that people type for a long password. A
# password that is a piece of one, forwards or backwards, is predictable.
RUNS = [
"0123456789" * 4,
"abcdefghijklmnopqrstuvwxyz" * 2,
"qwertyuiopasdfghjklzxcvbnm" * 2,
"1qaz2wsx3edc4rfv5tgb6yhn7ujm8ik9ol0p" * 2,
"q1w2e3r4t5y6u7i8o9p0" * 2
].flat_map { [ it, it.reverse ] }.freeze
def self.common = @common ||= Recipes::Auth::Account.seeds.fetch("common").to_set.freeze
def initialize(password: "", confirmation: "", username: "")
super(password: password.to_s, confirmation: confirmation.to_s, username: username.to_s)
end
# The list the form shows, in the order it shows it.
def checks
[
Check.new(key: :length, label: "At least #{MIN} characters", ok: long_enough?),
Check.new(key: :common, label: "Not a common or predictable password", ok: !password.empty? && !predictable?),
Check.new(key: :username, label: "Does not contain your username", ok: !password.empty? && !names_you?),
Check.new(key: :match, label: "Typed the same twice", ok: !password.empty? && password == confirmation)
]
end
def ok? = checks.all?(&:ok) && !too_long?
# The account's errors, one message per field.
def errors
{
password: password_error,
password_confirmation: ("The two passwords are not the same." unless password == confirmation)
}.compact
end
private
def password_error
if password.empty? then "Choose a password."
elsif !long_enough? then "At least #{MIN} characters, please. A few words in a row are easy to remember."
elsif too_long? then "bcrypt reads no more than #{MAX_BYTES} bytes, so keep it shorter than that."
elsif predictable? then "Attackers try that one early: it is a common password, a repeat or a run of keys."
elsif names_you? then "Your username is the first thing an attacker would try in it."
end
end
def long_enough? = password.length >= MIN
def too_long? = password.bytesize > MAX_BYTES
# Compared in lower case without spaces or punctuation, so "Correct horse
# battery staple!" is the same as the listed one.
def predictable?
plain = password.downcase.gsub(/[^a-z0-9]/, "")
return true if plain.empty?
self.class.common.include?(plain) ||
plain.chars.uniq.size < 5 || # aaaaaaaaaaaaaaaa, abababababababab
plain.match?(/\A(.+?)\1+\z/) || # monkeymonkeymonkey
RUNS.any? { it.include?(plain) } # 123456789012345, qwertyuiopasdfg
end
def names_you?
name = username.strip.downcase
name.length >= 3 && password.downcase.include?(name)
end
end
# frozen_string_literal: true
# Where the Sign Up recipe's form posts. The channel's checks are help for
# the visitor; this is the gate, and it asks the same validations again.
# A made account is signed in, and the page reloads with it.
class Recipes::Auth::RegistrationsController < App::BaseController
include Recipes::Auth::Signing
Account = Recipes::Auth::Account
rate_limit to: 10, within: 1.hour, only: :create,
with: -> { redirect_to app_recipe_path("signup"), alert: "That is a lot of accounts from one place. Try again in an hour." }
def create
@signup = Account.new(params.permit(:username, :email, :password, :password_confirmation))
if save(@signup)
sign_in @signup
Account.prune!
redirect_to app_recipe_path("signup"), status: :see_other, notice: "Welcome, #{@signup.username}. You are signed in."
else
render_recipe "signup", status: :unprocessable_content
end
end
private
# Two visitors can ask for the same free name at once. The index lets one
# of them have it, and the other hears what the check would have said.
def save(account)
account.save
rescue ActiveRecord::RecordNotUnique
account.errors.add(:username, "That name is taken.")
false
end
end
# frozen_string_literal: true
# Signing a demo account in and out, the way the Rails 8 authentication
# generator does it for `User`: a session row per sign-in, its id in a
# signed, httponly cookie. Only the demo's own controllers include it, so
# no other page asks who is signed in.
module Recipes::Auth::Signing
Session = Recipes::Auth::Session
private
def demo_session = defined?(@demo_session) ? @demo_session : (@demo_session = Session.from(cookies))
# A new row each time, so a cookie from before the sign-in is worth
# nothing after it.
def sign_in(account)
demo_session&.destroy
@demo_session = Session.start(account, user_agent: request.user_agent)
cookies.signed.permanent[Session::COOKIE] = { value: @demo_session.id, httponly: true, same_site: :lax }
end
def sign_out
demo_session&.destroy
@demo_session = nil
cookies.delete(Session::COOKIE)
end
# Renders a recipe page from another controller, as App::RecipesController
# would, for a form that comes back with errors.
def render_recipe(slug, status:)
@recipe = App::Recipe.find(slug)
@recipe.category.lineage.each { |category| add_breadcrumb category.title }
add_breadcrumb @recipe.title
render "app/recipes/show", status:
end
end
<%# The page builds the form the channel does, so the first paint and every
later one ask the same rules. After a refused POST the controller hands
back the account it could not save as @signup, and the island starts
from its name and email. The password is never put back. %>
<% signup = @signup || Recipes::Auth::Account.new %>
<% form = Recipes::Signup::SignupForm.from(Recipes::Auth::Account.new(username: signup.username, email: signup.email)) %>
<% checked = form.username.presence %>
<% field = ->(name, event: :input) { on(:set_field, event:, with: { field: name }) } %>
<%= island Recipes::Signup::SignupChannel, params: { username: signup.username, email: signup.email }.compact_blank,
class: "panel mx-auto w-full max-w-xl p-4 sm:p-5" do %>
<header class="flex items-baseline justify-between gap-2">
<h2 class="text-xl font-bold">Make an account</h2>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</header>
<% if (session = demo_session) %>
<p class="mt-2 text-sm">
You are signed in as <b><%= session.account.username %></b>. Making another account signs you out of this one.
<%= link_to "Sign in or out", app_recipe_path("signin"), class: "link" %>
</p>
<% end %>
<% if signup.errors.any? %>
<div role="alert" class="alert alert-error alert-soft mt-3 text-sm">
<span>The account was not made. <%= signup.errors.map(&:message).uniq.to_sentence %></span>
</div>
<% end %>
<%# A plain form: it posts, the account is made and signed in, and the page
reloads. The fields also tell the channel what is typed, for the checks. %>
<%= form_with url: recipes_auth_registration_path, id: "signup_form", class: "mt-3 flex flex-col gap-1",
data: { turbo: false } do %>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Username</span>
<%= tag.input type: "text", name: "username", value: form.username, maxlength: 20, required: true,
autocomplete: "username", autocapitalize: "none", spellcheck: false, class: "input w-full", **field.(:username) %>
<%= render "app/recipes/signup/name", status: form.name_status(checked:, free: checked && Recipes::Auth::Account.free?(checked)) %>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Email <span class="font-normal opacity-70">(made up: anything at example.com, or ending in .test)</span></span>
<%# autocomplete is off because the address should be made up. A real
form says "email", so the browser can fill it in. %>
<%= tag.input type: "email", name: "email", value: form.email, maxlength: 60, required: true,
autocomplete: "off", placeholder: "you@example.com", class: "input w-full", **field.(:email) %>
<p class="min-h-5 text-sm text-error"><%= reactive :signup_email_error, form.email_message %></p>
</label>
<%= render "app/recipes/signup/password", rolled: nil %>
<%= render "app/recipes/signup/checks", checks: form.checks %>
<%= render "app/recipes/signup/actions", ready: false %>
<% end %>
<% end %>
<%# locals: (status:) -%>
<%# Morphed by the channel each time the answer about the name changes. %>
<% tone = { success: "text-success", error: "text-error", muted: "opacity-70" }[status&.tone] %>
<p id="signup_name" class="min-h-5 text-sm <%= tone %>"><%= status&.text %></p>
<%# locals: (rolled:) -%>
<%# Rendered with the page, and again only when a password is rolled, so
typing is never interrupted. A rolled password fills both fields and is
shown once in plain text, so it can be written down. %>
<% field = ->(name) { on(:set_field, event: :input, with: { field: name }) } %>
<div id="signup_password" class="flex flex-col gap-1">
<div class="grid gap-x-3 gap-y-1 sm:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Password</span>
<%= tag.input type: "password", name: "password", value: rolled, required: true,
autocomplete: "new-password", class: "input w-full", **field.(:password) %>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">The same again</span>
<%= tag.input type: "password", name: "password_confirmation", value: rolled, required: true,
autocomplete: "new-password", class: "input w-full", **field.(:password_confirmation) %>
</label>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<%= tag.button type: "button", id: "signup_roll", class: "btn btn-sm", **on(:roll) do %>
Roll a password <span class="badge badge-warning badge-sm">demo only</span>
<% end %>
<% if rolled %>
<p id="signup_rolled" class="text-sm">
Rolled <code class="font-semibold"><%= rolled %></code>. Write it down to sign in with it.
</p>
<% end %>
</div>
</div>
<%# locals: (checks:) -%>
<%# The password rules, ticking off as they are met. Morphed by the channel
each time a tick changes, not on every keystroke. %>
<ul id="signup_checks" class="mt-2 flex flex-col gap-0.5 text-sm" aria-label="Password rules">
<% checks.each do |check| %>
<li id="signup_check_<%= check.key %>" class="flex items-center gap-2 <%= check.ok ? "text-success" : "opacity-70" %>">
<span aria-hidden="true" class="w-4 text-center font-bold"><%= check.ok ? "✓" : "○" %></span>
<span><%= check.label %></span>
<span class="sr-only"><%= check.ok ? "(met)" : "(not yet)" %></span>
</li>
<% end %>
</ul>
<%# locals: (ready:) -%>
<%# Morphed by the channel. The button waits for every check, but it is
aria-disabled, not disabled, so Enter still posts and the server says
what is wrong: the checks here are help, the POST is the gate. %>
<div id="signup_actions" class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 border-t-2 border-base-300 pt-3">
<%= tag.button "Make the account", type: "submit", id: "signup_submit",
class: [ "btn btn-primary btn-sm", ("btn-disabled" unless ready) ], aria: { disabled: !ready } %>
<p id="signup_note" class="text-sm opacity-70">
<%= ready ? "Every check is met." : "The button waits for every check." %>
</p>
</div>
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:
-
The field sends
set_fieldwith the password so far (with a 250ms debounce). -
set_fieldwrites thepasswordstate. -
rulesreads it, so it is worked out again, andchecksafter it. -
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.