Merge pull request #1266 from basecamp/flavorjones/qb-signup

Signup user workflow (simple QB version)
This commit is contained in:
Mike Dalessio
2025-10-08 13:39:48 -04:00
committed by GitHub
6 changed files with 394 additions and 0 deletions
@@ -0,0 +1,31 @@
class SignupsController < ApplicationController
require_untenanted_access
rate_limit only: :create, name: "short-term", to: 5, within: 3.minutes,
with: -> { redirect_to saas.new_signup_path, alert: "Try again later." }
rate_limit only: :create, name: "long-term", to: 10, within: 30.minutes,
with: -> { redirect_to saas.new_signup_path, alert: "Try again later." }
http_basic_authenticate_with \
name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name,
password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password
def new
@signup = Signup.new
end
def create
@signup = Signup.new(signup_params)
if @signup.process
redirect_to root_url(script_name: @signup.account.slug)
else
render :new, status: :unprocessable_entity
end
end
private
def signup_params
params.require(:signup).permit(*Signup::PERMITTED_KEYS)
end
end
+121
View File
@@ -0,0 +1,121 @@
class Signup
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
PERMITTED_KEYS = %i[ full_name email_address password company_name ]
# Input attributes
attr_accessor :company_name, :full_name, :email_address, :password
validates_presence_of :company_name, :full_name, :email_address, :password
# Output attributes
attr_reader :tenant, :account, :user, :queenbee_account
def initialize(...)
@company_name = nil
@full_name = nil
@email_address = nil
@password = nil
@tenant = nil
@account = nil
@user = nil
@queenbee_account = nil
super
end
def process
return false unless valid?
create_queenbee_account
create_tenant
true
rescue => error
destroy_tenant
destroy_queenbee_account
errors.add(:base, "An error occurred during signup: #{error.message}")
false
end
private
def create_queenbee_account
@queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes)
end
def destroy_queenbee_account
@queenbee_account&.cancel
@queenbee_account = nil
end
def create_tenant
@tenant = queenbee_account.id.to_s
ApplicationRecord.create_tenant(tenant) do
@account = Account.create_with_admin_user(
account: {
external_account_id: tenant,
name: company_name
},
owner: {
name: full_name,
email_address: email_address,
password: password
}
)
@user = User.first
@account.setup_basic_template
end
end
def destroy_tenant
if tenant.present? && ApplicationRecord.tenant_exist?(tenant)
ApplicationRecord.destroy_tenant(tenant)
end
@user = nil
@account = nil
@tenant = nil
end
def queenbee_account_attributes
{}.tap do |attributes|
# Tell Queenbee to skip the request to create a local account. We've created it ourselves.
attributes[:skip_remote] = true
# # TODO: once we are doing our own email validation, consider setting this
# # Queenbee should not do spam checks on this account, we've done our own.
# attributes[:auto_allow] = true
# # TODO: Terms of Service
# attributes[:terms_of_service] = true
attributes[:product_name] = "fizzy"
attributes[:name] = company_name
attributes[:owner_name] = full_name
attributes[:owner_email] = email_address
attributes[:trial] = true
attributes[:subscription] = subscription_attributes
attributes[:remote_request] = request_attributes
end
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,60 @@
<% @page_title = "Sign up for Fizzy" %>
<div class="panel shadow center margin-block-double <%= "shake" if flash[:alert] %>" style="--panel-size: 65ch;">
<h1 class="txt-xx-large margin-block-end-double">Fizzy</h1>
<h2 class="txt-large margin-block-end-double">Create your account</h2>
<%= form_with model: @signup, url: saas.signup_path, scope: "signup", class: "flex flex-column gap txt-large", data: { turbo: false } do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.text_field :full_name, class: "input", autocomplete: "name", placeholder: "Your name", autofocus: true, required: true %>
<%= icon_tag "person", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Your email address", required: true %>
<%= icon_tag "email", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.text_field :company_name, class: "input", autocomplete: "organization", placeholder: "Your organization's name", required: true %>
<%= icon_tag "building", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.password_field :password, class: "input", autocomplete: "new-password", placeholder: "Password (at least 12 characters)", required: true, maxlength: 72, minlength: 12 %>
<%= icon_tag "password", class: "txt-large" %>
</label>
</div>
<% if @signup.errors.any? %>
<div class="alert alert--error">
<ul class="margin-block-none">
<% @signup.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<button type="submit" class="btn btn--reversed center">
<%= icon_tag "arrow-right" %>
<span>Create your account</span>
</button>
<% end %>
<!-- TODO: Add this back when we have Identities and magic link login working
<footer class="margin-block-start-double txt-center">
<p class="txt-small txt-muted">
Already have an account?
<%= link_to "Sign in", login_url, class: "decorated" %>
</p>
</footer>
-->
</div>
+1
View File
@@ -1,3 +1,4 @@
Fizzy::Saas::Engine.routes.draw do
resource :signup, only: [ :new, :create ]
Queenbee.routes(self)
end
@@ -0,0 +1,87 @@
require "test_helper"
class SignupsControllerTest < ActionDispatch::IntegrationTest
setup do
@signup_params = {
full_name: "Brian Wilson",
email_address: "brian@example.com",
company_name: "Beach Boys",
password: SecureRandom.hex(16)
}
@starting_tenants = ApplicationRecord.tenants
# Clear script_name for untenanted signup tests
integration_session.default_url_options[:script_name] = nil
end
test "should require http basic authentication" do
get saas.new_signup_url
assert_response :unauthorized
end
test "should get new" do
get saas.new_signup_url, headers: http_basic_auth_headers
assert_response :success
assert_select "h2", "Create your account"
assert_select "input[name='signup[full_name]']"
assert_select "input[name='signup[email_address]']"
assert_select "input[name='signup[company_name]']"
assert_select "input[name='signup[password]']"
end
test "should create signup and redirect to tenant root on success" do
Account.any_instance.expects(:setup_basic_template).once
assert_difference -> { ApplicationRecord.tenants.count }, 1 do
post saas.signup_url, params: { signup: @signup_params }, headers: http_basic_auth_headers
end
assert_response :redirect
new_tenant = (ApplicationRecord.tenants - @starting_tenants).first
ApplicationRecord.with_tenant(new_tenant) do
account = Account.sole
assert account, "Account should have been created"
assert_equal @signup_params[:company_name], account.name
user = User.find_by(email_address: @signup_params[:email_address])
assert user, "User should have been created"
assert_equal @signup_params[:full_name], user.name
assert_equal @signup_params[:email_address], user.email_address
assert_redirected_to root_url(script_name: account.slug)
end
end
test "should render new with errors when signup fails validation" do
invalid_params = @signup_params.merge(password: "")
assert_no_difference -> { ApplicationRecord.tenants.count } do
post saas.signup_url, params: { signup: invalid_params }, headers: http_basic_auth_headers
end
assert_response :unprocessable_entity
assert_select ".alert--error"
assert_select ".alert--error li", /Password can't be blank/
end
test "should render new with errors when signup processing fails" do
Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data")
assert_no_difference -> { ApplicationRecord.tenants.count } do
post saas.signup_url, params: { signup: @signup_params }, headers: http_basic_auth_headers
end
assert_response :unprocessable_entity
assert_select ".alert--error"
assert_select ".alert--error li", /An error occurred during signup/
end
private
def http_basic_auth_headers
credentials = ActionController::HttpAuthentication::Basic.encode_credentials("testname", "testpassword")
{ "HTTP_AUTHORIZATION" => credentials }
end
end
@@ -0,0 +1,94 @@
require "test_helper"
class SignupTest < ActiveSupport::TestCase
setup do
@starting_tenants = ApplicationRecord.tenants
@signup = Signup.new(
email_address: "brian@example.com",
full_name: "Brian Wilson",
company_name: "Beach Boys",
password: SecureRandom.hex(16)
)
end
test "#process creates all the necessary objects for a new Fizzy account" do
Account.any_instance.expects(:setup_basic_template).once
assert @signup.process, @signup.errors.full_messages.to_sentence(words_connector: ". ")
assert_empty @signup.errors
assert @signup.tenant
assert_includes ApplicationRecord.tenants, @signup.tenant
assert @signup.account
assert @signup.account.persisted?
assert @signup.account.external_account_id
assert_equal @signup.company_name, @signup.account.name
assert_equal @signup.tenant, @signup.account.external_account_id.to_s
assert_equal @signup.tenant, @signup.account.tenant
assert @signup.user
assert @signup.user.persisted?
assert_equal @signup.full_name, @signup.user.name
assert_equal @signup.email_address, @signup.user.email_address
auth_params = { email_address: @signup.email_address, password: @signup.password }
user = ApplicationRecord.with_tenant(@signup.tenant) { User.authenticate_by(**auth_params) }
assert user, "User should be able to authenticate with #{auth_params.inspect}"
assert_equal @signup.user, user
assert_equal @signup.tenant, @signup.user.tenant
end
test "#process does nothing if a basic validation error occurs" do
@signup.password = ""
assert_not @signup.process
assert_not_empty @signup.errors[:password]
assert_nil @signup.tenant
assert_nil @signup.account
assert_nil @signup.user
assert_equal @starting_tenants, ApplicationRecord.tenants
end
test "#process does nothing if an error occurs creating the queenbee record" do
Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data")
assert_not @signup.process
assert_not_empty @signup.errors[:base]
assert_nil @signup.tenant
assert_nil @signup.account
assert_nil @signup.user
assert_equal @starting_tenants, ApplicationRecord.tenants
end
test "#process does nothing if an error occurs creating the tenant" do
ApplicationRecord.stubs(:create_tenant).raises(RuntimeError, "Tenant already exists")
Queenbee::Remote::Account.any_instance.expects(:cancel).once
assert_not @signup.process
assert_not_empty @signup.errors[:base]
assert_nil @signup.tenant
assert_nil @signup.account
assert_nil @signup.user
assert_equal @starting_tenants, ApplicationRecord.tenants
end
test "#process does nothing if an error occurs creating the tenanted records" do
Account.stubs(:create_with_admin_user).raises(ActiveRecord::RecordInvalid, "Validation failed: Name can't be blank")
Queenbee::Remote::Account.any_instance.expects(:cancel).once
assert_not @signup.process
assert_not_empty @signup.errors[:base]
assert_nil @signup.tenant
assert_nil @signup.account
assert_nil @signup.user
assert_equal @starting_tenants, ApplicationRecord.tenants
end
end