diff --git a/gems/fizzy-saas/app/controllers/signups_controller.rb b/gems/fizzy-saas/app/controllers/signups_controller.rb
new file mode 100644
index 000000000..73bcdeada
--- /dev/null
+++ b/gems/fizzy-saas/app/controllers/signups_controller.rb
@@ -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
diff --git a/gems/fizzy-saas/app/views/signups/new.html.erb b/gems/fizzy-saas/app/views/signups/new.html.erb
new file mode 100644
index 000000000..c357d3c0f
--- /dev/null
+++ b/gems/fizzy-saas/app/views/signups/new.html.erb
@@ -0,0 +1,60 @@
+<% @page_title = "Sign up for Fizzy" %>
+
+
" style="--panel-size: 65ch;">
+
Fizzy
+
Create your account
+
+ <%= form_with model: @signup, url: saas.signup_path, scope: "signup", class: "flex flex-column gap txt-large", data: { turbo: false } do |form| %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <% if @signup.errors.any? %>
+
+
+ <% @signup.errors.full_messages.each do |message| %>
+ - <%= message %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <% end %>
+
+
+
diff --git a/gems/fizzy-saas/config/routes.rb b/gems/fizzy-saas/config/routes.rb
index cb130af4f..698095350 100644
--- a/gems/fizzy-saas/config/routes.rb
+++ b/gems/fizzy-saas/config/routes.rb
@@ -1,3 +1,4 @@
Fizzy::Saas::Engine.routes.draw do
+ resource :signup, only: [ :new, :create ]
Queenbee.routes(self)
end
diff --git a/gems/fizzy-saas/test/controllers/signups_controller_test.rb b/gems/fizzy-saas/test/controllers/signups_controller_test.rb
new file mode 100644
index 000000000..8cbe8ec9f
--- /dev/null
+++ b/gems/fizzy-saas/test/controllers/signups_controller_test.rb
@@ -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