diff --git a/Dockerfile b/Dockerfile
index e2308f371..25ee37e68 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,7 +19,8 @@ FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
- apt-get install --no-install-recommends -y build-essential git libvips pkg-config libyaml-dev
+ apt-get install -y --no-install-recommends -y build-essential git libvips pkg-config libyaml-dev default-libmysqlclient-dev && \
+ rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY Gemfile Gemfile.lock .ruby-version ./
@@ -47,7 +48,7 @@ FROM base
# Install packages needed for deployment
RUN apt-get update -qq && \
- apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools && \
+ apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools default-libmysqlclient-dev && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Copy built artifacts: gems, application
diff --git a/Gemfile b/Gemfile
index 8c43ff533..e5e3637e9 100644
--- a/Gemfile
+++ b/Gemfile
@@ -32,6 +32,16 @@ gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
+# 37id and Queenbee integration
+gem "signal_id", bc: "signal_id", branch: "rails4"
+gem "mysql2" # needed by signal_id
+gem "queuety", bc: "queuety", branch: "rails4" # needed by signal_id
+gem "service_concurrency_prevention", bc: "service_concurrency_prevention" # needed by queuety
+gem "portfolio", ">= 4.6", bc: "portfolio" # needed by signal_id
+gem "file_repository", "~> 1.4.5", bc: "file_repository" # needed by portfolio
+gem "queenbee", bc: "queenbee-plugin"
+gem "activeresource", require: "active_resource" # needed by queenbee
+
# Telemetry, logging, and operations
gem "mission_control-jobs"
gem "sentry-ruby"
@@ -55,4 +65,5 @@ group :test do
gem "selenium-webdriver"
gem "webmock"
gem "vcr"
+ gem "mocha"
end
diff --git a/Gemfile.lock b/Gemfile.lock
index ab120f738..a1e1ced06 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -15,6 +15,12 @@ GIT
railties (>= 8.1.alpha)
zeitwerk
+GIT
+ remote: https://github.com/basecamp/file_repository
+ revision: 17e90fc35ccb9f0a0dae1ed8623f01386696002d
+ specs:
+ file_repository (1.4.5)
+
GIT
remote: https://github.com/basecamp/kamal
revision: 344e2d7995e0134aa52f195a984d8d5e24e31d94
@@ -32,6 +38,32 @@ GIT
thor (~> 1.3)
zeitwerk (>= 2.6.18, < 3.0)
+GIT
+ remote: https://github.com/basecamp/portfolio
+ revision: 26dd0ccbe4aad5a5c539f90c00e82b6175443636
+ specs:
+ portfolio (5.2.0)
+ activesupport
+ file_repository (>= 1.4.4)
+
+GIT
+ remote: https://github.com/basecamp/queenbee-plugin
+ revision: bf887b5c128570d1b19a0140146ae9aa4ae2915f
+ specs:
+ queenbee (3.2.0)
+ activeresource
+ builder
+ rexml
+
+GIT
+ remote: https://github.com/basecamp/queuety
+ revision: bf7e2a552fb674533d3e092f06dbf9b5d1ebc753
+ branch: rails4
+ specs:
+ queuety (4.0.2)
+ activerecord
+ service_concurrency_prevention
+
GIT
remote: https://github.com/basecamp/rails-structured-logging
revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6
@@ -40,6 +72,29 @@ GIT
json
rails (>= 6.0.0)
+GIT
+ remote: https://github.com/basecamp/service_concurrency_prevention
+ revision: 320da99431718615ed86f8723e8b9d3259c602a3
+ specs:
+ service_concurrency_prevention (1.1.1)
+ activesupport
+
+GIT
+ remote: https://github.com/basecamp/signal_id
+ revision: 129483ccc1d9452fa7713080fcbf0c012b2f4107
+ branch: rails4
+ specs:
+ signal_id (4.3.2)
+ bcrypt-ruby (>= 3.0.0)
+ google-id-token (>= 1.4.0)
+ mocha
+ multi_json
+ portfolio (>= 4.0)
+ pwned (~> 2.0)
+ queuety (>= 3.0)
+ rails (>= 4.0.2)
+ rails-controller-testing
+
GIT
remote: https://github.com/crmne/ruby_llm.git
revision: a35aa111a0bb36b7f0363b1f1f2a25e3d5378fd3
@@ -159,6 +214,14 @@ GEM
specs:
action_text-trix (2.1.15)
railties
+ activemodel-serializers-xml (1.0.3)
+ activemodel (>= 5.0.0.a)
+ activesupport (>= 5.0.0.a)
+ builder (~> 3.1)
+ activeresource (6.1.4)
+ activemodel (>= 6.0)
+ activemodel-serializers-xml (~> 1.0)
+ activesupport (>= 6.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
@@ -182,6 +245,8 @@ GEM
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
+ bcrypt-ruby (3.1.5)
+ bcrypt (>= 3.1.3)
bcrypt_pbkdf (1.1.1)
benchmark (0.4.1)
bigdecimal (3.2.2)
@@ -243,6 +308,8 @@ GEM
addressable (>= 2.5.0)
globalid (1.2.1)
activesupport (>= 6.1)
+ google-id-token (1.4.2)
+ jwt (>= 1)
hashdiff (1.2.0)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
@@ -263,6 +330,8 @@ GEM
activesupport (>= 5.0.0)
jmespath (1.6.2)
json (2.12.2)
+ jwt (2.10.1)
+ base64
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
@@ -292,8 +361,12 @@ GEM
railties (>= 7.1)
stimulus-rails
turbo-rails
+ mocha (2.7.1)
+ ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
+ multi_json (1.15.0)
multipart-post (2.4.1)
+ mysql2 (0.5.6)
net-http (0.6.0)
uri
net-imap (0.5.8)
@@ -343,6 +416,7 @@ GEM
public_suffix (6.0.2)
puma (6.6.0)
nio4r (~> 2.0)
+ pwned (2.4.1)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.16)
@@ -355,6 +429,10 @@ GEM
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
+ rails-controller-testing (1.0.5)
+ actionpack (>= 5.0.1.rc1)
+ actionview (>= 5.0.1.rc1)
+ activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -410,6 +488,7 @@ GEM
ruby-vips (2.2.3)
ffi (~> 1.12)
logger
+ ruby2_keywords (0.0.5)
rubyzip (2.4.1)
securerandom (0.4.1)
selenium-webdriver (4.33.0)
@@ -506,6 +585,7 @@ PLATFORMS
DEPENDENCIES
actiontext-lexical!
active_record-tenanted!
+ activeresource
aws-sdk-s3
bcrypt (~> 3.1.7)
bootsnap
@@ -513,15 +593,21 @@ DEPENDENCIES
bundler-audit
capybara
debug
+ file_repository (~> 1.4.5)!
geared_pagination (~> 1.2)
image_processing (~> 1.14)
importmap-rails
jbuilder
kamal!
mission_control-jobs
+ mocha
+ mysql2
platform_agent
+ portfolio (>= 4.6)!
propshaft
puma (>= 5.0)
+ queenbee!
+ queuety!
rails!
rails_structured_logging!
redcarpet
@@ -532,6 +618,8 @@ DEPENDENCIES
selenium-webdriver
sentry-rails
sentry-ruby
+ service_concurrency_prevention!
+ signal_id!
solid_cable (>= 3.0)
solid_cache (~> 1.0)
solid_queue (~> 1.1)
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index af52c2fd6..37f663be8 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
- include Authentication, CurrentTimezone, SetPlatform, WriterAffinity
+ include Authentication, CurrentRequest, CurrentTimezone, SetPlatform, WriterAffinity
stale_when_importmap_changes
allow_browser versions: :modern
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
index 746d3cda5..dc75fb777 100644
--- a/app/controllers/concerns/authentication.rb
+++ b/app/controllers/concerns/authentication.rb
@@ -2,6 +2,7 @@ module Authentication
extend ActiveSupport::Concern
included do
+ before_action :set_current_account
before_action :require_authentication
helper_method :authenticated?
end
@@ -16,6 +17,12 @@ module Authentication
skip_before_action :require_authentication, **options
before_action :resume_session, **options
end
+
+ def require_untenanted_access(**options)
+ skip_before_action :set_current_account, **options
+ skip_before_action :require_authentication, **options
+ before_action :redirect_tenanted_request, **options
+ end
end
private
@@ -40,6 +47,10 @@ module Authentication
def request_authentication
session[:return_to_after_authenticating] = request.url
+
+ # # When we're ready to flip the switch to Launchpad authentication, uncomment this line:
+ # redirect_to Launchpad.login_url(account: Current.account), allow_other_host: true
+
redirect_to new_session_path
end
@@ -51,6 +62,9 @@ module Authentication
redirect_to root_url if authenticated?
end
+ def redirect_tenanted_request
+ redirect_to root_url if ApplicationRecord.current_tenant
+ end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
@@ -64,6 +78,10 @@ module Authentication
cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax }
end
+ def set_current_account
+ Current.account = Account.first
+ end
+
def terminate_session
Current.session.destroy
cookies.delete(:session_token)
diff --git a/app/controllers/concerns/current_request.rb b/app/controllers/concerns/current_request.rb
new file mode 100644
index 000000000..b7e4af73d
--- /dev/null
+++ b/app/controllers/concerns/current_request.rb
@@ -0,0 +1,13 @@
+module CurrentRequest
+ extend ActiveSupport::Concern
+
+ included do
+ before_action do
+ Current.http_method = request.method
+ Current.request_id = request.uuid
+ Current.user_agent = request.user_agent
+ Current.ip_address = request.ip
+ Current.referrer = request.referrer
+ end
+ end
+end
diff --git a/app/controllers/sessions/launchpad_controller.rb b/app/controllers/sessions/launchpad_controller.rb
new file mode 100644
index 000000000..0e2ad16a7
--- /dev/null
+++ b/app/controllers/sessions/launchpad_controller.rb
@@ -0,0 +1,27 @@
+class Sessions::LaunchpadController < ApplicationController
+ require_unauthenticated_access
+
+ before_action :store_sig, only: :show
+ before_action :restore_and_clear_sig, only: :update
+
+ def show
+ end
+
+ def update
+ if user = Current.account.signal_account.authenticate(sig: @sig).try(:peer)
+ start_new_session_for user
+ redirect_to after_authentication_url
+ else
+ render plain: "Authentication failed. This is probably a bug.", status: :unauthorized
+ end
+ end
+
+ private
+ def store_sig
+ cookies[:_fizzy_launchpad_sig] = params.expect(:sig)
+ end
+
+ def restore_and_clear_sig
+ @sig = cookies.delete :_fizzy_launchpad_sig
+ end
+end
diff --git a/app/controllers/signup/accounts_controller.rb b/app/controllers/signup/accounts_controller.rb
new file mode 100644
index 000000000..4c0cf9de2
--- /dev/null
+++ b/app/controllers/signup/accounts_controller.rb
@@ -0,0 +1,30 @@
+class Signup::AccountsController < Signup::BaseController
+ before_action :reset_signup_storage
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_account_path, alert: "Try again later." }
+
+ def new
+ @signup = Signup.new
+ end
+
+ def create
+ @signup = Signup.new(signup_params)
+
+ if @signup.recognized?
+ store_signup
+ redirect_to Launchpad.authentication_url(purpose: "signup", login_hint: @signup.email_address, redirect_uri: signup_session_url), allow_other_host: true
+ elsif @signup.process
+ redirect_to_account(@signup.account)
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ private
+ def signup_params
+ params.require(:signup).permit(*Signup::PERMITTED_KEYS)
+ end
+
+ def store_signup
+ @signup.to_h.each { |key, value| signup_storage[key.to_s] = value }
+ end
+end
diff --git a/app/controllers/signup/base_controller.rb b/app/controllers/signup/base_controller.rb
new file mode 100644
index 000000000..ad11d6e1d
--- /dev/null
+++ b/app/controllers/signup/base_controller.rb
@@ -0,0 +1,35 @@
+class Signup::BaseController < ApplicationController
+ require_untenanted_access
+
+ http_basic_authenticate_with(
+ name: Rails.application.credentials.dig(:account_signup_http_basic_auth, :name),
+ password: Rails.application.credentials.dig(:account_signup_http_basic_auth, :password)
+ )
+
+ attr_reader :authenticated_identity
+
+ private
+ def signup_storage
+ session[:signup] ||= {}
+ end
+
+ def reset_signup_storage
+ session.delete(:signup)
+ end
+
+ def authenticated_identity=(identity)
+ @authenticated_identity = identity
+ signup_storage["identity_id"] = identity.try(:id)
+ end
+
+ def set_authenticated_identity
+ if identity_id = signup_storage["identity_id"]
+ @authenticated_identity = SignalId::Identity.find_by(id: identity_id)
+ end
+ end
+
+ def redirect_to_account(account)
+ redirect_to account.signal_account.owner.remote_login_url(proceed_to: root_path),
+ allow_other_host: true
+ end
+end
diff --git a/app/controllers/signup/completions_controller.rb b/app/controllers/signup/completions_controller.rb
new file mode 100644
index 000000000..4f97055a0
--- /dev/null
+++ b/app/controllers/signup/completions_controller.rb
@@ -0,0 +1,23 @@
+class Signup::CompletionsController < Signup::BaseController
+ before_action :set_authenticated_identity, only: :create
+
+ def new
+ end
+
+ def create
+ @signup = Signup.new(signup_params)
+ @signup.signal_identity = authenticated_identity
+
+ if @signup.process
+ reset_signup_storage
+ redirect_to_account(@signup.account)
+ else
+ render plain: "Could not complete signup. This is probably a bug.", status: :unprocessable_entity, layout: false
+ end
+ end
+
+ private
+ def signup_params
+ signup_storage.slice(*Signup::PERMITTED_KEYS.map(&:to_s))
+ end
+end
diff --git a/app/controllers/signup/sessions_controller.rb b/app/controllers/signup/sessions_controller.rb
new file mode 100644
index 000000000..b33c0cf2f
--- /dev/null
+++ b/app/controllers/signup/sessions_controller.rb
@@ -0,0 +1,9 @@
+class Signup::SessionsController < Signup::BaseController
+ def create
+ if self.authenticated_identity = SignalId::Identity.authenticate(params.permit(:sig))
+ redirect_to new_signup_completion_url
+ else
+ render plain: "Authentication failed. This is probably a bug.", status: :unauthorized
+ end
+ end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index afd022441..25b74efde 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -1,5 +1,14 @@
class Account < ApplicationRecord
- include Entropic, Joinable
+ include Entropic, Joinable, SignalAccount
has_many_attached :uploads
+
+ def setup_basic_template
+ user = User.first
+
+ Closure::Reason.create_defaults
+ Collection.create!(name: "Cards", creator: user, all_access: true)
+ workflow = Workflow.create!(name: "Basic")
+ Collection.first.update!(workflow: workflow)
+ end
end
diff --git a/app/models/account/signal_account.rb b/app/models/account/signal_account.rb
new file mode 100644
index 000000000..861626909
--- /dev/null
+++ b/app/models/account/signal_account.rb
@@ -0,0 +1,27 @@
+module Account::SignalAccount
+ extend ActiveSupport::Concern
+
+ included do
+ # TODO: remove the "optional: true" once we've populated the accounts properly
+ belongs_to :signal_account, class_name: "SignalId::Account", primary_key: :queenbee_id, foreign_key: :queenbee_id, optional: true
+ end
+
+ class_methods do
+ def create_with_admin_user(queenbee_id:)
+ new(queenbee_id: queenbee_id).tap do |account|
+ SignalId::Database.on_master do
+ account.name = account.signal_account.name
+ account.save!
+
+ User.create!(
+ name: account.signal_account.owner.name,
+ email_address: account.signal_account.owner.email_address,
+ signal_user_id: account.signal_account.owner.id,
+ role: "admin",
+ password: SecureRandom.hex(36) # TODO: remove password column?
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/current.rb b/app/models/current.rb
index a4d0f0864..5a85b2e16 100644
--- a/app/models/current.rb
+++ b/app/models/current.rb
@@ -1,5 +1,7 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
+ attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
+ attribute :account
delegate :user, to: :session, allow_nil: true
end
diff --git a/app/models/launchpad.rb b/app/models/launchpad.rb
new file mode 100644
index 000000000..6001dac82
--- /dev/null
+++ b/app/models/launchpad.rb
@@ -0,0 +1,20 @@
+# lifted from bc3 app/models/launchpad.rb
+module Launchpad
+ extend self
+
+ delegate :url, to: "SignalId.launchpad"
+
+ def login_url(product: false, account: nil, **params)
+ url product_account_path("/signin", product: product, account: account), params
+ end
+
+ def authentication_url(**params)
+ url "/authenticate", params.merge(product: :fizzy)
+ end
+
+ def product_account_path(path = nil, product: false, account: nil)
+ product_path = "/fizzy" if product || account
+ account_path = "/#{account.signal_account.id}" if account
+ [ product_path, account_path, path ].compact.join
+ end
+end
diff --git a/app/models/signup.rb b/app/models/signup.rb
new file mode 100644
index 000000000..e11ab3bfd
--- /dev/null
+++ b/app/models/signup.rb
@@ -0,0 +1,159 @@
+class Signup
+ include ActiveModel::Model
+ include ActiveModel::Attributes
+ include ActiveModel::Validations
+
+ PERMITTED_KEYS = %i[ full_name email_address password company_name ]
+
+ # Input attribute
+ attr_accessor :company_name
+
+ # Input attributes when not using an existing identity
+ attr_accessor :full_name, :email_address, :password
+
+ # Input attribute when using an existing identity, output when creating new identity
+ attr_accessor :signal_identity
+
+ # Output attributes
+ attr_reader :queenbee_account, :signal_account, :account, :user
+
+ validate :validate_new_identity
+
+ def initialize(...)
+ @full_name = nil
+ @email_address = nil
+ @password = nil
+ @company_name = nil
+ @signal_identity = nil
+ @queenbee_account = nil
+ @account = nil
+ @user = nil
+
+ super
+ end
+
+ def process(destroy_existing_tenant: false)
+ return false unless valid?
+
+ create_signal_identity
+ create_queenbee_account
+ create_tenant(destroy_existing_tenant:)
+
+ true
+ rescue => error
+ destroy_tenant
+ destroy_queenbee_account
+ destroy_signal_identity
+
+ errors.add(:base, "An error occurred during signup: #{error.message}")
+
+ false
+ end
+
+ def recognized?
+ SignalId::Identity.find_by_email_address(email_address).present?
+ end
+
+ def tenant_name
+ @tenant_name ||= signal_account.subdomain
+ end
+
+ def to_h
+ {
+ full_name: full_name,
+ email_address: email_address,
+ company_name: company_name
+ }.compact_blank
+ end
+
+ private
+ def create_signal_identity
+ unless signal_identity.present?
+ SignalId::Database.on_master do
+ @signal_identity = build_new_identity.tap(&:save!)
+ end
+ end
+ end
+
+ def create_queenbee_account
+ @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes)
+ SignalId::Database.on_master do
+ @signal_account = SignalId::Account.find_by(queenbee_id: @queenbee_account.id)
+ end
+ end
+
+ def create_tenant(destroy_existing_tenant: false, &block)
+ ApplicationRecord.destroy_tenant(tenant_name) if destroy_existing_tenant
+ ApplicationRecord.create_tenant(tenant_name) do
+ @account = Account.create_with_admin_user(queenbee_id: queenbee_account.id)
+ @account.setup_basic_template
+ @user = User.first
+ end
+ end
+
+ def destroy_tenant
+ if queenbee_account && ApplicationRecord.tenant_exist?(tenant_name)
+ ApplicationRecord.destroy_tenant(tenant_name)
+ @account = nil
+ @user = nil
+ end
+ end
+
+ def destroy_queenbee_account
+ queenbee_account&.cancel
+ @queenbee_account = nil
+ end
+
+ def destroy_signal_identity
+ SignalId::Database.on_master do
+ signal_identity&.destroy
+ end
+ @signal_identity = nil
+ end
+
+ def validate_new_identity
+ unless signal_identity.present?
+ build_new_identity.tap do |identity|
+ unless identity.valid?
+ identity.errors.each { |error| errors.add(error.attribute, error.message) }
+ end
+ end
+ end
+ end
+
+ def build_new_identity
+ SignalId::Identity.new do |identity|
+ identity.name = full_name || email_address
+ identity.email_address = email_address
+ identity.username = email_address
+ identity.password = password
+ end
+ end
+
+ def queenbee_account_attributes
+ {
+ skip_remote: true, # Fizzy creates its own local account
+ product_name: "fizzy",
+ name: queenbee_account_name,
+ owner_identity_id: signal_identity.id,
+ trial: false,
+ subscription: subscription_attributes,
+ remote_request: request_attributes
+ }
+ end
+
+ def subscription_attributes
+ subscription = FreeV1Subscription
+ { name: subscription.to_param, price: subscription.price }
+ end
+
+ def request_attributes
+ { remote_address: Current.ip_address, user_agent: Current.user_agent, referrer: Current.referrer }
+ end
+
+ def queenbee_account_name
+ name = company_name.presence || signal_identity.name
+ name += " (Beta)" if Rails.env.beta?
+ name
+ end
+end
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
new file mode 100644
index 000000000..cf9113b54
--- /dev/null
+++ b/app/models/subscription.rb
@@ -0,0 +1,9 @@
+class Subscription < Queenbee::Subscription
+ SHORT_NAMES = %w[ FreeV1 ]
+end
+
+class FreeV1Subscription < Subscription
+ property :proper_name, "Free Subscription"
+ property :price, 0
+ property :frequency, "yearly"
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 23b2f1cb2..5e295c4dd 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,5 +1,5 @@
class User < ApplicationRecord
- include Accessor, ActionText::Attachable, Assignee, Mentionable, Named, Role, Transferable
+ include Accessor, ActionText::Attachable, Assignee, Mentionable, Named, Role, SignalUser, Transferable
include Timelined # Depends on Accessor
has_one_attached :avatar
diff --git a/app/models/user/signal_user.rb b/app/models/user/signal_user.rb
new file mode 100644
index 000000000..16bee49bc
--- /dev/null
+++ b/app/models/user/signal_user.rb
@@ -0,0 +1,7 @@
+module User::SignalUser
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :signal_user, dependent: :destroy, class_name: "SignalId::User", optional: true
+ end
+end
diff --git a/app/views/sessions/launchpad/show.html.erb b/app/views/sessions/launchpad/show.html.erb
new file mode 100644
index 000000000..897e39922
--- /dev/null
+++ b/app/views/sessions/launchpad/show.html.erb
@@ -0,0 +1 @@
+<%= auto_submit_form_with method: :put %>
diff --git a/app/views/signup/accounts/new.html.erb b/app/views/signup/accounts/new.html.erb
new file mode 100644
index 000000000..9f69eba4b
--- /dev/null
+++ b/app/views/signup/accounts/new.html.erb
@@ -0,0 +1,58 @@
+<% @page_title = "Sign up for Fizzy" %>
+
+
" style="--panel-size: 65ch;">
+
Fizzy
+
Create your account
+
+ <%= form_with model: @signup, url: signup_accounts_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/app/views/signup/completions/new.html.erb b/app/views/signup/completions/new.html.erb
new file mode 100644
index 000000000..2fce66a3c
--- /dev/null
+++ b/app/views/signup/completions/new.html.erb
@@ -0,0 +1 @@
+<%= auto_submit_form_with url: signup_completions_path, data: { turbo: false } %>
diff --git a/bin/dev b/bin/dev
index 4099edaa0..e5fc62f6c 100755
--- a/bin/dev
+++ b/bin/dev
@@ -2,10 +2,8 @@
echo "Access with david@37signals.com / secret123456 on http://37signals.fizzy.localhost:3006"
echo "Access with david@37signals.com / secret123456 on http://honcho.fizzy.localhost:3006"
-echo "Access first run on http://first-run.fizzy.localhost:3006"
if [ -f tmp/solid-queue.txt ]; then
- # see also config/environments/development.rb
export SOLID_QUEUE_IN_PUMA=1
fi
diff --git a/bin/setup b/bin/setup
index c7223d499..61727ea2e 100755
--- a/bin/setup
+++ b/bin/setup
@@ -7,8 +7,13 @@ system("gem install bundler --conservative")
system("bundle check") || system!("bundle install")
puts "\n== Preparing database =="
-system! "bin/rails db:prepare"
-system! "bin/rails db:reset" if ARGV.include?("--reset")
+if ARGV.include?("--reset")
+ system! "bin/rails db:drop db:create db:schema:load db:prepare"
+ system! "bin/rails db:fixtures:load" # load signal id fixtures before running fizzy seeds
+ system! "bin/rails db:seed"
+else
+ system! "bin/rails db:prepare"
+end
puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"
diff --git a/config/ci.rb b/config/ci.rb
index c94990581..bc96a3831 100644
--- a/config/ci.rb
+++ b/config/ci.rb
@@ -10,6 +10,7 @@ CI.run do
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
step "Tests: Rails", "bin/rails test"
+ step "Tests: 37id", "bin/rails 37id:test:units"
if success?
step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
diff --git a/config/credentials/beta.yml.enc b/config/credentials/beta.yml.enc
index bff4f4de1..8eb55f4cd 100644
--- a/config/credentials/beta.yml.enc
+++ b/config/credentials/beta.yml.enc
@@ -1 +1 @@
-/M5cK3B/Wq1R2f4XsQQ8t7qJGCNMaBU3itDDKNZapeGMjrlE4A9CKOh2RPQsKlrsGjJdgGL4YdfDiT1LiF0pD/KL/t5Fw8Z8GMndzA+hmpvQ+hgufvd8n2ZzK/SIK6RmfC9BabdlvuQ0rr3/EdQJ5yIgk3WtWv73bOWDopqHerEAcaNl8KjCu5n2vokAidqEh0/O30yQQB9PAFMRqEwapLVcDSnaJXr6OQnAAm9tIoWaEaIF8ZJH3Rmn0COs8xZqYFyFv/pP3gVy3Mw+VL3aIkEf/eZ3IQWYNf7fiKPvIdNVsmghmeoQRqCp4E0dpn2XDSEGx5i5oq7ynSDCPky4GahiLtpe3vas+Z+lzD5hAAZmlX0G615b4hWjAViTEQU+CMXhmpj7eVpT9co+1oKmqtbDvHr7uCJIf4d17kC13u/8yjgl2SaCjRWx94wh8su/h6h9Xw0qewITbfM32pFRiH+R5bc=--fxg4Lf+qmYeuvyZp--qVZ+ELV0FRzBb69MUAqOsQ==
\ No newline at end of file
+yD+Wb/DYMXUXf16brcYmNb7OZngx7PuA6gLP8w6ZV7pA/GFweTIXQw67YoXebL6A7gWxhXX4tvpnrz9xPC1p5+5a8q00lOhcybc/480bSfenIXExDpg6mSDbQLRomrpwZsr+IqBWRC8WoXTtpVYRybEPRP88ZXdyE0MjnfOlGcr1fUcF3r9T8biXfNhS9v9UTxck1Q2yzGoNFRvBm3KgBN2QlJocG5AXaDYUSMfLoLmtkicO0KVOD7eYBxe0kDevUK7ZQGxbtATGIsjAJEifhuaPTj/Q2DH134TU4rlPr6n6vm9YEgC/CGVoqPIDgvovVd5IiqZlxGuVDDw3ct0cPH4/AnDLOVHiubi5/pZRCQyeOF+G1OmLVFUVeuZ6alPVrJSxHFxGVM/HT+pLWiReR/IJYacLzb3b2OOtBp/AKxbt25YxFFHuFHABfVEqPRo0qVwbQaxKZEyDyEW0t0ktTkqHywqkbmsx3HPU3skV42rTHf9c1udJhzTXZJPKIWKP96ahgX6eaOhy09rzwD99RnKLJuAuMl80Cw1L+ftLG5hM4tHYmv+JbpWDSInvP1jtA5iWtDRSiqhTxvXw4ItHximvsERI5k6hSPJzrzqnhLUxNVi10KQHh5KGq7EiC2J3HJwioY+fWVivzEJdw2WuVFsovcFyolrT3tyqcMVF5V3dQw4/gOGQ7gXsz0a9sb5wQgXJ9pyZBAmD4Bg7r4wD0f0JiL/5EdKOkAOTV2JDt0OgoSWzU+yuvByuWxPn2quoX0ZSG0huHvl2NK33RoR9aXLcSjtmi5XJGXpjATfKMoA2d8JBIDiJcOPAPwWkpI7AOnmvlV2SmrqPlLTkO0MfdJ528k8CFi2puSRQN+CEPhyhZclDO/J2IP94qmE35xAmWM7yjY6E+EZY9uhlF39uBhAIXylZwTyF31PSCqwpQJthH62pDFWpCgjy1iax482cCVt5n2HLQpmSNaPAvsI7hZAGFJsrDncwfJiK68YXrgmTeg8=--Gd2i+KrusH11oais--SraJ0tcCfEgIdRSJYoCoNw==
\ No newline at end of file
diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc
index affc4c6cd..ce2cc3c0f 100644
--- a/config/credentials/development.yml.enc
+++ b/config/credentials/development.yml.enc
@@ -1 +1 @@
-e6HcyVNJvgdJGTCcvW8FDHQ9pHYmTgNW2/71Espdwxd5LQX/Xb9+VkeqpEAL8ceFJo6ro49mMs85G2YngKbbXEh//8+XSS0inMR/kh9wTV3f6YBWnLVBbhrOnYgPcQ8vedsgxLG2Cs4y7ipfeT/dp9bb5m4IxV0OPiUjVFFJuAuGkMJt3pE7D6alqL1ACRxazV9UKV5WKEBfaQYeKpJ++5f86Nls1EwKo5tB1lJed9P5n2yH7meX/QyxjgQd38CIaPwp+F92y5DLKJD5MZRMJGgWf6MT6096LhjEC3vvtS3wwT/eDVcVKTplRkEszj7BZEfOs9Ondb9EVtbXmeoHYSgpaOq9/nEwtho5Uw==--KlHZy1apg3u1XxFs--N2ehRobLHNz+Sepc8p4EIA==
\ No newline at end of file
+hHi9VfqfZI57GYR5r75Rh00L3FYeoUgsdtCgBzJGdPhhGZLGlCgUAcY6tgVhqhgM/u5WxyPAzmoV+bbQKb0wO0U6KUmrDKDv9uRnNSUjXGYsg8gWdTUTfx7WBFOFawV3p1Dsm7zM1oUJbRWgy2TiMFl5kP9CN4Lk78n5a1/Y7mnx/wmzwUyQv+BUFJ0UKCfRmWieaNoLkm5kWwyepWHaSRU5EVda6J9VtuwP3hISIzncXm0jpr9r/tJm8flgUR3o2STIVHVgg+YcWz0j3st5RMv7i3D2OHfNGx84vsf3LNlsiLGB1ZU0IsmchLOSqPmlDK2oiB7UKepTPthGUFzNmcDG4LpUQfNwn7B8P7by0zadVefdLSB3kFCNLX+KCAbcjHPlWufkagwHo+dV+iIVACC+hfWl3L/IouqYS0ln2XCBDz6A8lI8PHeGaJe9UhfA5JEuJLRPui8znoq7LrKo4/oBB7ArKdHz/W/oHJA9+aLHcLOq9TPSExAsGi1wcjdgF0nk5mB9iX+az3//1zM64rRz8EyzNkBg1I6PV6wpzROLubttv1+I5nRVe4BernNfVJPi/hbvrlrcZmouU32oOzd2aZuPL5/H8gBhMOPlN25Rr51XCziaczgdEfOwTO8da6r7pxsJDkrvKi+Ju6efxhZ6GeotWJuEUtQtWhHnzvhcJAXfUtJf6A==--LWtZauLkWq+tD+4L--mkMcqYebO9TDi6RTRNUQvA==
\ No newline at end of file
diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc
index ef3036025..e97e68755 100644
--- a/config/credentials/production.yml.enc
+++ b/config/credentials/production.yml.enc
@@ -1 +1 @@
-Qkkt3krcGlOh76//7TmY5rXz31mhL3NNNiO6TCcRhx4lT6uZ8sLjW7X0yp2wZ30y2cJBeIsNMeOhL47JTpu0Z4d0FPtwgH4bzzXAiab0/YrfMjxyBt7/K1DzW/bePUvakJggIvVGGRA1NB1tPkwziQ4dgA34E/UUPAxUofFaoCIFVABmqt4DFg0DKYB8tImDDjZ5xUfSNxrKj3pH5mOIU9jqSK6LtBELFZcNxrJOI0UEgayRNwYAs44ujdM2/2zrAWlUCFZ3NnMU4bBJW02/d6LaxHtB809u8mx3bChEzWMtZB9CvpbRH7u6XfZJR1YGFztcNguzRbNX5x9pKZImAuAhVFKR7Fbi12Hg3w7U3A/YovwmyhHK6ksfOzYlYoDUTBnGYVwiqR16naaHCMekomK+uo0qtOn83RQkLuuqFJshPXn/lW8Rggt4dq/0THEl7Qj+dumadlWDVEgxhipI031RSBF1toNWw/zwaC31Ord1YXD8ZC09sPoj8Zd4hD2o2a0IUuIMXA8w5/2+AmdU8wF66opdKGdsZDRjM2Bul8aAbJ7/iV7xTuvlE4LmzXGArMvivIZ0GwrN3YAsDta2Y3ufibq7WZzSCOrpbPKJ9CEGg4a2AN8UGaGZ5ZEAYg8lhDgsZMjHRkGNuxk8QkDiVFDyNoI7H9ZQqbw+L1AJRTfDIcNB/9h5n+//Nk4ylLF1lnI7y/IQA/AuSQ+UN+okt3RhLugwGvWrs49MExJStLHpGveSoQ==--MQ2JycR3X6LIiKSY--45C7bleKX9FMGv4fc+G1yw==
\ No newline at end of file
+cPaSSbJ2XWIjNXDRi25iIH41V4zR1fKVTe3OV1XwZKsDE3950DL9Sk0RI/1bxYY+agT3x88vrsqm8xyqLwqcMeMg7J4el52WI4hORe5XLGJTcvs8CTgguut1s7c5Jb5ihOSPX9ZYEKnONxLU2BFe951WZgO+7Us2IBFbCQiqo8PXEBEONePuE9Aw2z1fUFW7NvxhptN9ws7DptP2O/wpHP8+KJmVuAJl+/qJHJoBh770dwQU3yCFz9mERowkARK4Z16Q+HelkrkvW9UziV53tMIExDRvfMnEKJz4wrU/AqVSiQIMQA6eVfTMQLxbVhBhLAlcV0Qic7yEbXLcAXwLX6Kga6aMOqyJvxh+JUnMpQavtHKikVncN8EI1lGxd/+mddS+khHZmCqkRvWl+LiaOe2WOkNENYXlGYANeRSTDy2BqMeT1ymAj3YxfWKYdR34Ukmm2iZKQ1d6fF+Dg/IT8/W/rIZPajQjMTO05WqeoE8kZ4lhgI7AxZHg9apHGsfkq6iBaf4kZUBFiLgOv0CR4Xz3zWVUHEyPx5afkkxs1u9NCspOaYWT4gA1tekFmaH7cMyhaQ/SEjOEs5AWyh47PeMBvwUumJKIEeil6p6K9109nSFG+BPszfhshY8G3aonuIbiWoJQAiGGnbvw4oZeAPjl8XNBYTWRspl2T9ep3krPg9L4eTrAyXdT0dG7GYXSa5NkrV86HugU5rDfx87N4QJVu6qr7Myzw6kEWlLRXHR6HPg+H7qZenNwuOfgnD3YoFOEWQfLiz9U3MqDRJIq5j+i2OfL4pv0VVpc51z42mWM1hvazGNiiTZvK0DohBja6D9h5Gl6ZP/eOGB8O6ZxIwn6mp23BIvxA5jBRGslCj/Op7msnkSAP0vtuI0hnCtfsOx0RfAPvj1/ETjVkRFhM7heWTgfqGQbjSVGvd/Dj6j3vbqGz3RASVL48juoWx/pezUMnWGZqRTqx5tD6FrZQy1yf/2Uoc8yMJQU14raqiShEqNveJswMv1D9kBnT3551DtpAmrMT+iuCX5aZHwVnY/sI8J8HdMAqFfpipMbCbccI/PgTc+U8uOqc+BBKYy8u04wHLnGu5wREewU5xRIkxcwGm4M4LkTLiVEJFUQuFEhylmxkCIDYG+WSBZ5xy/0dRUeZs26Uuvvs2//bXqn75Ia86Fpv54AzfdDqC9s0B1r5/ZqXtFjjL3bQyhv+SP5TMurAZcI0rLYay+oFcLZAv31zoo0dFcufiCKYTjbYEIlO+HW2aeVeOhUnCalqqslt4ql5A==--tVMd1KheRgH8F0gZ--x8Wzmk8JsMuHPG3JMOODLA==
\ No newline at end of file
diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc
index 8387cc6d8..ef3fa3ffb 100644
--- a/config/credentials/staging.yml.enc
+++ b/config/credentials/staging.yml.enc
@@ -1 +1 @@
-Tc514fYVlm1c2c81KUFmuAGZ9rSZT7XfeGUEdTEvJNDD6JE9IghKbAAGgc6AalIt2/VxzSV6AGvNagb0/qlJrMOFeSJLqzufKt1YoLRUZtSUZDtzmI/2WSzju0Tyu4za6rnLEu+WvDc0NM8KZEv/TwwUbSOwoEVDQmava/iuWyvFr5sOH1wNr2kwYbErwDpThKcYqacI4CdvgpgXc//3T0Z5vsVCs6d8723DymM0KkSK9so8WrcCesk2fmhM1K2hQV7QdckaVuOL0JEj1AI7KzdJm7REoeLwRwaBeVDkqDNo2fKxwZ2XmU2ViJOozPsowx+324wQQZjbsf/0ohtDkgjbfMmA2tjkuNJdJFwIIc5VyvILhVs5WQIJHSXmabhUKxzKRCRZNBSaadLGCb7/AGfULKvh2vP1BWqm80EkDmhHm42ucJW1vA377y9UYWVcdvqWtp5m2F7HlXqi9Uw/+kFV2X6Jw1c07FsCu35TrUb5u8G7mu/866GBgf5VlfDVs1BA3aru05CtcsXi2qKMw0II7fZps/4XpYCB+VehXAyLi2ABVeTnqSfF7HwasXvfD3Jgh7zJLD0hudNDpu6xkmoyeksVBn034dmQf0TwAIKmNjEKMDxgAG50T2JhnEvHZEnqzGl6O8if8DKM5rPgt3XEU3zN/U3yA8T0eTyFdGlWbYK7Ergu4TcGo3WPk6bJhTGOHQ9puD89t6JGHbKDYW77w44SHv+C42flmNkzcndXlpvZvnEafDYeW6ofRC4eeTRX4+WKL4CYhlel78+twD6Mh0MV42MYM2rmtl66NHN8fWxyfoyjrNcXveKVAsGMatB0+dOglq6GmOiXRAt/I6D+7QYZLyaUhYNmmiuZXF4=--H6KWPnHc9qUQE5AI--PN98cg9t9ZhxMUEax/Mfgg==
\ No newline at end of file
+UV9AzQwwjGoWFh78GyKkNewL4lmsL80XT+6ca0JIxZ632OMKbYzHbPtJAD6RK1gaEB4GRkiBE2mLEdk8nYI47p8K9NVqZuI/ykgl23Sr2JzBpnHfjtUYlPQC7nYyeAR3gYtASD5+jSXvP7z/9BquIVdTvih3hWPPujDK5OROXagDtlGTZ4SgKZwihCTZczTwZRLxx5UguwbtuowrtcuPTg+r/H+zdJyRUU9kF7vYqV5VrP0vj0mXZUf5hWNBpKvhDtqgKUJNU87ds7xTLwMuQT0MCASoJXS0JSpIKn653baXczQue6JyuFcOlqDPDmBWs2GLh6pq3XnNolmWoyNehD9WClmJ5cu0l1dPmYZ0WnOfPgJCPtuJI4onIUj2nLC5m0tHpQPNs9WjazQeCeiw7TJBESK3b3lB4/Q7STshfJd74IIJ0crzfm0a6MwxvT184etKpNRQkgJpwK6WRNPzzzHdlTmbkEher4ilcs3QrmyLPYaLcuG+0YyDcjgJXRvLumkZilONbuBHluMc64u4GAU8QyW1dp31DrCw/HTOTtPEsRJSKdTIiFFCDpYt0Uq16Qbp9Hsg1ruPqFPELXDqRSl+N9ixDdXjF8WyQc6Gx4i8Fsur0WBLGBEtCEkCCQvwsH+TVH47S68CSiUui0c2zX1sM2SEcOOL7Cn0TUDWg6d8MgHQ5977aGLGFUkeuldv8KfQSra5uHKESSxOwn/eJJQMERSiVHSEj1EK3v4R+shXnviPsFTr3uERjdn2s1CcSk3qJ7J5WFpmgTL3/qKRWDroI96QO24CSr8JWJ3MzrwkLLpiEf6qLyfswiRjJ79sxk1pTdx7zedb8MuG/596J/xFI7GdbOI6qr2NvVUHH6r6PqXltMBoT/JU4pO7QOXfuok8zYGOj2gMzktCOdvEoydh+8QrsKN6W3mpg8oweQoq/2tgRm/YTeJMz+MQMR+J+aid2lSu8/wiHtsv8WdxJ7qxI7G3DslzbTEz0e3FAP+CEFOVgwN+nitWZpSyst6WiupzU5Ou9NxYiWbmhy5nhDJFC+MiLs8bS2kWfq3z1/Wsuyp8hBn/T0+MYtQ/cRnAxTE0sUZMB5xpffuGPlx3lgrmxV7wlPAzGooCGkGVUQQ29ljYMoGWqW34EAniIYHLUnBfdS+bNbui5rxVEAFyD4r57eqEaYYOKCFwkhNfYhHUeV+noDQ8uvCzppvRHGKJrkUCS0/A6z4KP8iW8wsETMBX/ShvFf6m44KNGR4IPzfn+r7uO9Pbukit3RJ8RsHZcC3RmH7/gtO3vW8G1BBrT5nI6BdRp9a9LdTx2qn32cKQD7Wc7S1KyFYn5reWSewlxNHHmgy4R/pEXTtdZJ5tuzweeYK/jr78CTrgEEN/btNawNh6SdTwRls5lu33LAQ=--3mgI5AzJFOeaM6Y1--KgIowEbtW/iHXiDrWGk2ug==
\ No newline at end of file
diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc
index 24d6d9c49..8c7aab16b 100644
--- a/config/credentials/test.yml.enc
+++ b/config/credentials/test.yml.enc
@@ -1 +1 @@
-1QS4QGl05L2YvkEusirttYv2gIfRbNLEWTK7pOXG/HxqyWbWj9o5/GbTFeLI7K0sbcEjz+gcPcVKB+FGnkJECExynSvIN02bg5gP0QegTC3WA2vWInlr+gpoWCNgsazOgHKMxDBnQXc2myqhwDLKRsDHCQTW/5lIiKGBtBOLaml8KrJg84SeYCh54tJUjAp5yRkrhJQBHVgSSUfXRaFcrmdbPls5FDgB+rErACpp4BgYdXZoDds2RITDgQ==--kpoxQbOswL3cInhK--r+dB3hJHvjfUH1EBNzBLSg==
\ No newline at end of file
+0EWG0dpq/qELmTCqAgzEL2BZH7JCNFe6DDIynyKE06qiBCYhHs5+fMjmpzN0uwz43tqAAKJdhO/hZY4jXmTpxu+LSjwrUKAlxsWvSGu4vHEbJ5Fe+BzhnS84OFse6ma9akUAHaed3gt31wtrt2Y4AbSPedNvriqfYln7cvpgWbvjTgtEbHrcNMfjCBnaXXdO3n1zILunILgLiV7oBW0qurIrl2xOc4mbj4AqVAIElyh0md9fFtuBfsaRj2U9x/+7McbgTFODhHfeqoErEw7fc1RBFrNi2YNuHBSSqegQ6WtWbwq1Yg2Juv/Lw3g4IIdWadJsW3TdTMMiwmBMzOikx3lNX1Z399glvGNFVnJfBubKf6BxvFfAQ85PBzOZPl+/PTs=--GY44JMs5ZO1/eL6G--EhxEWuitiex6eHROpPQamw==
\ No newline at end of file
diff --git a/config/initializers/queenbee.rb b/config/initializers/queenbee.rb
new file mode 100644
index 000000000..eda1c0048
--- /dev/null
+++ b/config/initializers/queenbee.rb
@@ -0,0 +1,6 @@
+Queenbee.host_app = Fizzy
+
+Rails.application.config.to_prepare do
+ Queenbee::Subscription.short_names = Subscription::SHORT_NAMES
+ Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token)
+end
diff --git a/config/initializers/signal_id.rb b/config/initializers/signal_id.rb
new file mode 100644
index 000000000..c8804fda5
--- /dev/null
+++ b/config/initializers/signal_id.rb
@@ -0,0 +1,19 @@
+require "signal_id"
+
+ENV["SIGNAL_ID_SECRET"] = Rails.application.credentials.signal_id_secret
+
+Rails.application.config.to_prepare do
+ SignalId.product = "fizzy"
+
+ SignalId::Database.load_configuration SignalId::Database.default_configuration
+ SignalId::Database.enable_rw_splitting!
+
+ silence_warnings do
+ SignalId::Account::Peer = Account
+ SignalId::User::Peer = User
+ end
+end
+
+Rails.application.config.after_initialize do
+ ActiveRecord.yaml_column_permitted_classes << SignalId::PersonName
+end
diff --git a/config/initializers/tenanting.rb b/config/initializers/tenanting.rb
index 78e5d8e05..85ef97ba7 100644
--- a/config/initializers/tenanting.rb
+++ b/config/initializers/tenanting.rb
@@ -2,10 +2,17 @@ Rails.application.config.after_initialize do
# in production and staging, we're using a two-level subdomain like "tenant.fizzy.37signals.com".
# in development and beta it's only a single-level subdomain.
Rails.application.config.active_record_tenanted.tenant_resolver = ->(request) do
- next nil if request.path == "/up"
-
tld_length = [ "37signals.com", "37signals-staging.com" ].include?(request.domain) ? 2 : 1
- request.subdomain(tld_length).presence || "return-404" # this is a quick fix for now
+
+ if request.path == "/up"
+ nil
+ elsif subdomain = request.subdomain(tld_length).presence
+ subdomain
+ elsif request.path =~ %r{^/(queenbee|signup)\b}
+ nil
+ else
+ "return-404" # this is a quick fix for now
+ end
end
end
diff --git a/config/routes.rb b/config/routes.rb
index db0cf3cd6..0e32a0b25 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -85,6 +85,14 @@ Rails.application.routes.draw do
scope module: "sessions" do
resources :transfers, only: %i[ show update ]
end
+ resource :launchpad, only: %i[ show update ], controller: "sessions/launchpad"
+ end
+
+ namespace :signup do
+ get "/" => "accounts#new"
+ resources :accounts, only: %i[ new create ]
+ get "/session" => "sessions#create" # redirect from Launchpad after mid-signup authentication
+ resources :completions, only: %i[ new create ]
end
resources :users do
@@ -150,6 +158,8 @@ Rails.application.routes.draw do
root "events#index"
+ Queenbee.routes(self)
+
namespace :admin do
mount MissionControl::Jobs::Engine, at: "/jobs"
end
diff --git a/db/migrate/20250611203906_add_queenbee_id_to_accounts.rb b/db/migrate/20250611203906_add_queenbee_id_to_accounts.rb
new file mode 100644
index 000000000..8d1d55ad4
--- /dev/null
+++ b/db/migrate/20250611203906_add_queenbee_id_to_accounts.rb
@@ -0,0 +1,6 @@
+class AddQueenbeeIdToAccounts < ActiveRecord::Migration[8.1]
+ def change
+ add_column :accounts, :queenbee_id, :integer # I solemnly swear I will add a "NOT NULL" constraint once it's populated
+ add_index :accounts, :queenbee_id, unique: true
+ end
+end
diff --git a/db/migrate/20250612163028_add_signal_user_id_to_users.rb b/db/migrate/20250612163028_add_signal_user_id_to_users.rb
new file mode 100644
index 000000000..5caddaea9
--- /dev/null
+++ b/db/migrate/20250612163028_add_signal_user_id_to_users.rb
@@ -0,0 +1,6 @@
+class AddSignalUserIdToUsers < ActiveRecord::Migration[8.1]
+ def change
+ add_column :users, :signal_user_id, :integer
+ add_index :users, :signal_user_id, unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index dff0ccb02..0c7504173 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) do
+ActiveRecord::Schema[8.1].define(version: 2025_06_12_163028) do
create_table "accesses", force: :cascade do |t|
t.integer "collection_id", null: false
t.datetime "created_at", null: false
@@ -26,7 +26,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) do
t.datetime "created_at", null: false
t.string "join_code"
t.string "name", null: false
+ t.integer "queenbee_id"
t.datetime "updated_at", null: false
+ t.index ["queenbee_id"], name: "index_accounts_on_queenbee_id", unique: true
end
create_table "action_text_rich_texts", force: :cascade do |t|
@@ -334,9 +336,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) do
t.string "name", null: false
t.string "password_digest"
t.string "role", default: "member", null: false
+ t.integer "signal_user_id"
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
t.index ["role"], name: "index_users_on_role"
+ t.index ["signal_user_id"], name: "index_users_on_signal_user_id", unique: true
end
create_table "watches", force: :cascade do |t|
diff --git a/db/schema_cache.yml b/db/schema_cache.yml
index 89fca8331..2c6a24735 100644
--- a/db/schema_cache.yml
+++ b/db/schema_cache.yml
@@ -113,6 +113,16 @@ columns:
default_function:
collation:
comment:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: queenbee_id
+ cast_type: *1
+ sql_type_metadata: *2
+ 'null': true
+ default:
+ default_function:
+ collation:
+ comment:
- *9
action_text_rich_texts:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -983,6 +993,16 @@ columns:
default_function:
collation:
comment:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: signal_user_id
+ cast_type: *1
+ sql_type_metadata: *2
+ 'null': true
+ default:
+ default_function:
+ collation:
+ comment:
- *9
watches:
- *22
@@ -1163,7 +1183,23 @@ indexes:
nulls_not_distinct:
comment:
valid: true
- accounts: []
+ accounts:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: accounts
+ name: index_accounts_on_queenbee_id
+ unique: true
+ columns:
+ - queenbee_id
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
action_text_rich_texts:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: action_text_rich_texts
@@ -2250,6 +2286,22 @@ indexes:
nulls_not_distinct:
comment:
valid: true
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: users
+ name: index_users_on_signal_user_id
+ unique: true
+ columns:
+ - signal_user_id
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
watches:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: watches
@@ -2301,4 +2353,4 @@ indexes:
comment:
valid: true
workflows: []
-version: 20250609102553
+version: 20250612163028
diff --git a/db/seeds.rb b/db/seeds.rb
index 9af00fe66..a04ee160e 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -7,24 +7,52 @@ def seed_account(name)
puts " #{elapsed.round(2)} sec"
end
-def create_tenant(name)
- ApplicationRecord.destroy_tenant name
- ApplicationRecord.create_tenant name
- ApplicationRecord.current_tenant = name
+def create_tenant(signal_account_name)
+ signal_account = SignalId::Account.find_by_product_and_name!("fizzy", signal_account_name)
+ tenant_name = signal_account.subdomain
+
+ ApplicationRecord.destroy_tenant tenant_name
+ ApplicationRecord.create_tenant(tenant_name) do
+ account = Account.create_with_admin_user(queenbee_id: signal_account.queenbee_id)
+ account.setup_basic_template
+ end
+
+ ApplicationRecord.current_tenant = tenant_name
end
-def create_first_run(name, email_address, password: "secret123456")
- FirstRun.create!(name:, email_address:, password:)
+def find_or_create_user(full_name, email_address)
+ SignalId::Database.on_master do
+ unless signal_identity = SignalId::Identity.find_by_email_address(email_address)
+ signal_identity = SignalId::Identity.create!(
+ name: full_name,
+ email_address: email_address,
+ username: email_address,
+ password: "secret123456"
+ )
+ end
+
+ signal_account = Account.first.signal_account
+ signal_user = SignalId::User.find_or_create_by!(identity: signal_identity, account: signal_account)
+
+ if user = User.find_by(signal_user_id: signal_user.id)
+ user.password = "secret123456"
+ user.save!
+ user
+ else
+ User.create!(
+ signal_user_id: signal_user.id,
+ name: signal_identity.name,
+ email_address: signal_identity.email_address,
+ password: "secret123456"
+ )
+ end
+ end
end
def login_as(user)
Current.session = user.sessions.create
end
-def create_user(name, email_address, password: "secret123456")
- User.create!(name:, email_address:, password:)
-end
-
def create_collection(name, creator: Current.user, all_access: true, access_to: [])
Collection.create!(name:, creator:, all_access:).tap { it.accesses.grant_to(access_to) }
end
@@ -36,4 +64,3 @@ end
# Seed accounts
seed_account "37signals"
seed_account "honcho"
-seed_account "first-run"
diff --git a/db/seeds/37signals.rb b/db/seeds/37signals.rb
index 432030bbc..2bc322fdc 100644
--- a/db/seeds/37signals.rb
+++ b/db/seeds/37signals.rb
@@ -1,10 +1,10 @@
create_tenant "37signals"
-david = create_first_run "David Heinemeier Hansson", "david@37signals.com"
-login_as david
+david = find_or_create_user "David Heinemeier Hansson", "david@37signals.com"
+jz = find_or_create_user "Jason Zimdars", "jz@37signals.com"
+kevin = find_or_create_user "Kevin Mcconnell", "kevin@37signals.com"
-jz = create_user "Jason Zimdars", "jz@37signals.com"
-kevin = create_user "Kevin Mcconnell", "kevin@37signals.com"
+login_as david
create_collection("Fizzy", access_to: [ jz, kevin ]).tap do |fizzy|
create_card("Prepare sign-up page", description: "We need to do this before the launch.", collection: fizzy)
diff --git a/db/seeds/first-run.rb b/db/seeds/first-run.rb
deleted file mode 100644
index e6df219d9..000000000
--- a/db/seeds/first-run.rb
+++ /dev/null
@@ -1 +0,0 @@
-create_tenant "first-run"
diff --git a/db/seeds/honcho.rb b/db/seeds/honcho.rb
index 34599c600..939dc9147 100644
--- a/db/seeds/honcho.rb
+++ b/db/seeds/honcho.rb
@@ -1,15 +1,15 @@
-create_tenant "honcho"
+create_tenant "Honcho"
+
+david = find_or_create_user "David Heinemeier Hansson", "david@37signals.com"
+jason = find_or_create_user "Jason Zimdars", "jz@37signals.com"
+kevin = find_or_create_user "Kevin McConnell", "kevin@37signals.com"
+jorge = find_or_create_user "Jorge Manrubia", "jorge@37signals.com"
+mike = find_or_create_user "Mike Dalessio", "mike@37signals.com"
-david = create_first_run "David Heinemeier Hansson", "david@37signals.com"
login_as david
-jason = create_user "Jason Zimdars", "jz@37signals.com"
-kevin = create_user "Kevin Mcconnell", "kevin@37signals.com"
-sarah = create_user "Sarah Johnson", "sarah@37signals.com"
-mike = create_user "Mike Peterson", "mike@37signals.com"
-
# Array of authors for random assignment
-authors = [ david, jason, kevin, sarah, mike ]
+authors = [ david, jason, kevin, jorge, mike ]
# Card titles for reuse across collections
card_titles = [
diff --git a/script/bootstrap-signal-id.rb b/script/bootstrap-signal-id.rb
new file mode 100755
index 000000000..7618e8988
--- /dev/null
+++ b/script/bootstrap-signal-id.rb
@@ -0,0 +1,117 @@
+#!/usr/bin/env ruby
+
+require_relative "../config/environment"
+
+ActiveRecord::Base.logger = Logger.new(File::NULL)
+
+class BootstrapSignalId
+ def initialize(dry_run: false)
+ @dry_run = dry_run
+ end
+
+ def run
+ SignalId::Database.on_master do
+ ApplicationRecord.with_each_tenant do |tenant|
+ puts "\n# tenant: #{tenant}"
+
+ next unless check_account_preconditions
+ create_signal_id_account if Account.first.queenbee_id.nil?
+
+ create_signal_id_users
+ end
+ end
+ end
+
+ def check_account_preconditions
+ unless Account.count == 1
+ puts "There are #{Account.count} accounts, but exactly one is expected."
+ false
+ else
+ true
+ end
+ end
+
+ def create_signal_id_account
+ owner = SignalId::Identity.find_by_email_address!("kevin@37signals.com")
+ print_identity("New owner is:", owner)
+
+ unless @dry_run
+ qbattr = queenbee_account_attributes(owner)
+ queenbee_account = Queenbee::Remote::Account.create!(qbattr)
+
+ signal_id_account = SignalId::Account.find_by!(queenbee_id: queenbee_account.id)
+ signal_id_account.update_column :subdomain, ApplicationRecord.current_tenant
+
+ account = Account.first
+ account.queenbee_id = queenbee_account.id
+ account.name = ApplicationRecord.current_tenant
+ account.save!
+ end
+ end
+
+ def create_signal_id_users
+ signal_account = Account.first.signal_account
+
+ User.find_each do |user|
+ if !user.system? && user.signal_user_id.nil?
+ signal_identities = SignalId::Identity.where(email_address: user.email_address)
+ if signal_identities.length > 1
+ puts "Multiple identities found for #{user.email_address}:"
+ signal_identities.each { |identity| print_identity(" - ", identity) }
+ signal_identity = signal_identities.first
+ elsif signal_identities.length == 1
+ signal_identity = signal_identities.first
+ print_identity("Identity for #{user.email_address}:", signal_identity)
+ else
+ puts "No identity found for #{user.name} (#{user.email_address})"
+ signal_identity = nil
+ end
+
+ if signal_identity
+ unless @dry_run
+ signal_user = SignalId::User.find_or_create_by!(identity: signal_identity, account: signal_account)
+
+ user.signal_user_id = signal_user.id
+ user.save!
+ end
+ end
+ end
+ end
+ end
+
+ def print_identity(message = "Identity:", identity)
+ pad = " " * message.length
+ puts "#{message} #{identity.name} (#{identity.email_address})"
+ puts "#{pad } ID: #{identity.id}"
+ puts "#{pad } Username: #{identity.username}"
+ end
+
+ def queenbee_account_attributes(signal_identity)
+ {
+ skip_remote: true, # Fizzy creates its own local account
+ product_name: "fizzy",
+ name: account_name,
+ owner_identity_id: signal_identity.id,
+ trial: false,
+ subscription: subscription_attributes,
+ remote_request: request_attributes
+ }
+ end
+
+ def subscription_attributes
+ subscription = FreeV1Subscription
+ { name: subscription.to_param, price: subscription.price }
+ end
+
+ def request_attributes
+ { user_agent: "script/bootstrap-signal-id.rb" }
+ end
+
+ def account_name
+ name = ApplicationRecord.current_tenant
+ name += " (Beta)" if Rails.env.beta?
+ name
+ end
+end
+
+BootstrapSignalId.new(dry_run: false).run
diff --git a/test/controllers/sessions/launchpad_controller_test.rb b/test/controllers/sessions/launchpad_controller_test.rb
new file mode 100644
index 000000000..2ab50b2a0
--- /dev/null
+++ b/test/controllers/sessions/launchpad_controller_test.rb
@@ -0,0 +1,30 @@
+require "test_helper"
+
+class Sessions::LaunchpadControllerTest < ActionDispatch::IntegrationTest
+ test "show renders when not signed in" do
+ get session_launchpad_path(params: { sig: "test-sig" })
+
+ assert_response :success
+
+ assert_equal cookies[:_fizzy_launchpad_sig], "test-sig"
+ end
+
+ test "create establishes a session when the sig is valid" do
+ user = users(:david)
+ cookies[:_fizzy_launchpad_sig] = user.signal_user.perishable_signature
+
+ put session_launchpad_path
+
+ assert_redirected_to root_url
+ assert parsed_cookies.signed[:session_token]
+ end
+
+ test "returns 401 when the sig is invalid" do
+ user = users(:david)
+ cookies[:sig] = "not-valid"
+
+ put session_launchpad_path
+
+ assert_response :unauthorized
+ end
+end
diff --git a/test/controllers/signup/accounts_controller_test.rb b/test/controllers/signup/accounts_controller_test.rb
new file mode 100644
index 000000000..270edab58
--- /dev/null
+++ b/test/controllers/signup/accounts_controller_test.rb
@@ -0,0 +1,108 @@
+require "test_helper"
+
+class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest
+ test "new under a tenanted domain redirects to the root" do
+ get new_signup_account_url
+
+ assert_redirected_to root_url
+ end
+
+ test "new under an untenanted domain is OK" do
+ integration_session.host = "example.com" # no subdomain
+
+ get new_signup_account_url, headers: auth_headers
+
+ assert_response :success
+ end
+
+ test "create with invalid params" do
+ integration_session.host = "example.com" # no subdomain
+
+ post signup_accounts_url,
+ headers: auth_headers,
+ params: { signup: { full_name: "Jim", email_address: "jim@example.com", password: "", company_name: "" } }
+
+ assert_response :unprocessable_entity
+ assert_select "div.alert--error", text: /you need to choose a password/i
+ end
+
+ test "create for a new " do
+ integration_session.host = "example.com" # no subdomain
+
+ assert_difference -> { SignalId::Identity.count }, +1 do
+ assert_difference -> { SignalId::Account.count }, +1 do
+ post signup_accounts_url, headers: auth_headers,
+ params: {
+ signup: {
+ full_name: "Jim",
+ email_address: "jim@example.com",
+ password: SecureRandom.hex(12),
+ company_name: "signup-accounts-controller-test-1"
+ }
+ }
+ end
+ end
+
+ signal_account = SignalId::Account.last
+ assert_redirected_to(/#{signal_account.login_url}/)
+ end
+
+ test "create for an existing identity" do
+ integration_session.host = "example.com" # no subdomain
+
+ identity = signal_identities(:david)
+
+ post signup_accounts_url, headers: auth_headers,
+ params: { signup: { email_address: identity.email_address, company_name: "signup-accounts-controller-test-2" } }
+
+ assert_authentication_requested_for identity
+
+ assert_no_difference -> { SignalId::Identity.count } do
+ assert_difference -> { SignalId::Account.count } do
+ authenticate_via_launchpad_as(identity)
+ assert_redirected_to_account
+ assert_nil session[:signup]
+ end
+ end
+
+ signal_account = SignalId::Account.last
+ ApplicationRecord.with_tenant(signal_account.subdomain) do
+ assert_equal Account.last, signal_account.peer
+ end
+ end
+
+ test "actions require HTTP basic authentication while we're in internal-only mode" do
+ integration_session.host = "example.com" # no subdomain
+
+ get new_signup_account_url
+
+ assert_response :unauthorized
+ end
+
+ private
+ def auth_headers
+ {
+ "HTTP_AUTHORIZATION" => ActionController::HttpAuthentication::Basic.encode_credentials(
+ Rails.application.credentials.dig(:account_signup_http_basic_auth, :name),
+ Rails.application.credentials.dig(:account_signup_http_basic_auth, :password)
+ )
+ }
+ end
+
+ def assert_authentication_requested_for(identity)
+ assert_response :redirect
+ assert_match(/#{Regexp.escape(Launchpad.url("/authenticate", login_hint: identity.email_address))}.*&purpose=signup/, redirect_to_url)
+ end
+
+ def authenticate_via_launchpad_as(identity)
+ get signup_session_url, headers: auth_headers, params: { sig: identity.perishable_signature }
+ assert_equal identity.id, session[:signup]["identity_id"]
+ assert_redirected_to new_signup_completion_url
+ post signup_completions_url, headers: auth_headers
+ end
+
+ def assert_redirected_to_account(signal_account = SignalId::Account.last)
+ assert_response :redirect
+ assert_match(/#{Regexp.escape(signal_account.url("/session/launchpad"))}.*&sig=/, redirect_to_url)
+ end
+end
diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml
index 587f05a44..d394aedc3 100644
--- a/test/fixtures/accounts.yml
+++ b/test/fixtures/accounts.yml
@@ -1,3 +1,4 @@
37s:
name: 37signals
join_code: "ejpP-THlQ-Cc2f"
+ queenbee_id: <%= ActiveRecord::FixtureSet.identify :'37s_fizzy' %>
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index d12c1a4b3..3339d12d6 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -5,18 +5,21 @@ david:
email_address: david@37signals.com
password_digest: <%= digest %>
role: member
+ signal_user: 37s_fizzy_david
jz:
name: JZ
email_address: jz@37signals.com
password_digest: <%= digest %>
role: member
+ signal_user: 37s_fizzy_jzimdars
kevin:
name: Kevin
email_address: kevin@37signals.com
password_digest: <%= digest %>
role: admin
+ signal_user: 37s_fizzy_kevin
system:
name: System
diff --git a/test/models/account/signal_account_test.rb b/test/models/account/signal_account_test.rb
new file mode 100644
index 000000000..cee8ba0bf
--- /dev/null
+++ b/test/models/account/signal_account_test.rb
@@ -0,0 +1,39 @@
+require "test_helper"
+
+class Account::SignalAccountTest < ActiveSupport::TestCase
+ # # TODO(MIKE): Queenbee client API compliance tests
+ # include Queenbee::Testing::Client
+
+ setup do
+ @account = accounts("37s")
+ end
+
+ test "belongs to a signal_account via a shared queenbee_id" do
+ assert_not_nil @account.queenbee_id
+ assert_equal @account.queenbee_id, Account.new(signal_account: @account.signal_account).queenbee_id
+ assert_equal @account.signal_account, Account.new(queenbee_id: @account.queenbee_id).signal_account
+ end
+
+ test ".create_with_admin_user creates a new local account and user peers" do
+ ApplicationRecord.create_tenant("account-create-with-dependents") do
+ signal_account = signal_accounts(:honcho_fizzy)
+ account = Account.create_with_admin_user(queenbee_id: signal_account.queenbee_id)
+
+ assert_not_nil account
+ assert account.persisted?
+ assert_equal 1, Account.count
+ assert_equal signal_account.queenbee_id, account.queenbee_id
+ assert_equal signal_account.name, account.name
+ assert_equal account, signal_account.peer
+
+ assert_equal 1, User.count
+ User.first.tap do |user|
+ assert signal_account.owner.name, user.name
+ assert signal_account.owner.email_address, user.email_address
+ assert signal_account.owner.id, user.signal_user_id
+ assert_equal "admin", user.role
+ assert_equal user, signal_account.owner.peer
+ end
+ end
+ end
+end
diff --git a/test/models/account_test.rb b/test/models/account_test.rb
index 3f86f1cd4..94d4f83dd 100644
--- a/test/models/account_test.rb
+++ b/test/models/account_test.rb
@@ -1,7 +1,4 @@
require "test_helper"
-class OrganizationTest < ActiveSupport::TestCase
- # test "the truth" do
- # assert true
- # end
+class AccountTest < ActiveSupport::TestCase
end
diff --git a/test/models/signup_test.rb b/test/models/signup_test.rb
new file mode 100644
index 000000000..fc9f89d7a
--- /dev/null
+++ b/test/models/signup_test.rb
@@ -0,0 +1,149 @@
+require "test_helper"
+
+class SignupTest < ActiveSupport::TestCase
+ setup do
+ @signup = Signup.new(
+ email_address: "brian@example.com",
+ full_name: "Brian Wilson",
+ company_name: "Beach Boys",
+ password: SecureRandom.hex(16)
+ )
+ end
+
+ test "#to_h allows persistence of the signup data" do
+ actual = @signup.to_h
+ expected = {
+ email_address: @signup.email_address,
+ full_name: @signup.full_name,
+ company_name: @signup.company_name
+ }
+ assert_equal expected, actual
+ end
+
+ test "#process creates all the necessary objects for a new identity" 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.signal_identity
+ assert @signup.signal_identity.persisted?
+
+ assert @signup.queenbee_account
+ assert @signup.queenbee_account.id
+
+ assert @signup.signal_account
+ assert @signup.signal_account.persisted?
+ assert_equal @signup.signal_identity, @signup.signal_account.owner.identity
+ assert_equal @signup.company_name, @signup.signal_account.name
+
+ assert @signup.account
+ assert @signup.account.persisted?
+
+ assert @signup.user
+ assert @signup.user.persisted?
+ assert_equal @signup.user.signal_user, @signup.signal_account.owner
+
+ assert_includes ApplicationRecord.tenants, @signup.signal_account.subdomain
+ end
+
+ test "the new account is named with company name if present" do
+ assert_equal @signup.company_name, @signup.send(:queenbee_account_name)
+ end
+
+ test "in beta, the new account is named distinctly" do
+ Rails.stubs(:env).returns(stub(beta?: true))
+
+ assert_equal "#{@signup.company_name} (Beta)", @signup.send(:queenbee_account_name)
+ end
+
+ test "#process creates all the necessary objects for an existing identity" do
+ signal_identity = SignalId::Identity.find_by_email_address("david@37signals.com")
+
+ @signup = Signup.new(
+ signal_identity: signal_identity,
+ company_name: "Team LeMans",
+ )
+
+ 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.signal_identity
+ assert_equal signal_identity, @signup.signal_identity
+
+ assert @signup.queenbee_account
+ assert @signup.queenbee_account.id
+
+ assert @signup.signal_account
+ assert @signup.signal_account.persisted?
+ assert_equal @signup.signal_identity, @signup.signal_account.owner.identity
+
+ assert @signup.account
+ assert @signup.account.persisted?
+
+ assert @signup.user
+ assert @signup.user.persisted?
+ assert_equal @signup.user.signal_user, @signup.signal_account.owner
+
+ assert_includes ApplicationRecord.tenants, @signup.signal_account.subdomain
+ end
+
+ test "#process does nothing if a validation error occurs creating identity" do
+ @signup.password = ""
+
+ assert_not @signup.process
+ assert_not_empty @signup.errors[:password]
+
+ assert_nil @signup.signal_identity
+ assert_nil @signup.queenbee_account
+ assert_nil @signup.account
+ assert_nil @signup.user
+ end
+
+ test "#process does nothing if a validation error occurs creating the queenbee account" do
+ Queenbee::Remote::Account.stubs(:create!).raises(RuntimeError, "Invalid account data")
+
+ SignalId::Identity.any_instance.expects(:destroy).once
+
+ assert_not @signup.process
+ assert_not_empty @signup.errors[:base]
+
+ assert_nil @signup.signal_identity
+ assert_nil @signup.queenbee_account
+ assert_nil @signup.account
+ assert_nil @signup.user
+ end
+
+ test "#process does nothing if a validation error occurs creating the tenant" do
+ ApplicationRecord.stubs(:create_tenant).raises(RuntimeError, "Tenant already exists")
+
+ Queenbee::Remote::Account.any_instance.expects(:cancel).once
+ SignalId::Identity.any_instance.expects(:destroy).once
+
+ assert_not @signup.process
+ assert_not_empty @signup.errors[:base]
+
+ assert_nil @signup.signal_identity
+ assert_nil @signup.queenbee_account
+ assert_nil @signup.account
+ assert_nil @signup.user
+ end
+
+ test "#process does nothing if a validation error occurs creating the account" do
+ Account.stubs(:create_with_admin_user).raises(RuntimeError, "Account creation failed")
+
+ ApplicationRecord.expects(:destroy_tenant).once
+ Queenbee::Remote::Account.any_instance.expects(:cancel).once
+ SignalId::Identity.any_instance.expects(:destroy).once
+
+ assert_not @signup.process
+ assert_not_empty @signup.errors[:base]
+
+ assert_nil @signup.signal_identity
+ assert_nil @signup.queenbee_account
+ assert_nil @signup.account
+ assert_nil @signup.user
+ end
+end
diff --git a/test/models/user/signal_user_test.rb b/test/models/user/signal_user_test.rb
new file mode 100644
index 000000000..81071517b
--- /dev/null
+++ b/test/models/user/signal_user_test.rb
@@ -0,0 +1,12 @@
+require "test_helper"
+
+class User::SignalUserTest < ActiveSupport::TestCase
+ setup do
+ @user = users(:david)
+ end
+
+ test "belongs to a Signal::User" do
+ assert_not_nil @user.signal_user_id
+ assert_equal signal_users("37s_fizzy_david"), @user.signal_user
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 2cce7f8d6..622c41a0e 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -3,6 +3,9 @@ require_relative "../config/environment"
require "rails/test_help"
require "webmock/minitest"
require "vcr"
+require "signal_id/testing"
+require "queenbee/testing/mocks"
+require "mocha/minitest"
WebMock.allow_net_connect!
@@ -25,6 +28,7 @@ module ActiveSupport
fixtures :all
include ActiveJob::TestHelper
+ include SignalId::Testing
include ActionTextTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper
end
end
@@ -32,3 +36,11 @@ end
RubyLLM.configure do |config|
config.openai_api_key ||= "DUMMY-TEST-KEY" # Run tests with VCR without having to configure OpenAI API key locally.
end
+
+Queenbee::Remote::Account.class_eval do
+ # because we use the account ID as the tenant name, we need it to be unique in each test to avoid
+ # parallelized tests clobbering each other.
+ def next_id
+ super + Random.rand(1000000)
+ end
+end