Remove the internal API

* Bind sessions to identities
* Remove references to the identity token
* Move email changes to identity
* Move account menu into a turbo-frame
* Create tenants from a tenanted route
This commit is contained in:
Stanko Krtalić
2025-10-29 13:02:29 +01:00
committed by Stanko K.R.
parent 440631c790
commit 98755844a1
108 changed files with 1096 additions and 1796 deletions
+3 -2
View File
@@ -4,13 +4,14 @@ module ApplicationCable
def connect
super
ApplicationRecord.with_tenant(current_tenant) { set_current_user || reject_unauthorized_connection }
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = find_session_by_cookie
self.current_user = session.user
membership = session.identity.memberships.find_by!(tenant: current_tenant)
self.current_user = membership.user if membership.user.active?
end
end
+6 -61
View File
@@ -21,24 +21,13 @@ module Authentication
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
before_action :resume_identity, **options
before_action :resume_session, **options
end
def require_untenanted_access(**options)
skip_before_action :require_tenant, **options
skip_before_action :require_authentication, **options
before_action :redirect_tenanted_request, **options
end
def require_identified_access(**options)
before_action :require_identity, **options
end
def require_unidentified_access(**options)
before_action :resume_identity, **options
before_action :redirect_request_with_identification, **options
end
end
private
@@ -48,34 +37,12 @@ module Authentication
def require_tenant
unless ApplicationRecord.current_tenant.present?
if resume_identity
redirect_to session_login_menu_url(script_name: nil)
else
request_authentication
end
redirect_to session_menu_url(script_name: nil)
end
end
def require_identity
resume_identity || request_authentication
end
def require_authentication
if resume_identity
resume_session || request_session_for_identity
else
request_authentication
end
end
def request_session_for_identity
redirect_to session_login_menu_url(script_name: nil)
end
def resume_identity
if identity_token = find_identity_by_cookie
set_current_identity(identity_token)
end
resume_session || request_authentication
end
def resume_session
@@ -84,10 +51,6 @@ module Authentication
end
end
def find_identity_by_cookie
IdentityProvider.verify_token(cookies.signed[:identity_token])
end
def find_session_by_cookie
Session.find_signed(cookies.signed[:session_token])
end
@@ -104,10 +67,6 @@ module Authentication
session.delete(:return_to_after_authenticating) || root_url
end
def after_identification_url
session.delete(:return_to_after_identification) || session_login_menu_path
end
def redirect_authenticated_user
redirect_to root_url if authenticated?
end
@@ -116,34 +75,20 @@ module Authentication
redirect_to root_url if ApplicationRecord.current_tenant
end
def redirect_request_with_identification
redirect_to session_login_menu_path(script_name: nil) if Current.identity_token.present?
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
def start_new_session_for(identity)
identity.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
set_current_session session
end
end
def set_current_identity(identity_token)
Current.identity_token = if identity_token
cookies.signed.permanent[:identity_token] = { value: identity_token.to_h, httponly: true, same_site: :lax }
identity_token
else
nil
end
end
def set_current_session(session)
logger.struct " Authorized User##{session.user.id}", authentication: { user: { id: session.user.id } }
logger.struct " Authorized Identity##{session.identity.id}", authentication: { identity: { id: session.identity.id } }
Current.session = session
cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax, path: Account.sole.slug }
cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax }
end
def terminate_session
Current.session.destroy
cookies.delete(:session_token)
cookies.delete(:identity_token)
end
end
+45
View File
@@ -1,4 +1,21 @@
module Authorization
extend ActiveSupport::Concern
included do
before_action :ensure_can_access_account, if: -> { ApplicationRecord.current_tenant && Current.session}
end
class_methods do
def allow_unauthorized_access(**options)
skip_before_action :ensure_can_access_account, **options
end
def require_access_without_a_user(**options)
skip_before_action :ensure_can_access_account, **options
before_action :redirect_existing_user, **options
end
end
private
def ensure_admin
head :forbidden unless Current.user.admin?
@@ -7,4 +24,32 @@ module Authorization
def ensure_staff
head :forbidden unless Current.user.staff?
end
def ensure_can_access_account
if Current.membership.blank?
redirect_to session_menu_url(script_name: nil)
elsif Current.user.nil? && Current.membership.join_code.present?
redirect_to new_user_path
elsif !Current.user&.active?
redirect_to unlink_membership_url(script_name: nil, membership_id: Current.membership.signed_id(purpose: :unlinking))
end
end
def redirect_existing_user
redirect_to root_path if Current.user
end
def account_entry_url(membership)
if !ApplicationRecord.tenant_exists?(membership.tenant)
elsif membership.user.blank?
# We are joining an account. This means the user doesn't yet exist and
# we have to create it
new_user_url(script_name: "/#{membership.tenant}", membership: membership.to_signed(for: :user_creation))
# The user exists, but was deactivated, we want to remove the membership
unlink_membership_url(script_name: nil, membership: membership.to_signed(for: :unlinking))
else
# Everything is fine, let the user enter the account
root_url(script_name: "/#{membership.tenant}")
end
end
end
@@ -0,0 +1,34 @@
class Identity::EmailAddresses::ConfirmationsController < ApplicationController
require_untenanted_access
before_action :set_identity
rate_limit to: 3, within: 1.hour, only: :create
def show
end
def create
@identity.change_email_address_using_token(token)
# Redirect to the tenant that initiated the change
tenant = SignedGlobalID.parse(token, for: Identity::EmailAddressChangeable::EMAIL_CHANGE_TOKEN_PURPOSE)&.params&.fetch("tenant")
if tenant
redirect_to "#{tenant}/users/#{Current.user.id}/edit"
else
redirect_to session_menu_path
end
rescue ArgumentError => e
flash[:alert] = e.message
render :show, status: :unprocessable_entity
end
private
def set_identity
@identity = Current.identity
end
def token
params.expect :email_address_token
end
end
@@ -0,0 +1,36 @@
class Identity::EmailAddressesController < ApplicationController
require_untenanted_access
before_action :set_identity
rate_limit to: 3, within: 1.hour, only: :create
def new
@tenant = params[:tenant]
@user = ApplicationRecord.with_tenant(@tenant) { Current.identity.user }
end
def create
@tenant = tenant
@user = ApplicationRecord.with_tenant(@tenant) { Current.identity.user }
if Identity.exists?(email_address: new_email_address)
flash.now[:alert] = "Someone else already uses that email"
render :new, status: :unprocessable_entity
else
@identity.send_email_address_change_confirmation(new_email_address, tenant: tenant)
end
end
private
def set_identity
@identity = Current.identity
end
def new_email_address
params.expect :email_address
end
def tenant
params.expect :tenant
end
end
+32
View File
@@ -0,0 +1,32 @@
class JoinCodesController < ApplicationController
require_untenanted_access
allow_unauthenticated_access
before_action :set_join_code
def new
@account_name = ApplicationRecord.with_tenant(tenant) { Account.sole.name }
end
def create
Identity.transaction do
identity = Identity.find_or_create_by(email_address: params.expect(:email_address))
identity.memberships.create!(tenant: tenant, join_code: code)
identity.send_magic_link
end
redirect_to session_magic_link_path
end
private
def set_join_code
@join_code ||= ApplicationRecord.with_tenant(tenant) { Account::JoinCode.active.find_by(code: code) }
end
def tenant
params.expect(:tenant)
end
def code
params.expect(:code)
end
end
@@ -0,0 +1,17 @@
class Memberships::UnlinkController < ApplicationController
require_untenanted_access
before_action :set_membership
def show
end
def create
@membership.destroy
redirect_to session_menu_path
end
private
def set_membership
@membership = Current.identity.memberships.find_signed!(params[:membership_id], purpose: :unlinking)
end
end
+1 -1
View File
@@ -2,6 +2,6 @@ class My::MenusController < ApplicationController
include FilterScoped
def show
fresh_when etag: [ @user_filtering, Current.identity_token ]
fresh_when etag: [ @user_filtering, Current.session ]
end
end
@@ -1,5 +1,6 @@
class Notifications::UnsubscribesController < ApplicationController
allow_unauthenticated_access
allow_unauthorized_access
skip_before_action :verify_authenticity_token
before_action :set_user
@@ -2,6 +2,7 @@ class Public::CardsController < ApplicationController
include CachedPublicly, PublicCardScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -2,6 +2,7 @@ class Public::Collections::Columns::ClosedsController < ApplicationController
include CachedPublicly, PublicCollectionScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -2,6 +2,7 @@ class Public::Collections::Columns::NotNowsController < ApplicationController
include CachedPublicly, PublicCollectionScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -2,6 +2,7 @@ class Public::Collections::Columns::StreamsController < ApplicationController
include CachedPublicly, PublicCollectionScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -2,6 +2,7 @@ class Public::Collections::ColumnsController < ApplicationController
include ActionView::RecordIdentifier, CachedPublicly, PublicCollectionScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -2,6 +2,7 @@ class Public::CollectionsController < ApplicationController
include CachedPublicly, PublicCollectionScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -1,10 +0,0 @@
class Sessions::LoginMenusController < ApplicationController
require_untenanted_access
require_identified_access
layout "public"
def show
@tenants = IdentityProvider.tenants_for(resume_identity)
end
end
@@ -1,6 +1,6 @@
class Sessions::MagicLinksController < ApplicationController
require_untenanted_access
require_unidentified_access
require_unauthenticated_access
rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Try again in 15 minutes." }
layout "public"
@@ -9,13 +9,11 @@ class Sessions::MagicLinksController < ApplicationController
end
def create
identity_token = IdentityProvider.consume_magic_link(code)
if identity_token.blank?
redirect_to session_magic_link_path, alert: "Try another code."
if identity = MagicLink.consume(code)
start_new_session_for identity
redirect_to after_authentication_url
else
set_current_identity(identity_token)
redirect_to after_identification_url
redirect_to session_magic_link_path, alert: "Try another code."
end
end
@@ -0,0 +1,17 @@
class Sessions::MenusController < ApplicationController
require_untenanted_access
layout "public"
def show
if params[:menu_section]
request.variant = :menu_section
end
@memberships = Current.identity.memberships
if params[:without]
@memberships = @memberships.where.not(tenant: params[:without])
end
end
end
@@ -1,22 +0,0 @@
class Sessions::StartsController < ApplicationController
allow_unauthenticated_access
require_identified_access
layout "public"
def new
end
def create
email_address = IdentityProvider.resolve_token(resume_identity)
user = User.find_by(email_address: email_address)
if user
start_new_session_for(user)
redirect_to after_authentication_url
else
IdentityProvider.unlink(email_address: email_address, from: ApplicationRecord.current_tenant)
redirect_to session_login_menu_path, alert: "You can't access this account"
end
end
end
@@ -1,13 +1,14 @@
class Sessions::TransfersController < ApplicationController
require_untenanted_access
require_unauthenticated_access
def show
end
def update
if user = User.active.find_by_transfer_id(params[:id])
start_new_session_for user
redirect_to root_path
if identity = Identity.find_by_transfer_id(params[:id])
start_new_session_for identity
redirect_to session_menu_path(script_name: nil)
else
head :bad_request
end
+3 -7
View File
@@ -1,6 +1,6 @@
class SessionsController < ApplicationController
require_untenanted_access only: %i[ new create ]
require_unidentified_access only: %i[ new create ]
require_untenanted_access
require_unauthenticated_access except: :destroy
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
layout "public"
@@ -9,11 +9,7 @@ class SessionsController < ApplicationController
end
def create
magic_link_code = IdentityProvider.send_magic_link(email_address)
if magic_link_code && Rails.env.development?
flash[:notice] = "Magic Link Code: #{magic_link_code}"
end
Identity.find_by_email_address(email_address)&.send_magic_link
redirect_to session_magic_link_path
end
@@ -1,21 +0,0 @@
class Users::EmailAddresses::ConfirmationsController < ApplicationController
before_action :set_user
rate_limit to: 3, within: 1.hour, only: :create
def show
end
def create
@user.change_email_address_using_token(token)
redirect_to edit_user_path(@user)
end
private
def set_user
@user = User.find(params[:user_id])
end
def token
params.expect :email_address_token
end
end
@@ -1,25 +0,0 @@
class Users::EmailAddressesController < ApplicationController
before_action :set_user
rate_limit to: 3, within: 1.hour, only: :create
def new
end
def create
if User.exists?(email_address: new_email_address)
flash.now[:alert] = "Someone else already uses that email"
render :new, status: :unprocessable_entity
else
@user.send_email_address_change_confirmation(new_email_address)
end
end
private
def set_user
@user = User.find(params[:user_id])
end
def new_email_address
params.expect :email_address
end
end
+16 -14
View File
@@ -1,11 +1,12 @@
class UsersController < ApplicationController
require_unauthenticated_access only: %i[ new create ]
include FilterScoped
before_action :set_user, only: %i[ show edit update destroy ]
require_access_without_a_user only: %i[ new create ]
before_action :set_join_code, only: %i[ new create]
before_action :ensure_join_code_is_valid, only: %i[ new create ]
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
before_action :set_user, only: %i[ show edit update destroy ]
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
before_action :set_filter, only: %i[ edit show ]
before_action :set_user_filtering, only: %i[ edit show]
@@ -13,12 +14,11 @@ class UsersController < ApplicationController
end
def create
if Account::JoinCode.redeem(params[:join_code])
User.invite(**invite_params)
redirect_to session_magic_link_path(script_name: nil)
else
head :forbidden
@join_code.redeem do
User.create!(user_params.merge(membership: Current.membership))
end
redirect_to root_path
end
def edit
@@ -40,8 +40,14 @@ class UsersController < ApplicationController
end
private
def set_join_code
@join_code = Account::JoinCode.active.find_by(code: Current.membership.join_code)
end
def ensure_join_code_is_valid
head :forbidden unless Account::JoinCode.active?(params[:join_code])
unless @join_code&.active?
redirect_to unlink_membership_url(script_name: nil, membership_id: Current.membership.signed_id(purpose: :unlinking))
end
end
def set_user
@@ -63,8 +69,4 @@ class UsersController < ApplicationController
def user_params
params.expect(user: [ :name, :avatar ])
end
def invite_params
params.expect(user: [ :name, :email_address ])
end
end
+9
View File
@@ -0,0 +1,9 @@
class IdentityMailer < ApplicationMailer
def email_change_confirmation(identity:, email_address:, token:, tenant:)
@identity = identity
@token = token
@tenant = tenant
mail to: email_address, subject: "Confirm your new email address"
end
end
+1 -1
View File
@@ -10,7 +10,7 @@ class Notification::BundleMailer < ApplicationMailer
@unsubscribe_token = @user.generate_token_for(:unsubscribe)
mail \
to: bundle.user.email_address,
to: bundle.user.identity.email_address,
subject: "Latest Activity in BOXCAR"
end
end
+4 -14
View File
@@ -9,20 +9,10 @@ class Account::JoinCode < ApplicationRecord
validates :usage_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :usage_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
class << self
def redeem(code)
join_code = find_by(code: code)
if join_code&.active?
join_code.increment!(:usage_count)
true
else
false
end
end
def active?(code)
active.exists?(code: code)
def redeem
transaction do
increment!(:usage_count)
yield if block_given?
end
end
+11 -2
View File
@@ -1,6 +1,15 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session, :identity_token
attribute :session, :membership
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
delegate :user, to: :session, allow_nil: true
delegate :identity, to: :session, allow_nil: true
delegate :user, to: :membership, allow_nil: true
def session=(value)
super(value)
unless value.nil?
self.membership = identity.memberships.find_by(tenant: ApplicationRecord.current_tenant)
end
end
end
+11 -13
View File
@@ -1,18 +1,12 @@
class Identity < UntenantedRecord
include EmailAddressChangeable, Transferable
has_many :memberships, dependent: :destroy
has_many :magic_links, dependent: :delete_all
has_many :magic_links, dependent: :destroy
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(value) { value.strip.downcase }
class << self
def link(email_address:, to:)
find_or_create_by!(email_address: email_address).tap { |identity| identity.link_to(to) }
end
def unlink(email_address:, from:)
find_by(email_address: email_address)&.unlink_from(from)
end
end
validates :email_address, presence: true
def send_magic_link
magic_links.create!.tap do |magic_link|
@@ -20,13 +14,17 @@ class Identity < UntenantedRecord
end
end
def link_to(tenant)
def link_to(tenant, context: nil)
memberships.find_or_create_by!(tenant: tenant) do |membership|
membership.account_name = ApplicationRecord.with_tenant(membership.tenant) { Account.sole.name }
membership.context = context
end
end
def unlink_from(tenant)
memberships.find_by(tenant: tenant)&.destroy
end
def staff?
email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com")
end
end
@@ -0,0 +1,58 @@
module Identity::EmailAddressChangeable
EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address"
EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes
extend ActiveSupport::Concern
def send_email_address_change_confirmation(new_email_address, tenant:)
token = generate_email_address_change_token(to: new_email_address, tenant: tenant, expires_in: EMAIL_CHANGE_TOKEN_EXPIRATION)
IdentityMailer.email_change_confirmation(identity: self, email_address: new_email_address, token: token, tenant: tenant).deliver_later
end
def generate_email_address_change_token(from: email_address, to:, tenant:, **options)
options = options.reverse_merge(
for: EMAIL_CHANGE_TOKEN_PURPOSE,
old_email_address: from,
new_email_address: to,
tenant: tenant
)
to_sgid(**options).to_s
end
def change_email_address_using_token(token)
parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE)
if parsed_token.nil?
raise ArgumentError, "The token is invalid"
elsif parsed_token.find != self
raise ArgumentError, "The token is for another identity"
elsif email_address != parsed_token.params.fetch("old_email_address")
raise ArgumentError, "The token was generated for a different email address"
else
tenant = parsed_token.params.fetch("tenant")
new_email_address = parsed_token.params.fetch("new_email_address")
change_email_address(new_email_address, tenant: tenant)
end
end
private
def change_email_address(new_email_address, tenant:)
old_email_address = email_address
# First update the identity email
update!(email_address: new_email_address)
begin
# Then update the membership to point to the new identity
Membership.change_email_address(from: old_email_address, to: new_email_address, tenant: tenant)
# Also update the user's email in the tenant database (read-only from untenanted context)
# This will be handled by the user updating their own record
rescue => e
# Rollback identity email if membership update fails
update!(email_address: old_email_address)
raise e
end
end
end
+15
View File
@@ -0,0 +1,15 @@
module Identity::Transferable
extend ActiveSupport::Concern
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
class_methods do
def find_by_transfer_id(id)
find_signed(id, purpose: :transfer)
end
end
def transfer_id
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
end
end
-19
View File
@@ -1,19 +0,0 @@
module IdentityProvider
class Error < StandardError; end
Token = Data.define(:id, :updated_at) do
delegate :dig, to: :to_h
def to_h
{ "id" => id, "updated_at" => updated_at }
end
end
Tenant = Data.define(:id, :name)
extend self
mattr_accessor :backend, default: IdentityProvider::LocalBackend
delegate :link, :unlink, :change_email_address, :send_magic_link, :consume_magic_link, :tenants_for, :token_for, :resolve_token, :verify_token, to: :backend
end
@@ -1,54 +0,0 @@
module IdentityProvider::LocalBackend
extend self
def link(email_address:, to:)
Identity.link(email_address: email_address, to: to)
end
def unlink(email_address:, from:)
Identity.unlink(email_address: email_address, from: from)
end
def change_email_address(from:, to:, tenant:)
Membership.change_email_address(from: from, to: to, tenant: tenant)
end
def send_magic_link(email_address)
magic_link = Identity.find_by(email_address: email_address)&.send_magic_link
magic_link&.code
end
def consume_magic_link(code)
identity = MagicLink.consume(code)
wrap_identity(identity)
end
def token_for(email_address)
identity = Identity.find_by(email_address: email_address)
wrap_identity(identity)
end
def resolve_token(token)
Identity.find_signed(token&.dig("id"))&.email_address
end
def verify_token(token)
identity = Identity.find_signed(token&.dig("id"))
wrap_identity(identity)
end
def tenants_for(token)
Identity.find_signed(token&.dig("id")).memberships.pluck(:tenant, :account_name).map do |id, name|
IdentityProvider::Tenant.new(id: id, name: name)
end
end
private
def wrap_identity(identity)
if identity
IdentityProvider::Token.new(identity.signed_id, identity.updated_at)
else
nil
end
end
end
+15
View File
@@ -1,6 +1,9 @@
class Membership < UntenantedRecord
belongs_to :identity, touch: true
serialize :context, coder: JSON
store_accessor :context, :join_code
class << self
def change_email_address(from:, to:, tenant:)
identity = Identity.find_by(email_address: from)
@@ -12,4 +15,16 @@ class Membership < UntenantedRecord
end
end
end
def account_name
ApplicationRecord.with_tenant(tenant) { Account.sole.name }
rescue ActiveRecord::Tenanted::TenantDoesNotExistError
nil
end
def user
ApplicationRecord.with_tenant(tenant) { User.find_by(membership_id: id) }
rescue ActiveRecord::Tenanted::TenantDoesNotExistError
nil
end
end
+2 -2
View File
@@ -1,3 +1,3 @@
class Session < ApplicationRecord
belongs_to :user
class Session < UntenantedRecord
belongs_to :identity
end
+8 -18
View File
@@ -1,14 +1,15 @@
class User < ApplicationRecord
include Accessor, Assignee, Attachable, Configurable, EmailAddressChangeable,
Identifiable, Invitable, Mentionable, Named, Notifiable, Role, Searcher, Staff,
Transferable, Watcher
include Accessor, Assignee, Attachable, Configurable,
Mentionable, Named, Notifiable, Role, Searcher, Watcher
include Timelined # Depends on Accessor
self.ignored_columns = %i[ password_digest ]
has_one_attached :avatar
has_many :sessions, dependent: :destroy
belongs_to :membership, optional: true
has_one :identity, through: :membership, disable_joins: true
has_many :comments, inverse_of: :creator, dependent: :destroy
@@ -19,21 +20,10 @@ class User < ApplicationRecord
normalizes :email_address, with: ->(value) { value.strip.downcase }
delegate :staff?, to: :identity, allow_nil: true
def deactivate
old_email_address = email_address
sessions.delete_all
accesses.destroy_all
update! active: false, email_address: deactivated_email_address
IdentityProvider.unlink(email_address: old_email_address, from: tenant)
rescue => e
update! active: true, email_address: old_email_address
raise e
update! active: false
end
private
def deactivated_email_address
email_address.sub(/@/, "-deactivated-#{SecureRandom.uuid}@")
end
end
@@ -1,48 +0,0 @@
module User::EmailAddressChangeable
EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address"
EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes
extend ActiveSupport::Concern
def send_email_address_change_confirmation(new_email_address)
token = generate_email_address_change_token(to: new_email_address, expires_in: EMAIL_CHANGE_TOKEN_EXPIRATION)
UserMailer.email_change_confirmation(user: self, email_address: new_email_address, token: token).deliver_later
end
def generate_email_address_change_token(from: email_address, to:, **options)
options = options.reverse_merge(
for: EMAIL_CHANGE_TOKEN_PURPOSE,
old_email_address: from,
new_email_address: to
)
to_sgid(**options).to_s
end
def change_email_address_using_token(token)
parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE)
if parsed_token.nil?
raise ArgumentError, "The token is invalid"
elsif parsed_token.find != self
raise ArgumentError, "The token is for another user"
elsif email_address != parsed_token.params.fetch("old_email_address")
raise ArgumentError, "The token was generated for a different email address"
else
change_email_address(parsed_token.params.fetch("new_email_address"))
end
end
private
def change_email_address(new_email_address)
old_email_address = email_address
update!(email_address: new_email_address)
begin
IdentityProvider.change_email_address(from: old_email_address, to: new_email_address, tenant: tenant)
rescue => e
update!(email_address: old_email_address)
raise e
end
end
end
-21
View File
@@ -1,21 +0,0 @@
module User::Identifiable
extend ActiveSupport::Concern
included do
after_create_commit :link_identity, unless: :system?
after_destroy_commit :unlink_identity, unless: :system?
end
def identity
Identity.find_by(email_address: email_address)
end
private
def link_identity
IdentityProvider.link(email_address: email_address, to: tenant)
end
def unlink_identity
IdentityProvider.unlink(email_address: email_address, from: tenant)
end
end
-14
View File
@@ -1,14 +0,0 @@
module User::Invitable
extend ActiveSupport::Concern
class_methods do
def invite(**attributes)
create!(attributes).tap do |user|
IdentityProvider.send_magic_link(user.email_address)
rescue => e
user.destroy!
raise e
end
end
end
end
-7
View File
@@ -1,7 +0,0 @@
module User::Staff
extend ActiveSupport::Concern
def staff?
email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com")
end
end
+2 -2
View File
@@ -15,7 +15,7 @@
<p class="txt-medium margin-none">Share the link below to invite people to this account</p>
</header>
<% url = join_url(join_code: @join_code.code, script_name: "/#{@join_code.tenant}") %>
<% url = join_url(code: @join_code.code, tenant: @join_code.tenant, script_name: nil) %>
<div class="flex align-center gap-half">
<input type="text" class="input flex-item-grow" value="<%= url %>" readonly>
<%= button_to account_join_code_path, method: :delete, class: "btn btn--circle txt-small",
@@ -65,4 +65,4 @@
<% end %>
</p>
</footer>
</div>
</div>
@@ -1,9 +1,5 @@
<% @page_title = "Confirm email change" %>
<% content_for :header do %>
<%= render "filters/menu" %>
<% end %>
<div class="panel shadow center flex flex-column gap-half" style="--panel-size: 45ch;">
<header>
<h1 class="txt-x-large font-weight-black margin-none">
@@ -12,7 +8,7 @@
<p class="margin-none">Just a sec while we confirm your new email address.</p>
</header>
<%= form_with url: user_email_address_confirmation_path(@user, params[:email_address_token]), method: :post, data: { controller: "auto-submit" } do |form| %>
<%= form_with url: identity_email_address_confirmation_path(params[:email_address_token]), method: :post, data: { controller: "auto-submit" } do |form| %>
<%= form.hidden_field :email_address_token, value: params[:email_address_token] %>
<button type="submit" class="btn btn--link center">
@@ -1,9 +1,8 @@
<% @page_title = "Confirm your new email address" %>
<% content_for :header do %>
<%= render "filters/menu" %>
<div class="header__actions header__actions--start">
<%= link_to user_path(@user), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<div class="header__title">
<%= link_to edit_user_path(@user, script_name: "/#{@tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">&larr; <strong>My profile</strong></span>
<% end %>
</div>
@@ -14,5 +13,5 @@
<p class="margin-none">We just sent an email to <strong><%= params[:email_address] %></strong></p>
<p class="margin-none-block-start">Hit the link in the email to confirm this is the email address you want to use with Fizzy going forward.</p>
<%= link_to "Done", edit_user_path(@user), class: "btn btn--link center" %>
<%= link_to "Done", edit_user_path(@user, script_name: "/#{@tenant}"), class: "btn btn--link center" %>
</div>
@@ -1,9 +1,8 @@
<% @page_title = "Change your email" %>
<% content_for :header do %>
<%= render "filters/menu" %>
<div class="header__actions header__actions--start">
<%= link_to user_path(@user), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<div class="header__title">
<%= link_to edit_user_path(@user, script_name: "/#{@tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">&larr; <strong>My profile</strong></span>
<% end %>
</div>
@@ -14,10 +13,11 @@
<h1 class="txt-x-large font-weight-black margin-none">
<%= @page_title %>
</h1>
<p class="margin-none">Enter your new email address. Well send you a confirmation to verify it.</p>
<p class="margin-none">Enter your new email address. We'll send you a confirmation to verify it.</p>
</header>
<%= form_with url: user_email_addresses_path(@user), method: :post, class: "flex flex-column gap", data: { turbo: false } do |form| %>
<%= form_with url: identity_email_addresses_path(tenant: @tenant), method: :post, class: "flex flex-column gap", data: { turbo: false } do |form| %>
<%= form.hidden_field :tenant, value: @tenant %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.email_field :email_address, class: "input full-width", autocomplete: "email", placeholder: "New email address", autofocus: true, required: true %>
+27
View File
@@ -0,0 +1,27 @@
<% @page_title = "Join #{@account_name}" %>
<div class="panel panel--centered flex flex-column gap-half">
<header>
<h1 class="txt-x-large font-weight-black margin-none">
<%= @page_title %>
</h1>
<p class="margin-none-block-start">Enter your email address to continue</p>
</header>
<%= form_with url: join_path(code: params[:code], tenant: params[:tenant]), class: "flex flex-column gap txt-medium" do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.email_field :email_address, required: true, class: "input full-width", autofocus: true, autocomplete: "username", placeholder: "Email address" %>
</label>
</div>
<button type="submit" id="log_in" class="btn btn--link center">
<span>Join</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
@@ -1,7 +1,7 @@
<h1 class="title">Confirm your email address change</h1>
<p class="subtitle">Hit the button below to use this email address in Fizzy.</p>
<%= link_to "Yes use use this email address", user_email_address_confirmation_url(@user, @token), class: "btn" %>
<%= link_to "Yes use use this email address", identity_email_address_confirmation_url(@token), class: "btn" %>
<p class="margin-block-start-double">If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.</p>
@@ -3,6 +3,6 @@ Confirm your email address change
Hit the link below to use this email address in Fizzy:
<%= user_email_address_confirmation_url(@user, @token) %>
<%= identity_email_address_confirmation_url(@token) %>
If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.
@@ -0,0 +1,24 @@
<% @page_title = "Leaving #{@membership.account_name}" %>
<div class="panel panel--centered flex flex-column gap-half">
<header>
<h1 class="txt-x-large font-weight-black margin-none">
<%= @page_title %>
</h1>
<p class="margin-none-block-start">You no longer have access to this account.</p>
</header>
<%= form_with \
url: unlink_membership_path(membership_id: params[:membership_id]),
class: "flex flex-column gap txt-medium",
data: { controller: "auto-submit" } do |form| %>
<button type="submit" id="log_in" class="btn btn--link center">
<span>Leave</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+2 -6
View File
@@ -1,7 +1,3 @@
<%= collapsible_nav_section "Accounts" do %>
<% cache [ Current.user, Current.identity_token ] do %>
<% Current.user.identity.memberships.where.not(tenant: Current.user.tenant).each do |membership| %>
<%= filter_place_menu_item new_session_start_url(script_name: "/#{membership.tenant}"), "#{membership.account_name}", "marker", new_window: true %>
<% end %>
<% end %>
<%= turbo_frame_tag dom_id(Current.identity, :account_menu), src: session_menu_url(script_name: nil, menu_section: true, without: ApplicationRecord.current_tenant) do %>
Loading...
<% end %>
@@ -0,0 +1,9 @@
<%= turbo_frame_tag dom_id(Current.identity, :account_menu) do %>
<% cache [ Current.identity, @memberships ] do %>
<%= collapsible_nav_section "Accounts" do %>
<% @memberships.each do |membership| %>
<%= filter_place_menu_item root_url(script_name: "/#{membership.tenant}"), "#{membership.account_name}", "marker", new_window: true %>
<% end %>
<% end %>
<% end %>
<% end %>
@@ -1,20 +1,20 @@
<% @page_title = "Choose an account" %>
<% cache [ Current.identity_token, @tenants ] do %>
<% cache [ Current.identity, @memberships ] do %>
<div
class="panel panel--centered flex flex-column gap-half"
style="--popup-icon-size: 24px; --popup-item-padding-inline: 0.5rem;"
>
<% if @tenants.present? %>
<% if @memberships.present? %>
<h1 class="txt-x-large font-weight-black margin-none">
Your BOXCAR accounts
</h1>
<menu class="popup__list pad border-radius border">
<% @tenants.each do |tenant| %>
<% @memberships.each do |membership| %>
<li class="popup__item txt-medium">
<%= icon_tag "marker", class: "popup__icon" %>
<%= link_to new_session_start_url(script_name: "/#{tenant.id}"), class: "btn overflow-ellipsis fill-transparent popup__btn" do %>
<strong><%= tenant.name %></strong>
<%= link_to root_url(script_name: "/#{membership.tenant}"), class: "btn overflow-ellipsis fill-transparent popup__btn" do %>
<strong><%= membership.account_name %></strong>
<% end %>
</li>
<% end %>
@@ -26,7 +26,7 @@
<% if defined?(saas) %>
<div class="margin-block-start">
<%= link_to saas.new_signup_completion_path, class: "btn btn--plain txt-link center txt-small" do %>
<%= link_to saas.new_signup_membership_path, class: "btn btn--plain txt-link center txt-small" do %>
<span>Sign up for a new BOXCAR account</span>
<% end %>
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
<div class="flex flex-column align-center gap txt-medium--responsive txt-medium">
<% url = session_transfer_url(user.transfer_id) %>
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>
<label class="flex flex-column gap full-width">
<div class="flex align-center gap justify-center">
+1 -1
View File
@@ -33,7 +33,7 @@
<div class="flex align-center gap">
<div class="flex align-center gap input input--actor">
<%= form.email_field :email_address, class: "input full-width", autocomplete: "username", placeholder: "Email address", required: true, readonly: true %>
<%= link_to "Change email", new_user_email_address_path(@user), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
<%= link_to "Change email", new_identity_email_address_url(script_name: nil, tenant: @user.tenant), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
</div>
</div>
<button type="submit" id="log_in" class="btn btn--reversed center">
+2 -8
View File
@@ -5,10 +5,10 @@
<h1 class="txt-x-large font-weight-black margin-none">
<%= @page_title %>
</h1>
<p class="margin-none-block-start">Enter your email address to continue</p>
<p class="margin-none-block-start">Enter your name to continue</p>
</header>
<%= form_with scope: "user", url: join_path(join_code: params[:join_code]), class: "flex flex-column gap txt-medium" do |form| %>
<%= form_with scope: "user", url: users_path(membership: params[:membership]), class: "flex flex-column gap txt-medium" do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.text_field :name, class: "input full-width", autocomplete: "name", placeholder: "Name", autofocus: true, required: true, data: { "1p-ignore": true } %>
@@ -16,12 +16,6 @@
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.email_field :email_address, required: true, class: "input full-width", autofocus: true, autocomplete: "username", placeholder: "Email address" %>
</label>
</div>
<button type="submit" id="log_in" class="btn btn--link center">
<span>Join</span>
<%= icon_tag "arrow-right" %>
+1 -1
View File
@@ -40,7 +40,7 @@
<%= render "users/transfer", user: @user %>
<div class="center margin-block-start-double">
<%= button_to session_path, method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %>
<%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %>
<span>Sign out</span>
<% end %>
</div>
+15 -5
View File
@@ -15,10 +15,6 @@ module AccountSlug
request.engine_script_name = request.script_name = $1
request.path_info = $'.empty? ? "/" : $'
# Limit session cookies to the slug path.
# TODO TEST ME
request.env["rack.session.options"][:path] = $1
# Return the account id for tenanting.
AccountSlug.decode($2)
end
@@ -26,10 +22,24 @@ module AccountSlug
def self.decode(slug) slug.to_i end
def self.encode(id) FORMAT % id end
def self.creation_request?(request)
if defined?(Fizzy::Saas) && request.post?
path = Fizzy::Saas::Engine.routes.url_helpers.signup_completion_path
request.path_info =~ /\A\/#{PATTERN}#{Regexp.escape(path)}\Z/
else
false
end
end
end
Rails.application.config.after_initialize do
Rails.application.config.active_record_tenanted.tenant_resolver = ->(request) do
AccountSlug.extract(request)
if AccountSlug.creation_request?(request)
AccountSlug.extract(request)
nil
else
AccountSlug.extract(request)
end
end
end
+15 -7
View File
@@ -1,5 +1,6 @@
Rails.application.routes.draw do
namespace :account do
post :enter, to: "entries#create"
resource :join_code, only: %i[ show edit update destroy ]
resource :settings
resource :entropy_configuration
@@ -8,9 +9,6 @@ Rails.application.routes.draw do
resources :users do
resource :role, module: :users
resources :push_subscriptions, module: :users
resources :email_addresses, only: %i[ new create ], param: :token, module: :users do
resource :confirmation, only: %i[ show create ], module: :email_addresses
end
end
resources :collections do
@@ -126,15 +124,21 @@ Rails.application.routes.draw do
get "/u/*slug" => "uploads#show", as: :upload
resources :qr_codes
get "join/:join_code", to: "users#new", as: :join
post "join/:join_code", to: "users#create"
get "join/:tenant/:code", to: "join_codes#new", as: :join
post "join/:tenant/:code", to: "join_codes#create"
resource :session do
scope module: "sessions" do
resources :transfers, only: %i[ show update ]
resource :magic_link, only: %i[ show create ]
resource :login_menu, only: %i[ show create ]
resource :start, only: %i[ new create ]
resource :menu, only: %i[ show create ]
end
end
resource :identity, only: [] do
resources :email_addresses, only: %i[ new create ], param: :token, module: :identity do
resource :confirmation, only: %i[ show create ], module: :email_addresses
end
end
@@ -152,6 +156,10 @@ Rails.application.routes.draw do
end
end
scope module: :memberships, path: "memberships/:membership_id" do
resource :unlink, only: %i[ show create ], controller: :unlink, as: :unlink_membership
end
namespace :my do
resources :pins
resource :timezone
@@ -0,0 +1,18 @@
class DropSessions < ActiveRecord::Migration[8.2]
def up
drop_table :sessions
end
def down
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
t.datetime "updated_at", null: false
t.string "user_agent"
t.integer "user_id", null: false
t.index ["user_id"], name: "index_sessions_on_user_id"
end
add_foreign_key "sessions", "users"
end
end
@@ -0,0 +1,6 @@
class AddMembershipIdToUsers < ActiveRecord::Migration[8.2]
def change
add_column :users, :membership_id, :integer
add_index :users, :membership_id
end
end
Generated
+2 -10
View File
@@ -350,15 +350,6 @@ ActiveRecord::Schema[8.2].define(version: 2025_10_29_161222) do
t.datetime "updated_at", null: false
end
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
t.datetime "updated_at", null: false
t.string "user_agent"
t.integer "user_id", null: false
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "steps", force: :cascade do |t|
t.integer "card_id", null: false
t.boolean "completed", default: false, null: false
@@ -399,11 +390,13 @@ ActiveRecord::Schema[8.2].define(version: 2025_10_29_161222) do
t.boolean "active", default: true, null: false
t.datetime "created_at", null: false
t.string "email_address"
t.integer "membership_id"
t.string "name", null: false
t.string "password_digest"
t.string "role", default: "member", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
t.index ["membership_id"], name: "index_users_on_membership_id"
t.index ["role"], name: "index_users_on_role"
end
@@ -473,7 +466,6 @@ ActiveRecord::Schema[8.2].define(version: 2025_10_29_161222) do
add_foreign_key "pins", "users"
add_foreign_key "push_subscriptions", "users"
add_foreign_key "search_queries", "users"
add_foreign_key "sessions", "users"
add_foreign_key "steps", "cards"
add_foreign_key "taggings", "cards"
add_foreign_key "taggings", "tags"
+104 -483
View File
@@ -20,7 +20,7 @@ columns:
default_function:
collation:
comment:
- &24 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &23 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: collection_id
cast_type: &3 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer
@@ -90,7 +90,7 @@ columns:
default_function:
collation:
comment:
- &18 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &28 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: user_id
cast_type: *3
@@ -282,7 +282,7 @@ columns:
collation:
comment:
- *8
- &19 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &18 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: key
cast_type: *5
@@ -325,44 +325,9 @@ columns:
default_function:
collation:
comment:
ai_quotas:
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: limit
cast_type: *3
sql_type_metadata: *4
'null': false
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: reset_at
cast_type: *1
sql_type_metadata: *2
'null': false
default:
default_function:
collation:
comment:
- *9
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: used
cast_type: *3
sql_type_metadata: *4
'null': false
default: 0
default_function:
collation:
comment:
- *18
ar_internal_metadata:
- *7
- *19
- *18
- *9
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -375,7 +340,7 @@ columns:
collation:
comment:
assignees_filters:
- &21 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &20 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: assignee_id
cast_type: *3
@@ -385,7 +350,7 @@ columns:
default_function:
collation:
comment:
- &20 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &19 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: filter_id
cast_type: *3
@@ -396,7 +361,7 @@ columns:
collation:
comment:
assigners_filters:
- &22 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &21 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: assigner_id
cast_type: *3
@@ -406,11 +371,11 @@ columns:
default_function:
collation:
comment:
- *20
- *19
assignments:
- *20
- *21
- *22
- &23 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &22 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: card_id
cast_type: *3
@@ -424,7 +389,7 @@ columns:
- *8
- *9
card_activity_spikes:
- *23
- *22
- *7
- *8
- *9
@@ -453,16 +418,16 @@ columns:
comment:
- *9
card_goldnesses:
- *23
- *22
- *7
- *8
- *9
card_not_nows:
- *23
- *22
- *7
- *8
- *9
- &25 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &24 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: user_id
cast_type: *3
@@ -473,7 +438,7 @@ columns:
collation:
comment:
cards:
- *24
- *23
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: column_id
@@ -485,7 +450,7 @@ columns:
collation:
comment:
- *7
- &26 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &25 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: creator_id
cast_type: *3
@@ -498,12 +463,12 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: due_on
cast_type: &38 !ruby/object:ActiveRecord::Type::Date
cast_type: !ruby/object:ActiveRecord::Type::Date
precision:
scale:
limit:
timezone:
sql_type_metadata: &39 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: date
type: :date
limit:
@@ -535,7 +500,7 @@ columns:
default_function:
collation:
comment:
- &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &34 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: title
cast_type: *5
@@ -557,15 +522,15 @@ columns:
default_function:
collation:
comment:
- *20
- *19
closures:
- *23
- *22
- *7
- *8
- *9
- *25
collection_publications:
- *24
collection_publications:
- *23
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -583,11 +548,11 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: all_access
cast_type: &33 !ruby/object:ActiveModel::Type::Boolean
cast_type: &31 !ruby/object:ActiveModel::Type::Boolean
precision:
scale:
limit:
sql_type_metadata: &34 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: boolean
type: :boolean
limit:
@@ -599,15 +564,15 @@ columns:
collation:
comment:
- *7
- *26
- *25
- *8
- *10
- *9
collections_filters:
- *24
- *20
- *23
- *19
columns:
- *24
- *23
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: color
@@ -623,133 +588,14 @@ columns:
- *10
- *9
comments:
- *23
- *22
- *7
- *26
- *25
- *8
- *9
conversation_messages:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: client_message_id
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: conversation_id
cast_type: *3
sql_type_metadata: *4
'null': false
default:
default_function:
collation:
comment:
- &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: cost_in_microcents
cast_type: *11
sql_type_metadata: *12
'null': true
default:
default_function:
collation:
comment:
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: input_cost_in_microcents
cast_type: *11
sql_type_metadata: *12
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: input_tokens
cast_type: *11
sql_type_metadata: *12
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: model_id
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: output_cost_in_microcents
cast_type: *11
sql_type_metadata: *12
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: output_tokens
cast_type: *11
sql_type_metadata: *12
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: role
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
collation:
comment:
- *9
conversations:
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: state
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: string
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
collation:
comment:
- *9
- *18
creators_filters:
- *26
- *20
- *25
- *19
entropy_configurations:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -795,9 +641,9 @@ columns:
default_function:
collation:
comment:
- *24
- *23
- *7
- *26
- *25
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: eventable_id
@@ -822,11 +668,11 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: particulars
cast_type: &27 !ruby/object:ActiveRecord::Type::Json
cast_type: &26 !ruby/object:ActiveRecord::Type::Json
precision:
scale:
limit:
sql_type_metadata: &28 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &27 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: json
type: :json
limit:
@@ -840,12 +686,12 @@ columns:
- *9
filters:
- *7
- *26
- *25
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: fields
cast_type: *27
sql_type_metadata: *28
cast_type: *26
sql_type_metadata: *27
'null': false
default: "{}"
default_function:
@@ -864,8 +710,8 @@ columns:
comment:
- *9
filters_tags:
- *20
- &36 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- *19
- &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: tag_id
cast_type: *3
@@ -953,7 +799,7 @@ columns:
collation:
comment:
- *9
- *18
- *28
notifications:
- *7
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -980,29 +826,13 @@ columns:
- *29
- *30
- *9
- *18
period_highlights:
- &35 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: content
cast_type: *15
sql_type_metadata: *16
'null': false
default:
default_function:
collation:
comment:
- *31
- *7
- *8
- *19
- *9
- *28
pins:
- *23
- *22
- *7
- *8
- *9
- *18
- *28
push_subscriptions:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -1037,7 +867,7 @@ columns:
collation:
comment:
- *9
- &32 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: user_agent
cast_type: *5
@@ -1047,7 +877,7 @@ columns:
default_function:
collation:
comment:
- *18
- *28
reactions:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -1103,43 +933,6 @@ columns:
default_function:
collation:
comment:
search_embeddings_vector_chunks00:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: rowid
cast_type: !ruby/object:ActiveModel::Type::Value
precision:
scale:
limit:
sql_type_metadata: !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: ''
type:
limit:
precision:
scale:
'null': true
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: vectors
cast_type: !ruby/object:ActiveModel::Type::Binary
precision:
scale:
limit:
sql_type_metadata: !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: BLOB
type: :binary
limit:
precision:
scale:
'null': false
default:
default_function:
collation:
comment:
search_queries:
- *7
- *8
@@ -1164,53 +957,46 @@ columns:
collation:
comment:
- *9
- *18
- *28
search_results:
- *7
- *8
- *9
sessions:
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: ip_address
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
collation:
comment:
- *9
- *32
- *18
steps:
- *23
- *22
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: completed
cast_type: *33
sql_type_metadata: *34
cast_type: *31
sql_type_metadata: *32
'null': false
default: false
default_function:
collation:
comment:
- *35
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: content
cast_type: *15
sql_type_metadata: *16
'null': false
default:
default_function:
collation:
comment:
- *7
- *8
- *9
taggings:
- *23
- *22
- *7
- *8
- *36
- *33
- *9
tags:
- *7
- *8
- *37
- *34
- *9
user_settings:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -1236,38 +1022,13 @@ columns:
collation:
comment:
- *9
- *18
user_weekly_highlights:
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: period_highlights_id
cast_type: *3
sql_type_metadata: *4
'null': false
default:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: starts_at
cast_type: *38
sql_type_metadata: *39
'null': false
default:
default_function:
collation:
comment:
- *9
- *18
- *28
users:
- &41 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &36 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: active
cast_type: *33
sql_type_metadata: *34
cast_type: *31
sql_type_metadata: *32
'null': false
default: true
default_function:
@@ -1285,6 +1046,16 @@ columns:
collation:
comment:
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: membership_id
cast_type: *3
sql_type_metadata: *4
'null': true
default:
default_function:
collation:
comment:
- *10
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -1308,16 +1079,16 @@ columns:
comment:
- *9
watches:
- *23
- *22
- *7
- *8
- *9
- *18
- *28
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: watching
cast_type: *33
sql_type_metadata: *34
cast_type: *31
sql_type_metadata: *32
'null': false
default: true
default_function:
@@ -1347,7 +1118,7 @@ columns:
comment:
- *8
- *9
- &40 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &35 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: webhook_id
cast_type: *3
@@ -1401,10 +1172,10 @@ columns:
collation:
comment:
- *9
- *40
- *35
webhooks:
- *41
- *24
- *36
- *23
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -1456,7 +1227,6 @@ primary_keys:
active_storage_attachments: id
active_storage_blobs: id
active_storage_variant_records: id
ai_quotas: id
ar_internal_metadata: key
assignees_filters:
assigners_filters:
@@ -1473,8 +1243,6 @@ primary_keys:
collections_filters:
columns: id
comments: id
conversation_messages: id
conversations: id
creators_filters:
entropy_configurations: id
events: id
@@ -1483,20 +1251,16 @@ primary_keys:
mentions: id
notification_bundles: id
notifications: id
period_highlights: id
pins: id
push_subscriptions: id
reactions: id
schema_migrations: version
search_embeddings_vector_chunks00: rowid
search_queries: id
search_results: id
sessions: id
steps: id
taggings: id
tags: id
user_settings: id
user_weekly_highlights: id
users: id
watches: id
webhook_delinquency_trackers: id
@@ -1510,7 +1274,6 @@ data_sources:
active_storage_attachments: true
active_storage_blobs: true
active_storage_variant_records: true
ai_quotas: true
ar_internal_metadata: true
assignees_filters: true
assigners_filters: true
@@ -1527,8 +1290,6 @@ data_sources:
collections_filters: true
columns: true
comments: true
conversation_messages: true
conversations: true
creators_filters: true
entropy_configurations: true
events: true
@@ -1537,20 +1298,16 @@ data_sources:
mentions: true
notification_bundles: true
notifications: true
period_highlights: true
pins: true
push_subscriptions: true
reactions: true
schema_migrations: true
search_embeddings_vector_chunks00: true
search_queries: true
search_results: true
sessions: true
steps: true
taggings: true
tags: true
user_settings: true
user_weekly_highlights: true
users: true
watches: true
webhook_delinquency_trackers: true
@@ -1763,39 +1520,6 @@ indexes:
nulls_not_distinct:
comment:
valid: true
ai_quotas:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: ai_quotas
name: index_ai_quotas_on_reset_at
unique: false
columns:
- reset_at
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: ai_quotas
name: index_ai_quotas_on_user_id
unique: false
columns:
- user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
ar_internal_metadata: []
assignees_filters:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
@@ -2247,40 +1971,6 @@ indexes:
nulls_not_distinct:
comment:
valid: true
conversation_messages:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: conversation_messages
name: index_conversation_messages_on_conversation_id
unique: false
columns:
- conversation_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
conversations:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: conversations
name: index_conversations_on_user_id
unique: true
columns:
- user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
creators_filters:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: creators_filters
@@ -2658,23 +2348,6 @@ indexes:
nulls_not_distinct:
comment:
valid: true
period_highlights:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: period_highlights
name: index_period_highlights_on_key_and_starts_at_and_duration
unique: true
columns:
- key
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
pins:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: pins
@@ -2843,7 +2516,6 @@ indexes:
comment:
valid: true
schema_migrations: []
search_embeddings_vector_chunks00: []
search_queries:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: search_queries
@@ -2896,23 +2568,6 @@ indexes:
comment:
valid: true
search_results: []
sessions:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: sessions
name: index_sessions_on_user_id
unique: false
columns:
- user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
steps:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: steps
@@ -3032,56 +2687,6 @@ indexes:
nulls_not_distinct:
comment:
valid: true
user_weekly_highlights:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: user_weekly_highlights
name: index_user_weekly_highlights_on_period_highlights_id
unique: false
columns:
- period_highlights_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: user_weekly_highlights
name: index_user_weekly_highlights_on_user_id
unique: false
columns:
- user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: user_weekly_highlights
name: index_user_weekly_highlights_on_user_id_and_starts_at
unique: true
columns:
- user_id
- starts_at
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
users:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: users
@@ -3099,6 +2704,22 @@ indexes:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: users
name: index_users_on_membership_id
unique: false
columns:
- membership_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: users
name: index_users_on_role
@@ -3231,4 +2852,4 @@ indexes:
nulls_not_distinct:
comment:
valid: true
version: 20251016153034
version: 20251029104115
+9 -11
View File
@@ -1,6 +1,4 @@
raise "Seeding is just for development" unless Rails.env.development?
IdentityProvider.backend = IdentityProvider::LocalBackend
require "active_support/testing/time_helpers"
include ActiveSupport::Testing::TimeHelpers
@@ -13,6 +11,9 @@ end
def create_tenant(signal_account_name)
tenant_id = ActiveRecord::FixtureSet.identify signal_account_name
email_address = "david@37signals.com"
identity = Identity.find_or_create_by!(email_address: email_address)
membership = identity.memberships.find_or_create_by!(tenant: tenant_id)
ApplicationRecord.destroy_tenant tenant_id
ApplicationRecord.create_tenant(tenant_id) do
@@ -23,35 +24,32 @@ def create_tenant(signal_account_name)
},
owner: {
name: "David Heinemeier Hansson",
email_address: "david@37signals.com"
membership: membership
}
)
account.setup_basic_template
end
ApplicationRecord.current_tenant = tenant_id
identity = Identity.find_or_create_by!(email_address: User.find_by(role: :admin).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)
if user = User.find_by(email_address: email_address)
user
else
identity = Identity.find_or_create_by!(email_address: email_address)
membership = identity.memberships.find_or_create_by!(tenant: ApplicationRecord.current_tenant)
user = User.create! \
name: full_name,
email_address: email_address
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)
membership: membership
user
end
end
def login_as(user)
Current.session = user.sessions.create
Current.session = user.identity.sessions.create
end
def create_collection(name, creator: Current.user, all_access: true, access_to: [])
@@ -42,6 +42,7 @@ class MoveEmailToIdentity < ActiveRecord::Migration[8.1]
add_index :identities, :email_address, unique: true
remove_column :memberships, :email_address
remove_column :memberships, :user_id, :bigint
remove_column :memberships, :account_name, :string
rename_column :memberships, :user_tenant, :tenant
end
end
@@ -0,0 +1,14 @@
class CreateSessions < ActiveRecord::Migration[8.2]
def change
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
t.datetime "updated_at", null: false
t.string "user_agent"
t.integer "identity_id", null: false
t.index ["identity_id"], name: "index_sessions_on_identity_id"
end
add_foreign_key "sessions", "identities"
end
end
@@ -0,0 +1,5 @@
class AddContextToMemberships < ActiveRecord::Migration[8.2]
def change
add_column :memberships, :context, :text
end
end
+12 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_10_15_173452) do
ActiveRecord::Schema[8.2].define(version: 2025_10_27_131911) do
create_table "identities", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email_address"
@@ -30,7 +30,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_10_15_173452) do
end
create_table "memberships", force: :cascade do |t|
t.string "account_name", null: false
t.text "context"
t.datetime "created_at", null: false
t.integer "identity_id", null: false
t.string "tenant", null: false
@@ -39,6 +39,16 @@ ActiveRecord::Schema[8.2].define(version: 2025_10_15_173452) do
t.index ["tenant"], name: "index_memberships_on_user_tenant_and_user_id"
end
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "identity_id", null: false
t.string "ip_address"
t.datetime "updated_at", null: false
t.string "user_agent"
t.index ["identity_id"], name: "index_sessions_on_identity_id"
end
add_foreign_key "magic_links", "identities"
add_foreign_key "memberships", "identities"
add_foreign_key "sessions", "identities"
end
@@ -1,28 +0,0 @@
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
@@ -2,8 +2,10 @@ module Restricted
extend ActiveSupport::Concern
included do
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
unless Rails.env.development?
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
end
end
end
@@ -1,23 +0,0 @@
class IdentitiesController < ApplicationController
include InternalApi
def link
IdentityProvider::LocalBackend.link(email_address: params[:email_address], to: params[:to])
head :ok
end
def unlink
IdentityProvider::LocalBackend.unlink(email_address: params[:email_address], from: params[:from])
head :ok
end
def change_email_address
IdentityProvider::LocalBackend.change_email_address(from: params[:from], to: params[:to], tenant: params[:tenant])
head :ok
end
def send_magic_link
code = IdentityProvider::LocalBackend.send_magic_link(params[:email_address])
render json: { code: code }
end
end
@@ -2,19 +2,18 @@ class Signups::CompletionsController < ApplicationController
include Restricted
require_untenanted_access
require_identified_access
layout "public"
def new
@signup = Signup.new
@signup = Signup.new(signup_params)
end
def create
@signup = Signup.new(signup_params)
if @signup.complete
redirect_to new_session_start_url(script_name: "/#{@signup.tenant}")
redirect_to root_url(script_name: "/#{@signup.tenant}")
else
render :new, status: :unprocessable_entity
end
@@ -22,13 +21,6 @@ class Signups::CompletionsController < ApplicationController
private
def signup_params
params.expect(signup: %i[ full_name company_name ]).with_defaults(
identity: identity,
email_address: identity.email_address
)
end
def identity
@identity ||= Identity.find_signed(Current.identity_token.id)
params.expect(signup: %i[ full_name company_name membership_id ]).with_defaults(identity: Current.identity)
end
end
@@ -0,0 +1,32 @@
class Signups::MembershipsController < ApplicationController
include Restricted
require_untenanted_access
layout "public"
def new
@signup = Signup.new(new_user: params.dig(:signup, :new_user) || false)
end
def create
@signup = Signup.new(signup_params)
if @signup.create_membership
redirect_to saas.new_signup_completion_path(
signup: {
membership_id: @signup.membership_id,
full_name: @signup.full_name,
company_name: @signup.company_name
}
)
else
render :new, status: :unprocessable_entity
end
end
private
def signup_params
params.expect(signup: %i[ full_name company_name new_user]).with_defaults(new_user: false, identity: Current.identity)
end
end
@@ -2,7 +2,7 @@ class SignupsController < ApplicationController
include Restricted
require_untenanted_access
require_unidentified_access
require_unauthenticated_access
layout "public"
@@ -19,7 +19,7 @@ class SignupsController < ApplicationController
@signup = Signup.new(signup_params)
if @signup.create_identity
session[:return_to_after_identification] = saas.new_signup_completion_path
session[:return_to_after_authenticating] = saas.new_signup_membership_path(signup: { new_user: @signup.new_user? })
redirect_to session_magic_link_path
else
render :new, status: :unprocessable_entity
@@ -1,48 +0,0 @@
module IdentityProvider::RemoteBackend
extend self
include Fizzy::Saas::Engine.routes.url_helpers
delegate :consume_magic_link, :token_for, :resolve_token, :verify_token, :tenants_for, to: IdentityProvider::LocalBackend
def default_url_options
Rails.application.config.action_mailer.default_url_options
end
def url_options
default_url_options.merge(script_name: nil)
end
def link(email_address:, to:)
response = InternalApiClient.new(link_identity_url).post({ email_address: email_address, to: to })
unless response.success?
raise IdentityProvider::Error, "Failed to link identity: #{response.error || response.code}"
end
end
def unlink(email_address:, from:)
response = InternalApiClient.new(unlink_identity_url).post({ email_address: email_address, from: from })
unless response.success?
raise IdentityProvider::Error, "Failed to unlink identity: #{response.error || response.code}"
end
end
def change_email_address(from:, to:, tenant:)
response = InternalApiClient.new(change_identity_email_address_url).post({ from: from, to: to, tenant: tenant })
unless response.success?
raise IdentityProvider::Error, "Failed to change email address: #{response.error || response.code}"
end
end
def send_magic_link(email_address)
response = InternalApiClient.new(send_magic_link_url).post({ email_address: email_address })
if response.success?
response.parsed_body["code"]
else
raise IdentityProvider::Error, "Failed to send magic link: #{response.error || response.code}"
end
end
end
@@ -1,99 +0,0 @@
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 = 60.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
+72 -18
View File
@@ -1,19 +1,25 @@
class Signup
BOOLEAN = ActiveRecord::Type::Boolean.new
MEMBERSHIP_PURPOSE = :account_creation
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
attr_accessor :company_name, :full_name, :email_address, :identity
attr_reader :queenbee_account, :account, :user, :tenant
attr_accessor :company_name, :full_name, :email_address, :identity, :membership_id, :new_user
attr_reader :queenbee_account, :account, :user, :tenant, :membership
with_options on: :identity_creation do
validates_presence_of :email_address
end
with_options on: :completion do
with_options on: :membership_creation do
validates_presence_of :company_name, :full_name, :identity
end
with_options on: :completion do
validates_presence_of :company_name, :full_name, :email_address, :tenant
end
def initialize(...)
@company_name = nil
@@ -23,39 +29,89 @@ class Signup
@account = nil
@user = nil
@queenbee_account = nil
@membership = nil
@membership_id = nil
@identity = nil
super
if @identity
@email_address = @identity.email_address
@membership = identity.memberships.find_signed(membership_id, purpose: MEMBERSHIP_PURPOSE)
@tenant = membership&.tenant
end
end
def create_identity
return false unless valid?(:identity_creation)
@identity = Identity.find_or_create_by!(email_address: email_address)
@new_user = @identity.new_record?
@identity.send_magic_link
end
def create_membership
self.company_name ||= personal_account_name if new_user?
if valid?(:membership_creation)
begin
create_queenbee_account
@membership = identity.memberships.create!(tenant: tenant)
@membership_id = @membership.signed_id(purpose: MEMBERSHIP_PURPOSE)
rescue => error
destroy_queenbee_account
@membership&.destroy
@membership = nil
@membership_id = nil
errors.add(:base, "Something went wrong, and we couldn't create your account. Please give it another try.")
Rails.error.report(error, severity: :error)
false
end
else
false
end
end
def complete
return false unless valid?(:completion)
if valid?(:completion)
begin
create_tenant
create_queenbee_account
create_tenant
true
rescue => error
destroy_tenant
identity.link_to(tenant)
errors.add(:base, "Something went wrong, and we couldn't create your account. Please give it another try.")
Rails.error.report(error, severity: :error)
true
rescue => error
destroy_tenant
destroy_queenbee_account
false
end
else
false
end
end
errors.add(:base, "Something went wrong, and we couldn't create your account. Please give it another try.")
Rails.error.report(error, severity: :error)
false
def new_user?
BOOLEAN.cast(new_user)
end
private
def personal_account_name
if full_name.present?
first_name = full_name.split(" ", 2).first
"#{first_name}'s BOXCAR"
else
nil
end
end
def create_queenbee_account
@queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes)
@tenant = queenbee_account.id.to_s
end
def destroy_queenbee_account
@@ -64,8 +120,6 @@ class Signup
end
def create_tenant
@tenant = queenbee_account.id.to_s
ApplicationRecord.create_tenant(tenant) do
@account = Account.create_with_admin_user(
account: {
@@ -74,7 +128,7 @@ class Signup
},
owner: {
name: full_name,
email_address: email_address
membership_id: membership.id
}
)
@user = User.find_by!(role: :admin)
@@ -1,11 +1,12 @@
<% @page_title = "Create your account" %>
<% @page_title = "Creating your account" %>
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] %>">
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
<%= form_with model: @signup, url: saas.signup_completion_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false } do |form| %>
<%= form.text_field :full_name, class: "input", autocomplete: "name", placeholder: "Your name", autofocus: true, required: true %>
<%= form.text_field :company_name, class: "input", autocomplete: "organization", placeholder: "Your organization", required: true %>
<%= form_with model: @signup, url: saas.signup_completion_url(script_name: "/#{@signup.tenant}"), scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: @signup.errors.blank? ? "auto-submit" : nil } do |form| %>
<%= form.hidden_field :membership_id %>
<%= form.hidden_field :full_name %>
<%= form.hidden_field :company_name %>
<% if @signup.errors.any? %>
<div class="margin-block-half">
@@ -26,4 +27,4 @@
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
<% end %>
@@ -0,0 +1,34 @@
<% @page_title = "Create your account" %>
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] %>">
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
<%= form_with model: @signup, url: saas.signup_membership_path, scope: "signup", class: "flex flex-column gap" do |form| %>
<%= form.hidden_field :new_user %>
<%= form.text_field :full_name, class: "input", autocomplete: "name", placeholder: "Your name", autofocus: true, required: true %>
<% unless @signup.new_user? %>
<%= form.text_field :company_name, class: "input", autocomplete: "organization", placeholder: "Your organization", required: true %>
<% end %>
<% if @signup.errors.any? %>
<div class="margin-block-half">
<ul class="margin-block-none txt-negative txt-small">
<% @signup.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<button type="submit" class="btn btn--link center">
<span>Continue</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
@@ -25,4 +25,4 @@
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
<% end %>
+3 -7
View File
@@ -1,16 +1,12 @@
Fizzy::Saas::Engine.routes.draw do
resource :signup, only: %i[ new create ] do
scope module: :signups do
scope module: :signups, as: :signup do
collection do
resource :completion, only: %i[ new create ], as: :signup_completion
resource :membership, only: %i[ new create ]
resource :completion, only: %i[ new create ]
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
-2
View File
@@ -5,8 +5,6 @@ module Fizzy
Queenbee.host_app = Fizzy
config.to_prepare do
IdentityProvider.backend = IdentityProvider::RemoteBackend
Queenbee::Subscription.short_names = Subscription::SHORT_NAMES
Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token)
@@ -1,105 +0,0 @@
require "test_helper"
class IdentitiesControllerTest < ActionDispatch::IntegrationTest
include Fizzy::Saas::Engine.routes.url_helpers
setup do
@token = InternalApiClient.token
end
test "link" do
new_email = "newuser@example.com"
tenant = ApplicationRecord.current_tenant
body = { email_address: new_email, to: tenant }.to_json
assert_difference -> { Identity.count }, 1 do
assert_difference -> { Membership.count }, 1 do
untenanted do
post link_identity_url(script_name: nil), params: body, headers: authenticated_headers(body)
end
end
end
assert_response :ok
assert Identity.find_by(email_address: new_email).memberships.exists?(tenant: tenant)
end
test "unlink" do
identity = identities(:kevin)
tenant = ApplicationRecord.current_tenant
body = { email_address: identity.email_address, from: tenant }.to_json
assert_difference -> { Membership.count }, -1 do
untenanted do
post unlink_identity_url(script_name: nil), params: body, headers: authenticated_headers(body)
end
end
assert_response :ok
end
test "change_email_address" do
identity = identities(:kevin)
tenant = ApplicationRecord.current_tenant
new_email = "newemail@example.com"
body = { from: identity.email_address, to: new_email, tenant: tenant }.to_json
untenanted do
post change_identity_email_address_url(script_name: nil), params: body, headers: authenticated_headers(body)
end
assert_response :ok
assert Identity.find_by(email_address: new_email).memberships.exists?(tenant: tenant)
end
test "send_magic_link" do
identity = identities(:kevin)
body = { email_address: identity.email_address }.to_json
assert_difference -> { MagicLink.count }, 1 do
untenanted do
post send_magic_link_url(script_name: nil), params: body, headers: authenticated_headers(body)
end
end
assert_response :ok
json = JSON.parse(response.body)
assert_equal MagicLink::CODE_LENGTH, json["code"].length
body = { email_address: "nonexistent@example.com" }.to_json
untenanted do
post send_magic_link_url(script_name: nil), params: body, headers: authenticated_headers(body)
end
assert_response :ok
json = JSON.parse(response.body)
assert_nil json["code"]
end
test "authentication" do
body = { email_address: "test@example.com" }.to_json
untenanted do
post send_magic_link_url(script_name: nil), params: body, headers: { "Content-Type" => "application/json" }
end
assert_response :unauthorized
untenanted do
post send_magic_link_url(script_name: nil), params: body, headers: {
"Content-Type" => "application/json",
"Authorization" => "Bearer #{@token}",
"X-Internal-Signature" => "invalid"
}
end
assert_response :unauthorized
end
private
def authenticated_headers(body)
{
"Content-Type" => "application/json",
"Authorization" => "Bearer #{@token}",
"X-Internal-Signature" => InternalApiClient.signature_for(body)
}
end
end
@@ -9,14 +9,28 @@ class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest
post session_magic_link_url, params: { code: magic_link.code }
assert_response :redirect, "Magic link should succeed"
cookie = cookies.get_cookie "identity_token"
assert_not_nil cookie, "Expected identity_token cookie to be set after magic link consumption"
cookie = cookies.get_cookie "session_token"
assert_not_nil cookie, "Expected session_token cookie to be set after magic link consumption"
end
# Create membership first (new step in the flow)
untenanted do
post saas.signup_membership_path, params: {
signup: {
full_name: "New User",
company_name: "New Company"
}
}, headers: http_basic_auth_headers
# Extract membership_id from redirect params
redirect_url = response.location
@membership_id = Rack::Utils.parse_query(URI.parse(redirect_url).query)["signup[membership_id]"]
end
end
test "new" do
untenanted do
get saas.new_signup_completion_path, headers: http_basic_auth_headers
get saas.new_signup_completion_path(signup: { membership_id: @membership_id, full_name: "New User", company_name: "New Company" }), headers: http_basic_auth_headers
assert_response :success
end
@@ -24,20 +38,21 @@ class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest
test "create" do
untenanted do
assert_difference -> { Membership.count }, 1 do
post saas.signup_completion_path, params: {
signup: {
full_name: "New User",
company_name: "New Company"
}
}, headers: http_basic_auth_headers
end
tenant = Membership.last.tenant
assert_redirected_to new_session_start_url(script_name: "/#{tenant}"), "Successful completion should redirect to start session in new tenant"
post saas.signup_completion_path, params: {
signup: {
membership_id: @membership_id,
full_name: "New User",
company_name: "New Company"
}
}, headers: http_basic_auth_headers
tenant = Membership.last.tenant
assert_redirected_to root_url(script_name: "/#{tenant}"), "Successful completion should redirect to root in new tenant"
# Test validation error
post saas.signup_completion_path, params: {
signup: {
membership_id: @membership_id,
full_name: "",
company_name: ""
}
@@ -0,0 +1,90 @@
require "test_helper"
class Signups::MembershipsControllerTest < ActionDispatch::IntegrationTest
setup do
@identity = Identity.create!(email_address: "newuser@example.com")
magic_link = @identity.send_magic_link
untenanted do
post session_magic_link_url, params: { code: magic_link.code }
assert_response :redirect, "Magic link should succeed"
cookie = cookies.get_cookie "session_token"
assert_not_nil cookie, "Expected session_token cookie to be set after magic link consumption"
end
end
test "new" do
untenanted do
get saas.new_signup_membership_path, headers: http_basic_auth_headers
assert_response :success
end
end
test "new with new_user param" do
untenanted do
get saas.new_signup_membership_path(signup: { new_user: true }), headers: http_basic_auth_headers
assert_response :success
end
end
test "create" do
untenanted do
assert_difference -> { Membership.count }, 1 do
post saas.signup_membership_path, params: {
signup: {
full_name: "New User",
company_name: "New Company"
}
}, headers: http_basic_auth_headers
end
assert_redirected_to saas.new_signup_completion_path(
signup: {
membership_id: Membership.last.signed_id(purpose: :account_creation),
full_name: "New User",
company_name: "New Company"
}
), "Successful membership creation should redirect to completion step"
end
end
test "create with validation errors" do
untenanted do
assert_no_difference -> { Membership.count } do
post saas.signup_membership_path, params: {
signup: {
full_name: "",
company_name: ""
}
}, headers: http_basic_auth_headers
end
assert_response :unprocessable_entity, "Invalid params should return unprocessable entity"
end
end
test "create with new_user flag generates personal account name" do
untenanted do
post saas.signup_membership_path, params: {
signup: {
full_name: "John Smith",
new_user: true
}
}, headers: http_basic_auth_headers
# When new_user is true and company_name is blank, it should use personal account name
# Follow the redirect to check the generated company name
assert_response :redirect
redirect_params = Rack::Utils.parse_query(URI.parse(response.location).query)
assert_equal "John's BOXCAR", redirect_params["signup[company_name]"]
end
end
private
def http_basic_auth_headers
{ "Authorization" => ActionController::HttpAuthentication::Basic.encode_credentials("testname", "testpassword") }
end
end
@@ -23,7 +23,7 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest
get saas.new_signup_url, headers: http_basic_auth_headers
assert_response :success
assert_select "h2", "Enter your email address to get started."
assert_select "h1", "Sign up"
assert_select "input[name='signup[email_address]']"
end
@@ -43,7 +43,7 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest
end
assert_response :unprocessable_entity
assert_select ".alert--error"
assert_select ".txt-negative"
end
private
@@ -1,132 +0,0 @@
require "test_helper"
class IdentityProvider::RemoteBackendTest < ActiveSupport::TestCase
setup do
WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/link})
.to_return do |request|
body = JSON.parse(request.body)
IdentityProvider::LocalBackend.link(email_address: body["email_address"], to: body["to"])
{ status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } }
end
WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/unlink})
.to_return do |request|
body = JSON.parse(request.body)
IdentityProvider::LocalBackend.unlink(email_address: body["email_address"], from: body["from"])
{ status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } }
end
WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/change_email_address})
.to_return do |request|
body = JSON.parse(request.body)
IdentityProvider::LocalBackend.change_email_address(from: body["from"], to: body["to"], tenant: body["tenant"])
{ status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } }
end
WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/send_magic_link})
.to_return do |request|
body = JSON.parse(request.body)
code = IdentityProvider::LocalBackend.send_magic_link(body["email_address"])
{ status: 200, body: { code: code }.to_json, headers: { "Content-Type" => "application/json" } }
end
end
test "link" do
new_email = "newuser@example.com"
tenant = ApplicationRecord.current_tenant
assert_difference -> { Identity.count }, 1 do
assert_difference -> { Membership.count }, 1 do
IdentityProvider::RemoteBackend.link(email_address: new_email, to: tenant)
end
end
identity = Identity.find_by(email_address: new_email)
assert identity.memberships.exists?(tenant: tenant)
end
test "unlink" do
identity = identities(:kevin)
tenant = ApplicationRecord.current_tenant
assert_difference -> { Membership.count }, -1 do
IdentityProvider::RemoteBackend.unlink(email_address: identity.email_address, from: tenant)
end
end
test "change_email_address" do
identity = identities(:kevin)
tenant = ApplicationRecord.current_tenant
new_email = "newemail@example.com"
IdentityProvider::RemoteBackend.change_email_address(from: identity.email_address, to: new_email, tenant: tenant)
new_identity = Identity.find_by(email_address: new_email)
assert new_identity.memberships.exists?(tenant: tenant)
end
test "send_magic_link" do
identity = identities(:kevin)
assert_difference -> { MagicLink.count }, 1 do
code = IdentityProvider::RemoteBackend.send_magic_link(identity.email_address)
assert_equal MagicLink::CODE_LENGTH, code.length
end
code = IdentityProvider::RemoteBackend.send_magic_link("nonexistent@example.com")
assert_nil code
end
test "consume_magic_link" do
identity = identities(:kevin)
magic_link = identity.send_magic_link
token = IdentityProvider::RemoteBackend.consume_magic_link(magic_link.code)
assert_equal identity.signed_id, token.id
assert_not MagicLink.exists?(magic_link.id)
token = IdentityProvider::RemoteBackend.consume_magic_link("invalid")
assert_nil token
end
test "token_for" do
identity = identities(:kevin)
token = IdentityProvider::RemoteBackend.token_for(identity.email_address)
assert_equal identity.signed_id, token.id
token = IdentityProvider::RemoteBackend.token_for("nonexistent@example.com")
assert_nil token
end
test "resolve_token" do
identity = identities(:kevin)
token = { "id" => identity.signed_id }
email = IdentityProvider::RemoteBackend.resolve_token(token)
assert_equal identity.email_address, email
email = IdentityProvider::RemoteBackend.resolve_token({ "id" => "invalid" })
assert_nil email
end
test "verify_token" do
identity = identities(:kevin)
token = { "id" => identity.signed_id }
result = IdentityProvider::RemoteBackend.verify_token(token)
assert_equal identity.signed_id, result.id
result = IdentityProvider::RemoteBackend.verify_token({ "id" => "invalid" })
assert_nil result
end
test "tenants_for" do
identity = identities(:kevin)
token = { "id" => identity.signed_id }
tenants = IdentityProvider::RemoteBackend.tenants_for(token)
assert tenants.all? { |t| t.is_a?(IdentityProvider::Tenant) }
assert_includes tenants.map(&:id), identity.memberships.first.tenant
end
end
+39 -5
View File
@@ -31,13 +31,46 @@ class SignupTest < ActiveSupport::TestCase
assert_not_empty signup_invalid.errors[:email_address], "Should have validation error for email_address"
end
test "#complete" do
Account.any_instance.expects(:setup_basic_template).once
test "#create_membership" do
signup = Signup.new(
full_name: "Kevin",
company_name: "37signals",
email_address: "kevin@example.com",
identity: identities(:kevin)
)
assert_difference -> { Membership.count }, 1 do
assert signup.create_membership, signup.errors.full_messages.to_sentence(words_connector: ". ")
end
assert signup.tenant
assert signup.membership
assert signup.membership_id
signup_invalid = Signup.new(
full_name: "",
company_name: "37signals",
identity: identities(:kevin)
)
assert_not signup_invalid.create_membership, "Create membership should fail with invalid params"
assert_not_empty signup_invalid.errors[:full_name], "Should have validation error for full_name"
end
test "#complete" do
Account.any_instance.expects(:setup_basic_template).once
# First create the membership
signup_for_membership = Signup.new(
full_name: "Kevin",
company_name: "37signals",
identity: identities(:kevin)
)
signup_for_membership.create_membership
# Then complete the signup
signup = Signup.new(
full_name: "Kevin",
company_name: "37signals",
membership_id: signup_for_membership.membership_id,
identity: identities(:kevin)
)
@@ -49,10 +82,11 @@ class SignupTest < ActiveSupport::TestCase
assert_equal "Kevin", signup.user.name
assert_equal "37signals", signup.account.name
# Test validation failure
signup_invalid = Signup.new(
full_name: "",
company_name: "37signals",
email_address: "kevin@example.com",
membership_id: signup_for_membership.membership_id,
identity: identities(:kevin)
)
assert_not signup_invalid.complete, "Complete should fail with invalid params"
@@ -0,0 +1,20 @@
#!/usr/bin/env ruby
require_relative "../../config/environment"
ApplicationRecord.with_each_tenant do |tenant|
puts "🏢 #{tenant}"
User.find_each do |user|
next if user.system? || !user.active?
if user.membership.present?
puts "✅ User #{user.id} has a membership"
else
puts "⏩ Creating membership for user #{user.id}"
identity = Identity.find_or_create_by(email_address: user.email_address)
membership = identity.memberships.find_or_create_by(tenant: tenant)
user.update_columns(membership_id: membership.id)
end
end
end
@@ -1,13 +1,13 @@
require "test_helper"
class ControllerAuthenticationTest < ActionDispatch::IntegrationTest
test "access without an account slug redirects to login menu" do
identify_as :kevin
test "access without an account slug redirects to menu" do
sign_in_as :kevin
integration_session.default_url_options[:script_name] = "" # no tenant
get cards_path
assert_redirected_to session_login_menu_path
assert_redirected_to session_menu_path
end
test "access with an account slug but no session redirects to new session" do
@@ -1,21 +0,0 @@
require "test_helper"
class Sessions::LoginMenusControllerTest < ActionDispatch::IntegrationTest
test "show" do
untenanted do
identify_as :kevin
get session_login_menu_url
assert_response :success
end
end
test "create" do
untenanted do
sign_in_as :kevin
assert cookies[:session_token].present?
end
end
end
@@ -10,27 +10,34 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
end
test "create" do
identity = identities(:kevin)
magic_link = MagicLink.create!(identity: identity)
untenanted do
identity = identities(:kevin)
magic_link = MagicLink.create!(identity: identity)
post session_magic_link_url, params: { code: magic_link.code }
assert_response :redirect, "Valid magic link should redirect"
assert cookies[:identity_token].present?, "Valid magic link should set identity token"
assert_not MagicLink.exists?(magic_link.id), "Valid magic link should be consumed"
post session_magic_link_url, params: { code: "INVALID" }
assert_response :redirect, "Invalid code should redirect"
expired_link = MagicLink.create!(identity: identity)
expired_link.update_column(:expires_at, 1.hour.ago)
post session_magic_link_url, params: { code: expired_link.code }
assert_response :redirect, "Expired magic link should redirect"
assert MagicLink.exists?(expired_link.id), "Expired magic link should not be consumed"
end
assert_response :redirect
assert cookies[:session_token].present?
assert_not MagicLink.exists?(magic_link.id), "The magic link should be consumed"
end
test "create with invalid code" do
identity = identities(:kevin)
magic_link = MagicLink.create!(identity: identity)
untenanted do
post session_magic_link_url, params: { code: "INVALID" }
end
assert_response :redirect, "Invalid code should redirect"
expired_link = MagicLink.create!(identity: identity)
expired_link.update_column(:expires_at, 1.hour.ago)
post session_magic_link_url, params: { code: expired_link.code }
assert_response :redirect, "Expired magic link should redirect"
assert MagicLink.exists?(expired_link.id), "Expired magic link should not be consumed"
end
end
@@ -0,0 +1,13 @@
require "test_helper"
class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest
test "show" do
untenanted do
sign_in_as :kevin
get session_menu_url
assert_response :success
end
end
end
@@ -2,17 +2,21 @@ require "test_helper"
class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest
test "show renders when not signed in" do
get session_transfer_path("some-token")
untenanted do
get session_transfer_path("some-token")
assert_response :success
assert_response :success
end
end
test "update establishes a session when the code is valid" do
user = users(:david)
identity = identities(:david)
put session_transfer_path(user.transfer_id)
untenanted do
put session_transfer_path(identity.transfer_id)
assert_redirected_to root_url
assert parsed_cookies.signed[:session_token]
assert_redirected_to session_menu_url(script_name: nil)
assert parsed_cookies.signed[:session_token]
end
end
end
+6 -4
View File
@@ -2,12 +2,14 @@ require "test_helper"
class SessionsControllerTest < ActionDispatch::IntegrationTest
test "destroy" do
sign_in_as :kevin
untenanted do
sign_in_as :kevin
delete session_path
delete session_path
assert_redirected_to new_session_path
assert_not cookies[:session_token].present?
assert_redirected_to new_session_path
assert_not cookies[:session_token].present?
end
end
test "new" do
+19 -8
View File
@@ -2,20 +2,31 @@ require "test_helper"
class UsersControllerTest < ActionDispatch::IntegrationTest
test "new" do
get new_user_path(params: { join_code: "bad" })
assert_response :forbidden
identity = Identity.create!(email_address: "new.user@example.com")
identity.memberships.create(tenant: ApplicationRecord.current_tenant, join_code: Account::JoinCode.sole.code)
sign_in_as identity
get new_user_path(params: { join_code: account_join_codes(:sole).code })
get new_user_path
assert_response :ok
end
test "new with invalid params" do
identity = Identity.create!(email_address: "new.user@example.com")
membership = identity.memberships.create(tenant: ApplicationRecord.current_tenant, join_code: "PHONY")
sign_in_as identity
get new_user_path
assert_redirected_to unlink_membership_url(script_name: nil, membership_id: membership.signed_id(purpose: :unlinking))
end
test "create" do
identity = Identity.create!(email_address: "newart.userbaum@example.com")
identity.memberships.create(tenant: ApplicationRecord.current_tenant, join_code: Account::JoinCode.sole.code)
sign_in_as identity
assert_difference -> { User.count }, +1 do
assert_difference -> { Identity.count }, +1 do
post users_path(params: { join_code: account_join_codes(:sole).code }),
params: { user: { name: "Dash", email_address: "dash@example.com" } }
assert_redirected_to session_magic_link_path(script_name: nil)
end
post users_path, params: { user: { name: "Newart Userbaum" } }
assert_redirected_to root_path
end
end
+4 -4
View File
@@ -1,8 +1,8 @@
kevin:
email_address: kevin@37signals.com
david:
email_address: david@37signals.com
jz:
email_address: jz@37signals.com
david:
email_address: david@37signals.com
kevin:
email_address: kevin@37signals.com
+4 -7
View File
@@ -1,14 +1,11 @@
kevin_in_37signals:
identity: kevin
david_in_37signals:
identity: david
tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %>
account_name: Fizzy
jz_in_37signals:
identity: jz
tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %>
account_name: Fizzy
david_in_37signals:
identity: david
kevin_in_37signals:
identity: kevin
tenant: <%= Rails.application.config.active_record_tenanted.default_tenant %>
account_name: Fizzy
+2 -2
View File
@@ -1,5 +1,5 @@
david:
user: david
identity: david
kevin:
user: kevin
identity: kevin
+3 -3
View File
@@ -1,17 +1,17 @@
david:
name: David
email_address: david@37signals.com
role: member
membership: david_in_37signals
jz:
name: JZ
email_address: jz@37signals.com
role: member
membership: jz_in_37signals
kevin:
name: Kevin
email_address: kevin@37signals.com
role: admin
membership: kevin_in_37signals
system:
name: System
+1 -1
View File
@@ -14,7 +14,7 @@ class Account::JoinCodeTest < ActiveSupport::TestCase
join_code = account_join_codes(:sole)
assert_difference -> { join_code.reload.usage_count }, 1 do
Account::JoinCode.redeem(join_code.code)
join_code.redeem
end
end
@@ -1,105 +0,0 @@
require "test_helper"
class IdentityProvider::LocalBackendTest < ActiveSupport::TestCase
test "link" do
new_email = "newuser@example.com"
tenant = ApplicationRecord.current_tenant
assert_difference -> { Identity.count }, 1 do
assert_difference -> { Membership.count }, 1 do
IdentityProvider::LocalBackend.link(email_address: new_email, to: tenant)
end
end
identity = Identity.find_by(email_address: new_email)
assert identity.memberships.exists?(tenant: tenant), "creates membership for tenant"
end
test "unlink" do
identity = identities(:kevin)
tenant = ApplicationRecord.current_tenant
assert_difference -> { Membership.count }, -1 do
IdentityProvider::LocalBackend.unlink(email_address: identity.email_address, from: tenant)
end
assert_not identity.reload.memberships.exists?(tenant: tenant), "removes membership from tenant"
end
test "change_email_address" do
identity = identities(:kevin)
tenant = ApplicationRecord.current_tenant
new_email = "newemail@example.com"
IdentityProvider::LocalBackend.change_email_address(from: identity.email_address, to: new_email, tenant: tenant)
assert_not identity.reload.memberships.exists?(tenant: tenant), "removes old identity membership"
new_identity = Identity.find_by(email_address: new_email)
assert new_identity.memberships.exists?(tenant: tenant), "creates new identity membership"
end
test "send_magic_link" do
identity = identities(:kevin)
assert_difference -> { MagicLink.count }, 1 do
code = IdentityProvider::LocalBackend.send_magic_link(identity.email_address)
assert_equal MagicLink::CODE_LENGTH, code.length, "returns code of correct length"
end
code = IdentityProvider::LocalBackend.send_magic_link("nonexistent@example.com")
assert_nil code, "returns nil for non-existent email"
end
test "consume_magic_link" do
identity = identities(:kevin)
magic_link = identity.send_magic_link
token = IdentityProvider::LocalBackend.consume_magic_link(magic_link.code)
assert_equal identity.signed_id, token.id, "returns token for valid code"
assert_not MagicLink.exists?(magic_link.id), "deletes magic link after consumption"
token = IdentityProvider::LocalBackend.consume_magic_link("invalid")
assert_nil token, "returns nil for invalid code"
end
test "token_for" do
identity = identities(:kevin)
token = IdentityProvider::LocalBackend.token_for(identity.email_address)
assert_equal identity.signed_id, token.id, "returns token for existing email"
token = IdentityProvider::LocalBackend.token_for("nonexistent@example.com")
assert_nil token, "returns nil for non-existent email"
end
test "resolve_token" do
identity = identities(:kevin)
token = { "id" => identity.signed_id }
email = IdentityProvider::LocalBackend.resolve_token(token)
assert_equal identity.email_address, email, "returns email address from valid token"
email = IdentityProvider::LocalBackend.resolve_token({ "id" => "invalid" })
assert_nil email, "returns nil for invalid token"
end
test "verify_token" do
identity = identities(:kevin)
token = { "id" => identity.signed_id }
result = IdentityProvider::LocalBackend.verify_token(token)
assert_equal identity.signed_id, result.id, "returns token from valid token hash"
result = IdentityProvider::LocalBackend.verify_token({ "id" => "invalid" })
assert_nil result, "returns nil for invalid token"
end
test "tenants_for" do
identity = identities(:kevin)
token = { "id" => identity.signed_id }
tenants = IdentityProvider::LocalBackend.tenants_for(token)
assert tenants.all? { |t| t.is_a?(IdentityProvider::Tenant) }, "returns Tenant objects"
assert_includes tenants.map(&:id), identity.memberships.first.tenant, "includes identity's tenant"
end
end

Some files were not shown because too many files have changed in this diff Show More