diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb
index f37665a64..302ffcd73 100644
--- a/app/views/users/new.html.erb
+++ b/app/views/users/new.html.erb
@@ -1,39 +1,48 @@
-<% @page_title = "Create your account" %>
+<% @page_title = "Join #{Account.sole.name}" %>
<% content_for :header do %>
-
-
+
-
BOXCAR
+
+
- <%= form_with model: @user, url: join_path(params[:join_code]), class: "flex flex-column gap" do |form| %>
+ <%= form_with scope: "user", url: join_path(join_code: params[:join_code]), class: "flex flex-column gap txt-medium" do |form| %>
+
-
-
-
-
+
+<% content_for :footer do %>
+
+ BOXCAR™ Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %>
+
+<% end %>
diff --git a/config/recurring.yml b/config/recurring.yml
index 8ed13f58d..89b43c567 100644
--- a/config/recurring.yml
+++ b/config/recurring.yml
@@ -26,7 +26,7 @@ production: &production
class: Webhook::CleanupDeliveriesJob
schedule: every 4 hours at minute 51
cleanup_magic_links:
- class: "MagicLink::CleanupJob"
+ command: "MagicLink.cleanup"
schedule: every 4 hours
sqlite_backups:
class: SQLiteBackupsJob
diff --git a/config/routes.rb b/config/routes.rb
index be32d7119..d8469db1d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,6 +1,6 @@
Rails.application.routes.draw do
namespace :account do
- resource :join_code
+ resource :join_code, only: %i[ show edit update destroy ]
resource :settings
resource :entropy_configuration
end
diff --git a/db/migrate/20251011080030_add_setup_status_to_accounts.rb b/db/migrate/20251011080030_add_setup_status_to_accounts.rb
deleted file mode 100644
index db5f5c537..000000000
--- a/db/migrate/20251011080030_add_setup_status_to_accounts.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-class AddSetupStatusToAccounts < ActiveRecord::Migration[8.1]
- def change
- add_column :accounts, :setup_status, :string
- end
-end
diff --git a/db/migrate/20251015081645_create_account_join_codes.rb b/db/migrate/20251015081645_create_account_join_codes.rb
new file mode 100644
index 000000000..79b137475
--- /dev/null
+++ b/db/migrate/20251015081645_create_account_join_codes.rb
@@ -0,0 +1,25 @@
+class CreateAccountJoinCodes < ActiveRecord::Migration[8.1]
+ def change
+ create_table :account_join_codes do |t|
+ t.string :code, null: false, index: { unique: true }
+ t.integer :usage_count, default: 0, null: false
+ t.integer :usage_limit, default: 10, null: false
+
+ t.timestamps
+ end
+
+ reversible do |dir|
+ dir.up do
+ execute <<-SQL
+ INSERT INTO account_join_codes (code, usage_count, usage_limit, created_at, updated_at)
+ SELECT join_code, 0, 10, datetime('now'), datetime('now')
+ FROM accounts
+ WHERE join_code IS NOT NULL;
+ SQL
+ end
+
+ dir.down do
+ end
+ end
+ end
+end
diff --git a/db/seeds.rb b/db/seeds.rb
index 76bcbd253..3a7dbf00e 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -18,8 +18,7 @@ def create_tenant(signal_account_name)
account = Account.create_with_admin_user(
account: {
external_account_id: tenant_id,
- name: signal_account_name,
- setup_status: :complete
+ name: signal_account_name
},
owner: {
name: "David Heinemeier Hansson",
@@ -32,8 +31,8 @@ def create_tenant(signal_account_name)
ApplicationRecord.current_tenant = tenant_id
- identity = Membership.find_by(email_address: User.first.email_address)&.identity
- User.first.set_identity(identity)
+ identity = Identity.find_or_create_by!(email_address: User.first.email_address)
+ identity.memberships.find_or_create_by!(tenant: tenant_id, account_name: Account.sole.name)
end
def find_or_create_user(full_name, email_address)
@@ -45,8 +44,8 @@ def find_or_create_user(full_name, email_address)
email_address: email_address,
password: "secret123456"
- identity = Membership.find_by(email_address: email_address)&.identity || Identity.create!
- user.set_identity(identity)
+ identity = Identity.find_or_create_by!(email_address: email_address)
+ identity.memberships.find_or_create_by!(tenant: ApplicationRecord.current_tenant, account_name: Account.sole.name)
user
end
diff --git a/db/untenanted_migrate/20251015170934_move_email_to_identity.rb b/db/untenanted_migrate/20251015170934_move_email_to_identity.rb
new file mode 100644
index 000000000..711fd5268
--- /dev/null
+++ b/db/untenanted_migrate/20251015170934_move_email_to_identity.rb
@@ -0,0 +1,47 @@
+class MoveEmailToIdentity < ActiveRecord::Migration[8.1]
+ def change
+ add_column :identities, :email_address, :string
+
+ # Create identities for each unique email
+ execute <<-SQL
+ INSERT INTO identities (email_address, created_at, updated_at)
+ SELECT DISTINCT email_address, datetime('now'), datetime('now')
+ FROM memberships
+ WHERE email_address IS NOT NULL
+ AND email_address NOT IN (SELECT email_address FROM identities WHERE email_address IS NOT NULL);
+ SQL
+
+ # And link memberships to them
+ execute <<-SQL
+ UPDATE memberships
+ SET identity_id = (
+ SELECT id
+ FROM identities
+ WHERE identities.email_address = memberships.email_address
+ )
+ WHERE email_address IS NOT NULL;
+ SQL
+
+ # Delete memberships associated with identities without an email address
+ execute <<-SQL
+ DELETE FROM memberships
+ WHERE identity_id IN (
+ SELECT id
+ FROM identities
+ WHERE email_address IS NULL
+ );
+ SQL
+
+ # Delete identities without an email address
+ execute <<-SQL
+ DELETE FROM identities
+ WHERE email_address IS NULL;
+ SQL
+
+ change_column_null :memberships, :identity_id, false
+ add_index :identities, :email_address, unique: true
+ remove_column :memberships, :email_address
+ remove_column :memberships, :user_id, :bigint
+ rename_column :memberships, :user_tenant, :tenant
+ end
+end
diff --git a/db/untenanted_migrate/20251015173452_associate_magic_links_to_identities.rb b/db/untenanted_migrate/20251015173452_associate_magic_links_to_identities.rb
new file mode 100644
index 000000000..67574ea12
--- /dev/null
+++ b/db/untenanted_migrate/20251015173452_associate_magic_links_to_identities.rb
@@ -0,0 +1,18 @@
+class AssociateMagicLinksToIdentities < ActiveRecord::Migration[8.1]
+ def change
+ add_reference :magic_links, :identity, foreign_key: true
+
+ # Re-associate magic links from memberships to their identity
+ execute <<-SQL
+ UPDATE magic_links
+ SET identity_id = (
+ SELECT identity_id
+ FROM memberships
+ WHERE memberships.id = magic_links.membership_id
+ )
+ WHERE membership_id IS NOT NULL;
+ SQL
+
+ remove_column :magic_links, :membership_id
+ end
+end
diff --git a/gems/fizzy-saas/app/controllers/concerns/internal_api.rb b/gems/fizzy-saas/app/controllers/concerns/internal_api.rb
new file mode 100644
index 000000000..4c88a6d46
--- /dev/null
+++ b/gems/fizzy-saas/app/controllers/concerns/internal_api.rb
@@ -0,0 +1,28 @@
+module InternalApi
+ extend ActiveSupport::Concern
+
+ included do
+ require_untenanted_access
+ skip_before_action :verify_authenticity_token
+ before_action :verify_request_authentication
+ before_action :verify_request_signature
+ end
+
+ private
+ def verify_request_authentication
+ authenticated = authenticate_with_http_token do |token, options|
+ ActiveSupport::SecurityUtils.secure_compare(token, InternalApiClient.token)
+ end
+
+ head :unauthorized unless authenticated
+ end
+
+ def verify_request_signature
+ signature = request.headers[InternalApiClient::SIGNATURE_HEADER].to_s
+ computed_signature = InternalApiClient.signature_for(request.raw_post)
+
+ unless ActiveSupport::SecurityUtils.secure_compare(signature, computed_signature)
+ head :unauthorized
+ end
+ end
+end
diff --git a/gems/fizzy-saas/app/controllers/identities_controller.rb b/gems/fizzy-saas/app/controllers/identities_controller.rb
new file mode 100644
index 000000000..0592bed79
--- /dev/null
+++ b/gems/fizzy-saas/app/controllers/identities_controller.rb
@@ -0,0 +1,23 @@
+class IdentitiesController < ApplicationController
+ include InternalApi
+
+ def link
+ Identity.link(email_address: params[:email_address], to: params[:to])
+ head :ok
+ end
+
+ def unlink
+ Identity.unlink(email_address: params[:email_address], from: params[:from])
+ head :ok
+ end
+
+ def change_email_address
+ Membership.change_email_address(from: params[:from], to: params[:to], tenant: params[:tenant])
+ head :ok
+ end
+
+ def send_magic_link
+ magic_link = Identity.find_by(email_address: params[:email_address])&.send_magic_link
+ render json: { code: magic_link&.code }
+ end
+end
diff --git a/gems/fizzy-saas/app/controllers/signups/completions_controller.rb b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb
index bf322354a..ecdf5b38d 100644
--- a/gems/fizzy-saas/app/controllers/signups/completions_controller.rb
+++ b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb
@@ -1,9 +1,10 @@
class Signups::CompletionsController < ApplicationController
- allow_unauthenticated_access
-
+ require_untenanted_access
before_action :require_identity
- before_action :ensure_setup_pending
- before_action :ensure_identity_of_an_admin
+
+ http_basic_authenticate_with \
+ name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name,
+ password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password
def new
@signup = Signup.new
@@ -13,34 +14,21 @@ class Signups::CompletionsController < ApplicationController
@signup = Signup.new(signup_params)
if @signup.complete
- start_new_session_for(@signup.user)
- redirect_to root_path
+ redirect_to session_login_menu_path(go_to: @signup.tenant)
else
render :new, status: :unprocessable_entity
end
end
private
- def ensure_setup_pending
- unless Account.sole.setup_pending?
- redirect_to root_path
- end
- end
-
- def ensure_identity_of_an_admin
- unless user&.admin?
- redirect_to root_path
- end
- end
-
def signup_params
params.expect(signup: %i[ full_name company_name ]).with_defaults(
- tenant: ApplicationRecord.current_tenant,
- user: user
+ identity: identity,
+ email_address: identity.email_address
)
end
- def user
- @user ||= User.all.admin.with_identity(Current.identity_token.identity).first
+ def identity
+ @identity ||= Identity.find_signed(Current.identity_token.id)
end
end
diff --git a/gems/fizzy-saas/app/controllers/signups_controller.rb b/gems/fizzy-saas/app/controllers/signups_controller.rb
index 8421270e2..ce82f23bb 100644
--- a/gems/fizzy-saas/app/controllers/signups_controller.rb
+++ b/gems/fizzy-saas/app/controllers/signups_controller.rb
@@ -17,7 +17,8 @@ class SignupsController < ApplicationController
def create
@signup = Signup.new(signup_params)
- if @signup.create_account
+ if @signup.create_identity
+ session[:return_to_after_identification] = saas.new_signup_completion_path
redirect_to session_magic_link_path
else
render :new, status: :unprocessable_entity
diff --git a/app/mailers/magic_link_mailer.rb b/gems/fizzy-saas/app/mailers/magic_link_mailer.rb
similarity index 70%
rename from app/mailers/magic_link_mailer.rb
rename to gems/fizzy-saas/app/mailers/magic_link_mailer.rb
index 11f0f5705..9b04d2ff5 100644
--- a/app/mailers/magic_link_mailer.rb
+++ b/gems/fizzy-saas/app/mailers/magic_link_mailer.rb
@@ -3,9 +3,9 @@ class MagicLinkMailer < ApplicationMailer
def sign_in_instructions(magic_link)
@magic_link = magic_link
- @membership = @magic_link.membership
+ @identity = @magic_link.identity
- mail to: @membership.email_address, subject: "Sign in to Fizzy"
+ mail to: @identity.email_address, subject: "Sign in to Fizzy"
end
private
diff --git a/gems/fizzy-saas/app/models/internal_api_client.rb b/gems/fizzy-saas/app/models/internal_api_client.rb
new file mode 100644
index 000000000..093fa75db
--- /dev/null
+++ b/gems/fizzy-saas/app/models/internal_api_client.rb
@@ -0,0 +1,99 @@
+class InternalApiClient
+ SECRET_KEY = "internal_api_client_signing_secret"
+ TOKEN_KEY = "internal_api_client_token"
+ USER_AGENT = "fizzy/1.0.0 InternalApiClient"
+ SIGNATURE_HEADER = "X-Internal-Signature"
+ DEFAULT_TIMEOUT = 5.seconds
+
+ class Error < StandardError; end
+ class TimeoutError < Error; end
+ class ConnectionError < Error; end
+
+ Response = Struct.new(:code, :body, :error) do
+ def parsed_body
+ @parsed_body ||= JSON.parse(body) if body.present?
+ end
+
+ def success?
+ error.nil? && code.between?(200, 299)
+ end
+ end
+
+ attr_reader :url, :response
+
+ class << self
+ def token
+ Rails.application.key_generator.generate_key(TOKEN_KEY, 32).unpack1("H*")
+ end
+
+ def signature_for(body)
+ OpenSSL::HMAC.hexdigest("SHA256", signing_secret, body)
+ end
+
+ private
+ def signing_secret
+ Rails.application.key_generator.generate_key(SECRET_KEY, 32).unpack1("H*")
+ end
+ end
+
+ def initialize(url, timeout: DEFAULT_TIMEOUT)
+ @url = url
+ @timeout = timeout
+ end
+
+ def post(body = nil, params: {})
+ uri = build_uri(@url, params)
+ payload = prepare_payload(body)
+ request = Net::HTTP::Post.new(uri, headers(payload))
+ request.body = payload
+ perform_request(uri, request)
+ end
+
+ private
+ def build_uri(url, params)
+ uri = URI(url)
+
+ if params.any?
+ existing_params = URI.decode_www_form(uri.query || "").to_h
+ uri.query = URI.encode_www_form(existing_params.merge(params))
+ end
+
+ uri
+ end
+
+ def prepare_payload(body)
+ case body
+ when nil
+ ""
+ when String
+ body
+ else
+ body.to_json
+ end
+ end
+
+ def headers(payload)
+ {
+ "User-Agent" => USER_AGENT,
+ "Content-Type" => "application/json",
+ "Authorization" => "Bearer #{self.class.token}",
+ SIGNATURE_HEADER => self.class.signature_for(payload)
+ }
+ end
+
+ def perform_request(uri, request)
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = (uri.scheme == "https")
+ http.open_timeout = @timeout
+ http.read_timeout = @timeout
+
+ response = http.request(request)
+ Response.new(code: response.code.to_i, body: response.body)
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT
+ Response.new(error: :connection_timeout)
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET, SocketError
+ Response.new(error: :destination_unreachable)
+ rescue OpenSSL::SSL::SSLError
+ Response.new(error: :failed_tls)
+ end
+end
diff --git a/gems/fizzy-saas/app/models/signup.rb b/gems/fizzy-saas/app/models/signup.rb
index e9d9dfbeb..b52da43a0 100644
--- a/gems/fizzy-saas/app/models/signup.rb
+++ b/gems/fizzy-saas/app/models/signup.rb
@@ -3,17 +3,15 @@ class Signup
include ActiveModel::Attributes
include ActiveModel::Validations
- PERMITTED_KEYS = %i[ full_name email_address company_name ]
+ attr_accessor :company_name, :full_name, :email_address, :identity
+ attr_reader :queenbee_account, :account, :user, :tenant
- attr_accessor :company_name, :full_name, :email_address, :user, :tenant
- attr_reader :queenbee_account, :account
-
- with_options on: :account_creation do
+ with_options on: :identity_creation do
validates_presence_of :email_address
end
with_options on: :completion do
- validates_presence_of :company_name, :full_name
+ validates_presence_of :company_name, :full_name, :identity
end
@@ -30,17 +28,22 @@ class Signup
super
end
- def create_account
- return false unless valid?(:account_creation)
+ def create_identity
+ return false unless valid?(:identity_creation)
- if membership = Membership.find_by(email_address: email_address)
- membership.send_magic_link
- else
- create_queenbee_account
- create_tenant
- user.membership.send_magic_link
- end
+ @identity = Identity.find_or_create_by!(email_address: email_address)
+ @identity.send_magic_link
+ end
+ def complete
+ return false unless valid?(:completion)
+
+ create_queenbee_account
+ create_tenant
+
+ identity.link_to(tenant)
+
+ true
rescue => error
destroy_tenant
destroy_queenbee_account
@@ -52,21 +55,6 @@ class Signup
false
end
- def complete
- return false unless valid?(:completion)
-
- ApplicationRecord.with_tenant(tenant) do
- @account = Account.sole
-
- ApplicationRecord.transaction do
- user.update!(name: full_name)
- account.update!(name: company_name, setup_status: :complete)
- user.membership.update!(account_name: company_name)
- end
- end
- # TODO: Update company and user name in QB
- end
-
private
def create_queenbee_account
@queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes)
@@ -78,17 +66,16 @@ class Signup
end
def create_tenant
- self.tenant = queenbee_account.id.to_s
+ @tenant = queenbee_account.id.to_s
ApplicationRecord.create_tenant(tenant) do
@account = Account.create_with_admin_user(
account: {
external_account_id: tenant,
- name: "New Account",
- setup_status: :pending
+ name: company_name
},
owner: {
- name: email_address,
+ name: full_name,
email_address: email_address
}
)
@@ -103,8 +90,8 @@ class Signup
end
@account = nil
- self.user = nil
- self.tenant = nil
+ @user = nil
+ @tenant = nil
end
def queenbee_account_attributes
diff --git a/gems/fizzy-saas/config/routes.rb b/gems/fizzy-saas/config/routes.rb
index 172eb6e6f..f6519a5ae 100644
--- a/gems/fizzy-saas/config/routes.rb
+++ b/gems/fizzy-saas/config/routes.rb
@@ -6,5 +6,11 @@ Fizzy::Saas::Engine.routes.draw do
end
end
end
+
Queenbee.routes(self)
+
+ post "identities/link", to: "identities#link", as: :link_identity
+ post "identities/unlink", to: "identities#unlink", as: :unlink_identity
+ post "identities/change_email_address", to: "identities#change_email_address", as: :change_identity_email_address
+ post "identities/send_magic_link", to: "identities#send_magic_link", as: :send_magic_link
end
diff --git a/gems/fizzy-saas/lib/fizzy/saas.rb b/gems/fizzy-saas/lib/fizzy/saas.rb
index bfdd34dfa..dd0d35492 100644
--- a/gems/fizzy-saas/lib/fizzy/saas.rb
+++ b/gems/fizzy-saas/lib/fizzy/saas.rb
@@ -3,6 +3,5 @@ require "fizzy/saas/engine"
module Fizzy
module Saas
- # Your code goes here...
end
end
diff --git a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb
index 5b24277dc..19c542ff0 100644
--- a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb
+++ b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb
@@ -2,7 +2,6 @@ require "test_helper"
class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest
setup do
- Account.sole.update!(setup_status: :pending)
set_identity_as :kevin
end
@@ -22,12 +21,9 @@ class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to root_path, "Successful completion should redirect to root"
assert cookies[:session_token].present?, "Successful completion should create a session"
- assert_equal "complete", Account.sole.reload.setup_status, "Account setup status should be complete"
assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated"
assert_equal "37signals", Account.sole.reload.name, "Account name should be updated"
- Account.sole.update!(setup_status: :pending)
-
post saas.signup_completion_path, params: {
signup: {
full_name: "",
diff --git a/gems/fizzy-saas/test/models/signup_test.rb b/gems/fizzy-saas/test/models/signup_test.rb
index 48c6f499b..e4552be8a 100644
--- a/gems/fizzy-saas/test/models/signup_test.rb
+++ b/gems/fizzy-saas/test/models/signup_test.rb
@@ -47,7 +47,6 @@ class SignupTest < ActiveSupport::TestCase
end
test "#complete" do
- Account.sole.update!(setup_status: :pending)
signup = Signup.new(
tenant: ApplicationRecord.current_tenant,
user: users(:kevin),
@@ -57,7 +56,6 @@ class SignupTest < ActiveSupport::TestCase
assert signup.complete, signup.errors.full_messages.to_sentence(words_connector: ". ")
- assert_equal "complete", Account.sole.reload.setup_status, "Account setup status should be complete"
assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated"
assert_equal "37signals", Account.sole.reload.name, "Account name should be updated"
assert_equal "37signals", users(:kevin).membership.reload.account_name, "Membership account name should be updated"
diff --git a/test/fixtures/account/invitation_tokens.yml b/test/fixtures/account/invitation_tokens.yml
new file mode 100644
index 000000000..bdd7db922
--- /dev/null
+++ b/test/fixtures/account/invitation_tokens.yml
@@ -0,0 +1,13 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ token: MyString
+ usage_count: 1
+ usage_limit: 1
+ creator: one
+
+two:
+ token: MyString
+ usage_count: 1
+ usage_limit: 1
+ creator: two
diff --git a/test/models/account/join_codes_test.rb b/test/models/account/join_codes_test.rb
new file mode 100644
index 000000000..7afcab749
--- /dev/null
+++ b/test/models/account/join_codes_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class Account::InvitationTokenTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end