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:
committed by
Stanko K.R.
parent
440631c790
commit
98755844a1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
class Session < ApplicationRecord
|
||||
belongs_to :user
|
||||
class Session < UntenantedRecord
|
||||
belongs_to :identity
|
||||
end
|
||||
|
||||
+8
-18
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
+1
-5
@@ -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">
|
||||
+3
-4
@@ -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">← <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>
|
||||
+5
-5
@@ -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">← <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. We’ll 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 %>
|
||||
@@ -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
-1
@@ -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 didn’t request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.</p>
|
||||
|
||||
+1
-1
@@ -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 didn’t 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 %>
|
||||
@@ -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 %>
|
||||
+6
-6
@@ -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,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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+4
-4
@@ -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
|
||||
|
||||
Vendored
+4
-7
@@ -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
|
||||
|
||||
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
||||
david:
|
||||
user: david
|
||||
identity: david
|
||||
|
||||
kevin:
|
||||
user: kevin
|
||||
identity: kevin
|
||||
|
||||
Vendored
+3
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1,13 +0,0 @@
|
||||
require "test_helper"
|
||||
|
||||
class IdentityTest < ActiveSupport::TestCase
|
||||
test "send_magic_link" do
|
||||
identity = identities(:kevin)
|
||||
|
||||
assert_difference -> { identity.magic_links.count }, 1 do
|
||||
identity.send_magic_link
|
||||
end
|
||||
|
||||
assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user