Merge pull request #631 from basecamp/flavorjones/signal-id-integration

Launchpad integration: step 1
This commit is contained in:
Mike Dalessio
2025-06-20 15:21:58 -04:00
committed by GitHub
52 changed files with 1204 additions and 48 deletions
+3 -2
View File
@@ -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
+11
View File
@@ -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
+88
View File
@@ -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)
+1 -1
View File
@@ -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
@@ -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)
@@ -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
@@ -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
@@ -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
+35
View File
@@ -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
@@ -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
@@ -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
+10 -1
View File
@@ -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
+27
View File
@@ -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
+2
View File
@@ -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
+20
View File
@@ -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
+159
View File
@@ -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
+9
View File
@@ -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
+1 -1
View File
@@ -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
+7
View File
@@ -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
@@ -0,0 +1 @@
<%= auto_submit_form_with method: :put %>
+58
View File
@@ -0,0 +1,58 @@
<% @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: signup_accounts_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 %>
<footer class="margin-block-start-double txt-center">
<p class="txt-small txt-muted">
Already have an account?
<%= link_to "Sign in", Launchpad.login_url(product: true), class: "decorated" %>
</p>
</footer>
</div>
@@ -0,0 +1 @@
<%= auto_submit_form_with url: signup_completions_path, data: { turbo: false } %>
-2
View File
@@ -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
+7 -2
View File
@@ -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"
+1
View File
@@ -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"
+1 -1
View File
@@ -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==
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==
+1 -1
View File
@@ -1 +1 @@
e6HcyVNJvgdJGTCcvW8FDHQ9pHYmTgNW2/71Espdwxd5LQX/Xb9+VkeqpEAL8ceFJo6ro49mMs85G2YngKbbXEh//8+XSS0inMR/kh9wTV3f6YBWnLVBbhrOnYgPcQ8vedsgxLG2Cs4y7ipfeT/dp9bb5m4IxV0OPiUjVFFJuAuGkMJt3pE7D6alqL1ACRxazV9UKV5WKEBfaQYeKpJ++5f86Nls1EwKo5tB1lJed9P5n2yH7meX/QyxjgQd38CIaPwp+F92y5DLKJD5MZRMJGgWf6MT6096LhjEC3vvtS3wwT/eDVcVKTplRkEszj7BZEfOs9Ondb9EVtbXmeoHYSgpaOq9/nEwtho5Uw==--KlHZy1apg3u1XxFs--N2ehRobLHNz+Sepc8p4EIA==
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==
+1 -1
View File
@@ -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==
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==
+1 -1
View File
@@ -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==
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==
+1 -1
View File
@@ -1 +1 @@
1QS4QGl05L2YvkEusirttYv2gIfRbNLEWTK7pOXG/HxqyWbWj9o5/GbTFeLI7K0sbcEjz+gcPcVKB+FGnkJECExynSvIN02bg5gP0QegTC3WA2vWInlr+gpoWCNgsazOgHKMxDBnQXc2myqhwDLKRsDHCQTW/5lIiKGBtBOLaml8KrJg84SeYCh54tJUjAp5yRkrhJQBHVgSSUfXRaFcrmdbPls5FDgB+rErACpp4BgYdXZoDds2RITDgQ==--kpoxQbOswL3cInhK--r+dB3hJHvjfUH1EBNzBLSg==
0EWG0dpq/qELmTCqAgzEL2BZH7JCNFe6DDIynyKE06qiBCYhHs5+fMjmpzN0uwz43tqAAKJdhO/hZY4jXmTpxu+LSjwrUKAlxsWvSGu4vHEbJ5Fe+BzhnS84OFse6ma9akUAHaed3gt31wtrt2Y4AbSPedNvriqfYln7cvpgWbvjTgtEbHrcNMfjCBnaXXdO3n1zILunILgLiV7oBW0qurIrl2xOc4mbj4AqVAIElyh0md9fFtuBfsaRj2U9x/+7McbgTFODhHfeqoErEw7fc1RBFrNi2YNuHBSSqegQ6WtWbwq1Yg2Juv/Lw3g4IIdWadJsW3TdTMMiwmBMzOikx3lNX1Z399glvGNFVnJfBubKf6BxvFfAQ85PBzOZPl+/PTs=--GY44JMs5ZO1/eL6G--EhxEWuitiex6eHROpPQamw==
+6
View File
@@ -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
+19
View File
@@ -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
+10 -3
View File
@@ -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
+10
View File
@@ -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
@@ -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
@@ -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
Generated
+5 -1
View File
@@ -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|
+54 -2
View File
@@ -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
+38 -11
View File
@@ -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"
+4 -4
View File
@@ -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)
-1
View File
@@ -1 +0,0 @@
create_tenant "first-run"
+8 -8
View File
@@ -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 = [
+117
View File
@@ -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
@@ -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
@@ -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
+1
View File
@@ -1,3 +1,4 @@
37s:
name: 37signals
join_code: "ejpP-THlQ-Cc2f"
queenbee_id: <%= ActiveRecord::FixtureSet.identify :'37s_fizzy' %>
+3
View File
@@ -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
@@ -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
+1 -4
View File
@@ -1,7 +1,4 @@
require "test_helper"
class OrganizationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
class AccountTest < ActiveSupport::TestCase
end
+149
View File
@@ -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
+12
View File
@@ -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
+12
View File
@@ -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