Bring simple signup flow from the fizzy-saas gem
We skip the QB code and we fill external account ids automatically on creation with a sequence See: https://github.com/basecamp/fizzy-saas/pull/7
This commit is contained in:
+1
-1
@@ -6,6 +6,6 @@ git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
|
||||
|
||||
gem "activeresource", require: "active_resource"
|
||||
gem "queenbee", bc: "queenbee-plugin"
|
||||
gem "fizzy-saas", bc: "fizzy-saas"
|
||||
gem "fizzy-saas", bc: "fizzy-saas", branch: "extract-signup"
|
||||
gem "rails_structured_logging", bc: "rails-structured-logging"
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
GIT
|
||||
remote: https://github.com/basecamp/fizzy-saas
|
||||
revision: 40f68d8604a1b410ba514f5793a93e9b11c00219
|
||||
revision: 3b012b9ffa7c8fdc1e4b9f5aa7d866ed4108a8ff
|
||||
branch: extract-signup
|
||||
specs:
|
||||
fizzy-saas (0.1.0)
|
||||
queenbee
|
||||
|
||||
@@ -4,7 +4,6 @@ class ApplicationController < ActionController::Base
|
||||
include CurrentRequest, CurrentTimezone, SetPlatform
|
||||
include RequestForgeryProtection
|
||||
include TurboFlash, ViewTransitions
|
||||
include Saas
|
||||
include RoutingHeaders
|
||||
|
||||
etag { "v1" }
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
module Saas
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
helper_method :signups_allowed?
|
||||
end
|
||||
|
||||
def signups_allowed?
|
||||
defined?(Signup) && defined?(saas)
|
||||
end
|
||||
end
|
||||
@@ -22,10 +22,8 @@ class SessionsController < ApplicationController
|
||||
magic_link = identity.send_magic_link
|
||||
flash[:magic_link_code] = magic_link&.code if Rails.env.development?
|
||||
redirect_to session_magic_link_path
|
||||
elsif signups_allowed?
|
||||
Signup.new(email_address: email_address).create_identity
|
||||
session[:return_to_after_authenticating] = saas.new_signup_completion_path
|
||||
redirect_to session_magic_link_path
|
||||
elsif
|
||||
process_new_signup
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,4 +36,10 @@ class SessionsController < ApplicationController
|
||||
def email_address
|
||||
params.expect(:email_address)
|
||||
end
|
||||
|
||||
def process_new_signup
|
||||
Signup.new(email_address: email_address).create_identity
|
||||
session[:return_to_after_authenticating] = new_signup_completion_path
|
||||
redirect_to session_magic_link_path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
class Signup::CompletionsController < ApplicationController
|
||||
layout "public"
|
||||
|
||||
disallow_account_scope
|
||||
|
||||
def new
|
||||
@signup = Signup.new(identity: Current.identity)
|
||||
end
|
||||
|
||||
def create
|
||||
@signup = Signup.new(signup_params)
|
||||
|
||||
if @signup.complete
|
||||
redirect_to landing_url(script_name: @signup.account.slug)
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def signup_params
|
||||
params.expect(signup: %i[ full_name ]).with_defaults(identity: Current.identity)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
class Signup
|
||||
include ActiveModel::Model
|
||||
include ActiveModel::Attributes
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_accessor :full_name, :email_address, :identity
|
||||
attr_reader :account, :user
|
||||
|
||||
with_options on: :completion do
|
||||
validates_presence_of :full_name, :identity
|
||||
end
|
||||
|
||||
def initialize(...)
|
||||
super
|
||||
|
||||
@email_address = @identity.email_address if @identity
|
||||
end
|
||||
|
||||
def create_identity
|
||||
@identity = Identity.find_or_create_by!(email_address: email_address)
|
||||
@identity.send_magic_link
|
||||
end
|
||||
|
||||
def complete
|
||||
if valid?(:completion)
|
||||
begin
|
||||
@tenant = create_tenant
|
||||
create_account
|
||||
true
|
||||
rescue => error
|
||||
destroy_account
|
||||
handle_account_creation_error(error)
|
||||
|
||||
errors.add(:base, "Something went wrong, and we couldn't create your account. Please give it another try.")
|
||||
Rails.error.report(error, severity: :error)
|
||||
Rails.logger.error error
|
||||
Rails.logger.error error.backtrace.join("\n")
|
||||
|
||||
false
|
||||
end
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Override to customize the handling of external accounts associated to the account.
|
||||
def create_tenant
|
||||
nil
|
||||
end
|
||||
|
||||
# Override to inject custom handling for account creation errors
|
||||
def handle_account_creation_error(error)
|
||||
end
|
||||
|
||||
def create_account
|
||||
@account = Account.create_with_admin_user(
|
||||
account: {
|
||||
external_account_id: @tenant,
|
||||
name: generate_account_name
|
||||
},
|
||||
owner: {
|
||||
name: full_name,
|
||||
identity: identity
|
||||
}
|
||||
)
|
||||
@user = @account.users.find_by!(role: :admin)
|
||||
@account.setup_customer_template
|
||||
end
|
||||
|
||||
def generate_account_name
|
||||
AccountNameGenerator.new(identity: identity, name: full_name).generate
|
||||
end
|
||||
|
||||
|
||||
def destroy_account
|
||||
@account&.destroy!
|
||||
|
||||
@user = nil
|
||||
@account = nil
|
||||
@tenant = nil
|
||||
end
|
||||
|
||||
def subscription_attributes
|
||||
subscription = FreeV1Subscription
|
||||
|
||||
{}.tap do |attributes|
|
||||
attributes[:name] = subscription.to_param
|
||||
attributes[:price] = subscription.price
|
||||
end
|
||||
end
|
||||
|
||||
def request_attributes
|
||||
{}.tap do |attributes|
|
||||
attributes[:remote_address] = Current.ip_address
|
||||
attributes[:user_agent] = Current.user_agent
|
||||
attributes[:referrer] = Current.referrer
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
class Signup::AccountNameGenerator
|
||||
SUFFIX = "Fizzy".freeze
|
||||
|
||||
attr_reader :identity, :name
|
||||
|
||||
def initialize(identity:, name:)
|
||||
@identity = identity
|
||||
@name = name
|
||||
end
|
||||
|
||||
def generate
|
||||
next_index = current_index + 1
|
||||
|
||||
if next_index == 1
|
||||
"#{prefix} #{SUFFIX}"
|
||||
else
|
||||
"#{prefix} #{next_index.ordinalize} #{SUFFIX}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def current_index
|
||||
existing_indices.max || 0
|
||||
end
|
||||
|
||||
def existing_indices
|
||||
Current.without_account do
|
||||
identity.accounts.filter_map do |account|
|
||||
if account.name.match?(first_account_name_regex)
|
||||
1
|
||||
elsif match = account.name.match(nth_account_name_regex)
|
||||
match[1].to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def first_account_name_regex
|
||||
@first_account_name_regex ||= /\A#{prefix}\s+#{SUFFIX}\Z/i
|
||||
end
|
||||
|
||||
def nth_account_name_regex
|
||||
@nth_account_name_regex ||= /\A#{prefix}\s+(1st|2nd|3rd|\d+th)\s+#{SUFFIX}/i
|
||||
end
|
||||
|
||||
def prefix
|
||||
@prefix ||= "#{first_name}'s"
|
||||
end
|
||||
|
||||
def first_name
|
||||
name.strip.split(" ", 2).first
|
||||
end
|
||||
end
|
||||
@@ -24,13 +24,11 @@
|
||||
<p class="margin-none-block-start">You don’t have any Fizzy accounts.</p>
|
||||
<% end %>
|
||||
|
||||
<% if signups_allowed? %>
|
||||
<div class="margin-block-start">
|
||||
<%= link_to saas.new_signup_completion_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %>
|
||||
<%= link_to new_signup_completion_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %>
|
||||
<span>Sign up for a new Fizzy account</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<% @page_title = "Complete your sign-up" %>
|
||||
|
||||
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] %>">
|
||||
<h1 class="txt-x-large font-weight-black margin-block-end"><%= @page_title %></h1>
|
||||
|
||||
<%= form_with model: @signup, url: signup_completion_path, scope: "signup", class: "flex flex-column gap", data: { controller: "form" } do |form| %>
|
||||
<%= form.text_field :full_name, class: "input txt-large", autocomplete: "name", placeholder: "Enter your full name…", autofocus: true, required: true %>
|
||||
|
||||
<p>You're one step away. Just enter your name to get your own Fizzy account.</p>
|
||||
|
||||
<% if @signup.errors.any? %>
|
||||
<div class="margin-block-half">
|
||||
<ul class="margin-block-none txt-negative txt-small">
|
||||
<% @signup.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<button type="submit" class="btn btn--link center" data-form-target="submit">
|
||||
<span>Continue</span>
|
||||
<%= icon_tag "arrow-right" %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% content_for :footer do %>
|
||||
<%= render "sessions/footer" %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,28 @@
|
||||
<% @page_title = "Sign up for Fizzy" %>
|
||||
|
||||
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] %>">
|
||||
<h1 class="txt-xx-large margin-block-end-double">Sign up</h1>
|
||||
|
||||
<%= form_with model: @signup, url: signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %>
|
||||
<%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Email address", required: true %>
|
||||
|
||||
<% if @signup.errors.any? %>
|
||||
<div class="margin-block-half">
|
||||
<ul class="margin-block-none txt-negative txt-small">
|
||||
<% @signup.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<button type="submit" class="btn btn--link center" data-form-target="submit">
|
||||
<span>Continue</span>
|
||||
<%= icon_tag "arrow-right" %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% content_for :footer do %>
|
||||
<%= render "sessions/footer" %>
|
||||
<% end %>
|
||||
@@ -155,6 +155,12 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
get "/signup/new", to: redirect("/session/new")
|
||||
|
||||
namespace :signup do
|
||||
resource :completion, only: %i[ new create ]
|
||||
end
|
||||
|
||||
resource :landing
|
||||
|
||||
namespace :my do
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class CreateAccountExternalIdSequences < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :account_external_id_sequences do |t|
|
||||
create_table :account_external_id_sequences, id: :uuid do |t|
|
||||
t.bigint :value, null: false, default: 0
|
||||
|
||||
t.index :value, unique: true
|
||||
|
||||
Generated
+6
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.2].define(version: 2025_11_25_130010) do
|
||||
ActiveRecord::Schema[8.2].define(version: 2025_11_27_000001) do
|
||||
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "accessed_at"
|
||||
t.uuid "account_id", null: false
|
||||
@@ -25,6 +25,11 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_25_130010) do
|
||||
t.index ["user_id"], name: "index_accesses_on_user_id"
|
||||
end
|
||||
|
||||
create_table "account_external_id_sequences", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "value", default: 0, null: false
|
||||
t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true
|
||||
end
|
||||
|
||||
create_table "account_join_codes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.uuid "account_id", null: false
|
||||
t.string "code", null: false
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_27_000001) do
|
||||
t.index ["user_id"], name: "index_accesses_on_user_id"
|
||||
end
|
||||
|
||||
create_table "account_external_id_sequences", force: :cascade do |t|
|
||||
create_table "account_external_id_sequences", id: :uuid, force: :cascade do |t|
|
||||
t.bigint "value", default: 0, null: false
|
||||
t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true
|
||||
end
|
||||
|
||||
@@ -21,6 +21,20 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "create for a new user" do
|
||||
untenanted do
|
||||
assert_difference -> { Identity.count }, +1 do
|
||||
assert_difference -> { MagicLink.count }, +1 do
|
||||
post session_path,
|
||||
params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" }
|
||||
end
|
||||
end
|
||||
|
||||
assert_redirected_to session_magic_link_path
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
test "destroy" do
|
||||
sign_in_as :kevin
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
require "test_helper"
|
||||
|
||||
class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@signup = Signup.new(email_address: "newuser@example.com", full_name: "New User")
|
||||
|
||||
@signup.create_identity || raise("Failed to create identity")
|
||||
|
||||
sign_in_as @signup.identity
|
||||
end
|
||||
|
||||
test "new" do
|
||||
untenanted do
|
||||
get new_signup_completion_path
|
||||
end
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "create" do
|
||||
untenanted do
|
||||
post signup_completion_path, params: {
|
||||
signup: {
|
||||
full_name: @signup.full_name
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :redirect, "Valid params should redirect"
|
||||
end
|
||||
|
||||
test "create with invalid params" do
|
||||
untenanted do
|
||||
post signup_completion_path, params: {
|
||||
signup: {
|
||||
full_name: ""
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity, "Invalid params should return unprocessable entity"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require "test_helper"
|
||||
|
||||
class Signup::AccountNameGeneratorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@identity = Identity.create!(email_address: "newart.userbaum@example.com")
|
||||
@name = "Newart userbaum"
|
||||
@generator = Signup::AccountNameGenerator.new(identity: @identity, name: @name)
|
||||
end
|
||||
|
||||
test "generate" do
|
||||
account_name = @generator.generate
|
||||
assert_equal "Newart's Fizzy", account_name, "The 1st account doesn't have 1st in the name"
|
||||
|
||||
first_account = Account.create!(external_account_id: "1st", name: account_name)
|
||||
Current.without_account do
|
||||
@identity.users.create!(account: first_account, name: @name)
|
||||
@identity.reload
|
||||
end
|
||||
|
||||
account_name = @generator.generate
|
||||
assert_equal "Newart's 2nd Fizzy", account_name
|
||||
|
||||
second_account = Account.create!(external_account_id: "2nd", name: account_name)
|
||||
Current.without_account do
|
||||
@identity.users.create!(account: second_account, name: @name)
|
||||
@identity.reload
|
||||
end
|
||||
|
||||
account_name = @generator.generate
|
||||
assert_equal "Newart's 3rd Fizzy", account_name
|
||||
|
||||
third_account = Account.create!(external_account_id: "3rd", name: account_name)
|
||||
Current.without_account do
|
||||
@identity.users.create!(account: third_account, name: @name)
|
||||
@identity.reload
|
||||
end
|
||||
|
||||
account_name = @generator.generate
|
||||
assert_equal "Newart's 4th Fizzy", account_name
|
||||
|
||||
fourth_account = Account.create!(external_account_id: "4th", name: account_name)
|
||||
Current.without_account do
|
||||
@identity.users.create!(account: fourth_account, name: @name)
|
||||
@identity.reload
|
||||
end
|
||||
|
||||
account_name = @generator.generate
|
||||
assert_equal "Newart's 5th Fizzy", account_name
|
||||
end
|
||||
|
||||
test "generate continues from the previous highest index" do
|
||||
account = Account.create!(external_account_id: "12th", name: "Newart's 12th Fizzy")
|
||||
Current.without_account do
|
||||
@identity.users.create!(account: account, name: @name)
|
||||
@identity.reload
|
||||
end
|
||||
|
||||
account_name = @generator.generate
|
||||
assert_equal "Newart's 13th Fizzy", account_name
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
require "test_helper"
|
||||
|
||||
class SignupTest < ActiveSupport::TestCase
|
||||
test "#create_identity" do
|
||||
signup = Signup.new(email_address: "brian@example.com")
|
||||
|
||||
assert_difference -> { Identity.count }, 1 do
|
||||
assert_difference -> { MagicLink.count }, 1 do
|
||||
assert signup.create_identity
|
||||
end
|
||||
end
|
||||
|
||||
assert_empty signup.errors
|
||||
assert signup.identity
|
||||
assert signup.identity.persisted?
|
||||
|
||||
signup_existing = Signup.new(email_address: "brian@example.com")
|
||||
|
||||
assert_no_difference -> { Identity.count } do
|
||||
assert_difference -> { MagicLink.count }, 1 do
|
||||
assert signup_existing.create_identity, "Should send magic link for existing identity"
|
||||
end
|
||||
end
|
||||
|
||||
signup_invalid = Signup.new(email_address: "")
|
||||
assert_raises do
|
||||
signup_invalid.create_identity
|
||||
end
|
||||
end
|
||||
|
||||
test "#complete" do
|
||||
Account.any_instance.expects(:setup_customer_template).once
|
||||
|
||||
Current.without_account do
|
||||
signup = Signup.new(
|
||||
full_name: "Kevin",
|
||||
identity: identities(:kevin)
|
||||
)
|
||||
|
||||
assert signup.complete
|
||||
|
||||
assert signup.account
|
||||
assert signup.user
|
||||
assert_equal "Kevin", signup.user.name
|
||||
|
||||
signup_invalid = Signup.new(
|
||||
full_name: "",
|
||||
identity: identities(:kevin)
|
||||
)
|
||||
assert_not signup_invalid.complete
|
||||
assert_not_empty signup_invalid.errors[:full_name]
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user